diff --git a/packages/flutter/lib/src/material/chip.dart b/packages/flutter/lib/src/material/chip.dart index cfdc23053c..6b9934d036 100644 --- a/packages/flutter/lib/src/material/chip.dart +++ b/packages/flutter/lib/src/material/chip.dart @@ -2,20 +2,33 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter/painting.dart'; +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/painting.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; -import 'colors.dart'; import 'debug.dart'; import 'feedback.dart'; import 'icons.dart'; import 'material_localizations.dart'; +import 'theme.dart'; import 'tooltip.dart'; +// Some design constants +const double _kChipHeight = 32.0; +const double _kDeleteIconSize = 18.0; +const int _kTextLabelAlpha = 0xde; +const int _kDeleteIconAlpha = 0xde; +const int _kContainerAlpha = 0x14; +const double _kEdgePadding = 4.0; + /// A material design chip. /// -/// Chips represent complex entities in small blocks, such as a contact. +/// Chips represent complex entities in small blocks, such as a contact, or a +/// choice. /// /// Supplying a non-null [onDeleted] callback will cause the chip to include a /// button for deleting the chip. @@ -40,48 +53,46 @@ import 'tooltip.dart'; /// * [CircleAvatar], which shows images or initials of people. /// * class Chip extends StatelessWidget { - /// Creates a material design chip. + /// Creates a material design chip /// - /// * [onDeleted] determines whether the chip has a delete button. This - /// callback runs when the delete button is pressed. + /// The [label] and [border] arguments may not be null. const Chip({ Key key, this.avatar, + this.deleteIcon, @required this.label, this.onDeleted, - TextStyle labelStyle, + this.labelStyle, this.deleteButtonTooltipMessage, this.backgroundColor, this.deleteIconColor, this.border: const StadiumBorder(), - }) : assert(label != null), - assert(border != null), - labelStyle = labelStyle ?? _defaultLabelStyle, - super(key: key); - - static const TextStyle _defaultLabelStyle = const TextStyle( - inherit: false, - fontSize: 13.0, - fontWeight: FontWeight.w400, - color: Colors.black87, - textBaseline: TextBaseline.alphabetic, - ); - - static const double _chipHeight = 32.0; + }) : assert(label != null), + assert(border != null), + super(key: key); /// A widget to display prior to the chip's label. /// /// Typically a [CircleAvatar] widget. final Widget avatar; + /// The icon displayed when [onDeleted] is non-null. + /// + /// This has no effect when [onDeleted] is null since no delete icon will be + /// shown. + /// + /// Defaults to an [Icon] widget containing [Icons.cancel]. + final Widget deleteIcon; + /// The primary content of the chip. /// /// Typically a [Text] widget. final Widget label; - /// Called when the user deletes the chip, e.g., by tapping the delete button. + /// Called when the user taps the delete button to delete the chip. /// - /// The delete button is included in the chip only if this callback is non-null. + /// This has no effect when [deleteIcon] is null since no delete icon will be + /// shown. final VoidCallback onDeleted; /// The style to be applied to the chip's label. @@ -90,7 +101,8 @@ class Chip extends StatelessWidget { /// such as [Text]. final TextStyle labelStyle; - /// Color to be used for the chip's background, the default being grey. + /// Color to be used for the chip's background, the default is based on the + /// ambient [IconTheme]. /// /// This color is used as the background of the container that will hold the /// widget's label. @@ -101,83 +113,642 @@ class Chip extends StatelessWidget { /// Defaults to a [StadiumBorder]. final ShapeBorder border; - /// Color for delete icon, the default being black. - /// - /// This has no effect when [onDelete] is null since no delete icon will be - /// shown. + /// Color for delete icon. The default is based on the ambient [IconTheme]. final Color deleteIconColor; /// Message to be used for the chip delete button's tooltip. - /// - /// This has no effect when [onDelete] is null since no delete icon will be - /// shown. final String deleteButtonTooltipMessage; @override Widget build(BuildContext context) { assert(debugCheckHasMaterial(context)); - final bool deletable = onDeleted != null; - double startPadding = 12.0; - double endPadding = 12.0; - - final List children = []; - - if (avatar != null) { - startPadding = 0.0; - children.add(new ExcludeSemantics( - child: new Container( - margin: const EdgeInsetsDirectional.only(end: 8.0), - width: _chipHeight, - height: _chipHeight, - child: avatar, - ), - )); - } - - children.add(new Flexible( - child: new DefaultTextStyle( - overflow: TextOverflow.ellipsis, - style: labelStyle, - child: label, - ), - )); - - if (deletable) { - endPadding = 0.0; - children.add(new GestureDetector( - onTap: Feedback.wrapForTap(onDeleted, context), - child: new Tooltip( - message: deleteButtonTooltipMessage ?? MaterialLocalizations.of(context).deleteButtonTooltip, - child: new Container( - padding: const EdgeInsets.symmetric(horizontal: 4.0), - child: new Icon( - Icons.cancel, - size: 24.0, - color: deleteIconColor ?? Colors.black54, + final ThemeData theme = Theme.of(context); + return new DefaultTextStyle( + overflow: TextOverflow.fade, + textAlign: TextAlign.start, + maxLines: 1, + softWrap: false, + style: labelStyle ?? + theme.textTheme.body2.copyWith( + color: theme.primaryColorDark.withAlpha(_kTextLabelAlpha), + ), + child: new _ChipRenderWidget( + theme: new _ChipRenderTheme( + label: label, + avatar: avatar, + deleteIcon: onDeleted == null + ? null + : new Tooltip( + message: deleteButtonTooltipMessage ?? MaterialLocalizations.of(context).deleteButtonTooltip, + child: new IconTheme( + data: theme.iconTheme.copyWith( + color: deleteIconColor ?? theme.iconTheme.color.withAlpha(_kDeleteIconAlpha), + ), + child: deleteIcon ?? const Icon(Icons.cancel, size: _kDeleteIconSize), + ), + ), + container: new Container( + decoration: new ShapeDecoration( + shape: border, + color: backgroundColor ?? theme.primaryColorDark.withAlpha(_kContainerAlpha), ), ), + padding: const EdgeInsets.all(_kEdgePadding), + labelPadding: const EdgeInsets.symmetric(horizontal: _kEdgePadding), ), - )); - } - - return new Semantics( - container: true, - child: new Container( - constraints: const BoxConstraints(minHeight: _chipHeight), - padding: new EdgeInsetsDirectional.only(start: startPadding, end: endPadding), - decoration: new ShapeDecoration( - color: backgroundColor ?? Colors.grey.shade300, - shape: border, - ), - child: new Center( - widthFactor: 1.0, - heightFactor: 1.0, - child: new Row( - children: children, - mainAxisSize: MainAxisSize.min, - ), - ), + key: key, + onDeleted: Feedback.wrapForTap(onDeleted, context), ), ); } -} \ No newline at end of file +} + +class _ChipRenderWidget extends RenderObjectWidget { + const _ChipRenderWidget({ + Key key, + @required this.theme, + this.onDeleted, + }) : assert(theme != null), + super(key: key); + + final _ChipRenderTheme theme; + final VoidCallback onDeleted; + + @override + _RenderChipElement createElement() => new _RenderChipElement(this); + + @override + void updateRenderObject(BuildContext context, _RenderChip renderObject) { + renderObject + ..theme = theme + ..textDirection = Directionality.of(context) + ..onDeleted = onDeleted; + } + + @override + RenderObject createRenderObject(BuildContext context) { + return new _RenderChip( + theme: theme, + textDirection: Directionality.of(context), + onDeleted: onDeleted, + ); + } +} + +enum _ChipSlot { + label, + avatar, + deleteIcon, + container, +} + +class _RenderChipElement extends RenderObjectElement { + _RenderChipElement(_ChipRenderWidget chip) : super(chip); + + final Map<_ChipSlot, Element> slotToChild = <_ChipSlot, Element>{}; + final Map childToSlot = {}; + + @override + _ChipRenderWidget get widget => super.widget; + + @override + _RenderChip get renderObject => super.renderObject; + + @override + void visitChildren(ElementVisitor visitor) { + slotToChild.values.forEach(visitor); + } + + @override + void forgetChild(Element child) { + assert(slotToChild.values.contains(child)); + assert(childToSlot.keys.contains(child)); + final _ChipSlot slot = childToSlot[child]; + childToSlot.remove(child); + slotToChild.remove(slot); + } + + void _mountChild(Widget widget, _ChipSlot slot) { + final Element oldChild = slotToChild[slot]; + final Element newChild = updateChild(oldChild, widget, slot); + if (oldChild != null) { + slotToChild.remove(slot); + childToSlot.remove(oldChild); + } + if (newChild != null) { + slotToChild[slot] = newChild; + childToSlot[newChild] = slot; + } + } + + @override + void mount(Element parent, dynamic newSlot) { + super.mount(parent, newSlot); + _mountChild(widget.theme.avatar, _ChipSlot.avatar); + _mountChild(widget.theme.deleteIcon, _ChipSlot.deleteIcon); + _mountChild(widget.theme.label, _ChipSlot.label); + _mountChild(widget.theme.container, _ChipSlot.container); + } + + void _updateChild(Widget widget, _ChipSlot slot) { + final Element oldChild = slotToChild[slot]; + final Element newChild = updateChild(oldChild, widget, slot); + if (oldChild != null) { + childToSlot.remove(oldChild); + slotToChild.remove(slot); + } + if (newChild != null) { + slotToChild[slot] = newChild; + childToSlot[newChild] = slot; + } + } + + @override + void update(_ChipRenderWidget newWidget) { + super.update(newWidget); + assert(widget == newWidget); + _updateChild(widget.theme.label, _ChipSlot.label); + _updateChild(widget.theme.avatar, _ChipSlot.avatar); + _updateChild(widget.theme.deleteIcon, _ChipSlot.deleteIcon); + _updateChild(widget.theme.container, _ChipSlot.container); + } + + void _updateRenderObject(RenderObject child, _ChipSlot slot) { + switch (slot) { + case _ChipSlot.avatar: + renderObject.avatar = child; + break; + case _ChipSlot.label: + renderObject.label = child; + break; + case _ChipSlot.deleteIcon: + renderObject.deleteIcon = child; + break; + case _ChipSlot.container: + renderObject.container = child; + break; + } + } + + @override + void insertChildRenderObject(RenderObject child, dynamic slotValue) { + assert(child is RenderBox); + assert(slotValue is _ChipSlot); + final _ChipSlot slot = slotValue; + _updateRenderObject(child, slot); + assert(renderObject.childToSlot.keys.contains(child)); + assert(renderObject.slotToChild.keys.contains(slot)); + } + + @override + void removeChildRenderObject(RenderObject child) { + assert(child is RenderBox); + assert(renderObject.childToSlot.keys.contains(child)); + _updateRenderObject(null, renderObject.childToSlot[child]); + assert(!renderObject.childToSlot.keys.contains(child)); + assert(!renderObject.slotToChild.keys.contains(slot)); + } + + @override + void moveChildRenderObject(RenderObject child, dynamic slotValue) { + assert(false, 'not reachable'); + } +} + +class _ChipRenderTheme { + const _ChipRenderTheme({ + @required this.avatar, + @required this.label, + @required this.deleteIcon, + @required this.container, + @required this.padding, + @required this.labelPadding, + }); + + final Widget avatar; + final Widget label; + final Widget deleteIcon; + final Widget container; + final EdgeInsets padding; + final EdgeInsets labelPadding; + + @override + bool operator ==(dynamic other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + final _ChipRenderTheme typedOther = other; + return typedOther.avatar == avatar && + typedOther.label == label && + typedOther.deleteIcon == deleteIcon && + typedOther.container == container && + typedOther.padding == padding && + typedOther.labelPadding == labelPadding; + } + + @override + int get hashCode { + return hashValues( + avatar, + label, + deleteIcon, + container, + padding, + labelPadding, + ); + } +} + +class _RenderChip extends RenderBox { + _RenderChip({ + @required _ChipRenderTheme theme, + @required TextDirection textDirection, + this.onDeleted, + }) : assert(theme != null), + assert(textDirection != null), + _theme = theme, + _textDirection = textDirection { + _tap = new TapGestureRecognizer(debugOwner: this) + ..onTapDown = _handleTapDown + ..onTap = _handleTap; + } + + // Set this to true to have outlines of the tap targets drawn over + // the chip. This should never be checked in while set to 'true'. + static const bool _debugShowTapTargetOutlines = false; + static const EdgeInsets _iconPadding = const EdgeInsets.all(_kEdgePadding); + + final Map<_ChipSlot, RenderBox> slotToChild = <_ChipSlot, RenderBox>{}; + final Map childToSlot = {}; + + TapGestureRecognizer _tap; + + VoidCallback onDeleted; + Rect _deleteButtonRect; + Rect _actionRect; + Offset _tapDownLocation; + + RenderBox _updateChild(RenderBox oldChild, RenderBox newChild, _ChipSlot slot) { + if (oldChild != null) { + dropChild(oldChild); + childToSlot.remove(oldChild); + slotToChild.remove(slot); + } + if (newChild != null) { + childToSlot[newChild] = slot; + slotToChild[slot] = newChild; + adoptChild(newChild); + } + return newChild; + } + + RenderBox _avatar; + RenderBox get avatar => _avatar; + set avatar(RenderBox value) { + _avatar = _updateChild(_avatar, value, _ChipSlot.avatar); + } + + RenderBox _deleteIcon; + RenderBox get deleteIcon => _deleteIcon; + set deleteIcon(RenderBox value) { + _deleteIcon = _updateChild(_deleteIcon, value, _ChipSlot.deleteIcon); + } + + RenderBox _label; + RenderBox get label => _label; + set label(RenderBox value) { + _label = _updateChild(_label, value, _ChipSlot.label); + } + + RenderBox _container; + RenderBox get container => _container; + set container(RenderBox value) { + _container = _updateChild(_container, value, _ChipSlot.container); + } + + _ChipRenderTheme get theme => _theme; + _ChipRenderTheme _theme; + set theme(_ChipRenderTheme value) { + if (_theme == value) { + return; + } + _theme = value; + markNeedsLayout(); + } + + TextDirection get textDirection => _textDirection; + TextDirection _textDirection; + set textDirection(TextDirection value) { + if (_textDirection == value) { + return; + } + _textDirection = value; + markNeedsLayout(); + } + + // The returned list is ordered for hit testing. + Iterable get _children sync* { + if (avatar != null) { + yield avatar; + } + if (label != null) { + yield label; + } + if (deleteIcon != null) { + yield deleteIcon; + } + if (container != null) { + yield container; + } + } + + @override + void handleEvent(PointerEvent event, BoxHitTestEntry entry) { + assert(debugHandleEvent(event, entry)); + if (event is PointerDownEvent && deleteIcon != null) { + _tap.addPointer(event); + } + } + + void _handleTapDown(TapDownDetails details) { + if (deleteIcon != null) { + _tapDownLocation = globalToLocal(details.globalPosition); + } + } + + void _handleTap() { + if (_tapDownLocation == null) { + return; + } + if (deleteIcon != null && onDeleted != null && _deleteButtonRect.contains(_tapDownLocation)) { + onDeleted(); + } + } + + @override + void attach(PipelineOwner owner) { + super.attach(owner); + for (RenderBox child in _children) { + child.attach(owner); + } + } + + @override + void detach() { + super.detach(); + for (RenderBox child in _children) { + child.detach(); + } + } + + @override + void redepthChildren() { + _children.forEach(redepthChild); + } + + @override + void visitChildren(RenderObjectVisitor visitor) { + _children.forEach(visitor); + } + + @override + List debugDescribeChildren() { + final List value = []; + void add(RenderBox child, String name) { + if (child != null) { + value.add(child.toDiagnosticsNode(name: name)); + } + } + + add(avatar, 'avatar'); + add(label, 'label'); + add(deleteIcon, 'deleteIcon'); + add(container, 'container'); + return value; + } + + @override + bool get sizedByParent => false; + + static double _minWidth(RenderBox box, double height) { + return box == null ? 0.0 : box.getMinIntrinsicWidth(height); + } + + static double _maxWidth(RenderBox box, double height) { + return box == null ? 0.0 : box.getMaxIntrinsicWidth(height); + } + + static double _minHeight(RenderBox box, double width) { + return box == null ? 0.0 : box.getMinIntrinsicWidth(width); + } + + static Size _boxSize(RenderBox box) => box == null ? Size.zero : box.size; + + static BoxParentData _boxParentData(RenderBox box) => box.parentData; + + @override + double computeMinIntrinsicWidth(double height) { + // The overall padding isn't affected by missing avatar or delete icon + // because we add the padding regardless to give extra padding for the label + // when they're missing. + final double overallPadding = theme.labelPadding.horizontal + _iconPadding.horizontal * 2.0; + return overallPadding + _minWidth(avatar, height) + _minWidth(label, height) + _minWidth(deleteIcon, height); + } + + @override + double computeMaxIntrinsicWidth(double height) { + // The overall padding isn't affected by missing avatar or delete icon + // because we add the padding regardless to give extra padding for the label + // when they're missing. + final double overallPadding = theme.labelPadding.horizontal + _iconPadding.horizontal * 2.0; + return overallPadding + _maxWidth(avatar, height) + _maxWidth(label, height) + _maxWidth(deleteIcon, height); + } + + @override + double computeMinIntrinsicHeight(double width) { + // This widget is sized to the height of the label only, as long as it's + // larger than _kChipHeight. The other widgets are sized to match the + // label. + return math.max(_kChipHeight, theme.labelPadding.vertical + _minHeight(label, width)); + } + + @override + double computeMaxIntrinsicHeight(double width) => computeMinIntrinsicHeight(width); + + @override + double computeDistanceToActualBaseline(TextBaseline baseline) { + // The baseline of this widget is the baseline of the label. + return label.computeDistanceToActualBaseline(baseline); + } + + @override + void performLayout() { + double overallHeight = _kChipHeight; + if (label != null) { + label.layout(constraints.loosen(), parentUsesSize: true); + // Now that we know the height, we can determine how much to shrink the + // constraints by for the "real" layout. Ignored if the constraints are + // infinite. + overallHeight = math.max(overallHeight, _boxSize(label).height); + if (constraints.maxWidth.isFinite) { + final double allPadding = _iconPadding.horizontal * 2.0 + theme.labelPadding.horizontal; + final double iconSizes = (avatar != null ? overallHeight - _iconPadding.vertical : 0.0) + + (deleteIcon != null ? overallHeight - _iconPadding.vertical : 0.0); + label.layout( + constraints.loosen().copyWith( + maxWidth: math.max(0.0, constraints.maxWidth - iconSizes - allPadding), + ), + parentUsesSize: true, + ); + } + } + final double labelWidth = theme.labelPadding.horizontal + _boxSize(label).width; + final double iconSize = overallHeight - _iconPadding.vertical; + final BoxConstraints iconConstraints = new BoxConstraints.tightFor( + width: iconSize, + height: iconSize, + ); + double avatarWidth = _iconPadding.horizontal; + if (avatar != null) { + avatar.layout(iconConstraints, parentUsesSize: true); + avatarWidth += _boxSize(avatar).width; + } + double deleteIconWidth = _iconPadding.horizontal; + if (deleteIcon != null) { + deleteIcon.layout(iconConstraints, parentUsesSize: true); + deleteIconWidth += _boxSize(deleteIcon).width; + } + final double overallWidth = avatarWidth + labelWidth + deleteIconWidth; + + if (container != null) { + final BoxConstraints containerConstraints = new BoxConstraints.tightFor( + height: overallHeight, + width: overallWidth, + ); + container.layout(containerConstraints, parentUsesSize: true); + _boxParentData(container).offset = Offset.zero; + } + + double centerLayout(RenderBox box, double x) { + _boxParentData(box).offset = new Offset(x, (overallHeight - box.size.height) / 2.0); + return box.size.width; + } + + const double left = 0.0; + final double right = overallWidth; + + switch (textDirection) { + case TextDirection.rtl: + double start = right - _kEdgePadding; + if (avatar != null) { + start -= centerLayout(avatar, start - avatar.size.width); + } + start -= _iconPadding.left + theme.labelPadding.right; + if (label != null) { + start -= centerLayout(label, start - label.size.width); + } + start -= _iconPadding.right + theme.labelPadding.left; + double deleteButtonWidth = 0.0; + if (deleteIcon != null) { + _deleteButtonRect = new Rect.fromLTWH( + 0.0, + 0.0, + iconSize + _iconPadding.horizontal, + iconSize + _iconPadding.vertical, + ); + deleteButtonWidth = _deleteButtonRect.width; + start -= centerLayout(deleteIcon, start - deleteIcon.size.width); + } + if (avatar != null || label != null) { + _actionRect = new Rect.fromLTWH( + deleteButtonWidth, + 0.0, + overallWidth - deleteButtonWidth, + overallHeight, + ); + } + break; + case TextDirection.ltr: + double start = left + _kEdgePadding; + if (avatar != null) { + start += centerLayout(avatar, start); + } + start += _iconPadding.right + theme.labelPadding.left; + if (label != null) { + start += centerLayout(label, start); + } + start += _iconPadding.left + theme.labelPadding.right; + if (avatar != null || label != null) { + _actionRect = new Rect.fromLTWH( + 0.0, + 0.0, + deleteIcon != null ? (start - _kEdgePadding) : overallWidth, + overallHeight, + ); + } + if (deleteIcon != null) { + _deleteButtonRect = new Rect.fromLTWH( + start - _kEdgePadding, + 0.0, + iconSize + _iconPadding.horizontal, + iconSize + _iconPadding.vertical, + ); + centerLayout(deleteIcon, start); + } + break; + } + + size = constraints.constrain(new Size(overallWidth, overallHeight)); + assert(size.width == constraints.constrainWidth(overallWidth)); + assert(size.height == constraints.constrainHeight(overallHeight)); + } + + @override + void paint(PaintingContext context, Offset offset) { + void doPaint(RenderBox child) { + if (child != null) { + context.paintChild(child, _boxParentData(child).offset + offset); + } + } + + assert(!_debugShowTapTargetOutlines || + () { + // Draws a rect around the tap targets to help with visualizing where + // they really are. + final Paint outlinePaint = new Paint() + ..color = const Color(0xff800000) + ..strokeWidth = 1.0 + ..style = PaintingStyle.stroke; + if (deleteIcon != null) { + context.canvas.drawRect(_deleteButtonRect.shift(offset), outlinePaint); + } + context.canvas.drawRect( + _actionRect.shift(offset), + outlinePaint..color = const Color(0xff008000), + ); + return true; + }()); + + doPaint(container); + doPaint(avatar); + doPaint(deleteIcon); + doPaint(label); + } + + @override + bool hitTestSelf(Offset position) => true; + + @override + bool hitTestChildren(HitTestResult result, {@required Offset position}) { + assert(position != null); + for (RenderBox child in _children) { + if (child.hasSize && child.hitTest(result, position: position - _boxParentData(child).offset)) { + return true; + } + } + return false; + } +} diff --git a/packages/flutter/lib/src/material/circle_avatar.dart b/packages/flutter/lib/src/material/circle_avatar.dart index af9b94cdb2..e8d6aa2a4b 100644 --- a/packages/flutter/lib/src/material/circle_avatar.dart +++ b/packages/flutter/lib/src/material/circle_avatar.dart @@ -4,7 +4,6 @@ import 'package:flutter/widgets.dart'; -import 'colors.dart'; import 'constants.dart'; import 'theme.dart'; import 'theme_data.dart'; @@ -44,8 +43,8 @@ import 'theme_data.dart'; /// See also: /// /// * [Chip], for representing users or concepts in long form. -/// * [ListTile], which can combine an icon (such as a [CircleAvatar]) with some -/// text for a fixed height list entry. +/// * [ListTile], which can combine an icon (such as a [CircleAvatar]) with +/// some text for a fixed height list entry. /// * class CircleAvatar extends StatelessWidget { /// Creates a circle that represents a user. @@ -55,8 +54,11 @@ class CircleAvatar extends StatelessWidget { this.backgroundColor, this.backgroundImage, this.foregroundColor, - this.radius: 20.0, - }) : super(key: key); + this.radius, + this.minRadius, + this.maxRadius, + }) : assert(radius == null || (minRadius == null && maxRadius == null)), + super(key: key); /// The widget below this widget in the tree. /// @@ -67,13 +69,18 @@ class CircleAvatar extends StatelessWidget { /// The color with which to fill the circle. Changing the background /// color will cause the avatar to animate to the new color. /// - /// If a background color is not specified, the theme's primary color is used. + /// If a [backgroundColor] is not specified, the theme's + /// [ThemeData.primaryColorLight] is used with dark foreground colors, and + /// [ThemeData.primaryColorDark] with light foreground colors. final Color backgroundColor; /// The default text color for text in the circle. /// - /// Falls back to white if a background color is specified, or the primary - /// text theme color otherwise. + /// Defaults to the primary text theme color if no [backgroundColor] is + /// specified. + /// + /// Defaults to [ThemeData.primaryColorLight] for dark background colors, and + /// [ThemeData.primaryColorDark] for light background colors. final Color foregroundColor; /// The background image of the circle. Changing the background @@ -85,48 +92,112 @@ class CircleAvatar extends StatelessWidget { /// The size of the avatar. Changing the radius will cause the /// avatar to animate to the new size. /// + /// If [radius] is specified, then neither [minRadius] nor [maxRadius] may be + /// specified. Specifying [radius] is equivalent to specifying a [minRadius] + /// and [maxRadius], both with the value of [radius]. + /// /// Defaults to 20 logical pixels. final double radius; + /// The minimum size of the avatar. + /// + /// Changing the minRadius may cause the avatar to animate to the new size, if + /// constraints allow. + /// + /// If minRadius is specified, then [radius] must not also be specified. + /// + /// Defaults to zero. + final double minRadius; + + /// The maximum size of the avatar. + /// + /// Changing the maxRadius will cause the avatar to animate to the new size, + /// if constraints allow. + /// + /// If maxRadius is specified, then [radius] must not also be specified. + /// + /// Defaults to [double.infinity]. + final double maxRadius; + + // The default radius if nothing is specified. + static const double _defaultRadius = 20.0; + + // The default min if only the max is specified. + static const double _defaultMinRadius = 0.0; + + // The default max if only the min is specified. + static const double _defaultMaxRadius = double.infinity; + + double get _minDiameter { + if (radius == null && minRadius == null && maxRadius == null) { + return _defaultRadius * 2.0; + } + return 2.0 * (radius ?? minRadius ?? _defaultMinRadius); + } + + double get _maxDiameter { + if (radius == null && minRadius == null && maxRadius == null) { + return _defaultRadius * 2.0; + } + return 2.0 * (radius ?? maxRadius ?? _defaultMaxRadius); + } + @override Widget build(BuildContext context) { assert(debugCheckHasMediaQuery(context)); final ThemeData theme = Theme.of(context); - TextStyle textStyle = theme.primaryTextTheme.title; - if (foregroundColor != null) { - textStyle = textStyle.copyWith(color: foregroundColor); - } else if (backgroundColor != null) { - switch (ThemeData.estimateBrightnessForColor(backgroundColor)) { + TextStyle textStyle = theme.primaryTextTheme.title.copyWith(color: foregroundColor); + Color effectiveBackgroundColor = backgroundColor; + if (effectiveBackgroundColor == null) { + switch (ThemeData.estimateBrightnessForColor(textStyle.color)) { case Brightness.dark: - textStyle = textStyle.copyWith(color: Colors.white); + effectiveBackgroundColor = theme.primaryColorLight; break; case Brightness.light: - textStyle = textStyle.copyWith(color: Colors.black); + effectiveBackgroundColor = theme.primaryColorDark; + break; + } + } else if (foregroundColor == null) { + switch (ThemeData.estimateBrightnessForColor(backgroundColor)) { + case Brightness.dark: + textStyle = textStyle.copyWith(color: theme.primaryColorLight); + break; + case Brightness.light: + textStyle = textStyle.copyWith(color: theme.primaryColorDark); break; } } + final double minDiameter = _minDiameter; + final double maxDiameter = _maxDiameter; return new AnimatedContainer( - width: radius * 2.0, - height: radius * 2.0, + constraints: new BoxConstraints( + minHeight: minDiameter, + minWidth: minDiameter, + maxWidth: maxDiameter, + maxHeight: maxDiameter, + ), duration: kThemeChangeDuration, decoration: new BoxDecoration( - color: backgroundColor ?? theme.primaryColor, - image: backgroundImage != null ? new DecorationImage( - image: backgroundImage - ) : null, + color: effectiveBackgroundColor, + image: backgroundImage != null ? new DecorationImage(image: backgroundImage) : null, shape: BoxShape.circle, ), - child: child != null ? new Center( - child: new MediaQuery( - // Need to reset the textScaleFactor here so that the - // text doesn't escape the avatar when the textScaleFactor is large. - data: MediaQuery.of(context).copyWith(textScaleFactor: 1.0), - child: new DefaultTextStyle( - style: textStyle.copyWith(color: foregroundColor), - child: child, - ), - ) - ) : null, + child: child == null + ? null + : new Center( + child: new MediaQuery( + // Need to ignore the ambient textScaleFactor here so that the + // text doesn't escape the avatar when the textScaleFactor is large. + data: MediaQuery.of(context).copyWith(textScaleFactor: 1.0), + child: new IconTheme( + data: theme.iconTheme.copyWith(color: textStyle.color), + child: new DefaultTextStyle( + style: textStyle, + child: child, + ), + ), + ), + ), ); } } diff --git a/packages/flutter/lib/src/material/tooltip.dart b/packages/flutter/lib/src/material/tooltip.dart index 85edc3a5cb..600452e07b 100644 --- a/packages/flutter/lib/src/material/tooltip.dart +++ b/packages/flutter/lib/src/material/tooltip.dart @@ -170,9 +170,9 @@ class _TooltipState extends State with SingleTickerProviderStateMixin { @override void deactivate() { + super.deactivate(); if (_entry != null) _controller.reverse(); - super.deactivate(); } @override diff --git a/packages/flutter/lib/src/rendering/box.dart b/packages/flutter/lib/src/rendering/box.dart index d1143f6bda..a6697851f0 100644 --- a/packages/flutter/lib/src/rendering/box.dart +++ b/packages/flutter/lib/src/rendering/box.dart @@ -1443,7 +1443,7 @@ abstract class RenderBox extends RenderObject { /// of those functions, call [markNeedsLayout] instead to schedule a layout of /// the box. Size get size { - assert(hasSize); + assert(hasSize, 'RenderBox was not laid out: ${toString()}'); assert(() { if (_size is _DebugSize) { final _DebugSize _size = this._size; diff --git a/packages/flutter/lib/src/rendering/shifted_box.dart b/packages/flutter/lib/src/rendering/shifted_box.dart index 700453646a..29bf327ea5 100644 --- a/packages/flutter/lib/src/rendering/shifted_box.dart +++ b/packages/flutter/lib/src/rendering/shifted_box.dart @@ -590,7 +590,7 @@ class RenderConstrainedOverflowBox extends RenderAligningShiftedBox { /// child, the child will be clipped. /// /// In debug mode, if the child overflows the box, a warning will be printed on -/// the console, and black and yellow striped areas will appear where theR +/// the console, and black and yellow striped areas will appear where the /// overflow occurs. /// /// See also: diff --git a/packages/flutter/test/material/chip_test.dart b/packages/flutter/test/material/chip_test.dart index 32de24a600..0934133904 100644 --- a/packages/flutter/test/material/chip_test.dart +++ b/packages/flutter/test/material/chip_test.dart @@ -11,9 +11,11 @@ void main() { /// Tests that a [Chip] that has its size constrained by its parent is /// further constraining the size of its child, the label widget. /// Optionally, adding an avatar or delete icon to the chip should not - /// cause the chip or label to exceed its constrained size. - Future _testConstrainedLabel(WidgetTester tester, { - CircleAvatar avatar, VoidCallback onDeleted, + /// cause the chip or label to exceed its constrained height. + Future _testConstrainedLabel( + WidgetTester tester, { + CircleAvatar avatar, + VoidCallback onDeleted, }) async { const double labelWidth = 100.0; const double labelHeight = 50.0; @@ -55,36 +57,26 @@ void main() { testWidgets('Chip control test', (WidgetTester tester) async { final FeedbackTester feedback = new FeedbackTester(); final List deletedChipLabels = []; - await tester.pumpWidget( - new MaterialApp( + await tester.pumpWidget(new MaterialApp( home: new Material( - child: new Column( - children: [ - new Chip( - avatar: const CircleAvatar( - child: const Text('A') - ), - label: const Text('Chip A'), - onDeleted: () { - deletedChipLabels.add('A'); - }, - deleteButtonTooltipMessage: 'Delete chip A', - ), - new Chip( - avatar: const CircleAvatar( - child: const Text('B') - ), - label: const Text('Chip B'), - onDeleted: () { - deletedChipLabels.add('B'); - }, - deleteButtonTooltipMessage: 'Delete chip B', - ), - ] - ) - ) - ) - ); + child: new Column(children: [ + new Chip( + avatar: const CircleAvatar(child: const Text('A')), + label: const Text('Chip A'), + onDeleted: () { + deletedChipLabels.add('A'); + }, + deleteButtonTooltipMessage: 'Delete chip A', + ), + new Chip( + avatar: const CircleAvatar(child: const Text('B')), + label: const Text('Chip B'), + onDeleted: () { + deletedChipLabels.add('B'); + }, + deleteButtonTooltipMessage: 'Delete chip B', + ), + ])))); expect(tester.widget(find.byTooltip('Delete chip A')), isNotNull); expect(tester.widget(find.byTooltip('Delete chip B')), isNotNull); @@ -107,17 +99,17 @@ void main() { feedback.dispose(); }); - testWidgets('Chip does not constrain size of label widget if it does not exceed ' - 'the available space', (WidgetTester tester) async { + testWidgets( + 'Chip does not constrain size of label widget if it does not exceed ' + 'the available space', (WidgetTester tester) async { const double labelWidth = 50.0; const double labelHeight = 30.0; final Key labelKey = new UniqueKey(); await tester.pumpWidget( - new Directionality( - textDirection: TextDirection.ltr, - child: new Material( - child: new Center( + new Material( + child: new MaterialApp( + home: new Center( child: new Container( width: 500.0, height: 500.0, @@ -143,36 +135,36 @@ void main() { expect(labelSize.height, labelHeight); }); - testWidgets('Chip constrains the size of the label widget when it exceeds the ' - 'available space', (WidgetTester tester) async { + testWidgets( + 'Chip constrains the size of the label widget when it exceeds the ' + 'available space', (WidgetTester tester) async { await _testConstrainedLabel(tester); }); - testWidgets('Chip constrains the size of the label widget when it exceeds the ' - 'available space and the avatar is present', (WidgetTester tester) async { + testWidgets( + 'Chip constrains the size of the label widget when it exceeds the ' + 'available space and the avatar is present', (WidgetTester tester) async { await _testConstrainedLabel( tester, - avatar: const CircleAvatar( - child: const Text('A') - ), + avatar: const CircleAvatar(child: const Text('A')), ); }); - testWidgets('Chip constrains the size of the label widget when it exceeds the ' - 'available space and the delete icon is present', (WidgetTester tester) async { + testWidgets( + 'Chip constrains the size of the label widget when it exceeds the ' + 'available space and the delete icon is present', (WidgetTester tester) async { await _testConstrainedLabel( tester, onDeleted: () {}, ); }); - testWidgets('Chip constrains the size of the label widget when it exceeds the ' - 'available space and both avatar and delete icons are present', (WidgetTester tester) async { + testWidgets( + 'Chip constrains the size of the label widget when it exceeds the ' + 'available space and both avatar and delete icons are present', (WidgetTester tester) async { await _testConstrainedLabel( tester, - avatar: const CircleAvatar( - child: const Text('A') - ), + avatar: const CircleAvatar(child: const Text('A')), onDeleted: () {}, ); }); @@ -228,7 +220,7 @@ void main() { return new Material( child: new Center( child: new Chip( - onDeleted: () { }, + onDeleted: () {}, label: const Text('ABC'), ), ), @@ -276,15 +268,11 @@ void main() { child: new Column( children: const [ const Chip( - avatar: const CircleAvatar( - child: const Text('A') - ), + avatar: const CircleAvatar(child: const Text('A')), label: const Text('Chip A'), ), const Chip( - avatar: const CircleAvatar( - child: const Text('B') - ), + avatar: const CircleAvatar(child: const Text('B')), label: const Text('Chip B'), ), ], @@ -297,20 +285,14 @@ void main() { // https://github.com/flutter/flutter/issues/12357 expect( tester.getSize(find.text('Chip A')), - anyOf(const Size(79.0, 13.0), const Size(78.0, 13.0)), + anyOf(const Size(84.0, 14.0), const Size(83.0, 14.0)), ); expect( tester.getSize(find.text('Chip B')), - anyOf(const Size(79.0, 13.0), const Size(78.0, 13.0)), - ); - expect( - tester.getSize(find.byType(Chip).first), - anyOf(const Size(131.0, 32.0), const Size(130.0, 32.0)) - ); - expect( - tester.getSize(find.byType(Chip).last), - anyOf(const Size(131.0, 32.0), const Size(130.0, 32.0)) + anyOf(const Size(84.0, 14.0), const Size(83.0, 14.0)), ); + expect(tester.getSize(find.byType(Chip).first), anyOf(const Size(132.0, 32.0), const Size(131.0, 32.0))); + expect(tester.getSize(find.byType(Chip).last), anyOf(const Size(132.0, 32.0), const Size(131.0, 32.0))); await tester.pumpWidget( new MaterialApp( @@ -320,15 +302,11 @@ void main() { child: new Column( children: const [ const Chip( - avatar: const CircleAvatar( - child: const Text('A') - ), + avatar: const CircleAvatar(child: const Text('A')), label: const Text('Chip A'), ), const Chip( - avatar: const CircleAvatar( - child: const Text('B') - ), + avatar: const CircleAvatar(child: const Text('B')), label: const Text('Chip B'), ), ], @@ -340,12 +318,12 @@ void main() { // TODO(gspencer): Update this test when the font metric bug is fixed to remove the anyOfs. // https://github.com/flutter/flutter/issues/12357 - expect(tester.getSize(find.text('Chip A')), anyOf(const Size(234.0, 39.0), const Size(235.0, 39.0))); - expect(tester.getSize(find.text('Chip B')), anyOf(const Size(234.0, 39.0), const Size(235.0, 39.0))); - expect(tester.getSize(find.byType(Chip).first).width, anyOf(286.0, 287.0)); - expect(tester.getSize(find.byType(Chip).first).height, equals(39.0)); - expect(tester.getSize(find.byType(Chip).last).width, anyOf(286.0, 287.0)); - expect(tester.getSize(find.byType(Chip).last).height, equals(39.0)); + expect(tester.getSize(find.text('Chip A')), anyOf(const Size(252.0, 42.0), const Size(251.0, 42.0))); + expect(tester.getSize(find.text('Chip B')), anyOf(const Size(252.0, 42.0), const Size(251.0, 42.0))); + expect(tester.getSize(find.byType(Chip).first).width, anyOf(310.0, 309.0)); + expect(tester.getSize(find.byType(Chip).first).height, equals(42.0)); + expect(tester.getSize(find.byType(Chip).last).width, anyOf(310.0, 309.0)); + expect(tester.getSize(find.byType(Chip).last).height, equals(42.0)); // Check that individual text scales are taken into account. await tester.pumpWidget( @@ -354,15 +332,11 @@ void main() { child: new Column( children: const [ const Chip( - avatar: const CircleAvatar( - child: const Text('A') - ), + avatar: const CircleAvatar(child: const Text('A')), label: const Text('Chip A', textScaleFactor: 3.0), ), const Chip( - avatar: const CircleAvatar( - child: const Text('B') - ), + avatar: const CircleAvatar(child: const Text('B')), label: const Text('Chip B'), ), ], @@ -373,11 +347,11 @@ void main() { // TODO(gspencer): Update this test when the font metric bug is fixed to remove the anyOfs. // https://github.com/flutter/flutter/issues/12357 - expect(tester.getSize(find.text('Chip A')), anyOf(const Size(234.0, 39.0), const Size(235.0, 39.0))); - expect(tester.getSize(find.text('Chip B')), anyOf(const Size(78.0, 13.0), const Size(79.0, 13.0))); - expect(tester.getSize(find.byType(Chip).first).width, anyOf(286.0, 287.0)); - expect(tester.getSize(find.byType(Chip).first).height, equals(39.0)); - expect(tester.getSize(find.byType(Chip).last), anyOf(const Size(130.0, 32.0), const Size(131.0, 32.0))); + expect(tester.getSize(find.text('Chip A')), anyOf(const Size(252.0, 42.0), const Size(251.0, 42.0))); + expect(tester.getSize(find.text('Chip B')), anyOf(const Size(84.0, 14.0), const Size(83.0, 14.0))); + expect(tester.getSize(find.byType(Chip).first).width, anyOf(310.0, 309.0)); + expect(tester.getSize(find.byType(Chip).first).height, equals(42.0)); + expect(tester.getSize(find.byType(Chip).last), anyOf(const Size(132.0, 32.0), const Size(131.0, 32.0))); }); testWidgets('Labels can be non-text widgets', (WidgetTester tester) async { @@ -389,15 +363,11 @@ void main() { child: new Column( children: [ new Chip( - avatar: const CircleAvatar( - child: const Text('A') - ), + avatar: const CircleAvatar(child: const Text('A')), label: new Text('Chip A', key: keyA), ), new Chip( - avatar: const CircleAvatar( - child: const Text('B') - ), + avatar: const CircleAvatar(child: const Text('B')), label: new Container(key: keyB, width: 10.0, height: 10.0), ), ], @@ -410,18 +380,16 @@ void main() { // https://github.com/flutter/flutter/issues/12357 expect( tester.getSize(find.byKey(keyA)), - anyOf(const Size(79.0, 13.0), const Size(78.0, 13.0)), + anyOf(const Size(84.0, 14.0), const Size(83.0, 14.0)), ); expect(tester.getSize(find.byKey(keyB)), const Size(10.0, 10.0)); expect( tester.getSize(find.byType(Chip).first), - anyOf(const Size(131.0, 32.0), const Size(130.0, 32.0)), + anyOf(const Size(132.0, 32.0), const Size(131.0, 32.0)), ); - expect(tester.getSize(find.byType(Chip).last), const Size(62.0, 32.0)); + expect(tester.getSize(find.byType(Chip).last), const Size(58.0, 32.0)); }); - - testWidgets('Chip padding - LTR', (WidgetTester tester) async { final GlobalKey keyA = new GlobalKey(); final GlobalKey keyB = new GlobalKey(); @@ -442,8 +410,8 @@ void main() { child: new Center( child: new Chip( avatar: new Placeholder(key: keyA), - label: new Placeholder(key: keyB), - onDeleted: () { }, + label: new Container(key: keyB, width: 40.0, height: 40.0,), + onDeleted: () {}, ), ), ); @@ -454,12 +422,12 @@ void main() { ), ), ); - expect(tester.getTopLeft(find.byKey(keyA)), const Offset(0.0, 284.0)); - expect(tester.getBottomRight(find.byKey(keyA)), const Offset(32.0, 316.0)); - expect(tester.getTopLeft(find.byKey(keyB)), const Offset(40.0, 0.0)); - expect(tester.getBottomRight(find.byKey(keyB)), const Offset(768.0, 600.0)); - expect(tester.getTopLeft(find.byType(Icon)), const Offset(772.0, 288.0)); - expect(tester.getBottomRight(find.byType(Icon)), const Offset(796.0, 312.0)); + expect(tester.getTopLeft(find.byKey(keyA)), const Offset(340.0, 284.0)); + expect(tester.getBottomRight(find.byKey(keyA)), const Offset(372.0, 316.0)); + expect(tester.getTopLeft(find.byKey(keyB)), const Offset(380.0, 280.0)); + expect(tester.getBottomRight(find.byKey(keyB)), const Offset(420.0, 320.0)); + expect(tester.getTopLeft(find.byType(Icon)), const Offset(428.0, 284.0)); + expect(tester.getBottomRight(find.byType(Icon)), const Offset(460.0, 316.0)); }); testWidgets('Chip padding - RTL', (WidgetTester tester) async { @@ -482,8 +450,8 @@ void main() { child: new Center( child: new Chip( avatar: new Placeholder(key: keyA), - label: new Placeholder(key: keyB), - onDeleted: () { }, + label: new Container(key: keyB, width: 40.0, height: 40.0,), + onDeleted: () {}, ), ), ); @@ -494,11 +462,12 @@ void main() { ), ), ); - expect(tester.getTopRight(find.byKey(keyA)), const Offset(800.0 - 0.0, 284.0)); - expect(tester.getBottomLeft(find.byKey(keyA)), const Offset(800.0 - 32.0, 316.0)); - expect(tester.getTopRight(find.byKey(keyB)), const Offset(800.0 - 40.0, 0.0)); - expect(tester.getBottomLeft(find.byKey(keyB)), const Offset(800.0 - 768.0, 600.0)); - expect(tester.getTopRight(find.byType(Icon)), const Offset(800.0 - 772.0, 288.0)); - expect(tester.getBottomLeft(find.byType(Icon)), const Offset(800.0 - 796.0, 312.0)); + + expect(tester.getTopLeft(find.byKey(keyA)), const Offset(428.0, 284.0)); + expect(tester.getBottomRight(find.byKey(keyA)), const Offset(460.0, 316.0)); + expect(tester.getTopLeft(find.byKey(keyB)), const Offset(380.0, 280.0)); + expect(tester.getBottomRight(find.byKey(keyB)), const Offset(420.0, 320.0)); + expect(tester.getTopLeft(find.byType(Icon)), const Offset(340.0, 284.0)); + expect(tester.getBottomRight(find.byType(Icon)), const Offset(372.0, 316.0)); }); } diff --git a/packages/flutter/test/material/circle_avatar_test.dart b/packages/flutter/test/material/circle_avatar_test.dart index 4f71c552ba..5de7770154 100644 --- a/packages/flutter/test/material/circle_avatar_test.dart +++ b/packages/flutter/test/material/circle_avatar_test.dart @@ -27,7 +27,7 @@ void main() { expect(decoration.color, equals(backgroundColor)); final RenderParagraph paragraph = tester.renderObject(find.text('Z')); - expect(paragraph.text.style.color, equals(Colors.white)); + expect(paragraph.text.style.color, equals(new ThemeData.fallback().primaryColorLight)); }); testWidgets('CircleAvatar with light background color', (WidgetTester tester) async { @@ -50,7 +50,7 @@ void main() { expect(decoration.color, equals(backgroundColor)); final RenderParagraph paragraph = tester.renderObject(find.text('Z')); - expect(paragraph.text.style.color, equals(Colors.black)); + expect(paragraph.text.style.color, equals(new ThemeData.fallback().primaryColorDark)); }); testWidgets('CircleAvatar with foreground color', (WidgetTester tester) async { @@ -71,13 +71,13 @@ void main() { expect(box.size.height, equals(40.0)); final RenderDecoratedBox child = box.child; final BoxDecoration decoration = child.decoration; - expect(decoration.color, equals(fallback.primaryColor)); + expect(decoration.color, equals(fallback.primaryColorDark)); final RenderParagraph paragraph = tester.renderObject(find.text('Z')); expect(paragraph.text.style.color, equals(foregroundColor)); }); - testWidgets('CircleAvatar with theme', (WidgetTester tester) async { + testWidgets('CircleAvatar with light theme', (WidgetTester tester) async { final ThemeData theme = new ThemeData( primaryColor: Colors.grey.shade100, primaryColorBrightness: Brightness.light, @@ -96,7 +96,32 @@ void main() { final RenderConstrainedBox box = tester.renderObject(find.byType(CircleAvatar)); final RenderDecoratedBox child = box.child; final BoxDecoration decoration = child.decoration; - expect(decoration.color, equals(theme.primaryColor)); + expect(decoration.color, equals(theme.primaryColorLight)); + + final RenderParagraph paragraph = tester.renderObject(find.text('Z')); + expect(paragraph.text.style.color, equals(theme.primaryTextTheme.title.color)); + }); + + testWidgets('CircleAvatar with dark theme', (WidgetTester tester) async { + final ThemeData theme = new ThemeData( + primaryColor: Colors.grey.shade800, + primaryColorBrightness: Brightness.dark, + ); + await tester.pumpWidget( + wrap( + child: new Theme( + data: theme, + child: const CircleAvatar( + child: const Text('Z'), + ), + ), + ), + ); + + final RenderConstrainedBox box = tester.renderObject(find.byType(CircleAvatar)); + final RenderDecoratedBox child = box.child; + final BoxDecoration decoration = child.decoration; + expect(decoration.color, equals(theme.primaryColorDark)); final RenderParagraph paragraph = tester.renderObject(find.text('Z')); expect(paragraph.text.style.color, equals(theme.primaryTextTheme.title.color)); @@ -144,6 +169,78 @@ void main() { ); expect(tester.getSize(find.text('Z')), equals(const Size(20.0, 20.0))); }); + + testWidgets('CircleAvatar respects minRadius', (WidgetTester tester) async { + final Color backgroundColor = Colors.blue.shade900; + await tester.pumpWidget( + wrap( + child: new UnconstrainedBox( + child: new CircleAvatar( + backgroundColor: backgroundColor, + minRadius: 50.0, + child: const Text('Z'), + ), + ), + ), + ); + + final RenderConstrainedBox box = tester.renderObject(find.byType(CircleAvatar)); + expect(box.size.width, equals(100.0)); + expect(box.size.height, equals(100.0)); + final RenderDecoratedBox child = box.child; + final BoxDecoration decoration = child.decoration; + expect(decoration.color, equals(backgroundColor)); + + final RenderParagraph paragraph = tester.renderObject(find.text('Z')); + expect(paragraph.text.style.color, equals(new ThemeData.fallback().primaryColorLight)); + }); + + testWidgets('CircleAvatar respects maxRadius', (WidgetTester tester) async { + final Color backgroundColor = Colors.blue.shade900; + await tester.pumpWidget( + wrap( + child: new CircleAvatar( + backgroundColor: backgroundColor, + maxRadius: 50.0, + child: const Text('Z'), + ), + ), + ); + + final RenderConstrainedBox box = tester.renderObject(find.byType(CircleAvatar)); + expect(box.size.width, equals(100.0)); + expect(box.size.height, equals(100.0)); + final RenderDecoratedBox child = box.child; + final BoxDecoration decoration = child.decoration; + expect(decoration.color, equals(backgroundColor)); + + final RenderParagraph paragraph = tester.renderObject(find.text('Z')); + expect(paragraph.text.style.color, equals(new ThemeData.fallback().primaryColorLight)); + }); + + testWidgets('CircleAvatar respects setting both minRadius and maxRadius', (WidgetTester tester) async { + final Color backgroundColor = Colors.blue.shade900; + await tester.pumpWidget( + wrap( + child: new CircleAvatar( + backgroundColor: backgroundColor, + maxRadius: 50.0, + minRadius: 50.0, + child: const Text('Z'), + ), + ), + ); + + final RenderConstrainedBox box = tester.renderObject(find.byType(CircleAvatar)); + expect(box.size.width, equals(100.0)); + expect(box.size.height, equals(100.0)); + final RenderDecoratedBox child = box.child; + final BoxDecoration decoration = child.decoration; + expect(decoration.color, equals(backgroundColor)); + + final RenderParagraph paragraph = tester.renderObject(find.text('Z')); + expect(paragraph.text.style.color, equals(new ThemeData.fallback().primaryColorLight)); + }); } Widget wrap({ Widget child }) {