From 21bc9f1b02f657d156980adf2b7436e9872cae35 Mon Sep 17 00:00:00 2001 From: matthew-carroll Date: Wed, 15 Aug 2018 12:59:40 -0700 Subject: [PATCH] iOS Dialog blur, brightness, and layout (#18381) Rewrote CupertinoAlertDialog to look nearly identical to an alert dialog in iOS. This includes considerations for blur, translucent white background color, button sizing, gap dividers between buttons, and text scaling layout behavior. (#18381) --- .../flutter/lib/src/cupertino/dialog.dart | 1543 ++++++++++++++--- .../lib/src/painting/box_decoration.dart | 12 +- .../flutter/test/cupertino/dialog_test.dart | 857 ++++++--- packages/flutter_test/lib/src/controller.dart | 11 + .../lib/src/test_async_utils.dart | 16 +- 5 files changed, 2009 insertions(+), 430 deletions(-) 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