diff --git a/packages/flutter/lib/src/cupertino/dialog.dart b/packages/flutter/lib/src/cupertino/dialog.dart index 593706a1017..79dba6a834b 100644 --- a/packages/flutter/lib/src/cupertino/dialog.dart +++ b/packages/flutter/lib/src/cupertino/dialog.dart @@ -2,7 +2,11 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:math' as math; import 'dart:ui' show ImageFilter; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'colors.dart'; @@ -14,9 +18,8 @@ const TextStyle _kCupertinoDialogTitleStyle = TextStyle( fontFamily: '.SF UI Display', inherit: false, fontSize: 18.0, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, color: CupertinoColors.black, - height: 1.06, letterSpacing: 0.48, textBaseline: TextBaseline.alphabetic, ); @@ -25,9 +28,10 @@ const TextStyle _kCupertinoDialogContentStyle = TextStyle( fontFamily: '.SF UI Text', inherit: false, fontSize: 13.4, - fontWeight: FontWeight.w300, + fontWeight: FontWeight.w400, color: CupertinoColors.black, height: 1.036, + letterSpacing: -0.25, textBaseline: TextBaseline.alphabetic, ); @@ -40,20 +44,62 @@ const TextStyle _kCupertinoDialogActionStyle = TextStyle( textBaseline: TextBaseline.alphabetic, ); +// iOS dialogs have a normal display width and another display width that is +// used when the device is in accessibility mode. Each of these widths are +// listed below. const double _kCupertinoDialogWidth = 270.0; -const BoxDecoration _kCupertinoDialogFrontFillDecoration = BoxDecoration( - color: Color(0xccffffff), -); -const BoxDecoration _kCupertinoDialogBackFill = BoxDecoration( - color: Color(0x77ffffff), +const double _kAccessibilityCupertinoDialogWidth = 310.0; + +// _kCupertinoDialogBlurOverlayDecoration is applied to the blurred backdrop to +// lighten the blurred image. Brightening is done to counteract the dark modal +// barrier that appears behind the dialog. The overlay blend mode does the +// brightening. The white color doesn't paint any white, it's just the basis +// for the overlay blend mode. +const BoxDecoration _kCupertinoDialogBlurOverlayDecoration = BoxDecoration( + color: CupertinoColors.white, + backgroundBlendMode: BlendMode.overlay, ); +const double _kBlurAmount = 20.0; const double _kEdgePadding = 20.0; -const double _kButtonHeight = 45.0; +const double _kMinButtonHeight = 45.0; +const double _kMinButtonFontSize = 10.0; +const double _kDialogCornerRadius = 12.0; +const double _kDividerThickness = 1.0; -// TODO(gspencer): This color isn't correct. Instead, we should carve a hole in -// the dialog and show more of the background. -const Color _kButtonDividerColor = Color(0xffd5d5d5); +// Translucent white that is painted on top of the blurred backdrop as the +// dialog's background color. +const Color _kDialogColor = Color(0xC0FFFFFF); + +// Translucent white that is painted on top of the blurred backdrop as the +// background color of a pressed button. +const Color _kDialogPressedColor = Color(0x90FFFFFF); + +// Translucent white that is painted on top of the blurred backdrop in the +// gap areas between the content section and actions section, as well as between +// buttons. +const Color _kButtonDividerColor = Color(0x40FFFFFF); + +// The alert dialog layout policy changes depending on whether the user is using +// a "regular" font size vs a "large" font size. This is a spectrum. There are +// many "regular" font sizes and many "large" font sizes. But depending on which +// policy is currently being used, a dialog is laid out differently. +// +// Empirically, the jump from one policy to the other occurs at the following text +// scale factors: +// Largest regular scale factor: 1.3529411764705883 +// Smallest large scale factor: 1.6470588235294117 +// +// The following constant represents a division in text scale factor beyond which +// we want to change how the dialog is laid out. +const double _kMaxRegularTextScaleFactor = 1.4; + +// Accessibility mode on iOS is determined by the text scale factor that the +// user has selected. +bool _isInAccessibilityMode(BuildContext context) { + final MediaQueryData data = MediaQuery.of(context, nullOk: true); + return data != null && data.textScaleFactor > _kMaxRegularTextScaleFactor; +} /// An iOS-style dialog. /// @@ -83,17 +129,14 @@ class CupertinoDialog extends StatelessWidget { Widget build(BuildContext context) { return new Center( child: new ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(12.0)), - child: new DecoratedBox( - // To get the effect, 2 white fills are needed. One blended with the - // background before applying the blur and one overlaid on top of - // the blur. - decoration: _kCupertinoDialogBackFill, - child: new BackdropFilter( - filter: new ImageFilter.blur(sigmaX: 10.0, sigmaY: 10.0), + borderRadius: BorderRadius.circular(_kDialogCornerRadius), + child: new BackdropFilter( + filter: new ImageFilter.blur(sigmaX: _kBlurAmount, sigmaY: _kBlurAmount), + child: new Container( + width: _kCupertinoDialogWidth, + decoration: _kCupertinoDialogBlurOverlayDecoration, child: new Container( - width: _kCupertinoDialogWidth, - decoration: _kCupertinoDialogFrontFillDecoration, + color: _kDialogColor, child: child, ), ), @@ -181,125 +224,537 @@ class CupertinoAlertDialog extends StatelessWidget { /// section when it is long. final ScrollController actionScrollController; - @override - Widget build(BuildContext context) { + Widget _buildContent() { final List children = []; if (title != null || content != null) { - final Widget titleSection = new _CupertinoAlertTitleSection( + final Widget titleSection = new _CupertinoAlertContentSection( title: title, content: content, scrollController: scrollController, ); children.add(new Flexible(flex: 3, child: titleSection)); - // Add padding between the sections. - children.add(const Padding(padding: EdgeInsets.only(top: 8.0))); } + return new Container( + color: _kDialogColor, + child: new Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children, + ), + ); + } + + Widget _buildActions() { + Widget actionSection = new Container( + height: 0.0, + ); if (actions.isNotEmpty) { - final Widget actionSection = new _CupertinoAlertActionSection( + actionSection = new _CupertinoAlertActionSection( children: actions, scrollController: actionScrollController, ); - children.add( - new Flexible(child: actionSection), - ); } - return new Padding( - padding: const EdgeInsets.symmetric(vertical: _kEdgePadding), - child: new CupertinoDialog( - child: new Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: children, - ), - ), - ); + return actionSection; } -} - -/// A button typically used in a [CupertinoAlertDialog]. -/// -/// See also: -/// -/// * [CupertinoAlertDialog], a dialog that informs the user about situations -/// that require acknowledgement -class CupertinoDialogAction extends StatelessWidget { - /// Creates an action for an iOS-style dialog. - const CupertinoDialogAction({ - this.onPressed, - this.isDefaultAction = false, - this.isDestructiveAction = false, - @required this.child, - }) : assert(child != null); - - /// The callback that is called when the button is tapped or otherwise - /// activated. - /// - /// If this is set to null, the button will be disabled. - final VoidCallback onPressed; - - /// Set to true if button is the default choice in the dialog. - /// - /// Default buttons are bold. - final bool isDefaultAction; - - /// Whether this action destroys an object. - /// - /// For example, an action that deletes an email is destructive. - final bool isDestructiveAction; - - /// The widget below this widget in the tree. - /// - /// Typically a [Text] widget. - final Widget child; - - /// Whether the button is enabled or disabled. Buttons are disabled by - /// default. To enable a button, set its [onPressed] property to a non-null - /// value. - bool get enabled => onPressed != null; @override Widget build(BuildContext context) { - TextStyle style = _kCupertinoDialogActionStyle; - - if (isDefaultAction) { - style = style.copyWith(fontWeight: FontWeight.w600); - } - - if (isDestructiveAction) { - style = style.copyWith(color: CupertinoColors.destructiveRed); - } - - if (!enabled) { - style = style.copyWith(color: style.color.withOpacity(0.5)); - } - - final double textScaleFactor = MediaQuery.textScaleFactorOf(context); - return new GestureDetector( - onTap: onPressed, - behavior: HitTestBehavior.opaque, - child: new Container( - alignment: Alignment.center, - padding: new EdgeInsets.all(8.0 * textScaleFactor), - child: new DefaultTextStyle( - style: style, - child: child, - textAlign: TextAlign.center, - ), + final bool isInAccessibilityMode = _isInAccessibilityMode(context); + final double textScaleFactor = MediaQuery.of(context).textScaleFactor; + return new MediaQuery( + data: MediaQuery.of(context).copyWith( + // iOS does not shrink dialog content below a 1.0 scale factor + textScaleFactor: math.max(textScaleFactor, 1.0), + ), + child: new LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return new Center( + child: new Container( + margin: const EdgeInsets.symmetric(vertical: _kEdgePadding), + width: isInAccessibilityMode + ? _kAccessibilityCupertinoDialogWidth + : _kCupertinoDialogWidth, + // The following clip is critical. The BackdropFilter needs to have + // rounded corners, but Skia cannot internally create a blurred rounded + // rect. Therefore, we have no choice but to clip, ourselves. + // TODO(mattcarroll): Skia bug filed: https://bugs.chromium.org/p/skia/issues/detail?id=8238 + child: ClipRRect( + borderRadius: BorderRadius.circular(_kDialogCornerRadius), + child: new BackdropFilter( + filter: new ImageFilter.blur(sigmaX: _kBlurAmount, sigmaY: _kBlurAmount), + child: new Container( + decoration: _kCupertinoDialogBlurOverlayDecoration, + child: new _CupertinoDialogRenderWidget( + contentSection: _buildContent(), + actionsSection: _buildActions(), + ), + ), + ), + ), + ), + ); + }, ), ); } } -// Constructs a text content section typically used in a CupertinoAlertDialog. +// iOS style layout policy widget for sizing an alert dialog's content section and +// action button section. +// +// See [_RenderCupertinoDialog] for specific layout policy details. +class _CupertinoDialogRenderWidget extends RenderObjectWidget { + const _CupertinoDialogRenderWidget({ + Key key, + @required this.contentSection, + @required this.actionsSection, + }) : super(key: key); + + final Widget contentSection; + final Widget actionsSection; + + @override + RenderObject createRenderObject(BuildContext context) { + return new _RenderCupertinoDialog( + dividerThickness: _kDividerThickness / MediaQuery.of(context).devicePixelRatio, + isInAccessibilityMode: _isInAccessibilityMode(context), + ); + } + + @override + void updateRenderObject(BuildContext context, _RenderCupertinoDialog renderObject) { + renderObject.isInAccessibilityMode = _isInAccessibilityMode(context); + } + + @override + RenderObjectElement createElement() { + return _CupertinoDialogRenderElement(this); + } +} + +class _CupertinoDialogRenderElement extends RenderObjectElement { + _CupertinoDialogRenderElement(_CupertinoDialogRenderWidget widget) : super(widget); + + Element _contentElement; + Element _actionsElement; + + @override + _CupertinoDialogRenderWidget get widget => super.widget; + + @override + _RenderCupertinoDialog get renderObject => super.renderObject; + + @override + void visitChildren(ElementVisitor visitor) { + if (_contentElement != null) { + visitor(_contentElement); + } + if (_actionsElement != null) { + visitor(_actionsElement); + } + } + + @override + void mount(Element parent, dynamic newSlot) { + super.mount(parent, newSlot); + _contentElement = updateChild(_contentElement, widget.contentSection, _AlertDialogSections.contentSection); + _actionsElement = updateChild(_actionsElement, widget.actionsSection, _AlertDialogSections.actionsSection); + } + + @override + void insertChildRenderObject(RenderObject child, _AlertDialogSections slot) { + assert(slot != null); + switch (slot) { + case _AlertDialogSections.contentSection: + renderObject.contentSection = child; + break; + case _AlertDialogSections.actionsSection: + renderObject.actionsSection = child; + break; + } + } + + @override + void moveChildRenderObject(RenderObject child, _AlertDialogSections slot) { + assert(false); + } + + @override + void update(RenderObjectWidget newWidget) { + super.update(newWidget); + _contentElement = updateChild(_contentElement, widget.contentSection, _AlertDialogSections.contentSection); + _actionsElement = updateChild(_actionsElement, widget.actionsSection, _AlertDialogSections.actionsSection); + } + + @override + void forgetChild(Element child) { + assert(child == _contentElement || child == _actionsElement); + if (_contentElement == child) { + _contentElement = null; + } else { + assert(_actionsElement == child); + _actionsElement = null; + } + } + + @override + void removeChildRenderObject(RenderObject child) { + assert(child == renderObject.contentSection || child == renderObject.actionsSection); + if (renderObject.contentSection == child) { + renderObject.contentSection = null; + } else { + assert(renderObject.actionsSection == child); + renderObject.actionsSection = null; + } + } +} + +// iOS style layout policy for sizing an alert dialog's content section and action +// button section. +// +// The policy is as follows: +// +// If all content and buttons fit on screen: +// The content section and action button section are sized intrinsically and centered +// vertically on screen. +// +// If all content and buttons do not fit on screen, and iOS is NOT in accessibility mode: +// A minimum height for the action button section is calculated. The action +// button section will not be rendered shorter than this minimum. See +// [_RenderCupertinoDialogActions] for the minimum height calculation. +// +// With the minimum action button section calculated, the content section can +// take up as much space as is available, up to the point that it hits the +// minimum button height at the bottom. +// +// After the content section is laid out, the action button section is allowed +// to take up any remaining space that was not consumed by the content section. +// +// If all content and buttons do not fit on screen, and iOS IS in accessibility mode: +// The button section is given up to 50% of the available height. Then the content +// section is given whatever height remains. +class _RenderCupertinoDialog extends RenderBox { + _RenderCupertinoDialog({ + RenderBox contentSection, + RenderBox actionsSection, + double dividerThickness = 0.0, + bool isInAccessibilityMode = false, + }) : _contentSection = contentSection, + _actionsSection = actionsSection, + _dividerThickness = dividerThickness, + _isInAccessibilityMode = isInAccessibilityMode; + + RenderBox get contentSection => _contentSection; + RenderBox _contentSection; + set contentSection(RenderBox newContentSection) { + if (newContentSection != _contentSection) { + if (_contentSection != null) { + dropChild(_contentSection); + } + _contentSection = newContentSection; + if (_contentSection != null) { + adoptChild(_contentSection); + } + } + } + + RenderBox get actionsSection => _actionsSection; + RenderBox _actionsSection; + set actionsSection(RenderBox newActionsSection) { + if (newActionsSection != _actionsSection) { + if (null != _actionsSection) { + dropChild(_actionsSection); + } + _actionsSection = newActionsSection; + if (null != _actionsSection) { + adoptChild(_actionsSection); + } + } + } + + bool get isInAccessibilityMode => _isInAccessibilityMode; + bool _isInAccessibilityMode; + set isInAccessibilityMode(bool newValue) { + if (newValue != _isInAccessibilityMode) { + _isInAccessibilityMode = newValue; + markNeedsLayout(); + } + } + + double get _dialogWidth => isInAccessibilityMode + ? _kAccessibilityCupertinoDialogWidth + : _kCupertinoDialogWidth; + + final double _dividerThickness; + + final Paint _dividerPaint = new Paint() + ..color = _kButtonDividerColor + ..style = PaintingStyle.fill; + + @override + void attach(PipelineOwner owner) { + super.attach(owner); + if (null != contentSection) { + contentSection.attach(owner); + } + if (null != actionsSection) { + actionsSection.attach(owner); + } + } + + @override + void detach() { + super.detach(); + if (null != contentSection) { + contentSection.detach(); + } + if (null != actionsSection) { + actionsSection.detach(); + } + } + + @override + void redepthChildren() { + if (null != contentSection) { + redepthChild(contentSection); + } + if (null != actionsSection) { + redepthChild(actionsSection); + } + } + + @override + void setupParentData(RenderBox child) { + if (child.parentData is! BoxParentData) { + child.parentData = new BoxParentData(); + } + } + + @override + void visitChildren(RenderObjectVisitor visitor) { + if (contentSection != null) { + visitor(contentSection); + } + if (actionsSection != null) { + visitor(actionsSection); + } + } + + @override + List debugDescribeChildren() { + final List value = []; + if (contentSection != null) { + value.add(contentSection.toDiagnosticsNode(name: 'content')); + } + if (actionsSection != null) { + value.add(actionsSection.toDiagnosticsNode(name: 'actions')); + } + return value; + } + + @override + double computeMinIntrinsicWidth(double height) { + return _dialogWidth; + } + + @override + double computeMaxIntrinsicWidth(double height) { + return _dialogWidth; + } + + @override + double computeMinIntrinsicHeight(double width) { + final double contentHeight = contentSection.getMinIntrinsicHeight(width); + final double actionsHeight = actionsSection.getMinIntrinsicHeight(width); + final bool hasDivider = contentHeight > 0.0 && actionsHeight > 0.0; + final double height = contentHeight + (hasDivider ? _dividerThickness : 0.0) + actionsHeight; + + if (height.isFinite) + return height; + return 0.0; + } + + @override + double computeMaxIntrinsicHeight(double width) { + final double contentHeight = contentSection.getMaxIntrinsicHeight(width); + final double actionsHeight = actionsSection.getMaxIntrinsicHeight(width); + final bool hasDivider = contentHeight > 0.0 && actionsHeight > 0.0; + final double height = contentHeight + (hasDivider ? _dividerThickness : 0.0) + actionsHeight; + + if (height.isFinite) + return height; + return 0.0; + } + + @override + void performLayout() { + if (isInAccessibilityMode) { + // When in accessibility mode, an alert dialog will allow buttons to take + // up to 50% of the dialog height, even if the content exceeds available space. + performAccessibilityLayout(); + } else { + // When not in accessibility mode, an alert dialog might reduce the space + // for buttons to just over 1 button's height to make room for the content + // section. + performRegularLayout(); + } + } + + void performRegularLayout() { + final bool hasDivider = contentSection.getMaxIntrinsicHeight(_dialogWidth) > 0.0 + && actionsSection.getMaxIntrinsicHeight(_dialogWidth) > 0.0; + final double dividerThickness = hasDivider ? _dividerThickness : 0.0; + + final double minActionsHeight = actionsSection.getMinIntrinsicHeight(_dialogWidth); + + // Size alert dialog content. + contentSection.layout( + constraints.deflate(new EdgeInsets.only(bottom: minActionsHeight + dividerThickness)), + parentUsesSize: true, + ); + final Size contentSize = contentSection.size; + + // Size alert dialog actions. + actionsSection.layout( + constraints.deflate(new EdgeInsets.only(top: contentSize.height + dividerThickness)), + parentUsesSize: true, + ); + final Size actionsSize = actionsSection.size; + + // Calculate overall dialog height. + final double dialogHeight = contentSize.height + dividerThickness + actionsSize.height; + + // Set our size now that layout calculations are complete. + size = constraints.constrain( + new Size(_dialogWidth, dialogHeight) + ); + + // Set the position of the actions box to sit at the bottom of the dialog. + // The content box defaults to the top left, which is where we want it. + assert(actionsSection.parentData is BoxParentData); + final BoxParentData actionParentData = actionsSection.parentData; + actionParentData.offset = new Offset(0.0, contentSize.height + dividerThickness); + } + + void performAccessibilityLayout() { + final bool hasDivider = contentSection.getMaxIntrinsicHeight(_dialogWidth) > 0.0 + && actionsSection.getMaxIntrinsicHeight(_dialogWidth) > 0.0; + final double dividerThickness = hasDivider ? _dividerThickness : 0.0; + + final double maxContentHeight = contentSection.getMaxIntrinsicHeight(_dialogWidth); + final double maxActionsHeight = actionsSection.getMaxIntrinsicHeight(_dialogWidth); + + Size contentSize; + Size actionsSize; + if (maxContentHeight + dividerThickness + maxActionsHeight > constraints.maxHeight) { + // There isn't enough room for everything. Following iOS's accessibility dialog + // layout policy, first we allow the actions to take up to 50% of the dialog + // height. Second we fill the rest of the available space with the content + // section. + + // Size alert dialog actions. + actionsSection.layout( + constraints.deflate(new EdgeInsets.only(top: constraints.maxHeight / 2.0)), + parentUsesSize: true, + ); + actionsSize = actionsSection.size; + + // Size alert dialog content. + contentSection.layout( + constraints.deflate(new EdgeInsets.only(bottom: actionsSize.height + dividerThickness)), + parentUsesSize: true, + ); + contentSize = contentSection.size; + } else { + // Everything fits. Give content and actions all the space they want. + + // Size alert dialog content. + contentSection.layout( + constraints, + parentUsesSize: true, + ); + contentSize = contentSection.size; + + // Size alert dialog actions. + actionsSection.layout( + constraints.deflate(new EdgeInsets.only(top: contentSize.height)), + parentUsesSize: true, + ); + actionsSize = actionsSection.size; + } + + // Calculate overall dialog height. + final double dialogHeight = contentSize.height + dividerThickness + actionsSize.height; + + // Set our size now that layout calculations are complete. + size = constraints.constrain( + new Size(_dialogWidth, dialogHeight) + ); + + // Set the position of the actions box to sit at the bottom of the dialog. + // The content box defaults to the top left, which is where we want it. + assert(actionsSection.parentData is BoxParentData); + final BoxParentData actionParentData = actionsSection.parentData; + actionParentData.offset = new Offset(0.0, contentSize.height + dividerThickness); + } + + @override + void paint(PaintingContext context, Offset offset) { + final BoxParentData contentParentData = contentSection.parentData; + contentSection.paint(context, offset + contentParentData.offset); + + final bool hasDivider = contentSection.size.height > 0.0 && actionsSection.size.height > 0.0; + if (hasDivider) { + _paintDividerBetweenContentAndActions(context.canvas, offset); + } + + final BoxParentData actionsParentData = actionsSection.parentData; + actionsSection.paint(context, offset + actionsParentData.offset); + } + + void _paintDividerBetweenContentAndActions(Canvas canvas, Offset offset) { + canvas.drawRect( + Rect.fromLTWH( + offset.dx, + offset.dy + contentSection.size.height, + size.width, + _dividerThickness, + ), + _dividerPaint, + ); + } + + @override + bool hitTestChildren(HitTestResult result, { Offset position }) { + bool isHit = false; + final BoxParentData contentSectionParentData = contentSection.parentData; + final BoxParentData actionsSectionParentData = actionsSection.parentData; + if (contentSection.hitTest(result, position: position - contentSectionParentData.offset)) { + isHit = true; + } else if (actionsSection.hitTest(result, position: position - actionsSectionParentData.offset)) { + isHit = true; + } + return isHit; + } +} + +// Visual components of an alert dialog that need to be explicitly sized and +// laid out at runtime. +enum _AlertDialogSections { + contentSection, + actionsSection, +} + +// The "content section" of a CupertinoAlertDialog. // // If title is missing, then only content is added. If content is // missing, then only title is added. If both are missing, then it returns // a SingleChildScrollView with a zero-sized Container. -class _CupertinoAlertTitleSection extends StatelessWidget { - const _CupertinoAlertTitleSection({ +class _CupertinoAlertContentSection extends StatelessWidget { + const _CupertinoAlertContentSection({ Key key, this.title, this.content, @@ -327,6 +782,7 @@ class _CupertinoAlertTitleSection extends StatelessWidget { @override Widget build(BuildContext context) { + final double textScaleFactor = MediaQuery.of(context).textScaleFactor; final List titleContentGroup = []; if (title != null) { titleContentGroup.add(new Padding( @@ -334,7 +790,7 @@ class _CupertinoAlertTitleSection extends StatelessWidget { left: _kEdgePadding, right: _kEdgePadding, bottom: content == null ? _kEdgePadding : 1.0, - top: _kEdgePadding, + top: _kEdgePadding * textScaleFactor, ), child: new DefaultTextStyle( style: _kCupertinoDialogTitleStyle, @@ -350,7 +806,7 @@ class _CupertinoAlertTitleSection extends StatelessWidget { padding: new EdgeInsets.only( left: _kEdgePadding, right: _kEdgePadding, - bottom: _kEdgePadding, + bottom: _kEdgePadding * textScaleFactor, top: title == null ? _kEdgePadding : 1.0, ), child: new DefaultTextStyle( @@ -369,11 +825,6 @@ class _CupertinoAlertTitleSection extends StatelessWidget { ); } - // Add padding between the widgets if necessary. - if (titleContentGroup.length > 1) { - titleContentGroup.insert(1, const Padding(padding: EdgeInsets.only(top: 8.0))); - } - return new CupertinoScrollbar( child: new SingleChildScrollView( controller: scrollController, @@ -387,13 +838,11 @@ class _CupertinoAlertTitleSection extends StatelessWidget { } } -// An Action Items section typically used in a CupertinoAlertDialog. +// The "actions section" of a [CupertinoAlertDialog]. // -// If _layoutActionsVertically is true, they are laid out vertically -// in a column; else they are laid out horizontally in a row. If there isn't -// enough room to show all the children vertically, they are wrapped in a -// CupertinoScrollbar widget. If children is null or empty, it returns null. -class _CupertinoAlertActionSection extends StatelessWidget { +// See [_RenderCupertinoDialogActions] for details about action button sizing +// and layout. +class _CupertinoAlertActionSection extends StatefulWidget { const _CupertinoAlertActionSection({ Key key, @required this.children, @@ -410,106 +859,786 @@ class _CupertinoAlertActionSection extends StatelessWidget { // don't have many actions. final ScrollController scrollController; - bool get _layoutActionsVertically => children.length > 2; + @override + _CupertinoAlertActionSectionState createState() => new _CupertinoAlertActionSectionState(); +} + +class _CupertinoAlertActionSectionState extends State<_CupertinoAlertActionSection> { + @override + Widget build(BuildContext context) { + final double devicePixelRatio = MediaQuery.of(context).devicePixelRatio; + + final List interactiveButtons = []; + for (int i = 0; i < widget.children.length; i += 1) { + interactiveButtons.add( + new _PressableActionButton( + child: widget.children[i], + ), + ); + } + + return new CupertinoScrollbar( + child: new SingleChildScrollView( + controller: widget.scrollController, + child: new _CupertinoDialogActionsRenderWidget( + actionButtons: interactiveButtons, + dividerThickness: _kDividerThickness / devicePixelRatio, + ), + ), + ); + } +} + +// Button that updates its render state when pressed. +// +// The pressed state is forwarded to an _ActionButtonParentDataWidget. The +// corresponding _ActionButtonParentData is then interpreted and rendered +// appropriately by _RenderCupertinoDialogActions. +class _PressableActionButton extends StatefulWidget { + const _PressableActionButton({ + @required this.child, + }); + + final Widget child; + + @override + _PressableActionButtonState createState() => new _PressableActionButtonState(); +} + +class _PressableActionButtonState extends State<_PressableActionButton> { + bool _isPressed = false; @override Widget build(BuildContext context) { - if (children.isEmpty) { - return new SingleChildScrollView( - controller: scrollController, - child: new Container(width: 0.0, height: 0.0), - ); + return new _ActionButtonParentDataWidget( + isPressed: _isPressed, + // TODO(mattcarroll): Button press dynamics need overhaul for iOS: https://github.com/flutter/flutter/issues/19786 + child: new GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (TapDownDetails details) => setState(() { + _isPressed = true; + }), + onTapUp: (TapUpDetails details) => setState(() { + _isPressed = false; + }), + // TODO(mattcarroll): Cancel is currently triggered when user moves past slop instead of off button: https://github.com/flutter/flutter/issues/19783 + onTapCancel: () => setState(() => _isPressed = false), + child: widget.child, + ), + ); + } +} + +// ParentDataWidget that updates _ActionButtonParentData for an action button. +// +// Each action button requires knowledge of whether or not it is pressed so that +// the dialog can correctly render the button. The pressed state is held within +// _ActionButtonParentData. _ActionButtonParentDataWidget is responsible for +// updating the pressed state of an _ActionButtonParentData based on the +// incoming [isPressed] property. +class _ActionButtonParentDataWidget extends ParentDataWidget<_CupertinoDialogActionsRenderWidget> { + const _ActionButtonParentDataWidget({ + Key key, + this.isPressed, + @required Widget child, + }) : super(key: key, child: child); + + final bool isPressed; + + @override + void applyParentData(RenderObject renderObject) { + assert(renderObject.parentData is _ActionButtonParentData); + final _ActionButtonParentData parentData = renderObject.parentData; + if (parentData.isPressed != isPressed) { + parentData.isPressed = isPressed; + + // Force a repaint. + final AbstractNode targetParent = renderObject.parent; + if (targetParent is RenderObject) + targetParent.markNeedsPaint(); } + } +} - // TODO(abarth): Listen for the buttons being highlighted. +// ParentData applied to individual action buttons that report whether or not +// that button is currently pressed by the user. +class _ActionButtonParentData extends MultiChildLayoutParentData { + _ActionButtonParentData({ + this.isPressed = false, + }); - if (_layoutActionsVertically) { - // Skip the first divider - final List buttons = [children.first]; - buttons.addAll( - children.sublist(1).map( - (Widget child) { - return new CustomPaint( - painter: new _CupertinoVerticalDividerPainter(), - child: child, - ); - }, - ), - ); + bool isPressed; +} - return new CupertinoScrollbar( - child: new SingleChildScrollView( - controller: scrollController, - child: new Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: buttons, - ), - ), - ); - } else { - // For a horizontal layout, we don't need the scrollController in most - // cases, but it still has to be always attached to a scroll view. - return new CupertinoScrollbar( - child: new SingleChildScrollView( - controller: scrollController, - child: new CustomPaint( - painter: new _CupertinoHorizontalDividerPainter(children.length), - child: new UnconstrainedBox( - constrainedAxis: Axis.horizontal, - child: new ConstrainedBox( - constraints: const BoxConstraints(minHeight: _kButtonHeight), - child: new Row( - children: children.map((Widget button) { - return new Expanded(child: button); - }).toList(), - ), - ), +/// A button typically used in a [CupertinoAlertDialog]. +/// +/// See also: +/// +/// * [CupertinoAlertDialog], a dialog that informs the user about situations +/// that require acknowledgement +class CupertinoDialogAction extends StatelessWidget { + /// Creates an action for an iOS-style dialog. + const CupertinoDialogAction({ + this.onPressed, + this.isDefaultAction = false, + this.isDestructiveAction = false, + this.textStyle, + @required this.child, + }) : assert(child != null); + + /// The callback that is called when the button is tapped or otherwise + /// activated. + /// + /// If this is set to null, the button will be disabled. + final VoidCallback onPressed; + + /// Set to true if button is the default choice in the dialog. + /// + /// Default buttons are bold. + final bool isDefaultAction; + + /// Whether this action destroys an object. + /// + /// For example, an action that deletes an email is destructive. + final bool isDestructiveAction; + + /// [TextStyle] to apply to any text that appears in this button. + /// + /// Dialog actions have a built-in text resizing policy for long text. To + /// ensure that this resizing policy always works as expected, [textStyle] + /// must be used if a text size is desired other than that specified in + /// [_kCupertinoDialogActionStyle]. + final TextStyle textStyle; + + /// The widget below this widget in the tree. + /// + /// Typically a [Text] widget. + final Widget child; + + /// Whether the button is enabled or disabled. Buttons are disabled by + /// default. To enable a button, set its [onPressed] property to a non-null + /// value. + bool get enabled => onPressed != null; + + double _calculatePadding(BuildContext context) { + return 8.0 * MediaQuery.textScaleFactorOf(context); + } + + // Dialog action content shrinks to fit, up to a certain point, and if it still + // cannot fit at the minimum size, the text content is ellipsized. + // + // This policy only applies when the device is not in accessibility mode. + Widget _buildContentWithRegularSizingPolicy({ + @required BuildContext context, + @required TextStyle textStyle, + @required Widget content, + }) { + final bool isInAccessibilityMode = _isInAccessibilityMode(context); + final double dialogWidth = isInAccessibilityMode + ? _kAccessibilityCupertinoDialogWidth + : _kCupertinoDialogWidth; + final double textScaleFactor = MediaQuery.textScaleFactorOf(context); + // The fontSizeRatio is the ratio of the current text size (including any + // iOS scale factor) vs the minimum text size that we allow in action + // buttons. This ratio information is used to automatically scale down action + // button text to fit the available space. + final double fontSizeRatio = (textScaleFactor * textStyle.fontSize) / _kMinButtonFontSize; + final double padding = _calculatePadding(context); + + return new IntrinsicHeight( + child: new SizedBox( + width: double.infinity, + child: new FittedBox( + fit: BoxFit.scaleDown, + child: new ConstrainedBox( + constraints: new BoxConstraints( + maxWidth: fontSizeRatio * (dialogWidth - (2 * padding)), + ), + child: new DefaultTextStyle( + style: textStyle, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + maxLines: 1, + child: content, ), ), ), + ), + ); + } + + // Dialog action content is permitted to be as large as it wants when in + // accessibility mode. If text is used as the content, the text wraps instead + // of ellipsizing. + Widget _buildContentWithAccessibilitySizingPolicy({ + @required TextStyle textStyle, + @required Widget content, + }) { + return new DefaultTextStyle( + style: textStyle, + textAlign: TextAlign.center, + child: content, + ); + } + + @override + Widget build(BuildContext context) { + TextStyle style = _kCupertinoDialogActionStyle; + style = style.merge(textStyle); + + if (isDestructiveAction) { + style = style.copyWith(color: CupertinoColors.destructiveRed); + } + + if (!enabled) { + style = style.copyWith(color: style.color.withOpacity(0.5)); + } + + // Apply a sizing policy to the action button's content based on whether or + // not the device is in accessibility mode. + // TODO(mattcarroll): The following logic is not entirely correct. It is also + // the case that if content text does not contain a space, it should also + // wrap instead of ellipsizing. We are consciously not implementing that + // now due to complexity. + final Widget sizedContent = _isInAccessibilityMode(context) + ? _buildContentWithAccessibilitySizingPolicy( + textStyle: style, + content: child, + ) + : _buildContentWithRegularSizingPolicy( + context: context, + textStyle: style, + content: child, + ); + + return new GestureDetector( + onTap: onPressed, + behavior: HitTestBehavior.opaque, + child: new ConstrainedBox( + constraints: const BoxConstraints( + minHeight: _kMinButtonHeight, + ), + child: new Container( + alignment: Alignment.center, + padding: new EdgeInsets.all(_calculatePadding(context)), + child: sizedContent, + ), + ), + ); + } +} + +// iOS style dialog action button layout. +// +// [_CupertinoDialogActionsRenderWidget] does not provide any scrolling +// behavior for its buttons. It only handles the sizing and layout of buttons. +// Scrolling behavior can be composed on top of this widget, if desired. +// +// See [_RenderCupertinoDialogActions] for specific layout policy details. +class _CupertinoDialogActionsRenderWidget extends MultiChildRenderObjectWidget { + _CupertinoDialogActionsRenderWidget({ + Key key, + @required List actionButtons, + double dividerThickness = 0.0, + }) : _dividerThickness = dividerThickness, + super(key: key, children: actionButtons); + + final double _dividerThickness; + + @override + RenderObject createRenderObject(BuildContext context) { + return new _RenderCupertinoDialogActions( + dialogWidth: _isInAccessibilityMode(context) + ? _kAccessibilityCupertinoDialogWidth + : _kCupertinoDialogWidth, + dividerThickness: _dividerThickness, + ); + } + + @override + void updateRenderObject(BuildContext context, _RenderCupertinoDialogActions renderObject) { + renderObject.dialogWidth = _isInAccessibilityMode(context) + ? _kAccessibilityCupertinoDialogWidth + : _kCupertinoDialogWidth; + renderObject.dividerThickness = _dividerThickness; + } +} + +// iOS style layout policy for sizing and positioning an alert dialog's action +// buttons. +// +// The policy is as follows: +// +// If a single action button is provided, or if 2 action buttons are provided +// that can fit side-by-side, then action buttons are sized and laid out in a +// single horizontal row. The row is exactly as wide as the dialog, and the row +// is as tall as the tallest action button. A horizontal divider is drawn above +// the button row. If 2 action buttons are provided, a vertical divider is +// drawn between them. The thickness of the divider is set by [dividerThickness]. +// +// If 2 action buttons are provided but they cannot fit side-by-side, then the +// 2 buttons are stacked vertically. A horizontal divider is drawn above each +// button. The thickness of the divider is set by [dividerThickness]. The minimum +// height of this [RenderBox] in the case of 2 stacked buttons is as tall as +// the 2 buttons stacked. This is different than the 3+ button case where the +// minimum height is only 1.5 buttons tall. See the 3+ button explanation for +// more info. +// +// If 3+ action buttons are provided then they are all stacked vertically. A +// horizontal divider is drawn above each button. The thickness of the divider +// is set by [dividerThickness]. The minimum height of this [RenderBox] in the case +// of 3+ stacked buttons is as tall as the 1st button + 50% the height of the +// 2nd button. In other words, the minimum height is 1.5 buttons tall. This +// minimum height of 1.5 buttons is expected to work in tandem with a surrounding +// [ScrollView] to match the iOS dialog behavior. +// +// Each button is expected to have an _ActionButtonParentData which reports +// whether or not that button is currently pressed. If a button is pressed, +// then the dividers above and below that pressed button are not drawn - instead +// they are filled with the standard white dialog background color. The one +// exception is the very 1st divider which is always rendered. This policy comes +// from observation of native iOS dialogs. +class _RenderCupertinoDialogActions extends RenderBox + with ContainerRenderObjectMixin, + RenderBoxContainerDefaultsMixin { + _RenderCupertinoDialogActions({ + List children, + @required double dialogWidth, + double dividerThickness = 0.0, + }) : _dialogWidth = dialogWidth, + _dividerThickness = dividerThickness { + addAll(children); + } + + double get dialogWidth => _dialogWidth; + double _dialogWidth; + set dialogWidth(double newWidth) { + if (newWidth != _dialogWidth) { + _dialogWidth = newWidth; + markNeedsLayout(); + } + } + + // The thickness of the divider between buttons. + double get dividerThickness => _dividerThickness; + double _dividerThickness; + set dividerThickness(double newValue) { + if (newValue != _dividerThickness) { + _dividerThickness = newValue; + markNeedsLayout(); + } + } + + final Paint _buttonBackgroundPaint = new Paint() + ..color = _kDialogColor + ..style = PaintingStyle.fill; + + final Paint _pressedButtonBackgroundPaint = new Paint() + ..color = _kDialogPressedColor + ..style = PaintingStyle.fill; + + final Paint _dividerPaint = new Paint() + ..color = _kButtonDividerColor + ..style = PaintingStyle.fill; + + Iterable get _pressedButtons sync* { + RenderBox currentChild = firstChild; + while (currentChild != null) { + assert(currentChild.parentData is _ActionButtonParentData); + final _ActionButtonParentData parentData = currentChild.parentData; + if (parentData.isPressed) { + yield currentChild; + } + currentChild = childAfter(currentChild); + } + } + + bool get _isButtonPressed { + RenderBox currentChild = firstChild; + while (currentChild != null) { + assert(currentChild.parentData is _ActionButtonParentData); + final _ActionButtonParentData parentData = currentChild.parentData; + if (parentData.isPressed) { + return true; + } + currentChild = childAfter(currentChild); + } + return false; + } + + @override + void setupParentData(RenderBox child) { + if (child.parentData is! _ActionButtonParentData) + child.parentData = new _ActionButtonParentData(); + } + + @override + double computeMinIntrinsicWidth(double height) { + return dialogWidth; + } + + @override + double computeMaxIntrinsicWidth(double height) { + return dialogWidth; + } + + @override + double computeMinIntrinsicHeight(double width) { + double minHeight; + if (childCount == 0) { + minHeight = 0.0; + } else if (childCount == 1) { + // If only 1 button, display the button across the entire dialog. + minHeight = _computeMinIntrinsicHeightSideBySide(width); + } else { + if (childCount == 2 && _isSingleButtonRow(width)) { + // The first 2 buttons fit side-by-side. Display them horizontally. + minHeight = _computeMinIntrinsicHeightSideBySide(width); + } else { + // 3+ buttons are always stacked. The minimum height when stacked is + // 1.5 buttons tall. + minHeight = _computeMinIntrinsicHeightStacked(width); + } + } + return minHeight; + } + + // The minimum height for a single row of buttons is the larger of the buttons' + // min intrinsic heights. + double _computeMinIntrinsicHeightSideBySide(double width) { + assert(childCount >= 1 && childCount <= 2); + + double minHeight; + if (childCount == 1) { + minHeight = firstChild.getMinIntrinsicHeight(width); + } else { + final double perButtonWidth = (width - dividerThickness) / 2.0; + minHeight = math.max( + firstChild.getMinIntrinsicHeight(perButtonWidth), + lastChild.getMinIntrinsicHeight(perButtonWidth), + ); + } + return minHeight; + } + + // The minimum height for 2+ stacked buttons is the height of the 1st button + // + 50% the height of the 2nd button + the divider between the two. + double _computeMinIntrinsicHeightStacked(double width) { + assert(childCount >= 2); + + return firstChild.getMinIntrinsicHeight(width) + + dividerThickness + + (0.5 * childAfter(firstChild).getMinIntrinsicHeight(width)); + } + + @override + double computeMaxIntrinsicHeight(double width) { + double maxHeight; + if (childCount == 0) { + // No buttons. Zero height. + maxHeight = 0.0; + } else if (childCount == 1) { + // One button. Our max intrinsic height is equal to the button's. + maxHeight = firstChild.getMaxIntrinsicHeight(width); + } else if (childCount == 2) { + // Two buttons... + if (_isSingleButtonRow(width)) { + // The 2 buttons fit side by side so our max intrinsic height is equal + // to the taller of the 2 buttons. + final double perButtonWidth = (width - dividerThickness) / 2.0; + maxHeight = math.max( + firstChild.getMaxIntrinsicHeight(perButtonWidth), + lastChild.getMaxIntrinsicHeight(perButtonWidth), + ); + } else { + // The 2 buttons do not fit side by side. Measure total height as a + // vertical stack. + maxHeight = _computeMaxIntrinsicHeightStacked(width); + } + } else { + // Three+ buttons. Stack the buttons vertically with dividers and measure + // the overall height. + maxHeight = _computeMaxIntrinsicHeightStacked(width); + } + return maxHeight; + } + + // Max height of a stack of buttons is the sum of all button heights + a + // divider for each button. + double _computeMaxIntrinsicHeightStacked(double width) { + assert(childCount >= 2); + + final double allDividersHeight = (childCount - 1) * dividerThickness; + double heightAccumulation = allDividersHeight; + RenderBox button = firstChild; + while (button != null) { + heightAccumulation += button.getMaxIntrinsicHeight(width); + button = childAfter(button); + } + return heightAccumulation; + } + + bool _isSingleButtonRow(double width) { + bool isSingleButtonRow; + if (childCount == 1) { + isSingleButtonRow = true; + } else if (childCount == 2) { + // There are 2 buttons. If they can fit side-by-side then that's what + // we want to do. Otherwise, stack them vertically. + final double sideBySideWidth = firstChild.getMaxIntrinsicWidth(double.infinity) + + dividerThickness + + lastChild.getMaxIntrinsicWidth(double.infinity); + isSingleButtonRow = sideBySideWidth <= width; + } else { + isSingleButtonRow = false; + } + return isSingleButtonRow; + } + + @override + void performLayout() { + if (_isSingleButtonRow(dialogWidth)) { + if (childCount == 1) { + // We have 1 button. Our size is the width of the dialog and the height + // of the single button. + firstChild.layout( + constraints, + parentUsesSize: true, + ); + + size = constraints.constrain( + new Size(dialogWidth, firstChild.size.height) + ); + } else { + // Each button gets half the available width, minus a single divider. + final BoxConstraints perButtonConstraints = new BoxConstraints( + minWidth: (constraints.minWidth - dividerThickness) / 2.0, + maxWidth: (constraints.maxWidth - dividerThickness) / 2.0, + minHeight: 0.0, + maxHeight: double.infinity, + ); + + // Layout the 2 buttons. + firstChild.layout( + perButtonConstraints, + parentUsesSize: true, + ); + lastChild.layout( + perButtonConstraints, + parentUsesSize: true, + ); + + // The 2nd button needs to be offset to the right. + assert(lastChild.parentData is MultiChildLayoutParentData); + final MultiChildLayoutParentData secondButtonParentData = lastChild.parentData; + secondButtonParentData.offset = new Offset(firstChild.size.width + dividerThickness, 0.0); + + // Calculate our size based on the button sizes. + size = constraints.constrain( + new Size( + dialogWidth, + math.max( + firstChild.size.height, + lastChild.size.height, + ), + ) + ); + } + } else { + // We need to stack buttons vertically, plus dividers above each button (except the 1st). + final BoxConstraints perButtonConstraints = constraints.copyWith( + minHeight: 0.0, + maxHeight: double.infinity, + ); + + RenderBox child = firstChild; + int index = 0; + double verticalOffset = 0.0; + while (child != null) { + child.layout( + perButtonConstraints, + parentUsesSize: true, + ); + + assert(child.parentData is MultiChildLayoutParentData); + final MultiChildLayoutParentData parentData = child.parentData; + parentData.offset = new Offset(0.0, verticalOffset); + + verticalOffset += child.size.height; + if (index < childCount - 1) { + // Add a gap for the next divider. + verticalOffset += dividerThickness; + } + + index += 1; + child = childAfter(child); + } + + // Our height is the accumulated height of all buttons and dividers. + size = constraints.constrain( + new Size(dialogWidth, verticalOffset) ); } } -} - -// A CustomPainter to draw the divider lines. -// -// Draws the cross-axis divider lines, used when the layout is horizontal. -class _CupertinoHorizontalDividerPainter extends CustomPainter { - _CupertinoHorizontalDividerPainter(this.count); - - final int count; @override - void paint(Canvas canvas, Size size) { - final Paint paint = new Paint()..color = _kButtonDividerColor; + void paint(PaintingContext context, Offset offset) { + final Canvas canvas = context.canvas; - canvas.drawLine(Offset.zero, new Offset(size.width, 0.0), paint); - for (int i = 1; i < count; ++i) { - // TODO(abarth): Hide the divider when one of the adjacent buttons is - // highlighted. - final double x = size.width * i / count; - canvas.drawLine(new Offset(x, 0.0), new Offset(x, size.height), paint); + if (_isSingleButtonRow(size.width)) { + _drawButtonBackgroundsAndDividersSingleRow(canvas, offset); + } else { + _drawButtonBackgroundsAndDividersStacked(canvas, offset); + } + + _drawButtons(context, offset); + } + + void _drawButtonBackgroundsAndDividersSingleRow(Canvas canvas, Offset offset) { + // The vertical divider sits between the left button and right button (if + // the dialog has 2 buttons). The vertical divider is hidden if either the + // left or right button is pressed. + final Rect verticalDivider = childCount == 2 && !_isButtonPressed + ? new Rect.fromLTWH( + offset.dx + firstChild.size.width, + offset.dy, + dividerThickness, + math.max( + firstChild.size.height, + lastChild.size.height, + ), + ) + : Rect.zero; + + final List pressedButtonRects = _pressedButtons.map((RenderBox pressedButton) { + final MultiChildLayoutParentData buttonParentData = pressedButton.parentData; + + return new Rect.fromLTWH( + offset.dx + buttonParentData.offset.dx, + offset.dy + buttonParentData.offset.dy, + pressedButton.size.width, + pressedButton.size.height, + ); + }).toList(); + + // Create the button backgrounds path and paint it. + final Path backgroundFillPath = new Path() + ..fillType = PathFillType.evenOdd + ..addRect(Rect.largest) + ..addRect(verticalDivider); + + for (int i = 0; i < pressedButtonRects.length; i += 1) { + backgroundFillPath.addRect(pressedButtonRects[i]); + } + + canvas.drawPath( + backgroundFillPath, + _buttonBackgroundPaint, + ); + + // Create the pressed buttons background path and paint it. + final Path pressedBackgroundFillPath = new Path(); + for (int i = 0; i < pressedButtonRects.length; i += 1) { + pressedBackgroundFillPath.addRect(pressedButtonRects[i]); + } + + canvas.drawPath( + pressedBackgroundFillPath, + _pressedButtonBackgroundPaint, + ); + + // Create the dividers path and paint it. + final Path dividersPath = new Path() + ..addRect(verticalDivider); + + canvas.drawPath( + dividersPath, + _dividerPaint, + ); + } + + void _drawButtonBackgroundsAndDividersStacked(Canvas canvas, Offset offset) { + final Offset dividerOffset = new Offset(0.0, dividerThickness); + + final Path backgroundFillPath = new Path() + ..fillType = PathFillType.evenOdd + ..addRect(Rect.largest); + + final Path pressedBackgroundFillPath = new Path(); + + final Path dividersPath = new Path(); + + Offset accumulatingOffset = offset; + + RenderBox child = firstChild; + RenderBox prevChild; + while (child != null) { + assert(child.parentData is _ActionButtonParentData); + final _ActionButtonParentData currentButtonParentData = child.parentData; + final bool isButtonPressed = currentButtonParentData.isPressed; + + bool isPrevButtonPressed = false; + if (prevChild != null) { + assert(prevChild.parentData is _ActionButtonParentData); + final _ActionButtonParentData previousButtonParentData = prevChild + .parentData; + isPrevButtonPressed = previousButtonParentData.isPressed; + } + + final bool isDividerPresent = child != firstChild; + final bool isDividerPainted = isDividerPresent && !(isButtonPressed || isPrevButtonPressed); + final Rect dividerRect = new Rect.fromLTWH( + accumulatingOffset.dx, + accumulatingOffset.dy, + size.width, + dividerThickness, + ); + + final Rect buttonBackgroundRect = new Rect.fromLTWH( + accumulatingOffset.dx, + accumulatingOffset.dy + (isDividerPresent ? dividerThickness : 0.0), + size.width, + child.size.height, + ); + + // If this button is pressed, then we don't want a white background to be + // painted, so we erase this button from the background path. + if (isButtonPressed) { + backgroundFillPath.addRect(buttonBackgroundRect); + pressedBackgroundFillPath.addRect(buttonBackgroundRect); + } + + // If this divider is needed, then we erase the divider area from the + // background path, and on top of that we paint a translucent gray to + // darken the divider area. + if (isDividerPainted) { + backgroundFillPath.addRect(dividerRect); + dividersPath.addRect(dividerRect); + } + + accumulatingOffset += (isDividerPresent ? dividerOffset : Offset.zero) + + new Offset(0.0, child.size.height); + + prevChild = child; + child = childAfter(child); + } + + canvas.drawPath(backgroundFillPath, _buttonBackgroundPaint); + canvas.drawPath(pressedBackgroundFillPath, _pressedButtonBackgroundPaint); + canvas.drawPath(dividersPath, _dividerPaint); + } + + void _drawButtons(PaintingContext context, Offset offset) { + RenderBox child = firstChild; + while (child != null) { + final MultiChildLayoutParentData childParentData = child.parentData; + context.paintChild(child, childParentData.offset + offset); + child = childAfter(child); } } @override - bool shouldRepaint(_CupertinoHorizontalDividerPainter other) => count != other.count; -} - -// A CustomPainter to draw the divider lines. -// -// Draws the cross-axis divider lines, used when the layout is vertical. -class _CupertinoVerticalDividerPainter extends CustomPainter { - _CupertinoVerticalDividerPainter(); - - @override - void paint(Canvas canvas, Size size) { - final Paint paint = new Paint()..color = _kButtonDividerColor; - canvas.drawLine(const Offset(0.0, 0.0), new Offset(size.width, 0.0), paint); + bool hitTestChildren(HitTestResult result, { Offset position }) { + return defaultHitTestChildren(result, position: position); } - - @override - bool shouldRepaint(_CupertinoVerticalDividerPainter other) => false; -} +} \ No newline at end of file diff --git a/packages/flutter/lib/src/painting/box_decoration.dart b/packages/flutter/lib/src/painting/box_decoration.dart index ed3358b58ba..3c70a98b766 100644 --- a/packages/flutter/lib/src/painting/box_decoration.dart +++ b/packages/flutter/lib/src/painting/box_decoration.dart @@ -83,11 +83,11 @@ class BoxDecoration extends Decoration { this.backgroundBlendMode, this.shape = BoxShape.rectangle, }) : assert(shape != null), - // TODO(mattcarroll): Use "backgroundBlendMode == null" when Dart #31140 is in. + // TODO(mattcarroll): Use "backgroundBlendMode == null" when https://github.com/dart-lang/sdk/issues/31140 is in. assert( - identical(backgroundBlendMode, null) || color != null || gradient != null, - 'backgroundBlendMode applies to BoxDecoration\'s background color or' - 'gradient, but no color or gradient were provided.' + identical(backgroundBlendMode, null) || color != null || gradient != null, + 'backgroundBlendMode applies to BoxDecoration\'s background color or ' + 'gradient, but no color or gradient were provided.' ); @override @@ -146,10 +146,10 @@ class BoxDecoration extends Decoration { /// The blend mode applied to the [color] or [gradient] background of the box. /// - /// If no [backgroundBlendMode] is provided, then the default painting blend + /// If no [backgroundBlendMode] is provided then the default painting blend /// mode is used. /// - /// If no [color] or [gradient] is provided, then blend mode has no impact. + /// If no [color] or [gradient] is provided then the blend mode has no impact. final BlendMode backgroundBlendMode; /// The shape to fill the background [color], [gradient], and [image] into and diff --git a/packages/flutter/test/cupertino/dialog_test.dart b/packages/flutter/test/cupertino/dialog_test.dart index bdb2f18a537..a1f11db2b67 100644 --- a/packages/flutter/test/cupertino/dialog_test.dart +++ b/packages/flutter/test/cupertino/dialog_test.dart @@ -2,66 +2,52 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:math'; + import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../rendering/mock_canvas.dart'; + void main() { testWidgets('Alert dialog control test', (WidgetTester tester) async { bool didDelete = false; - await tester.pumpWidget(new MaterialApp( - home: new Material( - child: new Center( - child: new Builder( - builder: (BuildContext context) { - return new RaisedButton( + await tester.pumpWidget( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return new CupertinoAlertDialog( + title: const Text('The title'), + content: const Text('The content'), + actions: [ + const CupertinoDialogAction( + child: Text('Cancel'), + ), + new CupertinoDialogAction( + isDestructiveAction: true, onPressed: () { - showDialog( - context: context, - builder: (BuildContext context) { - return new CupertinoAlertDialog( - title: const Text('The title'), - content: const Text('The content'), - actions: [ - const CupertinoDialogAction( - child: Text('Cancel'), - ), - new CupertinoDialogAction( - isDestructiveAction: true, - onPressed: () { - didDelete = true; - Navigator.pop(context); - }, - child: const Text('Delete'), - ), - ], - ); - }, - ); + didDelete = true; + Navigator.pop(context); }, - child: const Text('Go'), - ); - }, - ), - ), + child: const Text('Delete'), + ), + ], + ); + }, ), - )); + ); await tester.tap(find.text('Go')); - await tester.pump(); - await tester.pump(const Duration(seconds: 1)); expect(didDelete, isFalse); await tester.tap(find.text('Delete')); + await tester.pump(); expect(didDelete, isTrue); - - await tester.pump(); - await tester.pump(const Duration(seconds: 1)); - expect(find.text('Delete'), findsNothing); }); @@ -85,7 +71,7 @@ void main() { final DefaultTextStyle widget = tester.widget(find.byType(DefaultTextStyle)); - expect(widget.style.fontWeight, equals(FontWeight.w600)); + expect(widget.style.fontWeight, equals(FontWeight.w400)); }); testWidgets('Default and destructive style', (WidgetTester tester) async { @@ -97,53 +83,38 @@ void main() { final DefaultTextStyle widget = tester.widget(find.byType(DefaultTextStyle)); - expect(widget.style.fontWeight, equals(FontWeight.w600)); + expect(widget.style.fontWeight, equals(FontWeight.w400)); expect(widget.style.color.red, greaterThan(widget.style.color.blue)); }); - testWidgets('Message is scrollable, has correct padding with large text sizes', - (WidgetTester tester) async { - final ScrollController scrollController = new ScrollController(keepScrollOffset: true); + testWidgets('Message is scrollable, has correct padding with large text sizes', (WidgetTester tester) async { + final ScrollController scrollController = new ScrollController(); await tester.pumpWidget( - new MaterialApp(home: new Material( - child: new Center( - child: new Builder(builder: (BuildContext context) { - return new RaisedButton( - onPressed: () { - showDialog( - context: context, - builder: (BuildContext context) { - return new MediaQuery( - data: MediaQuery.of(context).copyWith(textScaleFactor: 3.0), - child: new CupertinoAlertDialog( - title: const Text('The Title'), - content: new Text('Very long content ' * 20), - actions: const [ - CupertinoDialogAction( - child: Text('Cancel'), - ), - CupertinoDialogAction( - isDestructiveAction: true, - child: Text('OK'), - ), - ], - scrollController: scrollController, - ), - ); - }, - ); - }, - child: const Text('Go'), - ); - }), - ), - )), + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return new MediaQuery( + data: MediaQuery.of(context).copyWith(textScaleFactor: 3.0), + child: new CupertinoAlertDialog( + title: const Text('The Title'), + content: new Text('Very long content ' * 20), + actions: const [ + CupertinoDialogAction( + child: Text('Cancel'), + ), + CupertinoDialogAction( + isDestructiveAction: true, + child: Text('OK'), + ), + ], + scrollController: scrollController, + ), + ); + } + ) ); await tester.tap(find.text('Go')); - - await tester.pump(); - await tester.pump(const Duration(seconds: 1)); + await tester.pumpAndSettle(); expect(scrollController.offset, 0.0); scrollController.jumpTo(100.0); @@ -151,80 +122,113 @@ void main() { // Set the scroll position back to zero. scrollController.jumpTo(0.0); - // Find the actual dialog box. The first decorated box is the popup barrier. - expect(tester.getSize(find.byType(DecoratedBox).at(1)), equals(const Size(270.0, 560.0))); + await tester.pumpAndSettle(); - // Check sizes/locations of the text. - expect(tester.getSize(find.text('The Title')), equals(const Size(230.0, 171.0))); - expect(tester.getSize(find.text('Cancel')), equals(const Size(87.0, 300.0))); - expect(tester.getSize(find.text('OK')), equals(const Size(87.0, 100.0))); - expect(tester.getTopLeft(find.text('The Title')), equals(const Offset(285.0, 40.0))); + // Expect the modal dialog box to take all available height. + expect( + tester.getSize( + find.byType(ClipRRect) + ), + equals(const Size(310.0, 560.0)), + ); - // The Cancel and OK buttons have different Y values because "Cancel" is - // wrapping (as it should with large text sizes like this). - expect(tester.getTopLeft(find.text('Cancel')), equals(const Offset(289.0, 466.0))); - expect(tester.getTopLeft(find.text('OK')), equals(const Offset(424.0, 566.0))); + // Check sizes/locations of the text. The text is large so these 2 buttons are stacked. + // Visually the "Cancel" button and "OK" button are the same height when using the + // regular font. However, when using the test font, "Cancel" becomes 2 lines which + // is why the height we're verifying for "Cancel" is larger than "OK". + expect(tester.getSize(find.text('The Title')), equals(const Size(270.0, 162.0))); + expect(tester.getTopLeft(find.text('The Title')), equals(const Offset(265.0, 80.0))); + expect(tester.getSize(find.widgetWithText(CupertinoDialogAction, 'Cancel')), equals(const Size(310.0, 148.0))); + expect(tester.getSize(find.widgetWithText(CupertinoDialogAction, 'OK')), equals(const Size(310.0, 98.0))); }); - testWidgets('Button list is scrollable, has correct position with large text sizes.', - (WidgetTester tester) async { - const double textScaleFactor = 3.0; - final ScrollController scrollController = new ScrollController(keepScrollOffset: true); + testWidgets('Dialog respects small constraints.', (WidgetTester tester) async { + final ScrollController scrollController = new ScrollController(); await tester.pumpWidget( - new MaterialApp(home: new Material( - child: new Center( - child: new Builder(builder: (BuildContext context) { - return new RaisedButton( - onPressed: () { - showDialog( - context: context, - builder: (BuildContext context) { - return new MediaQuery( - data: MediaQuery.of(context).copyWith(textScaleFactor: textScaleFactor), - child: new CupertinoAlertDialog( - title: const Text('The title'), - content: const Text('The content.'), - actions: const [ - CupertinoDialogAction( - child: Text('One'), - ), - CupertinoDialogAction( - child: Text('Two'), - ), - CupertinoDialogAction( - child: Text('Three'), - ), - CupertinoDialogAction( - child: Text('Chocolate Brownies'), - ), - CupertinoDialogAction( - isDestructiveAction: true, - child: Text('Cancel'), - ), - ], - actionScrollController: scrollController, - ), - ); - }, - ); - }, - child: const Text('Go'), - ); - }), - ), - )), + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return new Center( + child: new ConstrainedBox( + // Constrain the dialog to a tiny size and ensure it respects + // these exact constraints. + constraints: new BoxConstraints.tight(const Size(200.0, 100.0)), + child: new CupertinoAlertDialog( + title: const Text('The Title'), + content: const Text('The message'), + actions: const [ + CupertinoDialogAction( + child: Text('Option 1'), + ), + CupertinoDialogAction( + child: Text('Option 2'), + ), + CupertinoDialogAction( + child: Text('Option 3'), + ), + ], + scrollController: scrollController, + ), + ), + ); + }, + ), + ); + + await tester.tap(find.text('Go')); + await tester.pump(); + + const double topAndBottomMargin = 40.0; + final Finder modalFinder = find.byType(ClipRRect); + expect( + tester.getSize(modalFinder), + equals(const Size(200.0, 100.0 - topAndBottomMargin)), + ); + }); + + testWidgets('Button list is scrollable, has correct position with large text sizes.', (WidgetTester tester) async { + final ScrollController actionScrollController = new ScrollController(); + await tester.pumpWidget( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return new MediaQuery( + data: MediaQuery.of(context).copyWith(textScaleFactor: 3.0), + child: new CupertinoAlertDialog( + title: const Text('The title'), + content: const Text('The content.'), + actions: const [ + CupertinoDialogAction( + child: Text('One'), + ), + CupertinoDialogAction( + child: Text('Two'), + ), + CupertinoDialogAction( + child: Text('Three'), + ), + CupertinoDialogAction( + child: Text('Chocolate Brownies'), + ), + CupertinoDialogAction( + isDestructiveAction: true, + child: Text('Cancel'), + ), + ], + actionScrollController: actionScrollController, + ), + ); + } + ) ); await tester.tap(find.text('Go')); await tester.pump(); - await tester.pump(const Duration(seconds: 1)); // Check that the action buttons list is scrollable. - expect(scrollController.offset, 0.0); - scrollController.jumpTo(100.0); - expect(scrollController.offset, 100.0); - scrollController.jumpTo(0.0); + expect(actionScrollController.offset, 0.0); + actionScrollController.jumpTo(100.0); + expect(actionScrollController.offset, 100.0); + actionScrollController.jumpTo(0.0); // Check that the action buttons are aligned vertically. expect(tester.getCenter(find.widgetWithText(CupertinoDialogAction, 'One')).dx, equals(400.0)); @@ -236,101 +240,498 @@ void main() { // Check that the action buttons are the correct heights. expect(tester.getSize(find.widgetWithText(CupertinoDialogAction, 'One')).height, equals(98.0)); expect(tester.getSize(find.widgetWithText(CupertinoDialogAction, 'Two')).height, equals(98.0)); - expect(tester.getSize(find.widgetWithText(CupertinoDialogAction, 'Three')).height, equals(148.0)); - expect(tester.getSize(find.widgetWithText(CupertinoDialogAction, 'Chocolate Brownies')).height, equals(298.0)); + expect(tester.getSize(find.widgetWithText(CupertinoDialogAction, 'Three')).height, equals(98.0)); + expect(tester.getSize(find.widgetWithText(CupertinoDialogAction, 'Chocolate Brownies')).height, equals(248.0)); expect(tester.getSize(find.widgetWithText(CupertinoDialogAction, 'Cancel')).height, equals(148.0)); }); - testWidgets('Title Section is empty, Button section is not empty.', - (WidgetTester tester) async { + testWidgets('Title Section is empty, Button section is not empty.', (WidgetTester tester) async { const double textScaleFactor = 1.0; - final ScrollController scrollController = new ScrollController(keepScrollOffset: true); + final ScrollController actionScrollController = new ScrollController(); await tester.pumpWidget( - new MaterialApp(home: new Material( - child: new Center( - child: new Builder(builder: (BuildContext context) { - return new RaisedButton( - onPressed: () { - showDialog( - context: context, - builder: (BuildContext context) { - return new MediaQuery( - data: MediaQuery.of(context).copyWith(textScaleFactor: textScaleFactor), - child: new CupertinoAlertDialog( - actions: const [ - CupertinoDialogAction( - child: Text('One'), - ), - CupertinoDialogAction( - child: Text('Two'), - ), - ], - actionScrollController: scrollController, - ), - ); - }, - ); - }, - child: const Text('Go'), - ); - }), - ), - )), + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return new MediaQuery( + data: MediaQuery.of(context).copyWith(textScaleFactor: textScaleFactor), + child: new CupertinoAlertDialog( + actions: const [ + CupertinoDialogAction( + child: Text('One'), + ), + CupertinoDialogAction( + child: Text('Two'), + ), + ], + actionScrollController: actionScrollController, + ), + ); + } + ), ); await tester.tap(find.text('Go')); await tester.pump(); - await tester.pump(const Duration(seconds: 1)); + + // Check that the dialog size is the same as the actions section size. This + // ensures that an empty content section doesn't accidentally render some + // empty space in the dialog. + final Finder contentSectionFinder = find.byElementPredicate((Element element) { + return element.widget.runtimeType.toString() == '_CupertinoAlertActionSection'; + }); + + final Finder modalBoundaryFinder = find.byType(ClipRRect); + + expect( + tester.getSize(contentSectionFinder), + tester.getSize(modalBoundaryFinder), + ); // Check that the title/message section is not displayed - expect(scrollController.offset, 0.0); - expect(tester.getTopLeft(find.widgetWithText(CupertinoDialogAction, 'One')).dy, equals(283.5)); + expect(actionScrollController.offset, 0.0); + expect(tester.getTopLeft(find.widgetWithText(CupertinoDialogAction, 'One')).dy, equals(277.5)); // Check that the button's vertical size is the same. expect(tester.getSize(find.widgetWithText(CupertinoDialogAction, 'One')).height, equals(tester.getSize(find.widgetWithText(CupertinoDialogAction, 'Two')).height)); }); - testWidgets('Button section is empty, Title section is not empty.', - (WidgetTester tester) async { + testWidgets('Button section is empty, Title section is not empty.', (WidgetTester tester) async { const double textScaleFactor = 1.0; - final ScrollController scrollController = new ScrollController(keepScrollOffset: true); + final ScrollController scrollController = new ScrollController(); await tester.pumpWidget( - new MaterialApp(home: new Material( - child: new Center( - child: new Builder(builder: (BuildContext context) { - return new RaisedButton( - onPressed: () { - showDialog( - context: context, - builder: (BuildContext context) { - return new MediaQuery( - data: MediaQuery.of(context).copyWith(textScaleFactor: textScaleFactor), - child: new CupertinoAlertDialog( - title: const Text('The title'), - content: const Text('The content.'), - scrollController: scrollController, - ), - ); - }, - ); - }, - child: const Text('Go'), - ); - }), - ), - )), + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return new MediaQuery( + data: MediaQuery.of(context).copyWith(textScaleFactor: textScaleFactor), + child: new CupertinoAlertDialog( + title: const Text('The title'), + content: const Text('The content.'), + scrollController: scrollController, + ), + ); + }, + ), ); await tester.tap(find.text('Go')); await tester.pump(); - await tester.pump(const Duration(seconds: 1)); // Check that there's no button action section. expect(scrollController.offset, 0.0); expect(find.widgetWithText(CupertinoDialogAction, 'One'), findsNothing); + + // Check that the dialog size is the same as the content section size. This + // ensures that an empty button section doesn't accidentally render some + // empty space in the dialog. + final Finder contentSectionFinder = find.byElementPredicate((Element element) { + return element.widget.runtimeType.toString() == '_CupertinoAlertContentSection'; + }); + + final Finder modalBoundaryFinder = find.byType(ClipRRect); + + expect( + tester.getSize(contentSectionFinder), + tester.getSize(modalBoundaryFinder), + ); + }); + + testWidgets('Actions section height for 1 button is height of button.', (WidgetTester tester) async { + final ScrollController scrollController = new ScrollController(); + await tester.pumpWidget( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return new CupertinoAlertDialog( + title: const Text('The Title'), + content: const Text('The message'), + actions: const [ + CupertinoDialogAction( + child: Text('OK'), + ), + ], + scrollController: scrollController, + ); + }, + ), + ); + + await tester.tap(find.text('Go')); + await tester.pump(); + + final RenderBox okButtonBox = findActionButtonRenderBoxByTitle(tester, 'OK'); + final RenderBox actionsSectionBox = findScrollableActionsSectionRenderBox(tester); + + expect(okButtonBox.size.width, actionsSectionBox.size.width); + expect(okButtonBox.size.height, actionsSectionBox.size.height); + }); + + testWidgets('Actions section height for 2 side-by-side buttons is height of tallest button.', (WidgetTester tester) async { + final ScrollController scrollController = new ScrollController(); + double dividerWidth; // Will be set when the dialog builder runs. Needs a BuildContext. + await tester.pumpWidget( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + dividerWidth = 1.0 / MediaQuery.of(context).devicePixelRatio; + return new CupertinoAlertDialog( + title: const Text('The Title'), + content: const Text('The message'), + actions: const [ + CupertinoDialogAction( + child: Text('OK'), + ), + CupertinoDialogAction( + isDestructiveAction: true, + child: Text('Cancel'), + ), + ], + scrollController: scrollController, + ); + }, + ), + ); + + await tester.tap(find.text('Go')); + await tester.pump(); + + final RenderBox okButtonBox = findActionButtonRenderBoxByTitle(tester, 'OK'); + final RenderBox cancelButtonBox = findActionButtonRenderBoxByTitle(tester, 'Cancel'); + final RenderBox actionsSectionBox = findScrollableActionsSectionRenderBox(tester); + + expect(okButtonBox.size.width, cancelButtonBox.size.width); + + expect( + actionsSectionBox.size.width, + okButtonBox.size.width + cancelButtonBox.size.width + dividerWidth, + ); + + expect( + actionsSectionBox.size.height, + max(okButtonBox.size.height, cancelButtonBox.size.height), + ); + }); + + testWidgets('Actions section height for 2 stacked buttons with enough room is height of both buttons.', (WidgetTester tester) async { + final ScrollController scrollController = new ScrollController(); + double dividerThickness; // Will be set when the dialog builder runs. Needs a BuildContext. + await tester.pumpWidget( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + dividerThickness = 1.0 / MediaQuery.of(context).devicePixelRatio; + return new CupertinoAlertDialog( + title: const Text('The Title'), + content: const Text('The message'), + actions: const [ + CupertinoDialogAction( + child: Text('OK'), + ), + CupertinoDialogAction( + isDestructiveAction: true, + child: Text('This is too long to fit'), + ), + ], + scrollController: scrollController, + ); + }, + ), + ); + + await tester.tap(find.text('Go')); + await tester.pump(); + + final RenderBox okButtonBox = findActionButtonRenderBoxByTitle(tester, 'OK'); + final RenderBox longButtonBox = findActionButtonRenderBoxByTitle(tester, 'This is too long to fit'); + final RenderBox actionsSectionBox = findScrollableActionsSectionRenderBox(tester); + + expect(okButtonBox.size.width, longButtonBox.size.width); + + expect(okButtonBox.size.width, actionsSectionBox.size.width); + + expect( + okButtonBox.size.height + dividerThickness + longButtonBox.size.height, + actionsSectionBox.size.height, + ); + }); + + testWidgets('Actions section height for 2 stacked buttons without enough room and regular font is 1.5 buttons tall.', (WidgetTester tester) async { + final ScrollController scrollController = new ScrollController(); + await tester.pumpWidget( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return new CupertinoAlertDialog( + title: const Text('The Title'), + content: new Text('The message\n' * 40), + actions: const [ + CupertinoDialogAction( + child: Text('OK'), + ), + CupertinoDialogAction( + isDestructiveAction: true, + child: Text('This is too long to fit'), + ), + ], + scrollController: scrollController, + ); + }, + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + + final RenderBox actionsSectionBox = findScrollableActionsSectionRenderBox(tester); + + expect( + actionsSectionBox.size.height, + 67.83333333333337, + ); + }); + + testWidgets('Actions section height for 2 stacked buttons without enough room and large accessibility font is 50% of dialog height.', (WidgetTester tester) async { + final ScrollController scrollController = new ScrollController(); + await tester.pumpWidget( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return new MediaQuery( + data: MediaQuery.of(context).copyWith(textScaleFactor: 3.0), + child: new CupertinoAlertDialog( + title: const Text('The Title'), + content: new Text('The message\n' * 20), + actions: const [ + CupertinoDialogAction( + child: Text('This button is multi line'), + ), + CupertinoDialogAction( + isDestructiveAction: true, + child: Text('This button is multi line'), + ), + ], + scrollController: scrollController, + ), + ); + }, + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + + final RenderBox actionsSectionBox = findScrollableActionsSectionRenderBox(tester); + + // The two multi-line buttons with large text are taller than 50% of the + // dialog height, but with the accessibility layout policy, the 2 buttons + // should be in a scrollable area equal to half the dialog height. + expect( + actionsSectionBox.size.height, + 280.0, + ); + }); + + testWidgets('Actions section height for 3 buttons without enough room is 1.5 buttons tall.', (WidgetTester tester) async { + final ScrollController scrollController = new ScrollController(); + await tester.pumpWidget( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return new CupertinoAlertDialog( + title: const Text('The Title'), + content: new Text('The message\n' * 40), + actions: const [ + CupertinoDialogAction( + child: Text('Option 1'), + ), + CupertinoDialogAction( + child: Text('Option 2'), + ), + CupertinoDialogAction( + child: Text('Option 3'), + ), + ], + scrollController: scrollController, + ); + }, + ), + ); + + await tester.tap(find.text('Go')); + await tester.pump(); + await tester.pumpAndSettle(); + + final RenderBox option1ButtonBox = findActionButtonRenderBoxByTitle(tester, 'Option 1'); + final RenderBox option2ButtonBox = findActionButtonRenderBoxByTitle(tester, 'Option 2'); + final RenderBox actionsSectionBox = findScrollableActionsSectionRenderBox(tester); + + expect(option1ButtonBox.size.width, option2ButtonBox.size.width); + expect(option1ButtonBox.size.width, actionsSectionBox.size.width); + + // Expected Height = button 1 + divider + 1/2 button 2 = 67.83333333333334 + // Technically the following number is off by 0.00000000000003 but I think it's a + // Dart precision issue. I ran the subtraction directly in dartpad and still + // got 67.83333333333337. + const double expectedHeight = 67.83333333333337; + expect( + actionsSectionBox.size.height, + expectedHeight, + ); + }); + + testWidgets('Actions section overscroll is painted white.', (WidgetTester tester) async { + final ScrollController scrollController = new ScrollController(); + await tester.pumpWidget( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return new CupertinoAlertDialog( + title: const Text('The Title'), + content: const Text('The message'), + actions: const [ + CupertinoDialogAction( + child: Text('Option 1'), + ), + CupertinoDialogAction( + child: Text('Option 2'), + ), + CupertinoDialogAction( + child: Text('Option 3'), + ), + ], + scrollController: scrollController, + ); + }, + ), + ); + + await tester.tap(find.text('Go')); + await tester.pump(); + + final RenderBox actionsSectionBox = findScrollableActionsSectionRenderBox(tester); + + // The way that overscroll white is accomplished in a scrollable action + // section is that the custom RenderBox that lays out the buttons and draws + // the dividers also paints a white background the size of Rect.largest. + // That background ends up being clipped by the containing ScrollView. + // + // Here we test that the largest Rect is contained within the painted Path. + // We don't test for exclusion because for some reason the Path is reporting + // that even points beyond Rect.largest are within the Path. That's not an + // issue for our use-case, so we don't worry about it. + expect(actionsSectionBox, paints..path( + includes: [ + new Offset(Rect.largest.left, Rect.largest.top), + new Offset(Rect.largest.right, Rect.largest.bottom), + ], + )); + }); + + testWidgets('Pressed button changes appearance and dividers disappear.', (WidgetTester tester) async { + final ScrollController scrollController = new ScrollController(); + double dividerThickness; // Will be set when the dialog builder runs. Needs a BuildContext. + await tester.pumpWidget( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + dividerThickness = 1.0 / MediaQuery.of(context).devicePixelRatio; + return new CupertinoAlertDialog( + title: const Text('The Title'), + content: const Text('The message'), + actions: const [ + CupertinoDialogAction( + child: Text('Option 1'), + ), + CupertinoDialogAction( + child: Text('Option 2'), + ), + CupertinoDialogAction( + child: Text('Option 3'), + ), + ], + scrollController: scrollController, + ); + }, + ), + ); + + await tester.tap(find.text('Go')); + await tester.pump(); + + const Color normalButtonBackgroundColor = Color(0xc0ffffff); + const Color pressedButtonBackgroundColor = Color(0x90ffffff); + final RenderBox firstButtonBox = findActionButtonRenderBoxByTitle(tester, 'Option 1'); + final RenderBox secondButtonBox = findActionButtonRenderBoxByTitle(tester, 'Option 2'); + final RenderBox actionsSectionBox = findScrollableActionsSectionRenderBox(tester); + + final Offset pressedButtonCenter = new Offset( + secondButtonBox.size.width / 2.0, + firstButtonBox.size.height + dividerThickness + (secondButtonBox.size.height / 2.0), + ); + final Offset topDividerCenter = new Offset( + secondButtonBox.size.width / 2.0, + firstButtonBox.size.height + (0.5 * dividerThickness), + ); + final Offset bottomDividerCenter = new Offset( + secondButtonBox.size.width / 2.0, + firstButtonBox.size.height + + dividerThickness + + secondButtonBox.size.height + + (0.5 * dividerThickness), + ); + + // Before pressing the button, verify following expectations: + // - Background includes the button that will be pressed + // - Background excludes the divider above and below the button that will be pressed + // - Pressed button background does NOT include the button that will be pressed + expect(actionsSectionBox, paints + ..path( + color: normalButtonBackgroundColor, + includes: [ + pressedButtonCenter, + ], + excludes: [ + topDividerCenter, + bottomDividerCenter, + ], + ) + ..path( + color: pressedButtonBackgroundColor, + excludes: [ + pressedButtonCenter, + ], + ), + ); + + // Press down on the button. + final TestGesture gesture = await tester.press(find.widgetWithText(CupertinoDialogAction, 'Option 2')); + await tester.pump(); + + // While pressing the button, verify following expectations: + // - Background excludes the pressed button + // - Background includes the divider above and below the pressed button + // - Pressed button background includes the pressed + expect(actionsSectionBox, paints + ..path( + color: normalButtonBackgroundColor, + // The background should contain the divider above and below the pressed + // button. While pressed, surrounding dividers disappear, which means + // they become part of the background. + includes: [ + topDividerCenter, + bottomDividerCenter, + ], + // The background path should not include the tapped button background... + excludes: [ + pressedButtonCenter, + ], + ) + // For a pressed button, a dedicated path is painted with a pressed button + // background color... + ..path( + color: pressedButtonBackgroundColor, + includes: [ + pressedButtonCenter, + ], + ), + ); + + // We must explicitly cause an "up" gesture to avoid a crash. + // todo(mattcarroll) remove this call when #19540 is fixed + await gesture.up(); }); testWidgets('ScaleTransition animation for showCupertinoDialog()', (WidgetTester tester) async { @@ -504,9 +905,45 @@ void main() { }); } +RenderBox findActionButtonRenderBoxByTitle(WidgetTester tester, String title) { + final RenderObject buttonBox = tester.renderObject(find.widgetWithText(CupertinoDialogAction, title)); + assert(buttonBox is RenderBox); + return buttonBox; +} + +RenderBox findScrollableActionsSectionRenderBox(WidgetTester tester) { + final RenderObject actionsSection = tester.renderObject(find.byElementPredicate( + (Element element) { + return element.widget.runtimeType.toString() == '_CupertinoAlertActionSection'; + }), + ); + assert(actionsSection is RenderBox); + return actionsSection; +} + +Widget createAppWithButtonThatLaunchesDialog({WidgetBuilder dialogBuilder}) { + return new MaterialApp( + home: new Material( + child: new Center( + child: new Builder(builder: (BuildContext context) { + return new RaisedButton( + onPressed: () { + showDialog( + context: context, + builder: dialogBuilder, + ); + }, + child: const Text('Go'), + ); + }), + ), + ), + ); +} + Widget boilerplate(Widget child) { return new Directionality( textDirection: TextDirection.ltr, child: child, ); -} +} \ No newline at end of file diff --git a/packages/flutter_test/lib/src/controller.dart b/packages/flutter_test/lib/src/controller.dart index 9baea2cb9fa..f0e97e11b8a 100644 --- a/packages/flutter_test/lib/src/controller.dart +++ b/packages/flutter_test/lib/src/controller.dart @@ -273,6 +273,17 @@ abstract class WidgetController { }); } + /// Dispatch a pointer down at the center of the given widget, assuming it is + /// exposed. + /// + /// If the center of the widget is not exposed, this might send events to + /// another object. + Future press(Finder finder, { int pointer }) { + return TestAsyncUtils.guard(() { + return startGesture(getCenter(finder), pointer: pointer); + }); + } + /// Dispatch a pointer down / pointer up sequence (with a delay of /// [kLongPressTimeout] + [kPressTimeout] between the two events) at the /// center of the given widget, assuming it is exposed. diff --git a/packages/flutter_test/lib/src/test_async_utils.dart b/packages/flutter_test/lib/src/test_async_utils.dart index bba63a4acfe..e70b5401e7a 100644 --- a/packages/flutter_test/lib/src/test_async_utils.dart +++ b/packages/flutter_test/lib/src/test_async_utils.dart @@ -57,7 +57,7 @@ class TestAsyncUtils { /// this one before this one has finished will throw an exception. /// /// This method first calls [guardSync]. - static Future guard(Future body()) { + static Future guard(Future body()) { guardSync(); final Zone zone = Zone.current.fork( zoneValues: { @@ -66,8 +66,9 @@ class TestAsyncUtils { ); final _AsyncScope scope = new _AsyncScope(StackTrace.current, zone); _scopeStack.add(scope); - final Future result = scope.zone.run(body); - Future completionHandler(dynamic error, StackTrace stack) { + final Future result = scope.zone.run>(body); + T resultValue; // This is set when the body of work completes with a result value. + Future completionHandler(dynamic error, StackTrace stack) { assert(_scopeStack.isNotEmpty); assert(_scopeStack.contains(scope)); bool leaked = false; @@ -102,11 +103,12 @@ class TestAsyncUtils { throw new FlutterError(message.toString().trimRight()); } if (error != null) - return new Future.error(error, stack); - return new Future.value(null); + return new Future.error(error, stack); + return new Future.value(resultValue); } - return result.then( - (Null value) { + return result.then( + (T value) { + resultValue = value; return completionHandler(null, null); }, onError: completionHandler