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