diff --git a/packages/flutter/lib/src/cupertino/dialog.dart b/packages/flutter/lib/src/cupertino/dialog.dart index 257666e2a00..153ceb4e50f 100644 --- a/packages/flutter/lib/src/cupertino/dialog.dart +++ b/packages/flutter/lib/src/cupertino/dialog.dart @@ -90,6 +90,7 @@ const double _kActionSheetEdgeVerticalPadding = 10.0; const double _kActionSheetContentHorizontalPadding = 16.0; const double _kActionSheetContentVerticalPadding = 12.0; const double _kActionSheetButtonHeight = 56.0; +const double _kActionSheetActionsSectionMinHeight = 84.3; // A translucent color that is painted on top of the blurred backdrop as the // dialog's background color @@ -561,9 +562,11 @@ class _CupertinoActionSheetState extends State { super.dispose(); } + bool get hasContent => widget.title != null || widget.message != null; + Widget _buildContent(BuildContext context) { final List content = []; - if (widget.title != null || widget.message != null) { + if (hasContent) { final Widget titleSection = _CupertinoAlertContentSection( title: widget.title, message: widget.message, @@ -601,28 +604,16 @@ class _CupertinoActionSheetState extends State { ); } - Widget _buildActions() { - if (widget.actions == null || widget.actions!.isEmpty) { - return const LimitedBox( - maxWidth: 0, - child: SizedBox(width: double.infinity, height: 0), - ); - } - return _CupertinoAlertActionSection( - scrollController: _effectiveActionScrollController, - hasCancelButton: widget.cancelButton != null, - isActionSheet: true, - children: widget.actions!, - ); - } - Widget _buildCancelButton() { + assert(widget.cancelButton != null); final double cancelPadding = (widget.actions != null || widget.message != null || widget.title != null) ? _kActionSheetCancelButtonPadding : 0.0; return Padding( padding: EdgeInsets.only(top: cancelPadding), - child: _CupertinoActionSheetCancelButton( - child: widget.cancelButton, + child: _ActionSheetButtonBackground( + isCancel: true, + onPressStateChange: (_) {}, + child: widget.cancelButton!, ), ); } @@ -632,15 +623,18 @@ class _CupertinoActionSheetState extends State { assert(debugCheckHasMediaQuery(context)); final List children = [ - Flexible(child: ClipRRect( + Flexible( + child: ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(12.0)), child: BackdropFilter( filter: ImageFilter.blur(sigmaX: _kBlurAmount, sigmaY: _kBlurAmount), - child: _CupertinoDialogRenderWidget( + child: _ActionSheetMainSheet( + scrollController: _effectiveActionScrollController, + hasContent: hasContent, + hasCancelButton: widget.cancelButton != null, contentSection: Builder(builder: _buildContent), - actionsSection: _buildActions(), + actions: widget.actions, dividerColor: _kActionSheetButtonDividerColor, - isActionSheet: true, ), ), ), @@ -684,7 +678,10 @@ class _CupertinoActionSheetState extends State { } } -/// A button typically used in a [CupertinoActionSheet]. +/// The content of a typical action button in a [CupertinoActionSheet]. +/// +/// This widget draws the content of a button, i.e. the text, while the +/// background of the button is drawn by [CupertinoActionSheet]. /// /// See also: /// @@ -759,37 +756,63 @@ class CupertinoActionSheetAction extends StatelessWidget { } } -class _CupertinoActionSheetCancelButton extends StatefulWidget { - const _CupertinoActionSheetCancelButton({ - this.child, +// Renders the background of a button (both the pressed background and the idle +// background) and reports its state to the parent with `onPressStateChange`. +class _ActionSheetButtonBackground extends StatefulWidget { + const _ActionSheetButtonBackground({ + this.isCancel = false, + this.onPressStateChange, + required this.child, }); - final Widget? child; + final bool isCancel; + + /// Called when the user taps down or lifts up on the button. + /// + /// The boolean value is true if the user is tapping down on the button. + final ValueSetter? onPressStateChange; + + /// The widget below this widget in the tree. + /// + /// Typically a [Text] widget. + final Widget child; @override - _CupertinoActionSheetCancelButtonState createState() => _CupertinoActionSheetCancelButtonState(); + _ActionSheetButtonBackgroundState createState() => _ActionSheetButtonBackgroundState(); } -class _CupertinoActionSheetCancelButtonState extends State<_CupertinoActionSheetCancelButton> { +class _ActionSheetButtonBackgroundState extends State<_ActionSheetButtonBackground> { bool isBeingPressed = false; void _onTapDown(TapDownDetails event) { setState(() { isBeingPressed = true; }); + widget.onPressStateChange?.call(true); } void _onTapUp(TapUpDetails event) { setState(() { isBeingPressed = false; }); + widget.onPressStateChange?.call(false); } void _onTapCancel() { setState(() { isBeingPressed = false; }); + widget.onPressStateChange?.call(false); } @override Widget build(BuildContext context) { - final Color backgroundColor = isBeingPressed - ? _kActionSheetCancelPressedColor + late final Color backgroundColor; + BorderRadius? borderRadius; + if (!widget.isCancel) { + backgroundColor = isBeingPressed + ? _kPressedColor + : CupertinoDynamicColor.resolve(_kActionSheetBackgroundColor, context); + } else { + backgroundColor = isBeingPressed + ? _kActionSheetCancelPressedColor : CupertinoColors.secondarySystemGroupedBackground; + borderRadius = const BorderRadius.all(Radius.circular(_kCornerRadius)); + } return GestureDetector( excludeFromSemantics: true, onTapDown: _onTapDown, @@ -797,15 +820,251 @@ class _CupertinoActionSheetCancelButtonState extends State<_CupertinoActionSheet onTapCancel: _onTapCancel, child: Container( decoration: BoxDecoration( - color: CupertinoDynamicColor.resolve(backgroundColor, context), - borderRadius: const BorderRadius.all(Radius.circular(_kCornerRadius)), + color: backgroundColor, + borderRadius: borderRadius, ), child: widget.child, + ) + ); + } +} + +// The divider of an action sheet. +// +// If the divider is not `hidden`, then it displays the `dividerColor`. +// Otherwise it displays the background color. A divider is hidden when either +// of its neighbor button is pressed. +class _ActionSheetDivider extends StatelessWidget { + const _ActionSheetDivider({ + required this.dividerColor, + required this.hidden, + }); + + final Color dividerColor; + final bool hidden; + + @override + Widget build(BuildContext context) { + final Color backgroundColor = CupertinoDynamicColor.resolve(_kActionSheetBackgroundColor, context); + return Container( + height: _kDividerThickness, + decoration: BoxDecoration( + color: hidden ? backgroundColor : dividerColor, ), ); } } +typedef _PressedUpdateHandler = void Function(int actionIndex, bool state); + +// The list of actions in an action sheet. +// +// This excludes the divider between the action section and the content section. +class _ActionSheetActionSection extends StatelessWidget { + const _ActionSheetActionSection({ + required this.actions, + required this.pressedIndex, + required this.dividerColor, + required this.backgroundColor, + required this.onPressedUpdate, + required this.scrollController, + }); + + final List? actions; + final _PressedUpdateHandler onPressedUpdate; + final int? pressedIndex; + final Color dividerColor; + final Color backgroundColor; + final ScrollController? scrollController; + + @override + Widget build(BuildContext context) { + if (actions == null || actions!.isEmpty) { + return const LimitedBox( + maxWidth: 0, + child: SizedBox(width: double.infinity, height: 0), + ); + } + final List column = []; + for (int actionIndex = 0; actionIndex < actions!.length; actionIndex += 1) { + if (actionIndex != 0) { + column.add(_ActionSheetDivider( + dividerColor: dividerColor, + hidden: pressedIndex == actionIndex - 1 || pressedIndex == actionIndex, + )); + } + column.add(_ActionSheetButtonBackground( + onPressStateChange: (bool state) { + onPressedUpdate(actionIndex, state); + }, + child: actions![actionIndex], + )); + } + return CupertinoScrollbar( + controller: scrollController, + child: SingleChildScrollView( + controller: scrollController, + child: Column( + children: column, + ), + ), + ); + } +} + +// The part of an action sheet without the cancel button. +class _ActionSheetMainSheet extends StatefulWidget { + const _ActionSheetMainSheet({ + required this.scrollController, + required this.actions, + required this.hasContent, + required this.hasCancelButton, + required this.contentSection, + required this.dividerColor, + }); + + final ScrollController? scrollController; + final List? actions; + final bool hasContent; + final bool hasCancelButton; + final Widget contentSection; + final Color dividerColor; + + @override + _ActionSheetMainSheetState createState() => _ActionSheetMainSheetState(); +} + +class _ActionSheetMainSheetState extends State<_ActionSheetMainSheet> { + int? _pressedIndex; + double _topOverscroll = 0; + double _bottomOverscroll = 0; + + // Fills the overscroll area at the top and bottom of the sheet. This is + // necessary because the action section's background is rendered by the + // buttons, so that a button's background can be _replaced_ by a different + // color when the button is pressed. + Widget _buildOverscroll() { + final Color backgroundColor = CupertinoDynamicColor.resolve(_kActionSheetBackgroundColor, context); + return Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + color: backgroundColor, + height: _topOverscroll, + ), + Container( + color: backgroundColor, + height: _bottomOverscroll, + ), + ], + ); + } + + bool _onScrollUpdate(ScrollUpdateNotification notification) { + final ScrollMetrics metrics = notification.metrics; + setState(() { + _topOverscroll = math.max(metrics.minScrollExtent - metrics.pixels, 0); + _bottomOverscroll = math.max(metrics.pixels - metrics.maxScrollExtent, 0); + }); + return false; + } + + bool _hasActions() => (widget.actions?.length ?? 0) != 0; + + // If `aggressivelyLayout` is true, then the content section takes as much + // space as needed up to `maxHeight`. + // + // If `aggressivelyLayout` is false, then the content section takes whatever + // space is left by the other sections. + Widget _buildContent({ + required bool hasActions, + required bool aggressivelyLayout, + required double maxHeight, + }) { + if (hasActions && aggressivelyLayout) { + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: maxHeight, + ), + child: widget.contentSection, + ); + } + return Flexible( + child: widget.contentSection, + ); + } + + void _onPressedUpdate(int actionIndex, bool state) { + if (!state) { + if (_pressedIndex == actionIndex) { + setState(() { + _pressedIndex = null; + }); + } + } else { + setState(() { + _pressedIndex = actionIndex; + }); + } + } + + @override + Widget build(BuildContext context) { + // The layout rule: + // + // 1. If there are <= 3 buttons and a cancel button, or 1 button without a + // cancel button, then the actions section should never scroll. + // 2. Otherwise, then the content section takes priority to take over spaces + // but must leave at least `actionsMinHeight` for the actions section. + final int numActions = widget.actions?.length ?? 0; + final bool actionsMightScroll = + (numActions > 3 && widget.hasCancelButton) || + (numActions > 1 && !widget.hasCancelButton) ; + final Color backgroundColor = CupertinoDynamicColor.resolve(_kActionSheetBackgroundColor, context); + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildContent( + hasActions: _hasActions(), + aggressivelyLayout: actionsMightScroll, + maxHeight: constraints.maxHeight - _kActionSheetActionsSectionMinHeight - _kDividerThickness, + ), + if (widget.hasContent && _hasActions()) + _ActionSheetDivider( + dividerColor: widget.dividerColor, + hidden: false, + ), + Flexible( + flex: actionsMightScroll ? 1 : 0, + child: Stack( + children: [ + Positioned.fill( + child: _buildOverscroll(), + ), + NotificationListener( + onNotification: _onScrollUpdate, + child: _ActionSheetActionSection( + actions: widget.actions, + scrollController: widget.scrollController, + pressedIndex: _pressedIndex, + dividerColor: widget.dividerColor, + backgroundColor: backgroundColor, + onPressedUpdate: _onPressedUpdate, + ), + ), + ], + ), + ), + ], + ); + }, + ); + } +} + // iOS style layout policy widget for sizing an alert dialog's content section and // action button section. // @@ -815,44 +1074,36 @@ class _CupertinoDialogRenderWidget extends RenderObjectWidget { required this.contentSection, required this.actionsSection, required this.dividerColor, - this.isActionSheet = false, }); final Widget contentSection; final Widget actionsSection; final Color dividerColor; - final bool isActionSheet; @override RenderObject createRenderObject(BuildContext context) { return _RenderCupertinoDialog( dividerThickness: _kDividerThickness, - isInAccessibilityMode: _isInAccessibilityMode(context) && !isActionSheet, + isInAccessibilityMode: _isInAccessibilityMode(context), dividerColor: CupertinoDynamicColor.resolve(dividerColor, context), - isActionSheet: isActionSheet, ); } @override void updateRenderObject(BuildContext context, _RenderCupertinoDialog renderObject) { renderObject - ..isInAccessibilityMode = _isInAccessibilityMode(context) && !isActionSheet - ..dividerColor = CupertinoDynamicColor.resolve(dividerColor, context) - ..isActionSheet = isActionSheet; + ..isInAccessibilityMode = _isInAccessibilityMode(context) + ..dividerColor = CupertinoDynamicColor.resolve(dividerColor, context); } @override RenderObjectElement createElement() { - return _CupertinoDialogRenderElement(this, allowMoveRenderObjectChild: isActionSheet); + return _CupertinoDialogRenderElement(this); } } class _CupertinoDialogRenderElement extends RenderObjectElement { - _CupertinoDialogRenderElement(_CupertinoDialogRenderWidget super.widget, {this.allowMoveRenderObjectChild = false}); - - // Whether to allow overridden method moveRenderObjectChild call or default to super. - // CupertinoActionSheet should default to [super] but CupertinoAlertDialog not. - final bool allowMoveRenderObjectChild; + _CupertinoDialogRenderElement(_CupertinoDialogRenderWidget super.widget); Element? _contentElement; Element? _actionsElement; @@ -885,12 +1136,8 @@ class _CupertinoDialogRenderElement extends RenderObjectElement { @override void moveRenderObjectChild(RenderObject child, _AlertDialogSections oldSlot, _AlertDialogSections newSlot) { - if (!allowMoveRenderObjectChild) { - assert(false); - return; - } - - _placeChildInSlot(child, newSlot); + assert(false); + return; } @override @@ -964,13 +1211,11 @@ class _RenderCupertinoDialog extends RenderBox { RenderBox? actionsSection, double dividerThickness = 0.0, bool isInAccessibilityMode = false, - bool isActionSheet = false, required Color dividerColor, }) : _contentSection = contentSection, _actionsSection = actionsSection, _dividerThickness = dividerThickness, _isInAccessibilityMode = isInAccessibilityMode, - _isActionSheet = isActionSheet, _dividerPaint = Paint() ..color = dividerColor ..style = PaintingStyle.fill; @@ -1012,15 +1257,6 @@ class _RenderCupertinoDialog extends RenderBox { } } - bool _isActionSheet; - bool get isActionSheet => _isActionSheet; - set isActionSheet(bool newValue) { - if (newValue != _isActionSheet) { - _isActionSheet = newValue; - markNeedsLayout(); - } - } - double get _dialogWidth => isInAccessibilityMode ? _kAccessibilityCupertinoDialogWidth : _kCupertinoDialogWidth; @@ -1072,7 +1308,7 @@ class _RenderCupertinoDialog extends RenderBox { @override void setupParentData(RenderBox child) { - if (!isActionSheet && child.parentData is! BoxParentData) { + if (child.parentData is! BoxParentData) { child.parentData = BoxParentData(); } else if (child.parentData is! MultiChildLayoutParentData) { child.parentData = MultiChildLayoutParentData(); @@ -1097,12 +1333,12 @@ class _RenderCupertinoDialog extends RenderBox { @override double computeMinIntrinsicWidth(double height) { - return isActionSheet ? constraints.minWidth : _dialogWidth; + return _dialogWidth; } @override double computeMaxIntrinsicWidth(double height) { - return isActionSheet ? constraints.maxWidth : _dialogWidth; + return _dialogWidth; } @override @@ -1110,11 +1346,8 @@ class _RenderCupertinoDialog extends RenderBox { 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; + final double height = contentHeight + (hasDivider ? _dividerThickness : 0.0) + actionsHeight; - if (isActionSheet && (actionsHeight > 0 || contentHeight > 0)) { - height -= 2 * _kActionSheetEdgeVerticalPadding; - } if (height.isFinite) { return height; } @@ -1126,11 +1359,8 @@ class _RenderCupertinoDialog extends RenderBox { 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; + final double height = contentHeight + (hasDivider ? _dividerThickness : 0.0) + actionsHeight; - if (isActionSheet && (actionsHeight > 0 || contentHeight > 0)) { - height -= 2 * _kActionSheetEdgeVerticalPadding; - } if (height.isFinite) { return height; } @@ -1155,17 +1385,9 @@ class _RenderCupertinoDialog extends RenderBox { // Set the position of the actions box to sit at the bottom of the dialog. // The content box defaults to the top left, which is where we want it. - assert( - (!isActionSheet && actionsSection!.parentData is BoxParentData) || - (isActionSheet && actionsSection!.parentData is MultiChildLayoutParentData), - ); - if (isActionSheet) { - final MultiChildLayoutParentData actionParentData = actionsSection!.parentData! as MultiChildLayoutParentData; - actionParentData.offset = Offset(0.0, dialogSizes.contentHeight + dialogSizes.dividerThickness); - } else { - final BoxParentData actionParentData = actionsSection!.parentData! as BoxParentData; - actionParentData.offset = Offset(0.0, dialogSizes.contentHeight + dialogSizes.dividerThickness); - } + assert(actionsSection!.parentData is BoxParentData); + final BoxParentData actionParentData = actionsSection!.parentData! as BoxParentData; + actionParentData.offset = Offset(0.0, dialogSizes.contentHeight + dialogSizes.dividerThickness); } _AlertDialogSizes _performLayout({required BoxConstraints constraints, required ChildLayouter layoutChild}) { @@ -1202,9 +1424,7 @@ class _RenderCupertinoDialog extends RenderBox { final double dialogHeight = contentSize.height + dividerThickness + actionsSize.height; return _AlertDialogSizes( - size: isActionSheet - ? Size(constraints.maxWidth, dialogHeight) - : constraints.constrain(Size(_dialogWidth, dialogHeight)), + size: constraints.constrain(Size(_dialogWidth, dialogHeight)), contentHeight: contentSize.height, dividerThickness: dividerThickness, ); @@ -1263,26 +1483,16 @@ class _RenderCupertinoDialog extends RenderBox { @override void paint(PaintingContext context, Offset offset) { - if (isActionSheet) { - final MultiChildLayoutParentData contentParentData = contentSection!.parentData! as MultiChildLayoutParentData; - contentSection!.paint(context, offset + contentParentData.offset); - } else { - final BoxParentData contentParentData = contentSection!.parentData! as BoxParentData; - contentSection!.paint(context, offset + contentParentData.offset); - } + final BoxParentData contentParentData = contentSection!.parentData! as BoxParentData; + 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); } - if (isActionSheet) { - final MultiChildLayoutParentData actionsParentData = actionsSection!.parentData! as MultiChildLayoutParentData; - actionsSection!.paint(context, offset + actionsParentData.offset); - } else { - final BoxParentData actionsParentData = actionsSection!.parentData! as BoxParentData; - actionsSection!.paint(context, offset + actionsParentData.offset); - } + final BoxParentData actionsParentData = actionsSection!.parentData! as BoxParentData; + actionsSection!.paint(context, offset + actionsParentData.offset); } void _paintDividerBetweenContentAndActions(Canvas canvas, Offset offset) { @@ -1299,27 +1509,6 @@ class _RenderCupertinoDialog extends RenderBox { @override bool hitTestChildren(BoxHitTestResult result, { required Offset position }) { - if (isActionSheet) { - final MultiChildLayoutParentData contentSectionParentData = contentSection!.parentData! as MultiChildLayoutParentData; - final MultiChildLayoutParentData actionsSectionParentData = actionsSection!.parentData! as MultiChildLayoutParentData; - return result.addWithPaintOffset( - offset: contentSectionParentData.offset, - position: position, - hitTest: (BoxHitTestResult result, Offset transformed) { - assert(transformed == position - contentSectionParentData.offset); - return contentSection!.hitTest(result, position: transformed); - }, - ) || - result.addWithPaintOffset( - offset: actionsSectionParentData.offset, - position: position, - hitTest: (BoxHitTestResult result, Offset transformed) { - assert(transformed == position - actionsSectionParentData.offset); - return actionsSection!.hitTest(result, position: transformed); - }, - ); - } - final BoxParentData contentSectionParentData = contentSection!.parentData! as BoxParentData; final BoxParentData actionsSectionParentData = actionsSection!.parentData! as BoxParentData; return result.addWithPaintOffset( @@ -1467,8 +1656,6 @@ class _CupertinoAlertActionSection extends StatelessWidget { const _CupertinoAlertActionSection({ required this.children, this.scrollController, - this.hasCancelButton = false, - this.isActionSheet = false, }); final List children; @@ -1480,14 +1667,6 @@ class _CupertinoAlertActionSection extends StatelessWidget { // don't have many actions. final ScrollController? scrollController; - // Used in ActionSheet to denote if ActionSheet has a separate so-called - // cancel button. - // - // Defaults to false, and is not needed in dialogs. - final bool hasCancelButton; - - final bool isActionSheet; - @override Widget build(BuildContext context) { return CupertinoScrollbar( @@ -1500,8 +1679,6 @@ class _CupertinoAlertActionSection extends StatelessWidget { _PressableActionButton(child: child), ], dividerThickness: _kDividerThickness, - hasCancelButton: hasCancelButton, - isActionSheet: isActionSheet, ), ), ); @@ -1783,47 +1960,38 @@ class _CupertinoDialogActionsRenderWidget extends MultiChildRenderObjectWidget { required List actionButtons, double dividerThickness = 0.0, bool hasCancelButton = false, - bool isActionSheet = false, }) : _dividerThickness = dividerThickness, _hasCancelButton = hasCancelButton, - _isActionSheet = isActionSheet, super(children: actionButtons); final double _dividerThickness; final bool _hasCancelButton; - final bool _isActionSheet; @override RenderObject createRenderObject(BuildContext context) { return _RenderCupertinoDialogActions( - dialogWidth: _isActionSheet - ? null - : _isInAccessibilityMode(context) + dialogWidth: _isInAccessibilityMode(context) ? _kAccessibilityCupertinoDialogWidth : _kCupertinoDialogWidth, dividerThickness: _dividerThickness, - dialogColor: CupertinoDynamicColor.resolve(_isActionSheet ? _kActionSheetBackgroundColor : _kDialogColor, context), + dialogColor: CupertinoDynamicColor.resolve(_kDialogColor, context), dialogPressedColor: CupertinoDynamicColor.resolve(_kPressedColor, context), - dividerColor: CupertinoDynamicColor.resolve(_isActionSheet ? _kActionSheetButtonDividerColor : CupertinoColors.separator, context), + dividerColor: CupertinoDynamicColor.resolve(CupertinoColors.separator, context), hasCancelButton: _hasCancelButton, - isActionSheet: _isActionSheet, ); } @override void updateRenderObject(BuildContext context, _RenderCupertinoDialogActions renderObject) { renderObject - ..dialogWidth = _isActionSheet - ? null - : _isInAccessibilityMode(context) + ..dialogWidth = _isInAccessibilityMode(context) ? _kAccessibilityCupertinoDialogWidth : _kCupertinoDialogWidth ..dividerThickness = _dividerThickness - ..dialogColor = CupertinoDynamicColor.resolve(_isActionSheet ? _kActionSheetBackgroundColor : _kDialogColor, context) + ..dialogColor = CupertinoDynamicColor.resolve(_kDialogColor, context) ..dialogPressedColor = CupertinoDynamicColor.resolve(_kPressedColor, context) - ..dividerColor = CupertinoDynamicColor.resolve(_isActionSheet ? _kActionSheetButtonDividerColor : CupertinoColors.separator, context) - ..hasCancelButton = _hasCancelButton - ..isActionSheet = _isActionSheet; + ..dividerColor = CupertinoDynamicColor.resolve(CupertinoColors.separator, context) + ..hasCancelButton = _hasCancelButton; } } @@ -1872,8 +2040,7 @@ class _RenderCupertinoDialogActions extends RenderBox required Color dialogPressedColor, required Color dividerColor, bool hasCancelButton = false, - bool isActionSheet = false, - }) : assert(isActionSheet || dialogWidth != null), + }) : assert(dialogWidth != null), _dialogWidth = dialogWidth, _buttonBackgroundPaint = Paint() ..color = dialogColor @@ -1885,8 +2052,7 @@ class _RenderCupertinoDialogActions extends RenderBox ..color = dividerColor ..style = PaintingStyle.fill, _dividerThickness = dividerThickness, - _hasCancelButton = hasCancelButton, - _isActionSheet = isActionSheet { + _hasCancelButton = hasCancelButton { addAll(children); } @@ -1953,17 +2119,6 @@ class _RenderCupertinoDialogActions extends RenderBox markNeedsPaint(); } - bool get isActionSheet => _isActionSheet; - bool _isActionSheet; - set isActionSheet(bool value) { - if (value == _isActionSheet) { - return; - } - - _isActionSheet = value; - markNeedsPaint(); - } - Iterable get _pressedButtons { final List boxes = []; RenderBox? currentChild = firstChild; @@ -2000,26 +2155,18 @@ class _RenderCupertinoDialogActions extends RenderBox @override double computeMinIntrinsicWidth(double height) { - return isActionSheet ? constraints.minWidth : dialogWidth!; + return dialogWidth!; } @override double computeMaxIntrinsicWidth(double height) { - return isActionSheet ? constraints.maxWidth : dialogWidth!; + return dialogWidth!; } @override double computeMinIntrinsicHeight(double width) { if (childCount == 0) { return 0.0; - } else if (isActionSheet) { - if (childCount == 1) { - return firstChild!.getMaxIntrinsicHeight(width) + dividerThickness; - } - if (hasCancelButton && childCount < 4) { - return _computeMinIntrinsicHeightWithCancel(width); - } - return _computeMinIntrinsicHeightStacked(width); } else if (childCount == 1) { // If only 1 button, display the button across the entire dialog. return _computeMinIntrinsicHeightSideBySide(width); @@ -2032,21 +2179,6 @@ class _RenderCupertinoDialogActions extends RenderBox return _computeMinIntrinsicHeightStacked(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 a single row of buttons is the larger of the buttons' // min intrinsic heights. double _computeMinIntrinsicHeightSideBySide(double width) { @@ -2084,11 +2216,6 @@ class _RenderCupertinoDialogActions extends RenderBox if (childCount == 0) { // No buttons. Zero height. return 0.0; - } else if (isActionSheet) { - if (childCount == 1) { - return firstChild!.getMaxIntrinsicHeight(width) + dividerThickness; - } - return _computeMaxIntrinsicHeightStacked(width); } else if (childCount == 1) { // One button. Our max intrinsic height is equal to the button's. return firstChild!.getMaxIntrinsicHeight(width); @@ -2160,7 +2287,7 @@ class _RenderCupertinoDialogActions extends RenderBox ? ChildLayoutHelper.dryLayoutChild : ChildLayoutHelper.layoutChild; - if (!isActionSheet && _isSingleButtonRow(dialogWidth!)) { + if (_isSingleButtonRow(dialogWidth!)) { if (childCount == 1) { // We have 1 button. Our size is the width of the dialog and the height // of the single button. @@ -2249,7 +2376,7 @@ class _RenderCupertinoDialogActions extends RenderBox void paint(PaintingContext context, Offset offset) { final Canvas canvas = context.canvas; - if (!isActionSheet && _isSingleButtonRow(size.width)) { + if (_isSingleButtonRow(size.width)) { _drawButtonBackgroundsAndDividersSingleRow(canvas, offset); } else { _drawButtonBackgroundsAndDividersStacked(canvas, offset); diff --git a/packages/flutter/test/cupertino/action_sheet_test.dart b/packages/flutter/test/cupertino/action_sheet_test.dart index c734737da08..9f743728826 100644 --- a/packages/flutter/test/cupertino/action_sheet_test.dart +++ b/packages/flutter/test/cupertino/action_sheet_test.dart @@ -2,6 +2,11 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(['reduced-test-set']) +library; + import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; @@ -266,7 +271,7 @@ void main() { final Finder finder = find.byElementPredicate( (Element element) { - return element.widget.runtimeType.toString() == '_CupertinoAlertActionSection'; + return element.widget.runtimeType.toString() == '_ActionSheetActionSection'; }, ); @@ -428,6 +433,62 @@ void main() { expect(scrollbars[0].controller != scrollbars[1].controller, isTrue); }); + testWidgets('Actions section correctly renders overscrolls', (WidgetTester tester) async { + // Verifies that when the actions section overscrolls, the overscroll part + // is correctly covered with background. + final ScrollController actionScrollController = ScrollController(); + addTearDown(actionScrollController.dispose); + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + Builder(builder: (BuildContext context) { + return CupertinoActionSheet( + actions: List.generate(12, (int i) => + CupertinoActionSheetAction( + onPressed: () {}, + child: Text('Button ${'*' * i}'), + ), + ), + ); + }), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('Button *'))); + await tester.pumpAndSettle(); + // The button should be pressed now, since the scrolling gesture has not + // taken over. + await expectLater( + find.byType(CupertinoActionSheet), + matchesGoldenFile('cupertinoActionSheet.overscroll.0.png'), + ); + // The dragging gesture must be dispatched in at least two segments. + // After the first movement, the gesture is started, but the delta is still + // zero. The second movement gives the delta. + await gesture.moveBy(const Offset(0, 40)); + await tester.pumpAndSettle(); + await gesture.moveBy(const Offset(0, 100)); + // Test the top overscroll. Use `pump` not `pumpAndSettle` to verify the + // rendering result of the immediate next frame. + await tester.pump(); + await expectLater( + find.byType(CupertinoActionSheet), + matchesGoldenFile('cupertinoActionSheet.overscroll.1.png'), + ); + + await gesture.moveBy(const Offset(0, -300)); + // Test the bottom overscroll. Use `pump` not `pumpAndSettle` to verify the + // rendering result of the immediate next frame. + await tester.pump(); + await expectLater( + find.byType(CupertinoActionSheet), + matchesGoldenFile('cupertinoActionSheet.overscroll.2.png'), + ); + await gesture.up(); + }); + testWidgets('Tap on button calls onPressed', (WidgetTester tester) async { bool wasPressed = false; await tester.pumpWidget( @@ -465,6 +526,47 @@ void main() { expect(find.text('One'), findsNothing); }); + testWidgets('Tap at the padding of buttons calls onPressed', (WidgetTester tester) async { + // Ensures that the entire button responds to hit tests, not just the text + // part. + bool wasPressed = false; + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + Builder(builder: (BuildContext context) { + return CupertinoActionSheet( + actions: [ + 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.tapAt( + tester.getTopLeft(find.text('One')) - const Offset(20, 0), + ); + + 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( @@ -1112,7 +1214,7 @@ void main() { RenderBox findScrollableActionsSectionRenderBox(WidgetTester tester) { final RenderObject actionsSection = tester.renderObject( find.byElementPredicate((Element element) { - return element.widget.runtimeType.toString() == '_CupertinoAlertActionSection'; + return element.widget.runtimeType.toString() == '_ActionSheetActionSection'; }), ); assert(actionsSection is RenderBox);