From 96326d4743d2684a75ddc48a8dfca3d15e29fe9a Mon Sep 17 00:00:00 2001 From: Natalie Sampsell Date: Thu, 9 Aug 2018 21:55:41 -0700 Subject: [PATCH] CupertinoActionSheet (#19232) Adding CupertinoActionSheet, showCupertinoModalPopup --- packages/flutter/lib/cupertino.dart | 1 + .../lib/src/cupertino/action_sheet.dart | 1267 +++++++++++++++++ packages/flutter/lib/src/cupertino/route.dart | 99 ++ .../lib/src/painting/box_decoration.dart | 22 +- .../lib/src/widgets/modal_barrier.dart | 23 +- packages/flutter/lib/src/widgets/routes.dart | 17 + .../test/cupertino/action_sheet_test.dart | 967 +++++++++++++ 7 files changed, 2392 insertions(+), 4 deletions(-) create mode 100644 packages/flutter/lib/src/cupertino/action_sheet.dart create mode 100644 packages/flutter/test/cupertino/action_sheet_test.dart diff --git a/packages/flutter/lib/cupertino.dart b/packages/flutter/lib/cupertino.dart index 7f9c9788273..172011d84a7 100644 --- a/packages/flutter/lib/cupertino.dart +++ b/packages/flutter/lib/cupertino.dart @@ -7,6 +7,7 @@ /// To use, import `package:flutter/cupertino.dart`. library cupertino; +export 'src/cupertino/action_sheet.dart'; export 'src/cupertino/activity_indicator.dart'; export 'src/cupertino/app.dart'; export 'src/cupertino/bottom_tab_bar.dart'; diff --git a/packages/flutter/lib/src/cupertino/action_sheet.dart b/packages/flutter/lib/src/cupertino/action_sheet.dart new file mode 100644 index 00000000000..99f42635c9c --- /dev/null +++ b/packages/flutter/lib/src/cupertino/action_sheet.dart @@ -0,0 +1,1267 @@ +// Copyright 2018 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:ui' show ImageFilter; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'colors.dart'; +import 'scrollbar.dart'; + +const TextStyle _kActionSheetActionStyle = TextStyle( + fontFamily: '.SF UI Text', + inherit: false, + fontSize: 20.0, + fontWeight: FontWeight.w400, + color: CupertinoColors.activeBlue, + textBaseline: TextBaseline.alphabetic, +); + +const TextStyle _kActionSheetContentStyle = TextStyle( + fontFamily: '.SF UI Text', + inherit: false, + fontSize: 13.0, + fontWeight: FontWeight.w400, + color: _kContentTextColor, + textBaseline: TextBaseline.alphabetic, +); + +// This decoration is applied to the blurred backdrop to lighten the blurred +// image. Brightening is done to counteract the dark modal barrier that +// appears behind the alert. 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 _kAlertBlurOverlayDecoration = BoxDecoration( + color: CupertinoColors.white, + backgroundBlendMode: BlendMode.overlay, +); + +// Translucent, very light gray that is painted on top of the blurred backdrop +// as the action sheet's background color. +const Color _kBackgroundColor = Color(0xD1F8F8F8); + +// Translucent, light gray that is painted on top of the blurred backdrop as +// the background color of a pressed button. +const Color _kPressedColor = Color(0xA6E5E5EA); + +// Translucent gray 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(0x403F3F3F); + +const Color _kContentTextColor = Color(0xFF8F8F8F); +const Color _kCancelButtonPressedColor = Color(0xFFEAEAEA); + +const double _kBlurAmount = 20.0; +const double _kEdgeHorizontalPadding = 8.0; +const double _kCancelButtonPadding = 8.0; +const double _kEdgeVerticalPadding = 10.0; +const double _kContentHorizontalPadding = 40.0; +const double _kContentVerticalPadding = 14.0; +const double _kButtonHeight = 56.0; +const double _kCornerRadius = 14.0; +const double _kDividerThickness = 1.0; + +/// An iOS-style action sheet. +/// +/// An action sheet is a specific style of alert that presents the user +/// with a set of two or more choices related to the current context. +/// An action sheet can have a title, an additional message, and a list +/// of actions. The title is displayed above the message and the actions +/// are displayed below this content. +/// +/// This action sheet styles its title and message to match standard iOS action +/// sheet title and message text style. +/// +/// To display action buttons that look like standard iOS action sheet buttons, +/// provide [CupertinoActionSheetAction]s for the [actions] given to this action sheet. +/// +/// To include a iOS-style cancel button separate from the other buttons, +/// provide an [CupertinoActionSheetAction] for the [cancelButton] given to this +/// action sheet. +/// +/// An action sheet is typically passed as the child widget to +/// [showCupertinoModalPopup], which displays the action sheet by sliding it up +/// from the bottom of the screen. +/// +/// See also: +/// +/// * [CupertinoActionSheetAction], which is an iOS-style action sheet button. +/// * +class CupertinoActionSheet extends StatelessWidget { + /// Creates an iOS-style action sheet. + /// + /// An action sheet must have a non-null value for at least one of the + /// following arguments: [actions], [title], [message], or [cancelButton]. + /// + /// Generally, action sheets are used to give the user a choice between + /// two or more choices for the current context. + const CupertinoActionSheet({ + Key key, + this.title, + this.message, + this.actions, + this.messageScrollController, + this.actionScrollController, + this.cancelButton, + }) : assert(actions != null || title != null || message != null || cancelButton != null, + 'An action sheet must have a non-null value for at least one of the following arguments: ' + 'actions, title, message, or cancelButton'), + super(key: key); + + /// An optional title of the action sheet. When the [message] is non-null, + /// the font of the [title] is bold. + /// + /// Typically a [Text] widget. + final Widget title; + + /// An optional descriptive message that provides more details about the + /// reason for the alert. + /// + /// Typically a [Text] widget. + final Widget message; + + /// The set of actions that are displayed for the user to select. + /// + /// Typically this is a list of [CupertinoActionSheetAction] widgets. + final List actions; + + /// A scroll controller that can be used to control the scrolling of the + /// [message] in the action sheet. + /// + /// This attribute is typically not needed, as alert messages should be + /// short. + final ScrollController messageScrollController; + + /// A scroll controller that can be used to control the scrolling of the + /// [actions] in the action sheet. + /// + /// This attribute is typically not needed. + final ScrollController actionScrollController; + + /// The optional cancel button that is grouped separately from the other + /// actions. + /// + /// Typically this is an [CupertinoActionSheetAction] widget. + final Widget cancelButton; + + Widget _buildContent() { + final List content = []; + if (title != null || message != null) { + final Widget titleSection = new _CupertinoAlertContentSection( + title: title, + message: message, + scrollController: messageScrollController, + ); + content.add(new Flexible(child: titleSection)); + } + + return new Container( + color: _kBackgroundColor, + child: new Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: content, + ), + ); + } + + Widget _buildActions() { + if (actions == null || actions.isEmpty) { + return new Container( + height: 0.0, + ); + } + return new Container( + child: new _CupertinoAlertActionSection( + children: actions, + scrollController: actionScrollController, + hasCancelButton: cancelButton != null, + ), + ); + } + + Widget _buildCancelButton() { + final double cancelPadding = (actions != null || message != null || title != null) + ? _kCancelButtonPadding : 0.0; + return Padding( + padding: new EdgeInsets.only(top: cancelPadding), + child: new _CupertinoActionSheetCancelButton( + child: cancelButton, + ), + ); + } + + @override + Widget build(BuildContext context) { + final List children = [ + new Flexible(child: new ClipRRect( + borderRadius: new BorderRadius.circular(12.0), + child: new BackdropFilter( + filter: new ImageFilter.blur(sigmaX: _kBlurAmount, sigmaY: _kBlurAmount), + child: new Container( + decoration: _kAlertBlurOverlayDecoration, + child: new _CupertinoAlertRenderWidget( + contentSection: _buildContent(), + actionsSection: _buildActions(), + ), + ), + ), + ), + ), + ]; + + if (cancelButton != null) { + children.add( + _buildCancelButton(), + ); + } + + final Orientation orientation = MediaQuery.of(context).orientation; + double actionSheetWidth; + if (orientation == Orientation.portrait) { + actionSheetWidth = MediaQuery.of(context).size.width - (_kEdgeHorizontalPadding * 2); + } else { + actionSheetWidth = MediaQuery.of(context).size.height - (_kEdgeHorizontalPadding * 2); + } + + return new SafeArea( + child: new Semantics( + namesRoute: true, + scopesRoute: true, + explicitChildNodes: true, + label: 'Alert', + child: new Container( + width: actionSheetWidth, + margin: const EdgeInsets.symmetric( + horizontal: _kEdgeHorizontalPadding, + vertical: _kEdgeVerticalPadding, + ), + child: new Column( + children: children, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + ), + ), + ), + ); + } +} + +/// A button typically used in a [CupertinoActionSheet]. +/// +/// See also: +/// +/// * [CupertinoActionSheet], an alert that presents the user with a set of two or +/// more choices related to the current context. +class CupertinoActionSheetAction extends StatelessWidget { + ///Creates an action for an iOS-style action sheet. + /// + /// The [child] and [onPressed] arguments must not be null. + const CupertinoActionSheetAction({ + @required this.onPressed, + this.isDefaultAction = false, + this.isDestructiveAction = false, + @required this.child, + }) : assert(child != null), + assert(onPressed != null); + + /// The callback that is called when the button is tapped. + /// + /// This attribute must not be null. + final VoidCallback onPressed; + + /// Whether this action is the default choice in the action sheet. + /// + /// Default buttons have bold text. + final bool isDefaultAction; + + /// Whether this action might change or delete data. + /// + /// Destructive buttons have red text. + final bool isDestructiveAction; + + /// The widget below this widget in the tree. + /// + /// Typically a [Text] widget. + final Widget child; + + @override + Widget build(BuildContext context) { + TextStyle style = _kActionSheetActionStyle; + + if (isDefaultAction) { + style = style.copyWith(fontWeight: FontWeight.w600); + } + + if (isDestructiveAction) { + style = style.copyWith(color: CupertinoColors.destructiveRed); + } + + return new GestureDetector( + onTap: onPressed, + behavior: HitTestBehavior.opaque, + child: new ConstrainedBox( + constraints: const BoxConstraints( + minHeight: _kButtonHeight, + ), + child: new Semantics( + button: true, + child: new Container( + alignment: Alignment.center, + padding: const EdgeInsets.symmetric( + vertical: 16.0, + horizontal: 10.0, + ), + child: new DefaultTextStyle( + style: style, + child: child, + textAlign: TextAlign.center, + ), + ), + ), + ), + ); + } +} + +class _CupertinoActionSheetCancelButton extends StatefulWidget { + const _CupertinoActionSheetCancelButton({ + Key key, + this.child, + }) : super(key: key); + + final Widget child; + + @override + _CupertinoActionSheetCancelButtonState createState() => _CupertinoActionSheetCancelButtonState(); +} + +class _CupertinoActionSheetCancelButtonState extends State<_CupertinoActionSheetCancelButton> { + Color _backgroundColor; + + @override + void initState() { + _backgroundColor = CupertinoColors.white; + super.initState(); + } + + void _onTapDown(TapDownDetails event) { + setState(() { + _backgroundColor = _kCancelButtonPressedColor; + }); + } + + void _onTapUp(TapUpDetails event) { + setState(() { + _backgroundColor = CupertinoColors.white; + }); + } + + void _onTapCancel() { + setState(() { + _backgroundColor = CupertinoColors.white; + }); + } + + @override + Widget build(BuildContext context) { + return new GestureDetector( + excludeFromSemantics: true, + onTapDown: _onTapDown, + onTapUp: _onTapUp, + onTapCancel: _onTapCancel, + child: new Container( + decoration: new BoxDecoration( + color: _backgroundColor, + borderRadius: new BorderRadius.circular(_kCornerRadius), + ), + child: widget.child, + ), + ); + } +} + +class _CupertinoAlertRenderWidget extends RenderObjectWidget { + const _CupertinoAlertRenderWidget({ + Key key, + @required this.contentSection, + @required this.actionsSection, + }) : super(key: key); + + final Widget contentSection; + final Widget actionsSection; + + @override + RenderObject createRenderObject(BuildContext context) { + return new _RenderCupertinoAlert( + dividerThickness: _kDividerThickness / MediaQuery.of(context).devicePixelRatio, + ); + } + + @override + RenderObjectElement createElement() { + return _CupertinoAlertRenderElement(this); + } +} + +class _CupertinoAlertRenderElement extends RenderObjectElement { + _CupertinoAlertRenderElement(_CupertinoAlertRenderWidget widget) : super(widget); + + Element _contentElement; + Element _actionsElement; + + @override + _CupertinoAlertRenderWidget get widget => super.widget; + + @override + _RenderCupertinoAlert 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, _AlertSections.contentSection); + _actionsElement = updateChild(_actionsElement, + widget.actionsSection, _AlertSections.actionsSection); + } + + @override + void insertChildRenderObject(RenderObject child, _AlertSections slot) { + _placeChildInSlot(child, slot); + } + + @override + void moveChildRenderObject(RenderObject child, _AlertSections slot) { + _placeChildInSlot(child, slot); + } + + @override + void update(RenderObjectWidget newWidget) { + super.update(newWidget); + _contentElement = updateChild(_contentElement, + widget.contentSection, _AlertSections.contentSection); + _actionsElement = updateChild(_actionsElement, + widget.actionsSection, _AlertSections.actionsSection); + } + + @override + void forgetChild(Element child) { + assert(child == _contentElement || child == _actionsElement); + if (_contentElement == child) { + _contentElement = null; + } else if (_actionsElement == child) { + _actionsElement = null; + } + } + + @override + void removeChildRenderObject(RenderObject child) { + assert(child == renderObject.contentSection || child == renderObject.actionsSection); + if (renderObject.contentSection == child) { + renderObject.contentSection = null; + } else if (renderObject.actionsSection == child) { + renderObject.actionsSection = null; + } + } + + void _placeChildInSlot(RenderObject child, _AlertSections slot) { + assert(slot != null); + switch (slot) { + case _AlertSections.contentSection: + renderObject.contentSection = child; + break; + case _AlertSections.actionsSection: + renderObject.actionsSection = child; + break; + } + } +} + +// An iOS-style layout policy for sizing an alert's content section and action +// button section. +// +// The policy is as follows: +// +// If all content and buttons fit on the screen: +// The content section and action button section are sized intrinsically. +// +// If all content and buttons do not fit on the screen: +// A minimum height for the action button section is calculated. The action +// button section will not be rendered shorter than this minimum. See +// _RenderCupertinoAlertActions for the minimum height calculation. +// +// With the minimum action button section calculated, the content section can +// take up as much of the remaining space as it needs. +// +// 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. +class _RenderCupertinoAlert extends RenderBox { + _RenderCupertinoAlert({ + RenderBox contentSection, + RenderBox actionsSection, + double dividerThickness = 0.0, + }) : _contentSection = contentSection, + _actionsSection = actionsSection, + _dividerThickness = dividerThickness; + + RenderBox get contentSection => _contentSection; + RenderBox _contentSection; + set contentSection(RenderBox newContentSection) { + if (newContentSection != _contentSection) { + if (null != _contentSection) { + dropChild(_contentSection); + } + _contentSection = newContentSection; + if (null != _contentSection) { + 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); + } + } + } + + 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! MultiChildLayoutParentData) { + child.parentData = new MultiChildLayoutParentData(); + } + } + + @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 constraints.minWidth; + } + + @override + double computeMaxIntrinsicWidth(double height) { + return constraints.maxWidth; + } + + @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; + double height = contentHeight + (hasDivider ? _dividerThickness : 0.0) + actionsHeight; + + if (actionsHeight > 0 || contentHeight > 0) + height -= 2 * _kEdgeVerticalPadding; + 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; + double height = contentHeight + (hasDivider ? _dividerThickness : 0.0) + actionsHeight; + + if (actionsHeight > 0 || contentHeight > 0) + height -= 2 * _kEdgeVerticalPadding; + if (height.isFinite) + return height; + return 0.0; + } + + @override + void performLayout() { + final bool hasDivider = contentSection.getMaxIntrinsicHeight(constraints.maxWidth) > 0.0 + && actionsSection.getMaxIntrinsicHeight(constraints.maxWidth) > 0.0; + final double dividerThickness = hasDivider ? _dividerThickness : 0.0; + + final double minActionsHeight = actionsSection.getMinIntrinsicHeight(constraints.maxWidth); + + // Size alert content. + contentSection.layout( + constraints.deflate(new EdgeInsets.only(bottom: minActionsHeight + dividerThickness)), + parentUsesSize: true, + ); + final Size contentSize = contentSection.size; + + // Size alert actions. + actionsSection.layout( + constraints.deflate(new EdgeInsets.only(top: contentSize.height + dividerThickness)), + parentUsesSize: true, + ); + final Size actionsSize = actionsSection.size; + + // Calculate overall alert height. + final double actionSheetHeight = contentSize.height + dividerThickness + actionsSize.height; + + // Set our size now that layout calculations are complete. + size = new Size(constraints.maxWidth, actionSheetHeight); + + // Set the position of the actions box to sit at the bottom of the alert. + // The content box defaults to the top left, which is where we want it. + assert(actionsSection.parentData is MultiChildLayoutParentData); + final MultiChildLayoutParentData actionParentData = actionsSection.parentData; + actionParentData.offset = new Offset(0.0, contentSize.height + dividerThickness); + } + + @override + void paint(PaintingContext context, Offset offset) { + final MultiChildLayoutParentData 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 MultiChildLayoutParentData 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 MultiChildLayoutParentData contentSectionParentData = contentSection.parentData; + final MultiChildLayoutParentData 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 that need to be explicitly sized and +// laid out at runtime. +enum _AlertSections { + contentSection, + actionsSection, +} + +// The "content section" of a CupertinoActionSheet. +// +// If title is missing, then only content is added. If content is +// missing, then only a title is added. If both are missing, then it returns +// a SingleChildScrollView with a zero-sized Container. +class _CupertinoAlertContentSection extends StatelessWidget { + const _CupertinoAlertContentSection({ + Key key, + this.title, + this.message, + this.scrollController, + }) : super(key: key); + + // An optional title of the action sheet. When the message is non-null, + // the font of the title is bold. + // + // Typically a Text widget. + final Widget title; + + // An optional descriptive message that provides more details about the + // reason for the alert. + // + // Typically a Text widget. + final Widget message; + + // A scroll controller that can be used to control the scrolling of the + // content in the action sheet. + // + // Defaults to null, and is typically not needed, since most alert contents + // are short. + final ScrollController scrollController; + + @override + Widget build(BuildContext context) { + final List titleContentGroup = []; + if (title != null) { + titleContentGroup.add(new Padding( + padding: const EdgeInsets.only( + left: _kContentHorizontalPadding, + right: _kContentHorizontalPadding, + bottom: _kContentVerticalPadding, + top: _kContentVerticalPadding, + ), + child: new DefaultTextStyle( + style: message == null ? _kActionSheetContentStyle + : _kActionSheetContentStyle.copyWith(fontWeight: FontWeight.w600), + textAlign: TextAlign.center, + child: title, + ), + )); + } + + if (message != null) { + titleContentGroup.add( + new Padding( + padding: new EdgeInsets.only( + left: _kContentHorizontalPadding, + right: _kContentHorizontalPadding, + bottom: title == null ? _kContentVerticalPadding : 22.0, + top: title == null ? _kContentVerticalPadding : 0.0, + ), + child: new DefaultTextStyle( + style: title == null ? _kActionSheetContentStyle.copyWith(fontWeight: FontWeight.w600) + : _kActionSheetContentStyle, + textAlign: TextAlign.center, + child: message, + ), + ), + ); + } + + if (titleContentGroup.isEmpty) { + return new SingleChildScrollView( + controller: scrollController, + child: new Container( + width: 0.0, + height: 0.0, + ), + ); + } + + // 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, + child: new Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: titleContentGroup, + ), + ), + ); + } +} + +// The "actions section" of a CupertinoActionSheet. +// +// See _RenderCupertinoAlertActions for details about action button sizing +// and layout. +class _CupertinoAlertActionSection extends StatefulWidget { + const _CupertinoAlertActionSection({ + Key key, + @required this.children, + this.scrollController, + this.hasCancelButton, + }) : assert(children != null), + super(key: key); + + final List children; + + // A scroll controller that can be used to control the scrolling of the + // actions in the action sheet. + // + // Defaults to null, and is typically not needed, since most alerts + // don't have many actions. + final ScrollController scrollController; + + final bool hasCancelButton; + + @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 _CupertinoAlertActionsRenderWidget( + actionButtons: interactiveButtons, + dividerThickness: _kDividerThickness / devicePixelRatio, + hasCancelButton: widget.hasCancelButton, + ), + ), + ); + } +} + +// A 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 _RenderCupertinoAlertActions. +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) { + return new _ActionButtonParentDataWidget( + isPressed: _isPressed, + // TODO:(mattcarroll): Button press dynamics need overhaul for iOS: https://github.com/flutter/flutter/issues/19786 + child: new GestureDetector( + excludeFromSemantics: true, + 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 alert 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<_CupertinoAlertActionsRenderWidget> { + 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(); + } + } +} + +// 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, + }); + + bool isPressed; +} + +// An iOS-style alert action button layout. +// +// See _RenderCupertinoAlertActions for specific layout policy details. +class _CupertinoAlertActionsRenderWidget extends MultiChildRenderObjectWidget { + _CupertinoAlertActionsRenderWidget({ + Key key, + @required List actionButtons, + double dividerThickness = 0.0, + bool hasCancelButton = false, + }) : _dividerThickness = dividerThickness, + _hasCancelButton = hasCancelButton, + super(key: key, children: actionButtons); + + final double _dividerThickness; + final bool _hasCancelButton; + + @override + RenderObject createRenderObject(BuildContext context) { + return new _RenderCupertinoAlertActions( + dividerThickness: _dividerThickness, + hasCancelButton: _hasCancelButton, + ); + } + + @override + void updateRenderObject(BuildContext context, _RenderCupertinoAlertActions renderObject) { + renderObject.dividerThickness = _dividerThickness; + renderObject.hasCancelButton = _hasCancelButton; + } +} + +// An iOS-style layout policy for sizing and positioning an action sheet's +// buttons. +// +// The policy is as follows: +// +// Action sheet buttons are always stacked vertically. In the case where the +// content section and the action section combined can not fit on the screen +// without scrolling, the height of the action section is determined as +// follows. +// +// If the user has included a separate cancel button, the height of the action +// section can be up to the height of 3 action buttons (i.e., the user can +// include 1, 2, or 3 action buttons and they will appear without needing to +// be scrolled). If 4+ action buttons are provided, the height of the action +// section shrinks to 1.5 buttons tall, and is scrollable. +// +// If the user has not included a separate cancel button, the height of the +// action section is at most 1.5 buttons tall. +class _RenderCupertinoAlertActions extends RenderBox + with ContainerRenderObjectMixin, + RenderBoxContainerDefaultsMixin { + _RenderCupertinoAlertActions({ + List children, + double dividerThickness = 0.0, + bool hasCancelButton = false, + }) : _dividerThickness = dividerThickness, + _hasCancelButton = hasCancelButton { + addAll(children); + } + + // The thickness of the divider between buttons. + double get dividerThickness => _dividerThickness; + double _dividerThickness; + set dividerThickness(double newValue) { + if (newValue == _dividerThickness) { + return; + } + + _dividerThickness = newValue; + markNeedsLayout(); + } + + bool _hasCancelButton; + bool get hasCancelButton => _hasCancelButton; + set hasCancelButton(bool newValue) { + if (newValue == _hasCancelButton) { + return; + } + + _hasCancelButton = newValue; + markNeedsLayout(); + } + + final Paint _buttonBackgroundPaint = new Paint() + ..color = _kBackgroundColor + ..style = PaintingStyle.fill; + + final Paint _pressedButtonBackgroundPaint = new Paint() + ..color = _kPressedColor + ..style = PaintingStyle.fill; + + final Paint _dividerPaint = new Paint() + ..color = _kButtonDividerColor + ..style = PaintingStyle.fill; + + @override + void setupParentData(RenderBox child) { + if (child.parentData is! _ActionButtonParentData) + child.parentData = new _ActionButtonParentData(); + } + + @override + double computeMinIntrinsicWidth(double height) { + return constraints.minWidth; + } + + @override + double computeMaxIntrinsicWidth(double height) { + return constraints.maxWidth; + } + + @override + double computeMinIntrinsicHeight(double width) { + if (childCount == 0) + return 0.0; + if (childCount == 1) + return firstChild.computeMaxIntrinsicHeight(width) + dividerThickness; + if (hasCancelButton && childCount < 4) + return _computeMinIntrinsicHeightWithCancel(width); + return _computeMinIntrinsicHeightWithoutCancel(width); + } + + // The minimum height for more than 2-3 buttons when a cancel button is + // included is the full height of button stack. + double _computeMinIntrinsicHeightWithCancel(double width) { + assert(childCount == 2 || childCount == 3); + if (childCount == 2) { + return firstChild.getMinIntrinsicHeight(width) + + childAfter(firstChild).getMinIntrinsicHeight(width) + + dividerThickness; + } + return firstChild.getMinIntrinsicHeight(width) + + childAfter(firstChild).getMinIntrinsicHeight(width) + + childAfter(childAfter(firstChild)).getMinIntrinsicHeight(width) + + (dividerThickness * 2); + } + + // The minimum height for more than 2 buttons when no cancel button or 4+ + // buttons when a cancel button is included is the height of the 1st button + // + 50% the height of the 2nd button + 2 dividers. + double _computeMinIntrinsicHeightWithoutCancel(double width) { + assert(childCount >= 2); + return firstChild.getMinIntrinsicHeight(width) + + dividerThickness + + (0.5 * childAfter(firstChild).getMinIntrinsicHeight(width)); + } + + @override + double computeMaxIntrinsicHeight(double width) { + if (childCount == 0) + return 0.0; + if (childCount == 1) + return firstChild.computeMaxIntrinsicHeight(width) + dividerThickness; + return _computeMaxIntrinsicHeightStacked(width); + } + + // 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; + } + + @override + void performLayout() { + 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); + } + + size = constraints.constrain( + new Size(constraints.maxWidth, verticalOffset) + ); + } + + @override + void paint(PaintingContext context, Offset offset) { + final Canvas canvas = context.canvas; + _drawButtonBackgroundsAndDividersStacked(canvas, offset); + _drawButtons(context, offset); + } + + 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 hitTestChildren(HitTestResult result, { Offset position }) { + return defaultHitTestChildren(result, position: position); + } +} diff --git a/packages/flutter/lib/src/cupertino/route.dart b/packages/flutter/lib/src/cupertino/route.dart index 330e91559e1..193a15d85c3 100644 --- a/packages/flutter/lib/src/cupertino/route.dart +++ b/packages/flutter/lib/src/cupertino/route.dart @@ -14,6 +14,9 @@ const double _kMinFlingVelocity = 1.0; // Screen widths per second. // Barrier color for a Cupertino modal barrier. const Color _kModalBarrierColor = Color(0x6604040F); +// The duration of the transition used when a modal popup is shown. +const Duration _kModalPopupTransitionDuration = Duration(milliseconds: 335); + // Offset from offscreen to the right to fully on screen. final Tween _kRightMiddleTween = new Tween( begin: const Offset(1.0, 0.0), @@ -715,6 +718,102 @@ class _CupertinoEdgeShadowPainter extends BoxPainter { } } +class _CupertinoModalPopupRoute extends PopupRoute { + _CupertinoModalPopupRoute({ + this.builder, + this.barrierLabel, + RouteSettings settings, + }) : super(settings: settings); + + final WidgetBuilder builder; + + @override + final String barrierLabel; + + @override + Color get barrierColor => _kModalBarrierColor; + + @override + bool get barrierDismissible => true; + + @override + bool get semanticsDismissible => false; + + @override + Duration get transitionDuration => _kModalPopupTransitionDuration; + + Animation _animation; + + Tween _offsetTween; + + @override + Animation createAnimation() { + assert(_animation == null); + _animation = new CurvedAnimation( + parent: super.createAnimation(), + curve: Curves.ease, + reverseCurve: Curves.ease.flipped, + ); + _offsetTween = new Tween( + begin: const Offset(0.0, 1.0), + end: const Offset(0.0, 0.0), + ); + return _animation; + } + + @override + Widget buildPage(BuildContext context, Animation animation, Animation secondaryAnimation) { + return builder(context); + } + + @override + Widget buildTransitions(BuildContext context, Animation animation, Animation secondaryAnimation, Widget child) { + return new Align( + alignment: Alignment.bottomCenter, + child: new FractionalTranslation( + translation: _offsetTween.evaluate(_animation), + child: child, + ), + ); + } +} + +/// Shows a modal iOS-style popup that slides up from the bottom of the screen. +/// +/// Such a popup is an alternative to a menu or a dialog and prevents the user +/// from interacting with the rest of the app. +/// +/// The `context` argument is used to look up the [Navigator] for the popup. +/// It is only used when the method is called. Its corresponding widget can be +/// safely removed from the tree before the popup is closed. +/// +/// The `builder` argument typically builds a [CupertinoActionSheet] widget. +/// Content below the widget is dimmed with a [ModalBarrier]. The widget built +/// by the `builder` does not share a context with the location that +/// `showCupertinoModalPopup` is originally called from. Use a +/// [StatefulBuilder] or a custom [StatefulWidget] if the widget needs to +/// update dynamically. +/// +/// Returns a `Future` that resolves to the value that was passed to +/// [Navigator.pop] when the popup was closed. +/// +/// See also: +/// +/// * [ActionSheet], which is the widget usually returned by the `builder` +/// argument to [showCupertinoModalPopup]. +/// * +Future showCupertinoModalPopup({ + @required BuildContext context, + @required WidgetBuilder builder, +}) { + return Navigator.of(context, rootNavigator: true).push( + new _CupertinoModalPopupRoute( + builder: builder, + barrierLabel: 'Dismiss', + ), + ); +} + Widget _buildCupertinoDialogTransitions(BuildContext context, Animation animation, Animation secondaryAnimation, Widget child) { final CurvedAnimation fadeAnimation = new CurvedAnimation( parent: animation, diff --git a/packages/flutter/lib/src/painting/box_decoration.dart b/packages/flutter/lib/src/painting/box_decoration.dart index 43e8c0783bc..ed3358b58ba 100644 --- a/packages/flutter/lib/src/painting/box_decoration.dart +++ b/packages/flutter/lib/src/painting/box_decoration.dart @@ -70,6 +70,7 @@ class BoxDecoration extends Decoration { /// [BoxShape.circle]. /// * If [boxShadow] is null, this decoration does not paint a shadow. /// * If [gradient] is null, this decoration does not paint gradients. + /// * If [backgroundBlendMode] is null, this decoration paints with [BlendMode.srcOver] /// /// The [shape] argument must not be null. const BoxDecoration({ @@ -79,13 +80,20 @@ class BoxDecoration extends Decoration { this.borderRadius, this.boxShadow, this.gradient, + this.backgroundBlendMode, this.shape = BoxShape.rectangle, - }) : assert(shape != null); + }) : assert(shape != null), + // TODO(mattcarroll): Use "backgroundBlendMode == null" when Dart #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.' + ); @override bool debugAssertIsValid() { assert(shape != BoxShape.circle || - borderRadius == null); // Can't have a border radius if you're a circle. + borderRadius == null); // Can't have a border radius if you're a circle. return super.debugAssertIsValid(); } @@ -136,6 +144,14 @@ class BoxDecoration extends Decoration { /// The [gradient] is drawn under the [image]. final Gradient gradient; + /// The blend mode applied to the [color] or [gradient] background of the box. + /// + /// 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. + final BlendMode backgroundBlendMode; + /// The shape to fill the background [color], [gradient], and [image] into and /// to cast as the [boxShadow]. /// @@ -332,6 +348,8 @@ class _BoxDecorationPainter extends BoxPainter { if (_cachedBackgroundPaint == null || (_decoration.gradient != null && _rectForCachedBackgroundPaint != rect)) { final Paint paint = new Paint(); + if (_decoration.backgroundBlendMode != null) + paint.blendMode = _decoration.backgroundBlendMode; if (_decoration.color != null) paint.color = _decoration.color; if (_decoration.gradient != null) { diff --git a/packages/flutter/lib/src/widgets/modal_barrier.dart b/packages/flutter/lib/src/widgets/modal_barrier.dart index ec6a90c7fd0..91aa0776484 100644 --- a/packages/flutter/lib/src/widgets/modal_barrier.dart +++ b/packages/flutter/lib/src/widgets/modal_barrier.dart @@ -33,6 +33,7 @@ class ModalBarrier extends StatelessWidget { this.color, this.dismissible = true, this.semanticsLabel, + this.barrierSemanticsDismissible = true, }) : super(key: key); /// If non-null, fill the barrier with this color. @@ -51,6 +52,13 @@ class ModalBarrier extends StatelessWidget { /// [ModalBarrier] built by [ModalRoute] pages. final bool dismissible; + /// Whether the modal barrier semantics are included in the semantics tree. + /// + /// See also: + /// * [ModalRoute.semanticsDismissible], which controls this property for + /// the [ModalBarrier] built by [ModalRoute] pages. + final bool barrierSemanticsDismissible; + /// Semantics label used for the barrier if it is [dismissable]. /// /// The semantics label is read out by accessibility tools (e.g. TalkBack @@ -66,10 +74,12 @@ class ModalBarrier extends StatelessWidget { Widget build(BuildContext context) { assert(!dismissible || semanticsLabel == null || debugCheckHasDirectionality(context)); final bool semanticsDismissible = dismissible && defaultTargetPlatform != TargetPlatform.android; + final bool modalBarrierSemanticsDismissible = barrierSemanticsDismissible ?? semanticsDismissible; return new BlockSemantics( child: new ExcludeSemantics( - // On Android, the back button is used to dismiss a modal. - excluding: !semanticsDismissible, + // On Android, the back button is used to dismiss a modal. On iOS, some + // modal barriers are not dismissible in accessibility mode. + excluding: !semanticsDismissible || !modalBarrierSemanticsDismissible, child: new GestureDetector( onTapDown: (TapDownDetails details) { if (dismissible) @@ -117,6 +127,7 @@ class AnimatedModalBarrier extends AnimatedWidget { Animation color, this.dismissible = true, this.semanticsLabel, + this.barrierSemanticsDismissible, }) : super(key: key, listenable: color); /// If non-null, fill the barrier with this color. @@ -145,12 +156,20 @@ class AnimatedModalBarrier extends AnimatedWidget { /// [ModalBarrier] built by [ModalRoute] pages. final String semanticsLabel; + /// Whether the modal barrier semantics are included in the semantics tree. + /// + /// See also: + /// * [ModalRoute.semanticsDismissible], which controls this property for + /// the [ModalBarrier] built by [ModalRoute] pages. + final bool barrierSemanticsDismissible; + @override Widget build(BuildContext context) { return new ModalBarrier( color: color?.value, dismissible: dismissible, semanticsLabel: semanticsLabel, + barrierSemanticsDismissible: barrierSemanticsDismissible, ); } } diff --git a/packages/flutter/lib/src/widgets/routes.dart b/packages/flutter/lib/src/widgets/routes.dart index aaa4c77d665..ced0c30dab9 100644 --- a/packages/flutter/lib/src/widgets/routes.dart +++ b/packages/flutter/lib/src/widgets/routes.dart @@ -898,6 +898,21 @@ abstract class ModalRoute extends TransitionRoute with LocalHistoryRoute true; + /// The color to use for the modal barrier. If this is null, the barrier will /// be transparent. /// @@ -1173,11 +1188,13 @@ abstract class ModalRoute extends TransitionRoute with LocalHistoryRoute[ + new CupertinoActionSheetAction( + child: const Text('One'), + onPressed: () {}, + ), + new CupertinoActionSheetAction( + child: const Text('Two'), + onPressed: () {}, + ), + ], + actionScrollController: actionScrollController, + ), + ), + ); + + await tester.tap(find.text('Go')); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + final Finder finder = find.byElementPredicate( + (Element element) { + return element.widget.runtimeType.toString() == '_CupertinoAlertActionSection'; + }, + ); + + // Check that the title/message section is not displayed (action section is + // at the top of the action sheet + padding). + expect(tester.getTopLeft(finder), + tester.getTopLeft(find.byType(CupertinoActionSheet)) + const Offset(8.0, 10.0)); + + expect(tester.getTopLeft(find.byType(CupertinoActionSheet)) + const Offset(8.0, 10.0), + tester.getTopLeft(find.widgetWithText(CupertinoActionSheetAction, 'One'))); + expect(tester.getBottomLeft(find.byType(CupertinoActionSheet)) + const Offset(8.0, -10.0), + tester.getBottomLeft(find.widgetWithText(CupertinoActionSheetAction, 'Two'))); + }); + + testWidgets('Action section is scrollable', (WidgetTester tester) async { + final ScrollController actionScrollController = new ScrollController(); + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + new Builder(builder: (BuildContext context) { + return new MediaQuery( + data: MediaQuery.of(context).copyWith(textScaleFactor: 3.0), + child: new CupertinoActionSheet( + title: const Text('The title'), + message: const Text('The message.'), + actions: [ + new CupertinoActionSheetAction( + child: const Text('One'), + onPressed: () {}, + ), + new CupertinoActionSheetAction( + child: const Text('Two'), + onPressed: () {}, + ), + new CupertinoActionSheetAction( + child: const Text('Three'), + onPressed: () {}, + ), + new CupertinoActionSheetAction( + child: const Text('Four'), + onPressed: () {}, + ), + new CupertinoActionSheetAction( + child: const Text('Five'), + onPressed: () {}, + ), + ], + 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(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(CupertinoActionSheetAction, 'One')).dx, equals(400.0)); + expect(tester.getCenter(find.widgetWithText(CupertinoActionSheetAction, 'Two')).dx, equals(400.0)); + expect(tester.getCenter(find.widgetWithText(CupertinoActionSheetAction, 'Three')).dx, equals(400.0)); + expect(tester.getCenter(find.widgetWithText(CupertinoActionSheetAction, 'Four')).dx, equals(400.0)); + expect(tester.getCenter(find.widgetWithText(CupertinoActionSheetAction, 'Five')).dx, equals(400.0)); + + // Check that the action buttons are the correct heights. + expect(tester.getSize(find.widgetWithText(CupertinoActionSheetAction, 'One')).height, equals(92.0)); + expect(tester.getSize(find.widgetWithText(CupertinoActionSheetAction, 'Two')).height, equals(92.0)); + expect(tester.getSize(find.widgetWithText(CupertinoActionSheetAction, 'Three')).height, equals(92.0)); + expect(tester.getSize(find.widgetWithText(CupertinoActionSheetAction, 'Four')).height, equals(92.0)); + expect(tester.getSize(find.widgetWithText(CupertinoActionSheetAction, 'Five')).height, equals(92.0)); + }); + + testWidgets('Content section is scrollable', (WidgetTester tester) async { + final ScrollController messageScrollController = new ScrollController(); + double screenHeight; + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + new Builder(builder: (BuildContext context) { + screenHeight = MediaQuery.of(context).size.height; + return new MediaQuery( + data: MediaQuery.of(context).copyWith(textScaleFactor: 3.0), + child: new CupertinoActionSheet( + title: const Text('The title'), + message: new Text('Very long content' * 200), + actions: [ + new CupertinoActionSheetAction( + child: const Text('One'), + onPressed: () {}, + ), + new CupertinoActionSheetAction( + child: const Text('Two'), + onPressed: () {}, + ), + ], + messageScrollController: messageScrollController, + ), + ); + }), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pump(); + + expect(messageScrollController.offset, 0.0); + messageScrollController.jumpTo(100.0); + expect(messageScrollController.offset, 100.0); + // Set the scroll position back to zero. + messageScrollController.jumpTo(0.0); + + // Expect the action sheet to take all available height. + expect(tester.getSize(find.byType(CupertinoActionSheet)).height, screenHeight); + }); + + testWidgets('Tap on button calls onPressed', (WidgetTester tester) async { + bool wasPressed = false; + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + new Builder(builder: (BuildContext context) { + return new CupertinoActionSheet( + actions: [ + new CupertinoActionSheetAction( + child: const Text('One'), + onPressed: () { + wasPressed = true; + Navigator.pop(context); + }, + ), + ], + ); + }), + ), + ); + + await tester.tap(find.text('Go')); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(wasPressed, isFalse); + + await tester.tap(find.text('One')); + + expect(wasPressed, isTrue); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(find.text('One'), findsNothing); + }); + + testWidgets('Action sheet width is correct when given infinite horizontal space', + (WidgetTester tester) async { + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + new Row( + children: [ + new CupertinoActionSheet( + actions: [ + new CupertinoActionSheetAction( + child: const Text('One'), + onPressed: () {}, + ), + new CupertinoActionSheetAction( + child: const Text('Two'), + onPressed: () {}, + ), + ], + ), + ], + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pump(); + + expect(tester.getSize(find.byType(CupertinoActionSheet)).width, 600.0); + }); + + testWidgets('Action sheet height is correct when given infinite vertical space', + (WidgetTester tester) async { + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + new Column( + children: [ + new CupertinoActionSheet( + actions: [ + new CupertinoActionSheetAction( + child: const Text('One'), + onPressed: () {}, + ), + new CupertinoActionSheetAction( + child: const Text('Two'), + onPressed: () {}, + ), + ], + ), + ], + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pump(); + + expect(tester.getSize(find.byType(CupertinoActionSheet)).height, + moreOrLessEquals(132.33333333333334)); + }); + + testWidgets('1 action button with cancel button', (WidgetTester tester) async { + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + new CupertinoActionSheet( + title: const Text('The title'), + message: new Text('Very long content' * 200), + actions: [ + new CupertinoActionSheetAction( + child: const Text('One'), + onPressed: () {}, + ), + ], + cancelButton: new CupertinoActionSheetAction( + child: const Text('Cancel'), + onPressed: () {}, + ), + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pump(); + + // Action section is size of one action button. + expect(findScrollableActionsSectionRenderBox(tester).size.height, 56.0); + }); + + testWidgets('2 action buttons with cancel button', (WidgetTester tester) async { + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + new CupertinoActionSheet( + title: const Text('The title'), + message: new Text('Very long content' * 200), + actions: [ + new CupertinoActionSheetAction( + child: const Text('One'), + onPressed: () {}, + ), + new CupertinoActionSheetAction( + child: const Text('Two'), + onPressed: () {}, + ), + ], + cancelButton: new CupertinoActionSheetAction( + child: const Text('Cancel'), + onPressed: () {}, + ), + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pump(); + + expect(findScrollableActionsSectionRenderBox(tester).size.height, + moreOrLessEquals(112.33333333333331)); + }); + + testWidgets('3 action buttons with cancel button', (WidgetTester tester) async { + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + new CupertinoActionSheet( + title: const Text('The title'), + message: new Text('Very long content' * 200), + actions: [ + new CupertinoActionSheetAction( + child: const Text('One'), + onPressed: () {}, + ), + new CupertinoActionSheetAction( + child: const Text('Two'), + onPressed: () {}, + ), + new CupertinoActionSheetAction( + child: const Text('Three'), + onPressed: () {}, + ), + ], + cancelButton: new CupertinoActionSheetAction( + child: const Text('Cancel'), + onPressed: () {}, + ), + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pump(); + + expect(findScrollableActionsSectionRenderBox(tester).size.height, + moreOrLessEquals(168.66666666666669)); + }); + + testWidgets('4+ action buttons with cancel button', (WidgetTester tester) async { + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + new CupertinoActionSheet( + title: const Text('The title'), + message: new Text('Very long content' * 200), + actions: [ + new CupertinoActionSheetAction( + child: const Text('One'), + onPressed: () {}, + ), + new CupertinoActionSheetAction( + child: const Text('Two'), + onPressed: () {}, + ), + new CupertinoActionSheetAction( + child: const Text('Three'), + onPressed: () {}, + ), + new CupertinoActionSheetAction( + child: const Text('Four'), + onPressed: () {}, + ), + ], + cancelButton: new CupertinoActionSheetAction( + child: const Text('Cancel'), + onPressed: () {}, + ), + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pump(); + + expect(findScrollableActionsSectionRenderBox(tester).size.height, + moreOrLessEquals(84.33333333333337)); + }); + + testWidgets('1 action button without cancel button', (WidgetTester tester) async { + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + new CupertinoActionSheet( + title: const Text('The title'), + message: new Text('Very long content' * 200), + actions: [ + new CupertinoActionSheetAction( + child: const Text('One'), + onPressed: () {}, + ), + ], + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pump(); + + expect(findScrollableActionsSectionRenderBox(tester).size.height, 56.0); + }); + + testWidgets('2+ action buttons without cancel button', (WidgetTester tester) async { + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + new CupertinoActionSheet( + title: const Text('The title'), + message: new Text('Very long content' * 200), + actions: [ + new CupertinoActionSheetAction( + child: const Text('One'), + onPressed: () {}, + ), + new CupertinoActionSheetAction( + child: const Text('Two'), + onPressed: () {}, + ), + ], + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pump(); + + expect(findScrollableActionsSectionRenderBox(tester).size.height, + moreOrLessEquals(84.33333333333337)); + }); + + testWidgets('Action sheet with just cancel button is correct', (WidgetTester tester) async { + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + new CupertinoActionSheet( + cancelButton: new CupertinoActionSheetAction( + child: const Text('Cancel'), + onPressed: (){}, + ), + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pump(); + + // Height should be cancel button height + padding + expect(tester.getSize(find.byType(CupertinoActionSheet)).height, 76.0); + expect(tester.getSize(find.byType(CupertinoActionSheet)).width, 600.0); + }); + + testWidgets('Cancel button tap calls onPressed', (WidgetTester tester) async { + bool wasPressed = false; + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + new Builder(builder: (BuildContext context) { + return new CupertinoActionSheet( + cancelButton: new CupertinoActionSheetAction( + child: const Text('Cancel'), + onPressed: () { + wasPressed = true; + Navigator.pop(context); + }, + ), + ); + }), + ), + ); + + await tester.tap(find.text('Go')); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(wasPressed, isFalse); + + await tester.tap(find.text('Cancel')); + + expect(wasPressed, isTrue); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(find.text('Cancel'), findsNothing); + }); + + testWidgets('Layout is correct when cancel button is present', (WidgetTester tester) async { + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + new CupertinoActionSheet( + title: const Text('The title'), + message: const Text('The message'), + actions: [ + new CupertinoActionSheetAction( + child: const Text('One'), + onPressed: () {}, + ), + new CupertinoActionSheetAction( + child: const Text('Two'), + onPressed: () {}, + ), + ], + cancelButton: new CupertinoActionSheetAction( + child: const Text('Cancel'), + onPressed: () {}, + ), + ), + ), + ); + + await tester.tap(find.text('Go')); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(tester.getBottomLeft(find.widgetWithText(CupertinoActionSheetAction, 'Cancel')).dy, 590.0); + expect(tester.getBottomLeft(find.widgetWithText(CupertinoActionSheetAction, 'One')).dy, + moreOrLessEquals(469.66666666666663)); + expect(tester.getBottomLeft(find.widgetWithText(CupertinoActionSheetAction, 'Two')).dy, 526.0); + }); + + testWidgets('Enter/exit animation is correct', (WidgetTester tester) async { + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + new CupertinoActionSheet( + title: const Text('The title'), + message: const Text('The message'), + actions: [ + new CupertinoActionSheetAction( + child: const Text('One'), + onPressed: () {}, + ), + new CupertinoActionSheetAction( + child: const Text('Two'), + onPressed: () {}, + ), + ], + cancelButton: new CupertinoActionSheetAction( + child: const Text('Cancel'), + onPressed: () {}, + ), + ), + ), + ); + + // Enter animation + await tester.tap(find.text('Go')); + + await tester.pump(); + expect(tester.getTopLeft(find.byType(CupertinoActionSheet)).dy, 600.0); + + await tester.pump(const Duration(milliseconds: 60)); + expect(tester.getTopLeft(find.byType(CupertinoActionSheet)).dy, closeTo(530.9, 0.1)); + + await tester.pump(const Duration(milliseconds: 60)); + expect(tester.getTopLeft(find.byType(CupertinoActionSheet)).dy, closeTo(426.7, 0.1)); + + await tester.pump(const Duration(milliseconds: 60)); + expect(tester.getTopLeft(find.byType(CupertinoActionSheet)).dy, closeTo(365.0, 0.1)); + + await tester.pump(const Duration(milliseconds: 60)); + expect(tester.getTopLeft(find.byType(CupertinoActionSheet)).dy, closeTo(334.0, 0.1)); + + await tester.pump(const Duration(milliseconds: 60)); + expect(tester.getTopLeft(find.byType(CupertinoActionSheet)).dy, closeTo(321.0, 0.1)); + + await tester.pump(const Duration(milliseconds: 60)); + expect(tester.getTopLeft(find.byType(CupertinoActionSheet)).dy, closeTo(319.3, 0.1)); + + // Action sheet has reached final height + await tester.pump(const Duration(milliseconds: 60)); + expect(tester.getTopLeft(find.byType(CupertinoActionSheet)).dy, closeTo(319.3, 0.1)); + + // Exit animation + await tester.tapAt(const Offset(20.0, 20.0)); + await tester.pump(); + expect(tester.getTopLeft(find.byType(CupertinoActionSheet)).dy, closeTo(319.3, 0.1)); + + await tester.pump(const Duration(milliseconds: 60)); + expect(tester.getTopLeft(find.byType(CupertinoActionSheet)).dy, closeTo(388.4, 0.1)); + + await tester.pump(const Duration(milliseconds: 60)); + expect(tester.getTopLeft(find.byType(CupertinoActionSheet)).dy, closeTo(492.6, 0.1)); + + await tester.pump(const Duration(milliseconds: 60)); + expect(tester.getTopLeft(find.byType(CupertinoActionSheet)).dy, closeTo(554.2, 0.1)); + + await tester.pump(const Duration(milliseconds: 60)); + expect(tester.getTopLeft(find.byType(CupertinoActionSheet)).dy, closeTo(585.2, 0.1)); + + await tester.pump(const Duration(milliseconds: 60)); + expect(tester.getTopLeft(find.byType(CupertinoActionSheet)).dy, closeTo(598.2, 0.1)); + + // Action sheet has disappeared + await tester.pump(const Duration(milliseconds: 60)); + expect(find.byType(CupertinoActionSheet), findsNothing); + }); + + testWidgets('Modal barrier is pressed during transition', (WidgetTester tester) async { + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + new CupertinoActionSheet( + title: const Text('The title'), + message: const Text('The message'), + actions: [ + new CupertinoActionSheetAction( + child: const Text('One'), + onPressed: () {}, + ), + new CupertinoActionSheetAction( + child: const Text('Two'), + onPressed: () {}, + ), + ], + cancelButton: new CupertinoActionSheetAction( + child: const Text('Cancel'), + onPressed: () {}, + ), + ), + ), + ); + + // Enter animation + await tester.tap(find.text('Go')); + + await tester.pump(); + expect(tester.getTopLeft(find.byType(CupertinoActionSheet)).dy, 600.0); + + await tester.pump(const Duration(milliseconds: 60)); + expect(tester.getTopLeft(find.byType(CupertinoActionSheet)).dy, closeTo(530.9, 0.1)); + + await tester.pump(const Duration(milliseconds: 60)); + expect(tester.getTopLeft(find.byType(CupertinoActionSheet)).dy, closeTo(426.7, 0.1)); + + await tester.pump(const Duration(milliseconds: 60)); + expect(tester.getTopLeft(find.byType(CupertinoActionSheet)).dy, closeTo(365.0, 0.1)); + + // Exit animation + await tester.tapAt(const Offset(20.0, 20.0)); + await tester.pump(const Duration(milliseconds: 60)); + + await tester.pump(const Duration(milliseconds: 60)); + expect(tester.getTopLeft(find.byType(CupertinoActionSheet)).dy, closeTo(426.7, 0.1)); + + await tester.pump(const Duration(milliseconds: 60)); + expect(tester.getTopLeft(find.byType(CupertinoActionSheet)).dy, closeTo(530.9, 0.1)); + + await tester.pump(const Duration(milliseconds: 60)); + expect(tester.getTopLeft(find.byType(CupertinoActionSheet)).dy, 600.0); + + // Action sheet has disappeared + await tester.pump(const Duration(milliseconds: 60)); + expect(find.byType(CupertinoActionSheet), findsNothing); + }); + + + testWidgets('Action sheet semantics', (WidgetTester tester) async { + final SemanticsTester semantics = new SemanticsTester(tester); + + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + new CupertinoActionSheet( + title: const Text('The title'), + message: const Text('The message'), + actions: [ + new CupertinoActionSheetAction( + child: const Text('One'), + onPressed: () {}, + ), + new CupertinoActionSheetAction( + child: const Text('Two'), + onPressed: () {}, + ), + ], + cancelButton: new CupertinoActionSheetAction( + child: const Text('Cancel'), + onPressed: () {}, + ), + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pump(); + + expect( + semantics, + hasSemantics( + new TestSemantics.root( + children: [ + new TestSemantics( + children: [ + new TestSemantics( + flags: [ + SemanticsFlag.scopesRoute, + SemanticsFlag.namesRoute, + ], + label: 'Alert', + children: [ + new TestSemantics( + children: [ + new TestSemantics( + label: 'The title', + ), + new TestSemantics( + label: 'The message', + ), + ], + ), + new TestSemantics( + children: [ + new TestSemantics( + flags: [ + SemanticsFlag.isButton, + ], + actions: [ + SemanticsAction.tap, + ], + label: 'One', + ), + new TestSemantics( + flags: [ + SemanticsFlag.isButton, + ], + actions: [ + SemanticsAction.tap, + ], + label: 'Two', + ), + ], + ), + new TestSemantics( + flags: [ + SemanticsFlag.isButton, + ], + actions: [ + SemanticsAction.tap, + ], + label: 'Cancel', + ) + ], + ), + ], + ), + ], + ), + ignoreId: true, + ignoreRect: true, + ignoreTransform: true, + ), + ); + + semantics.dispose(); + }); +} + +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 createAppWithButtonThatLaunchesActionSheet(Widget actionSheet) { + return new CupertinoApp( + home: new Center( + child: new Builder(builder: (BuildContext context) { + return new CupertinoButton( + onPressed: () { + showCupertinoModalPopup( + context: context, + builder: (BuildContext context) { + return actionSheet; + }, + ); + }, + child: const Text('Go'), + ); + }), + ), + ); +} + +Widget boilerplate(Widget child) { + return new Directionality( + textDirection: TextDirection.ltr, + child: child, + ); +} \ No newline at end of file