From 748afd977515f4211fa9a69e47f7cd6be32fd25e Mon Sep 17 00:00:00 2001 From: Tong Mu Date: Wed, 24 Jul 2024 16:18:02 -0700 Subject: [PATCH] [CupertinoAlertDialog] Rewrite (#150410) This PR rewrites `CupertinoAlertDialog` in a cleaner logic, mostly its layout. There are two major difficulties to lay out `CupertinoAlertDialog`: * Laying out the actions section, which switches between a horizontal mode (two buttons in a row) and a vertical mode (several buttons in a column). This PR rewrites it in a special layout widget, `_CupertinoAlertActionSection`. * Allocating vertical space between the content section and the actions section. This reuses `_PriorityColumn`, which was created for the action sheet. In a similar fashion to the action sheet, the management and configuration for dividers and background (overscrolls) were rewritten as well. This PR tries to keep as much original code and behavior as possible since this PR is already very large. As a result, almost no tests are broken. Further improvement will be done in future PRs. * The test that verifies painting overscrolls is replaced by a golden test, since the original test assumes that the background is painted in one rectangle. --- .../flutter/lib/src/cupertino/dialog.dart | 1709 +++++------------ .../flutter/test/cupertino/dialog_test.dart | 201 +- 2 files changed, 554 insertions(+), 1356 deletions(-) diff --git a/packages/flutter/lib/src/cupertino/dialog.dart b/packages/flutter/lib/src/cupertino/dialog.dart index 04da2bbb71..ffe7753a40 100644 --- a/packages/flutter/lib/src/cupertino/dialog.dart +++ b/packages/flutter/lib/src/cupertino/dialog.dart @@ -92,6 +92,9 @@ const double _kAccessibilityCupertinoDialogWidth = 310.0; const double _kDialogEdgePadding = 20.0; const double _kDialogMinButtonHeight = 45.0; const double _kDialogMinButtonFontSize = 10.0; +// The min height for a button excluding dividers. Derived by comparing on iOS +// 17 simulators. +const double _kDialogActionsSectionMinHeight = 67.8; // ActionSheet specific constants. const double _kActionSheetEdgePadding = 8.0; @@ -288,6 +291,11 @@ class CupertinoAlertDialog extends StatefulWidget { } class _CupertinoAlertDialogState extends State { + // The index of the action button that the user is holding on. + // + // Null if the user is not holding on any buttons. + int? _pressedIndex; + ScrollController? _backupScrollController; ScrollController? _backupActionScrollController; @@ -298,63 +306,128 @@ class _CupertinoAlertDialogState extends State { ScrollController get _effectiveActionScrollController => widget.actionScrollController ?? (_backupActionScrollController ??= ScrollController()); - Widget _buildContent(BuildContext context) { + + Widget? _buildContent(BuildContext context) { + final bool hasContent = widget.title != null || widget.content != null; + if (!hasContent) { + return null; + } + const double defaultFontSize = 14.0; final double effectiveTextScaleFactor = MediaQuery.textScalerOf(context).scale(defaultFontSize) / defaultFontSize; - final List children = [ - if (widget.title != null || widget.content != null) - Flexible( - flex: 3, - child: _CupertinoAlertContentSection( - title: widget.title, - message: widget.content, - scrollController: _effectiveScrollController, - titlePadding: EdgeInsets.only( - left: _kDialogEdgePadding, - right: _kDialogEdgePadding, - bottom: widget.content == null ? _kDialogEdgePadding : 1.0, - top: _kDialogEdgePadding * effectiveTextScaleFactor, - ), - messagePadding: EdgeInsets.only( - left: _kDialogEdgePadding, - right: _kDialogEdgePadding, - bottom: _kDialogEdgePadding * effectiveTextScaleFactor, - top: widget.title == null ? _kDialogEdgePadding : 1.0, - ), - titleTextStyle: _kCupertinoDialogTitleStyle.copyWith( - color: CupertinoDynamicColor.resolve(CupertinoColors.label, context), - ), - messageTextStyle: _kCupertinoDialogContentStyle.copyWith( - color: CupertinoDynamicColor.resolve(CupertinoColors.label, context), - ), - ), - ), - ]; + final Widget child = _CupertinoAlertContentSection( + title: widget.title, + message: widget.content, + scrollController: _effectiveScrollController, + titlePadding: EdgeInsets.only( + left: _kDialogEdgePadding, + right: _kDialogEdgePadding, + bottom: widget.content == null ? _kDialogEdgePadding : 1.0, + top: _kDialogEdgePadding * effectiveTextScaleFactor, + ), + messagePadding: EdgeInsets.only( + left: _kDialogEdgePadding, + right: _kDialogEdgePadding, + bottom: _kDialogEdgePadding * effectiveTextScaleFactor, + top: widget.title == null ? _kDialogEdgePadding : 1.0, + ), + titleTextStyle: _kCupertinoDialogTitleStyle.copyWith( + color: CupertinoDynamicColor.resolve(CupertinoColors.label, context), + ), + messageTextStyle: _kCupertinoDialogContentStyle.copyWith( + color: CupertinoDynamicColor.resolve(CupertinoColors.label, context), + ), + ); return ColoredBox( color: CupertinoDynamicColor.resolve(_kDialogColor, context), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: children, - ), + child: child, ); } - Widget _buildActions() { - Widget actionSection = const LimitedBox( - maxWidth: 0, - child: SizedBox(width: double.infinity, height: 0), - ); - if (widget.actions.isNotEmpty) { - actionSection = _CupertinoAlertActionSection( + void _onPressedUpdate(int actionIndex, bool isPressed) { + if (isPressed) { + setState(() { + _pressedIndex = actionIndex; + }); + } else { + if (_pressedIndex == actionIndex) { + setState(() { + _pressedIndex = null; + }); + } + } + } + + Widget? _buildActions() { + if (widget.actions.isEmpty) { + return null; + } else { + return _CupertinoAlertActionSection( scrollController: _effectiveActionScrollController, - children: widget.actions, + actions: widget.actions, + pressedIndex: _pressedIndex, + onPressedUpdate: _onPressedUpdate, ); } + } - return actionSection; + Widget _buildBody(BuildContext context) { + final Color backgroundColor = CupertinoDynamicColor.resolve(_kDialogColor, context); + const Color dividerColor = CupertinoColors.separator; + // Remove view padding here because the `Scrollbar` widget uses the view + // padding as padding, which is unwanted. + // https://github.com/flutter/flutter/issues/150544 + return MediaQuery.removePadding( + removeLeft: true, + removeTop: true, + removeRight: true, + removeBottom: true, + context: context, + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final Widget? contentSection = _buildContent(context); + final Widget? actionsSection = _buildActions(); + if (actionsSection == null) { + return contentSection ?? + const LimitedBox( + maxWidth: 0, + child: SizedBox(width: double.infinity, height: 0), + ); + } + final Widget scrolledActionsSection = _OverscrollBackground( + scrollController: _effectiveActionScrollController, + color: backgroundColor, + child: actionsSection, + ); + if (contentSection == null) { + return scrolledActionsSection; + } + // It is observed on the simulator that the minimal height varies + // depending on whether the device is in accessibility mode. + final double actionsMinHeight = _isInAccessibilityMode(context) + ? constraints.maxHeight / 2 + _kDividerThickness + : _kDialogActionsSectionMinHeight + _kDividerThickness; + return _PriorityColumn( + top: contentSection, + bottom: Column( + children: [ + _Divider( + dividerColor: dividerColor, + hiddenColor: backgroundColor, + hidden: false, + ), + Flexible( + child: scrolledActionsSection, + ), + ], + ), + bottomMinHeight: actionsMinHeight, + ); + }, + ), + ); } @override @@ -396,11 +469,7 @@ class _CupertinoAlertDialogState extends State { scopesRoute: true, explicitChildNodes: true, label: localizations.alertDialogLabel, - child: _CupertinoDialogRenderWidget( - contentSection: _buildContent(context), - actionsSection: _buildActions(), - dividerColor: CupertinoColors.separator, - ), + child: _buildBody(context), ), ), ), @@ -1328,32 +1397,135 @@ class _ActionSheetButtonBackgroundState extends State<_ActionSheetButtonBackgrou } } -// The divider of an action sheet. +// The divider of an action sheet or an alert dialog. +// +// The divider can function as either a horizontal divider (in a column) or a +// vertical divider (in a row) without widget-layer configuration. Instead, this +// is determined during the layout phase based on the constraints. This approach +// is necessary to allow the alert dialog to provide a list of widgets to the +// layout widget, which doesn't know its layout mode until the layout phase. +// +// The constraints provided to this widget should match the column container's +// width or the row container's height, while being unlimited in the other +// dimension. This unlimited dimension will result in the divider's thickness. // // 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({ +// Otherwise it displays the background color. +class _Divider extends StatelessWidget { + const _Divider({ required this.dividerColor, + required this.hiddenColor, required this.hidden, }); final Color dividerColor; + final Color hiddenColor; 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, + // The LimitedBox turns unconstrained dimension (typically the main axis of + // a flex container) to the divider thickness. + return LimitedBox( + maxHeight: _kDividerThickness, + maxWidth: _kDividerThickness, + // The constrained box prevents the divider from collapsing to nothing. + child: ConstrainedBox( + constraints: const BoxConstraints( + minHeight: _kDividerThickness, + minWidth: _kDividerThickness, + ), + child: DecoratedBox( + decoration: BoxDecoration( + color: hidden ? CupertinoDynamicColor.resolve(hiddenColor, context) : dividerColor, + ), + ), ), ); } } +// Fills the overscroll area at the top or bottom of a scrollable widget with a +// solid color. +// +// This is necessary for action sheets and alert dialogs, because their actions +// 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. +class _OverscrollBackground extends StatefulWidget { + const _OverscrollBackground({ + required this.color, + required this.scrollController, + required this.child, + }); + + // The color for the overscroll part. + // + // This value must be a resolved color instead of, for example, a + // CupertinoDynamicColor. + final Color color; + final ScrollController? scrollController; + final Widget child; + + @override + _OverscrollBackgroundState createState() => _OverscrollBackgroundState(); +} + +class _OverscrollBackgroundState extends State<_OverscrollBackground> { + double _topOverscroll = 0; + double _bottomOverscroll = 0; + + bool _onScrollUpdate(ScrollUpdateNotification notification) { + final ScrollMetrics metrics = notification.metrics; + setState(() { + // The sizes of the overscroll should not be longer than the height of the + // actions section. + _topOverscroll = math.min( + math.max(metrics.minScrollExtent - metrics.pixels, 0), + metrics.viewportDimension, + ); + _bottomOverscroll = math.min( + math.max(metrics.pixels - metrics.maxScrollExtent, 0), + metrics.viewportDimension, + ); + }); + return false; + } + + @override + Widget build(BuildContext context) { + final Widget overscroll = Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DecoratedBox( + decoration: BoxDecoration(color: widget.color), + child: SizedBox( + height: _topOverscroll, + ), + ), + DecoratedBox( + decoration: BoxDecoration(color: widget.color), + child: SizedBox( + height: _bottomOverscroll, + ), + ), + ], + ); + return Stack( + children: [ + Positioned.fill( + child: overscroll, + ), + NotificationListener( + onNotification: _onScrollUpdate, + child: widget.child, + ) + ], + ); + } +} + typedef _PressedUpdateHandler = void Function(int actionIndex, bool state); // The list of actions in an action sheet. @@ -1387,8 +1559,9 @@ class _ActionSheetActionSection extends StatelessWidget { final List column = []; for (int actionIndex = 0; actionIndex < actions!.length; actionIndex += 1) { if (actionIndex != 0) { - column.add(_ActionSheetDivider( + column.add(_Divider( dividerColor: dividerColor, + hiddenColor: _kActionSheetBackgroundColor, hidden: pressedIndex == actionIndex - 1 || pressedIndex == actionIndex, )); } @@ -1404,6 +1577,7 @@ class _ActionSheetActionSection extends StatelessWidget { child: SingleChildScrollView( controller: scrollController, child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: column, ), ), @@ -1431,47 +1605,6 @@ class _ActionSheetMainSheet extends StatefulWidget { 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(() { - // The sizes of the overscroll should not be longer than the height of the - // actions section. - _topOverscroll = math.min( - math.max(metrics.minScrollExtent - metrics.pixels, 0), - metrics.viewportDimension, - ); - _bottomOverscroll = math.min( - math.max(metrics.pixels - metrics.maxScrollExtent, 0), - metrics.viewportDimension, - ); - }); - return false; - } bool get _hasContent => widget.contentSection != null; bool get _hasActions => widget.actions.isNotEmpty; @@ -1500,28 +1633,23 @@ class _ActionSheetMainSheetState extends State<_ActionSheetMainSheet> { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (_hasContent) - _ActionSheetDivider( + _Divider( dividerColor: widget.dividerColor, + hiddenColor: backgroundColor, hidden: false, ), Flexible( - 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, - ), - ) - ], + child: _OverscrollBackground( + scrollController: widget.scrollController, + color: backgroundColor, + child: _ActionSheetActionSection( + actions: widget.actions, + scrollController: widget.scrollController, + pressedIndex: _pressedIndex, + dividerColor: widget.dividerColor, + backgroundColor: backgroundColor, + onPressedUpdate: _onPressedUpdate, + ), ), ), ], @@ -1547,490 +1675,6 @@ class _ActionSheetMainSheetState extends State<_ActionSheetMainSheet> { ); } -// iOS style layout policy widget for sizing an alert dialog's content section and -// action button section. -// -// See [_RenderCupertinoDialog] for specific layout policy details. -class _CupertinoDialogRenderWidget extends RenderObjectWidget { - const _CupertinoDialogRenderWidget({ - required this.contentSection, - required this.actionsSection, - required this.dividerColor, - }); - - final Widget contentSection; - final Widget actionsSection; - final Color dividerColor; - - @override - RenderObject createRenderObject(BuildContext context) { - return _RenderCupertinoDialog( - dividerThickness: _kDividerThickness, - isInAccessibilityMode: _isInAccessibilityMode(context), - dividerColor: CupertinoDynamicColor.resolve(dividerColor, context), - ); - } - - @override - void updateRenderObject(BuildContext context, _RenderCupertinoDialog renderObject) { - renderObject - ..isInAccessibilityMode = _isInAccessibilityMode(context) - ..dividerColor = CupertinoDynamicColor.resolve(dividerColor, context); - } - - @override - RenderObjectElement createElement() { - return _CupertinoDialogRenderElement(this); - } -} - -class _CupertinoDialogRenderElement extends RenderObjectElement { - _CupertinoDialogRenderElement(_CupertinoDialogRenderWidget super.widget); - - Element? _contentElement; - Element? _actionsElement; - - @override - _RenderCupertinoDialog get renderObject => super.renderObject as _RenderCupertinoDialog; - - @override - void visitChildren(ElementVisitor visitor) { - if (_contentElement != null) { - visitor(_contentElement!); - } - if (_actionsElement != null) { - visitor(_actionsElement!); - } - } - - @override - void mount(Element? parent, Object? newSlot) { - super.mount(parent, newSlot); - final _CupertinoDialogRenderWidget dialogRenderWidget = widget as _CupertinoDialogRenderWidget; - _contentElement = updateChild(_contentElement, dialogRenderWidget.contentSection, _AlertDialogSections.contentSection); - _actionsElement = updateChild(_actionsElement, dialogRenderWidget.actionsSection, _AlertDialogSections.actionsSection); - } - - @override - void insertRenderObjectChild(RenderObject child, _AlertDialogSections slot) { - _placeChildInSlot(child, slot); - } - - @override - void moveRenderObjectChild(RenderObject child, _AlertDialogSections oldSlot, _AlertDialogSections newSlot) { - assert(false); - return; - } - - @override - void update(RenderObjectWidget newWidget) { - super.update(newWidget); - final _CupertinoDialogRenderWidget dialogRenderWidget = widget as _CupertinoDialogRenderWidget; - _contentElement = updateChild(_contentElement, dialogRenderWidget.contentSection, _AlertDialogSections.contentSection); - _actionsElement = updateChild(_actionsElement, dialogRenderWidget.actionsSection, _AlertDialogSections.actionsSection); - } - - @override - void forgetChild(Element child) { - assert(child == _contentElement || child == _actionsElement); - if (_contentElement == child) { - _contentElement = null; - } else { - assert(_actionsElement == child); - _actionsElement = null; - } - super.forgetChild(child); - } - - @override - void removeRenderObjectChild(RenderObject child, _AlertDialogSections slot) { - assert(child == renderObject.contentSection || child == renderObject.actionsSection); - if (renderObject.contentSection == child) { - renderObject.contentSection = null; - } else { - assert(renderObject.actionsSection == child); - renderObject.actionsSection = null; - } - } - - void _placeChildInSlot(RenderObject child, _AlertDialogSections slot) { - switch (slot) { - case _AlertDialogSections.contentSection: - renderObject.contentSection = child as RenderBox; - case _AlertDialogSections.actionsSection: - renderObject.actionsSection = child as RenderBox; - } - } -} - -// iOS style layout policy for sizing an alert dialog's content section and action -// button section. -// -// The policy is as follows: -// -// If all content and buttons fit on screen: -// The content section and action button section are sized intrinsically and centered -// vertically on screen. -// -// If all content and buttons do not fit on screen, and iOS is NOT in accessibility mode: -// A minimum height for the action button section is calculated. The action -// button section will not be rendered shorter than this minimum. See -// [_RenderCupertinoDialogActions] for the minimum height calculation. -// -// With the minimum action button section calculated, the content section can -// take up as much space as is available, up to the point that it hits the -// minimum button height at the bottom. -// -// After the content section is laid out, the action button section is allowed -// to take up any remaining space that was not consumed by the content section. -// -// If all content and buttons do not fit on screen, and iOS IS in accessibility mode: -// The button section is given up to 50% of the available height. Then the content -// section is given whatever height remains. -class _RenderCupertinoDialog extends RenderBox { - _RenderCupertinoDialog({ - RenderBox? contentSection, - RenderBox? actionsSection, - double dividerThickness = 0.0, - bool isInAccessibilityMode = false, - required Color dividerColor, - }) : _contentSection = contentSection, - _actionsSection = actionsSection, - _dividerThickness = dividerThickness, - _isInAccessibilityMode = isInAccessibilityMode, - _dividerPaint = Paint() - ..color = dividerColor - ..style = PaintingStyle.fill; - - RenderBox? get contentSection => _contentSection; - RenderBox? _contentSection; - set contentSection(RenderBox? newContentSection) { - if (newContentSection != _contentSection) { - if (_contentSection != null) { - dropChild(_contentSection!); - } - _contentSection = newContentSection; - if (_contentSection != null) { - adoptChild(_contentSection!); - } - } - } - - RenderBox? get actionsSection => _actionsSection; - RenderBox? _actionsSection; - set actionsSection(RenderBox? newActionsSection) { - if (newActionsSection != _actionsSection) { - if (null != _actionsSection) { - dropChild(_actionsSection!); - } - _actionsSection = newActionsSection; - if (null != _actionsSection) { - adoptChild(_actionsSection!); - } - } - } - - bool get isInAccessibilityMode => _isInAccessibilityMode; - bool _isInAccessibilityMode; - set isInAccessibilityMode(bool newValue) { - if (newValue != _isInAccessibilityMode) { - _isInAccessibilityMode = newValue; - markNeedsLayout(); - } - } - - double get _dialogWidth => isInAccessibilityMode - ? _kAccessibilityCupertinoDialogWidth - : _kCupertinoDialogWidth; - - final double _dividerThickness; - final Paint _dividerPaint; - - Color get dividerColor => _dividerPaint.color; - set dividerColor(Color newValue) { - if (dividerColor == newValue) { - return; - } - - _dividerPaint.color = newValue; - markNeedsPaint(); - } - - @override - void attach(PipelineOwner owner) { - super.attach(owner); - if (null != contentSection) { - contentSection!.attach(owner); - } - if (null != actionsSection) { - actionsSection!.attach(owner); - } - } - - @override - void detach() { - super.detach(); - if (null != contentSection) { - contentSection!.detach(); - } - if (null != actionsSection) { - actionsSection!.detach(); - } - } - - @override - void redepthChildren() { - if (null != contentSection) { - redepthChild(contentSection!); - } - if (null != actionsSection) { - redepthChild(actionsSection!); - } - } - - @override - void setupParentData(RenderBox child) { - if (child.parentData is! BoxParentData) { - child.parentData = BoxParentData(); - } else if (child.parentData is! MultiChildLayoutParentData) { - child.parentData = MultiChildLayoutParentData(); - } - } - - @override - void visitChildren(RenderObjectVisitor visitor) { - if (contentSection != null) { - visitor(contentSection!); - } - if (actionsSection != null) { - visitor(actionsSection!); - } - } - - @override - List debugDescribeChildren() => [ - if (contentSection != null) contentSection!.toDiagnosticsNode(name: 'content'), - if (actionsSection != null) actionsSection!.toDiagnosticsNode(name: 'actions'), - ]; - - @override - double computeMinIntrinsicWidth(double height) { - return _dialogWidth; - } - - @override - double computeMaxIntrinsicWidth(double height) { - return _dialogWidth; - } - - @override - double computeMinIntrinsicHeight(double width) { - final double contentHeight = contentSection!.getMinIntrinsicHeight(width); - final double actionsHeight = actionsSection!.getMinIntrinsicHeight(width); - final bool hasDivider = contentHeight > 0.0 && actionsHeight > 0.0; - final double height = contentHeight + (hasDivider ? _dividerThickness : 0.0) + actionsHeight; - - if (height.isFinite) { - return height; - } - return 0.0; - } - - @override - double computeMaxIntrinsicHeight(double width) { - final double contentHeight = contentSection!.getMaxIntrinsicHeight(width); - final double actionsHeight = actionsSection!.getMaxIntrinsicHeight(width); - final bool hasDivider = contentHeight > 0.0 && actionsHeight > 0.0; - final double height = contentHeight + (hasDivider ? _dividerThickness : 0.0) + actionsHeight; - - if (height.isFinite) { - return height; - } - return 0.0; - } - - @override - Size computeDryLayout(BoxConstraints constraints) { - return _performLayout( - constraints: constraints, - layoutChild: ChildLayoutHelper.dryLayoutChild, - ).size; - } - - @override - void performLayout() { - final _AlertDialogSizes dialogSizes = _performLayout( - constraints: constraints, - layoutChild: ChildLayoutHelper.layoutChild, - ); - size = dialogSizes.size; - - // Set the position of the actions box to sit at the bottom of the dialog. - // The content box defaults to the top left, which is where we want it. - assert(actionsSection!.parentData is BoxParentData); - final BoxParentData actionParentData = actionsSection!.parentData! as BoxParentData; - actionParentData.offset = Offset(0.0, dialogSizes.contentHeight + dialogSizes.dividerThickness); - } - - _AlertDialogSizes _performLayout({required BoxConstraints constraints, required ChildLayouter layoutChild}) { - return isInAccessibilityMode - ? performAccessibilityLayout( - constraints: constraints, - layoutChild: layoutChild, - ) : performRegularLayout( - constraints: constraints, - layoutChild: layoutChild, - ); - } - - // When not in accessibility mode, an alert dialog might reduce the space - // for buttons to just over 1 button's height to make room for the content - // section. - _AlertDialogSizes performRegularLayout({required BoxConstraints constraints, required ChildLayouter layoutChild}) { - final bool hasDivider = contentSection!.getMaxIntrinsicHeight(getMaxIntrinsicWidth(0)) > 0.0 - && actionsSection!.getMaxIntrinsicHeight(getMaxIntrinsicWidth(0)) > 0.0; - final double dividerThickness = hasDivider ? _dividerThickness : 0.0; - - final double minActionsHeight = actionsSection!.getMinIntrinsicHeight(getMaxIntrinsicWidth(0)); - - final Size contentSize = layoutChild( - contentSection!, - constraints.deflate(EdgeInsets.only(bottom: minActionsHeight + dividerThickness)), - ); - - final Size actionsSize = layoutChild( - actionsSection!, - constraints.deflate(EdgeInsets.only(top: contentSize.height + dividerThickness)), - ); - - final double dialogHeight = contentSize.height + dividerThickness + actionsSize.height; - - return _AlertDialogSizes( - size: constraints.constrain(Size(_dialogWidth, dialogHeight)), - contentHeight: contentSize.height, - dividerThickness: dividerThickness, - ); - } - - // When in accessibility mode, an alert dialog will allow buttons to take - // up to 50% of the dialog height, even if the content exceeds available space. - _AlertDialogSizes performAccessibilityLayout({required BoxConstraints constraints, required ChildLayouter layoutChild}) { - final bool hasDivider = contentSection!.getMaxIntrinsicHeight(_dialogWidth) > 0.0 - && actionsSection!.getMaxIntrinsicHeight(_dialogWidth) > 0.0; - final double dividerThickness = hasDivider ? _dividerThickness : 0.0; - - final double maxContentHeight = contentSection!.getMaxIntrinsicHeight(_dialogWidth); - final double maxActionsHeight = actionsSection!.getMaxIntrinsicHeight(_dialogWidth); - - final Size contentSize; - final Size actionsSize; - if (maxContentHeight + dividerThickness + maxActionsHeight > constraints.maxHeight) { - // AlertDialog: There isn't enough room for everything. Following iOS's - // accessibility dialog layout policy, first we allow the actions to take - // up to 50% of the dialog height. Second we fill the rest of the - // available space with the content section. - - actionsSize = layoutChild( - actionsSection!, - constraints.deflate(EdgeInsets.only(top: constraints.maxHeight / 2.0)), - ); - - contentSize = layoutChild( - contentSection!, - constraints.deflate(EdgeInsets.only(bottom: actionsSize.height + dividerThickness)), - ); - } else { - // Everything fits. Give content and actions all the space they want. - - contentSize = layoutChild( - contentSection!, - constraints, - ); - - actionsSize = layoutChild( - actionsSection!, - constraints.deflate(EdgeInsets.only(top: contentSize.height)), - ); - } - - // Calculate overall dialog height. - final double dialogHeight = contentSize.height + dividerThickness + actionsSize.height; - - return _AlertDialogSizes( - size: constraints.constrain(Size(_dialogWidth, dialogHeight)), - contentHeight: contentSize.height, - dividerThickness: dividerThickness, - ); - } - - @override - void paint(PaintingContext context, Offset 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); - } - - final BoxParentData actionsParentData = actionsSection!.parentData! as BoxParentData; - 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(BoxHitTestResult result, { required Offset position }) { - final BoxParentData contentSectionParentData = contentSection!.parentData! as BoxParentData; - final BoxParentData actionsSectionParentData = actionsSection!.parentData! as BoxParentData; - 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); - }, - ); - } -} - -class _AlertDialogSizes { - const _AlertDialogSizes({ - required this.size, - required this.contentHeight, - required this.dividerThickness, - }); - - final Size size; - final double contentHeight; - final double dividerThickness; -} - -// Visual components of an alert dialog that need to be explicitly sized and -// laid out at runtime. -enum _AlertDialogSections { - contentSection, - actionsSection, -} - // The "content section" of a CupertinoAlertDialog. // // If title is missing, then only content is added. If content is @@ -2132,15 +1776,23 @@ class _CupertinoAlertContentSection extends StatelessWidget { // The "actions section" of a [CupertinoAlertDialog]. // -// See [_RenderCupertinoDialogActions] for details about action button sizing -// and layout. +// The `actions` must not be empty. class _CupertinoAlertActionSection extends StatelessWidget { const _CupertinoAlertActionSection({ - required this.children, - this.scrollController, - }); + required this.actions, + required this.onPressedUpdate, + required this.pressedIndex, + required this.scrollController, + }) : assert(actions.length != 0); - final List children; + // A list of action buttons. + // + // This list must not include the dividers between the buttons. If the list + // is empty, then this widget returns an empty box. + final List actions; + + final _PressedUpdateHandler onPressedUpdate; + final int? pressedIndex; // A scroll controller that can be used to control the scrolling of the // actions in the dialog. @@ -2151,109 +1803,107 @@ class _CupertinoAlertActionSection extends StatelessWidget { @override Widget build(BuildContext context) { + final Color dialogColor = CupertinoDynamicColor.resolve(_kDialogColor, context); + final Color dialogPressedColor = CupertinoDynamicColor.resolve(_kDialogPressedColor, context); + final Color dividerColor = CupertinoDynamicColor.resolve(CupertinoColors.separator, context); + + final List column = []; + for (int actionIndex = 0; actionIndex < actions.length; actionIndex += 1) { + if (actionIndex != 0) { + column.add(_Divider( + dividerColor: dividerColor, + hiddenColor: dialogColor, + hidden: pressedIndex == actionIndex - 1 || pressedIndex == actionIndex, + )); + } + column.add(_AlertDialogButtonBackground( + idleColor: dialogColor, + pressedColor: dialogPressedColor, + pressed: pressedIndex == actionIndex, + onPressStateChange: (bool state) { + onPressedUpdate(actionIndex, state); + }, + child: actions[actionIndex], + )); + } + return CupertinoScrollbar( controller: scrollController, child: SingleChildScrollView( controller: scrollController, - child: _CupertinoDialogActionsRenderWidget( - actionButtons: [ - for (final Widget child in children) - _PressableActionButton(child: child), - ], + child: _AlertDialogActionsLayout( dividerThickness: _kDividerThickness, + children: column, ), ), ); } } -// Button that updates its render state when pressed. -// -// The pressed state is forwarded to an _ActionButtonParentDataWidget. The -// corresponding _ActionButtonParentData is then interpreted and rendered -// appropriately by _RenderCupertinoDialogActions. -class _PressableActionButton extends StatefulWidget { - const _PressableActionButton({ +// Renders the background of a button (both the pressed background and the idle +// background) and reports its state to the parent with `onPressStateChange`. +class _AlertDialogButtonBackground extends StatelessWidget { + const _AlertDialogButtonBackground({ + required this.idleColor, + required this.pressedColor, + required this.pressed, + required this.onPressStateChange, required this.child, }); + /// Called whether the user is holding on this button. + final bool pressed; + + /// 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; + + final Color idleColor; + final Color pressedColor; + + /// The widget below this widget in the tree. + /// + /// Typically a [Text] widget. final Widget child; - @override - _PressableActionButtonState createState() => _PressableActionButtonState(); -} + void onTapDown(TapDownDetails details) { + onPressStateChange?.call(true); + } -class _PressableActionButtonState extends State<_PressableActionButton> { - bool _isPressed = false; + void onTapUp(TapUpDetails details) { + onPressStateChange?.call(false); + } + + void onTapCancel() { + onPressStateChange?.call(false); + } @override Widget build(BuildContext context) { - return _ActionButtonParentDataWidget( - isPressed: _isPressed, - child: MergeSemantics( - // TODO(mattcarroll): Button press dynamics need overhaul for iOS: - // https://github.com/flutter/flutter/issues/19786 - child: 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, + final Color backgroundColor = pressed ? pressedColor : idleColor; + return MergeSemantics( + // TODO(mattcarroll): Button press dynamics need overhaul for iOS: + // https://github.com/flutter/flutter/issues/19786 + child: GestureDetector( + excludeFromSemantics: true, + behavior: HitTestBehavior.opaque, + onTapDown: onTapDown, + onTapUp: onTapUp, + // TODO(mattcarroll): Cancel is currently triggered when user moves + // past slop instead of off button: https://github.com/flutter/flutter/issues/19783 + onTapCancel: onTapCancel, + child: Container( + decoration: BoxDecoration( + color: CupertinoDynamicColor.resolve(backgroundColor, context), + ), + child: child, ), ), ); } } -// ParentDataWidget that updates _ActionButtonParentData for an action button. -// -// Each action button requires knowledge of whether or not it is pressed so that -// the dialog can correctly render the button. The pressed state is held within -// _ActionButtonParentData. _ActionButtonParentDataWidget is responsible for -// updating the pressed state of an _ActionButtonParentData based on the -// incoming [isPressed] property. -class _ActionButtonParentDataWidget - extends ParentDataWidget<_ActionButtonParentData> { - const _ActionButtonParentDataWidget({ - required this.isPressed, - required super.child, - }); - - final bool isPressed; - - @override - void applyParentData(RenderObject renderObject) { - assert(renderObject.parentData is _ActionButtonParentData); - final _ActionButtonParentData parentData = - renderObject.parentData! as _ActionButtonParentData; - if (parentData.isPressed != isPressed) { - parentData.isPressed = isPressed; - - // Force a repaint. - final RenderObject? targetParent = renderObject.parent; - if (targetParent is RenderObject) { - targetParent.markNeedsPaint(); - } - } - } - - @override - Type get debugTypicalAncestorWidgetClass => _CupertinoDialogActionsRenderWidget; -} - -// ParentData applied to individual action buttons that report whether or not -// that button is currently pressed by the user. -class _ActionButtonParentData extends MultiChildLayoutParentData { - bool isPressed = false; -} - /// A button typically used in a [CupertinoAlertDialog]. /// /// See also: @@ -2332,26 +1982,21 @@ class CupertinoDialogAction extends StatelessWidget { // button text to fit the available space. final double fontSizeRatio = MediaQuery.textScalerOf(context).scale(textStyle.fontSize!) / _kDialogMinButtonFontSize; - return IntrinsicHeight( - child: SizedBox( - width: double.infinity, - child: FittedBox( - fit: BoxFit.scaleDown, - child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: fontSizeRatio * (dialogWidth - (2 * padding)), - ), - child: Semantics( - button: true, - onTap: onPressed, - child: DefaultTextStyle( - style: textStyle, - textAlign: TextAlign.center, - overflow: TextOverflow.ellipsis, - maxLines: 1, - child: content, - ), - ), + return FittedBox( + fit: BoxFit.scaleDown, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: fontSizeRatio * (dialogWidth - (2 * padding)), + ), + child: Semantics( + button: true, + onTap: onPressed, + child: DefaultTextStyle( + style: textStyle, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + maxLines: 1, + child: content, ), ), ), @@ -2432,121 +2077,56 @@ class CupertinoDialogAction extends StatelessWidget { // iOS style dialog action button layout. // -// [_CupertinoDialogActionsRenderWidget] does not provide any scrolling +// [_AlertDialogActionsLayout] does not provide any scrolling // behavior for its buttons. It only handles the sizing and layout of buttons. // Scrolling behavior can be composed on top of this widget, if desired. // -// See [_RenderCupertinoDialogActions] for specific layout policy details. -class _CupertinoDialogActionsRenderWidget extends MultiChildRenderObjectWidget { - const _CupertinoDialogActionsRenderWidget({ - required List actionButtons, - double dividerThickness = 0.0, - bool hasCancelButton = false, - }) : _dividerThickness = dividerThickness, - _hasCancelButton = hasCancelButton, - super(children: actionButtons); +// The layout operates in two modes: +// +// 1. Horizontal Mode: If there are exactly two buttons and they fit in a single +// row, the buttons are rendered side by side with a vertical divider between +// them. +// 2. Vertical Mode: In all other cases, the buttons are arranged in a column, +// separated by horizontal dividers. +// +// The `children` parameter must be a non-empty list containing button widgets +// and divider widgets in an alternating sequence. Therefore, the list must have +// an odd length. +class _AlertDialogActionsLayout extends MultiChildRenderObjectWidget { + const _AlertDialogActionsLayout({ + required double dividerThickness, + required super.children, + }) : _dividerThickness = dividerThickness; final double _dividerThickness; - final bool _hasCancelButton; @override RenderObject createRenderObject(BuildContext context) { - return _RenderCupertinoDialogActions( - dialogWidth: _isInAccessibilityMode(context) - ? _kAccessibilityCupertinoDialogWidth - : _kCupertinoDialogWidth, + return _RenderAlertDialogActionsLayout( dividerThickness: _dividerThickness, - dialogColor: CupertinoDynamicColor.resolve(_kDialogColor, context), - dialogPressedColor: CupertinoDynamicColor.resolve(_kDialogPressedColor, context), - dividerColor: CupertinoDynamicColor.resolve(CupertinoColors.separator, context), - hasCancelButton: _hasCancelButton, ); } @override - void updateRenderObject(BuildContext context, _RenderCupertinoDialogActions renderObject) { + void updateRenderObject(BuildContext context, _RenderAlertDialogActionsLayout renderObject) { renderObject - ..dialogWidth = _isInAccessibilityMode(context) - ? _kAccessibilityCupertinoDialogWidth - : _kCupertinoDialogWidth - ..dividerThickness = _dividerThickness - ..dialogColor = CupertinoDynamicColor.resolve(_kDialogColor, context) - ..dialogPressedColor = CupertinoDynamicColor.resolve(_kDialogPressedColor, context) - ..dividerColor = CupertinoDynamicColor.resolve(CupertinoColors.separator, context) - ..hasCancelButton = _hasCancelButton; + .dividerThickness = _dividerThickness; } } -// iOS style layout policy for sizing and positioning an alert dialog's action -// buttons. -// -// The policy is as follows: -// -// If a single action button is provided, or if 2 action buttons are provided -// that can fit side-by-side, then action buttons are sized and laid out in a -// single horizontal row. The row is exactly as wide as the dialog, and the row -// is as tall as the tallest action button. A horizontal divider is drawn above -// the button row. If 2 action buttons are provided, a vertical divider is -// drawn between them. The thickness of the divider is set by [dividerThickness]. -// -// If 2 action buttons are provided but they cannot fit side-by-side, then the -// 2 buttons are stacked vertically. A horizontal divider is drawn above each -// button. The thickness of the divider is set by [dividerThickness]. The minimum -// height of this [RenderBox] in the case of 2 stacked buttons is as tall as -// the 2 buttons stacked. This is different than the 3+ button case where the -// minimum height is only 1.5 buttons tall. See the 3+ button explanation for -// more info. -// -// If 3+ action buttons are provided then they are all stacked vertically. A -// horizontal divider is drawn above each button. The thickness of the divider -// is set by [dividerThickness]. The minimum height of this [RenderBox] in the case -// of 3+ stacked buttons is as tall as the 1st button + 50% the height of the -// 2nd button. In other words, the minimum height is 1.5 buttons tall. This -// minimum height of 1.5 buttons is expected to work in tandem with a surrounding -// [ScrollView] to match the iOS dialog behavior. -// -// Each button is expected to have an _ActionButtonParentData which reports -// whether or not that button is currently pressed. If a button is pressed, -// then the dividers above and below that pressed button are not drawn - instead -// they are filled with the standard white dialog background color. The one -// exception is the very 1st divider which is always rendered. This policy comes -// from observation of native iOS dialogs. -class _RenderCupertinoDialogActions extends RenderBox - with ContainerRenderObjectMixin, - RenderBoxContainerDefaultsMixin { - _RenderCupertinoDialogActions({ +class _RenderAlertDialogActionsLayout extends RenderFlex { + _RenderAlertDialogActionsLayout({ List? children, - double? dialogWidth, - double dividerThickness = 0.0, - required Color dialogColor, - required Color dialogPressedColor, - required Color dividerColor, - bool hasCancelButton = false, - }) : assert(dialogWidth != null), - _dialogWidth = dialogWidth, - _buttonBackgroundPaint = Paint() - ..color = dialogColor - ..style = PaintingStyle.fill, - _pressedButtonBackgroundPaint = Paint() - ..color = dialogPressedColor - ..style = PaintingStyle.fill, - _dividerPaint = Paint() - ..color = dividerColor - ..style = PaintingStyle.fill, - _dividerThickness = dividerThickness, - _hasCancelButton = hasCancelButton { + required double dividerThickness, + }) : _dividerThickness = dividerThickness, + super( + direction: Axis.vertical, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + ) { addAll(children); } - double? get dialogWidth => _dialogWidth; - double? _dialogWidth; - set dialogWidth(double? newWidth) { - if (newWidth != _dialogWidth) { - _dialogWidth = newWidth; - markNeedsLayout(); - } - } - // The thickness of the divider between buttons. double get dividerThickness => _dividerThickness; double _dividerThickness; @@ -2557,461 +2137,146 @@ class _RenderCupertinoDialogActions extends RenderBox } } - bool _hasCancelButton; - bool get hasCancelButton => _hasCancelButton; - set hasCancelButton(bool newValue) { - if (newValue == _hasCancelButton) { - return; - } - - _hasCancelButton = newValue; - markNeedsLayout(); - } - - Color get dialogColor => _buttonBackgroundPaint.color; - final Paint _buttonBackgroundPaint; - set dialogColor(Color value) { - if (value == _buttonBackgroundPaint.color) { - return; - } - - _buttonBackgroundPaint.color = value; - markNeedsPaint(); - } - - Color get dialogPressedColor => _pressedButtonBackgroundPaint.color; - final Paint _pressedButtonBackgroundPaint; - set dialogPressedColor(Color value) { - if (value == _pressedButtonBackgroundPaint.color) { - return; - } - - _pressedButtonBackgroundPaint.color = value; - markNeedsPaint(); - } - - Color get dividerColor => _dividerPaint.color; - final Paint _dividerPaint; - set dividerColor(Color value) { - if (value == _dividerPaint.color) { - return; - } - - _dividerPaint.color = value; - markNeedsPaint(); - } - - Iterable get _pressedButtons { - final List boxes = []; - RenderBox? currentChild = firstChild; - while (currentChild != null) { - assert(currentChild.parentData is _ActionButtonParentData); - final _ActionButtonParentData parentData = currentChild.parentData! as _ActionButtonParentData; - if (parentData.isPressed) { - boxes.add(currentChild); - } - currentChild = childAfter(currentChild); - } - return boxes; - } - - bool get _isButtonPressed { - RenderBox? currentChild = firstChild; - while (currentChild != null) { - assert(currentChild.parentData is _ActionButtonParentData); - final _ActionButtonParentData parentData = currentChild.parentData! as _ActionButtonParentData; - if (parentData.isPressed) { - return true; - } - currentChild = childAfter(currentChild); - } - return false; - } - - @override - void setupParentData(RenderBox child) { - if (child.parentData is! _ActionButtonParentData) { - child.parentData = _ActionButtonParentData(); - } - } - - @override - double computeMinIntrinsicWidth(double height) { - return dialogWidth!; - } - - @override - double computeMaxIntrinsicWidth(double height) { - return dialogWidth!; - } + double horizontalSlotWidthFor({required double overallWidth}) => + (overallWidth - dividerThickness) / 2; @override double computeMinIntrinsicHeight(double width) { - if (childCount == 0) { - return 0.0; - } else if (childCount == 1) { - // If only 1 button, display the button across the entire dialog. - return _computeMinIntrinsicHeightSideBySide(width); - } else if (childCount == 2 && _isSingleButtonRow(width)) { - // The first 2 buttons fit side-by-side. Display them horizontally. - return _computeMinIntrinsicHeightSideBySide(width); + if (!_useHorizontalLayout(width)) { + return super.computeMinIntrinsicHeight(width); } - // 3+ buttons are always stacked. The minimum height when stacked is - // 1.5 buttons tall. - return _computeMinIntrinsicHeightStacked(width); - } - // The minimum height for a single row of buttons is the larger of the buttons' - // min intrinsic heights. - double _computeMinIntrinsicHeightSideBySide(double width) { - assert(childCount >= 1 && childCount <= 2); - - final double minHeight; - if (childCount == 1) { - minHeight = firstChild!.getMinIntrinsicHeight(width); - } else { - final double perButtonWidth = (width - dividerThickness) / 2.0; - minHeight = math.max( - firstChild!.getMinIntrinsicHeight(perButtonWidth), - lastChild!.getMinIntrinsicHeight(perButtonWidth), - ); - } - return minHeight; - } - - // Dialog: The minimum height for 2+ stacked buttons is the height of the 1st - // button + 50% the height of the 2nd button + the divider between the two. - // - // ActionSheet: 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 _computeMinIntrinsicHeightStacked(double width) { - assert(childCount >= 2); - - return firstChild!.getMinIntrinsicHeight(width) - + dividerThickness - + (0.5 * childAfter(firstChild!)!.getMinIntrinsicHeight(width)); + final double slotWidth = horizontalSlotWidthFor(overallWidth: width); + double height = 0; + _forEachSlot((RenderBox slot) { + height = math.max(height, slot.getMinIntrinsicHeight(slotWidth)); + }); + return height; } @override double computeMaxIntrinsicHeight(double width) { - if (childCount == 0) { - // No buttons. Zero height. - return 0.0; - } else if (childCount == 1) { - // One button. Our max intrinsic height is equal to the button's. - return firstChild!.getMaxIntrinsicHeight(width); - } else if (childCount == 2) { - // Two buttons... - if (_isSingleButtonRow(width)) { - // The 2 buttons fit side by side so our max intrinsic height is equal - // to the taller of the 2 buttons. - final double perButtonWidth = (width - dividerThickness) / 2.0; - return math.max( - firstChild!.getMaxIntrinsicHeight(perButtonWidth), - lastChild!.getMaxIntrinsicHeight(perButtonWidth), - ); - } else { - // The 2 buttons do not fit side by side. Measure total height as a - // vertical stack. - return _computeMaxIntrinsicHeightStacked(width); - } + if (!_useHorizontalLayout(width)) { + return super.computeMaxIntrinsicHeight(width); } - // Three+ buttons. Stack the buttons vertically with dividers and measure - // the overall height. - 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; - } - - bool _isSingleButtonRow(double width) { - final bool isSingleButtonRow; - if (childCount == 1) { - isSingleButtonRow = true; - } else if (childCount == 2) { - // There are 2 buttons. If they can fit side-by-side then that's what - // we want to do. Otherwise, stack them vertically. - final double sideBySideWidth = firstChild!.getMaxIntrinsicWidth(double.infinity) - + dividerThickness - + lastChild!.getMaxIntrinsicWidth(double.infinity); - isSingleButtonRow = sideBySideWidth <= width; - } else { - isSingleButtonRow = false; - } - return isSingleButtonRow; + final double slotWidth = horizontalSlotWidthFor(overallWidth: width); + double height = 0; + _forEachSlot((RenderBox slot) { + height = math.max(height, slot.getMaxIntrinsicHeight(slotWidth)); + }); + return height; } @override - Size computeDryLayout(BoxConstraints constraints) { - return _performLayout(constraints: constraints, dry: true); + @protected + Size computeDryLayout(covariant BoxConstraints constraints) { + if (!_debugHasValidConstraints(constraints)) { + return Size.zero; + } + + final double overallWidth = constraints.maxWidth; + if (!_useHorizontalLayout(overallWidth)) { + return super.computeDryLayout(constraints); + } + + final double height = getMinIntrinsicHeight(overallWidth); + return Size(overallWidth, height); } @override void performLayout() { - size = _performLayout(constraints: constraints); - } + if (firstChild == null) { + size = constraints.smallest; + return; + } - Size _performLayout({required BoxConstraints constraints, bool dry = false}) { - final ChildLayouter layoutChild = dry - ? ChildLayoutHelper.dryLayoutChild - : ChildLayoutHelper.layoutChild; + if (!_debugHasValidConstraints(constraints)) { + size = constraints.smallest; + return; + } - 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. - final Size childSize = layoutChild( - firstChild!, - constraints, - ); + final double overallWidth = constraints.maxWidth; + if (!_useHorizontalLayout(overallWidth)) { + return super.performLayout(); + } - return constraints.constrain( - Size(dialogWidth!, childSize.height), - ); - } else { - // Each button gets half the available width, minus a single divider. - final BoxConstraints perButtonConstraints = BoxConstraints( - minWidth: (constraints.minWidth - dividerThickness) / 2.0, - maxWidth: (constraints.maxWidth - dividerThickness) / 2.0, - ); + final double slotWidth = horizontalSlotWidthFor(overallWidth: overallWidth); + final double height = getMinIntrinsicHeight(overallWidth); + size = Size(overallWidth, height); - // Layout the 2 buttons. - final Size firstChildSize = layoutChild( - firstChild!, - perButtonConstraints, - ); - final Size lastChildSize = layoutChild( - lastChild!, - perButtonConstraints, - ); + RenderBox slot = firstChild!; + double x = 0; + while (true) { + slot.layout(BoxConstraints.tight(Size(slotWidth, height)), parentUsesSize: true); + (slot.parentData! as FlexParentData).offset = Offset(x, 0); + x += slot.size.width; - if (!dry) { - // The 2nd button needs to be offset to the right. - assert(lastChild!.parentData is MultiChildLayoutParentData); - final MultiChildLayoutParentData secondButtonParentData = lastChild!.parentData! as MultiChildLayoutParentData; - secondButtonParentData.offset = Offset(firstChildSize.width + dividerThickness, 0.0); - } - - // Calculate our size based on the button sizes. - return constraints.constrain( - Size( - dialogWidth!, - math.max( - firstChildSize.height, - lastChildSize.height, - ), - ), - ); + final RenderBox? divider = childAfter(slot); + if (divider == null) { + break; } - } else { - // We need to stack buttons vertically, plus dividers above each button (except the 1st). - final BoxConstraints perButtonConstraints = constraints.copyWith( - minHeight: 0.0, - maxHeight: double.infinity, - ); + divider.layout(BoxConstraints.tight(Size(dividerThickness, height))); + (divider.parentData! as FlexParentData).offset = Offset(x, 0); + x += dividerThickness; - RenderBox? child = firstChild; - int index = 0; - double verticalOffset = 0.0; - while (child != null) { - final Size childSize = layoutChild( - child, - perButtonConstraints, - ); + slot = childAfter(divider)!; + } + } - if (!dry) { - assert(child.parentData is MultiChildLayoutParentData); - final MultiChildLayoutParentData parentData = child.parentData! as MultiChildLayoutParentData; - parentData.offset = Offset(0.0, verticalOffset); - } - verticalOffset += childSize.height; - if (index < childCount - 1) { - // Add a gap for the next divider. - verticalOffset += dividerThickness; - } - - index += 1; - child = childAfter(child); + bool _debugHasValidConstraints(BoxConstraints constraints) { + assert(() { + ErrorSummary? errorSummary; + if (constraints.maxWidth == double.infinity) { + errorSummary = ErrorSummary('The incoming width constraints are unbounded.'); } - - // Our height is the accumulated height of all buttons and dividers. - return constraints.constrain( - Size(getMaxIntrinsicWidth(0), verticalOffset), - ); - } - } - - @override - void paint(PaintingContext context, Offset offset) { - final Canvas canvas = context.canvas; - - if (_isSingleButtonRow(size.width)) { - _drawButtonBackgroundsAndDividersSingleRow(canvas, offset); - } else { - _drawButtonBackgroundsAndDividersStacked(canvas, offset); - } - - _drawButtons(context, offset); - } - - void _drawButtonBackgroundsAndDividersSingleRow(Canvas canvas, Offset offset) { - // The vertical divider sits between the left button and right button (if - // the dialog has 2 buttons). The vertical divider is hidden if either the - // left or right button is pressed. - final Rect verticalDivider = childCount == 2 && !_isButtonPressed - ? Rect.fromLTWH( - offset.dx + firstChild!.size.width, - offset.dy, - dividerThickness, - math.max( - firstChild!.size.height, - lastChild!.size.height, - ), - ) - : Rect.zero; - - final List pressedButtonRects = _pressedButtons.map((RenderBox pressedButton) { - final MultiChildLayoutParentData buttonParentData = pressedButton.parentData! as MultiChildLayoutParentData; - - return Rect.fromLTWH( - offset.dx + buttonParentData.offset.dx, - offset.dy + buttonParentData.offset.dy, - pressedButton.size.width, - pressedButton.size.height, - ); - }).toList(); - - // Create the button backgrounds path and paint it. - final Path backgroundFillPath = Path() - ..fillType = PathFillType.evenOdd - ..addRect(Rect.fromLTWH(0.0, 0.0, size.width, size.height)) - ..addRect(verticalDivider); - - for (int i = 0; i < pressedButtonRects.length; i += 1) { - backgroundFillPath.addRect(pressedButtonRects[i]); - } - - canvas.drawPath( - backgroundFillPath, - _buttonBackgroundPaint, - ); - - // Create the pressed buttons background path and paint it. - final Path pressedBackgroundFillPath = Path(); - for (int i = 0; i < pressedButtonRects.length; i += 1) { - pressedBackgroundFillPath.addRect(pressedButtonRects[i]); - } - - canvas.drawPath( - pressedBackgroundFillPath, - _pressedButtonBackgroundPaint, - ); - - // Create the dividers path and paint it. - final Path dividersPath = Path() - ..addRect(verticalDivider); - - canvas.drawPath( - dividersPath, - _dividerPaint, - ); - } - - void _drawButtonBackgroundsAndDividersStacked(Canvas canvas, Offset offset) { - final Offset dividerOffset = Offset(0.0, dividerThickness); - - final Path backgroundFillPath = Path() - ..fillType = PathFillType.evenOdd - ..addRect(Rect.fromLTWH(0.0, 0.0, size.width, size.height)); - - final Path pressedBackgroundFillPath = Path(); - - final Path dividersPath = Path(); - - Offset accumulatingOffset = offset; - - RenderBox? child = firstChild; - RenderBox? prevChild; - while (child != null) { - assert(child.parentData is _ActionButtonParentData); - final _ActionButtonParentData currentButtonParentData = child.parentData! as _ActionButtonParentData; - final bool isButtonPressed = currentButtonParentData.isPressed; - - bool isPrevButtonPressed = false; - if (prevChild != null) { - assert(prevChild.parentData is _ActionButtonParentData); - final _ActionButtonParentData previousButtonParentData = prevChild.parentData! as _ActionButtonParentData; - isPrevButtonPressed = previousButtonParentData.isPressed; + if (errorSummary != null) { + throw FlutterError.fromParts([ + errorSummary, + ErrorDescription('The incoming constraints are: $constraints'), + ]); } - - final bool isDividerPresent = child != firstChild; - final bool isDividerPainted = isDividerPresent && !(isButtonPressed || isPrevButtonPressed); - final Rect dividerRect = Rect.fromLTWH( - accumulatingOffset.dx, - accumulatingOffset.dy, - size.width, - dividerThickness, - ); - - final Rect buttonBackgroundRect = 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) - + Offset(0.0, child.size.height); - - prevChild = child; - child = childAfter(child); - } - - canvas.drawPath(backgroundFillPath, _buttonBackgroundPaint); - canvas.drawPath(pressedBackgroundFillPath, _pressedButtonBackgroundPaint); - canvas.drawPath(dividersPath, _dividerPaint); + return true; + }()); + return true; } - void _drawButtons(PaintingContext context, Offset offset) { - RenderBox? child = firstChild; - while (child != null) { - final MultiChildLayoutParentData childParentData = child.parentData! as MultiChildLayoutParentData; - context.paintChild(child, childParentData.offset + offset); - child = childAfter(child); + bool _useHorizontalLayout(double overallWidth) { + // Horizontal layout only applies to cases of 3 children: 2 action buttons + // and 1 divider. + if (childCount != 3) { + return false; } + final double slotWidth = horizontalSlotWidthFor(overallWidth: overallWidth); + RenderBox child = firstChild!; + while (true) { + // If both children fit into a half-row slot, use the horizontal layout. + // Max intrinsic widths are used here, which, according to + // [TextPainter.maxIntrinsicWidth], allows text to be displayed at their + // full font size. + if (child.getMaxIntrinsicWidth(double.infinity) > slotWidth) { + return false; + } + final RenderBox? divider = childAfter(child); + if (divider == null) { + break; + } + child = childAfter(divider)!; + } + return true; } - @override - bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { - return defaultHitTestChildren(result, position: position); + void _forEachSlot(ValueSetter action) { + assert(childCount.isOdd); + RenderBox slot = firstChild!; + while (true) { + action(slot); + final RenderBox? divider = childAfter(slot); + if (divider == null) { + break; + } + slot = childAfter(divider)!; + } } } diff --git a/packages/flutter/test/cupertino/dialog_test.dart b/packages/flutter/test/cupertino/dialog_test.dart index c8337893bb..5b1b517dad 100644 --- a/packages/flutter/test/cupertino/dialog_test.dart +++ b/packages/flutter/test/cupertino/dialog_test.dart @@ -765,10 +765,7 @@ void main() { final RenderBox actionsSectionBox = findScrollableActionsSectionRenderBox(tester); - expect( - actionsSectionBox.size.height, - 67.80000000000001, - ); + expect(actionsSectionBox.size.height, 67.8); }); testWidgets('Actions section height for 2 stacked buttons without enough room and large accessibility font is 50% of dialog height.', (WidgetTester tester) async { @@ -858,166 +855,102 @@ void main() { ); }); - testWidgets('Actions section overscroll is painted white.', (WidgetTester tester) async { - final ScrollController scrollController = ScrollController(); - addTearDown(scrollController.dispose); + 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( createAppWithButtonThatLaunchesDialog( dialogBuilder: (BuildContext context) { return CupertinoAlertDialog( - title: const Text('The Title'), - content: const Text('The message'), - actions: const [ + actions: List.generate(12, (int i) => CupertinoDialogAction( - child: Text('Option 1'), + onPressed: () {}, + child: Text('Button ${'*' * i}'), ), - CupertinoDialogAction( - child: Text('Option 2'), - ), - CupertinoDialogAction( - child: Text('Option 3'), - ), - ], - scrollController: scrollController, + ), ); }, ), ); 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(CupertinoAlertDialog), + matchesGoldenFile('cupertinoAlertDialog.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(CupertinoAlertDialog), + matchesGoldenFile('cupertinoAlertDialog.overscroll.1.png'), + ); - final RenderBox actionsSectionBox = findScrollableActionsSectionRenderBox(tester); - - // The way that overscroll white is accomplished in a scrollable action - // section is that the custom RenderBox that lays out the buttons and draws - // the dividers also paints a white background the size of Rect.largest. - // That background ends up being clipped by the containing ScrollView. - // - // Here we test that the Rect(0.0, 0.0, renderBox.size.width, renderBox.size.height) - // is contained within the painted Path. - // We don't test for exclusion because for some reason the Path is reporting - // that even points beyond Rect.largest are within the Path. That's not an - // issue for our use-case, so we don't worry about it. - expect(actionsSectionBox, paints..path( - includes: [ - Offset.zero, - Offset(actionsSectionBox.size.width, actionsSectionBox.size.height), - ], - )); + 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(CupertinoAlertDialog), + matchesGoldenFile('cupertinoAlertDialog.overscroll.2.png'), + ); + await gesture.up(); }); - testWidgets('Pressed button changes appearance and dividers disappear.', (WidgetTester tester) async { - final ScrollController scrollController = ScrollController(); - addTearDown(scrollController.dispose); - const double dividerThickness = 0.3; + testWidgets('Actions section correctly renders overscrolls with very far scrolls', (WidgetTester tester) async { + // When the scroll is really far, the overscroll might be longer than the + // actions section, causing overflow if not controlled. + final ScrollController actionScrollController = ScrollController(); + addTearDown(actionScrollController.dispose); await tester.pumpWidget( createAppWithButtonThatLaunchesDialog( dialogBuilder: (BuildContext context) { return CupertinoAlertDialog( - title: const Text('The Title'), - content: const Text('The message'), - actions: const [ - CupertinoDialogAction( - child: Text('Option 1'), + content: Text('content' * 1000), + actions: List.generate(4, (int i) => + CupertinoActionSheetAction( + onPressed: () {}, + child: Text('Button $i'), ), - CupertinoDialogAction( - child: Text('Option 2'), - ), - CupertinoDialogAction( - child: Text('Option 3'), - ), - ], - scrollController: scrollController, + ), ); }, ), ); await tester.tap(find.text('Go')); - await tester.pump(); + await tester.pumpAndSettle(); - const Color normalButtonBackgroundColor = Color(0xCCF2F2F2); - const Color pressedButtonBackgroundColor = Color(0xFFE1E1E1); - final RenderBox firstButtonBox = findActionButtonRenderBoxByTitle(tester, 'Option 1'); - final RenderBox secondButtonBox = findActionButtonRenderBoxByTitle(tester, 'Option 2'); - final RenderBox actionsSectionBox = findScrollableActionsSectionRenderBox(tester); - - final Offset pressedButtonCenter = Offset( - secondButtonBox.size.width / 2.0, - firstButtonBox.size.height + dividerThickness + (secondButtonBox.size.height / 2.0), + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('Button 0'))); + await tester.pumpAndSettle(); + await gesture.moveBy(const Offset(0, 40)); // A short drag to start the gesture. + await tester.pumpAndSettle(); + // The drag is far enough to make the overscroll longer than the section. + await gesture.moveBy(const Offset(0, 1000)); + await tester.pumpAndSettle(); + // The buttons should be out of the screen + expect( + tester.getTopLeft(find.text('Button 0')).dy, + greaterThan(tester.getBottomLeft(find.byType(ClipRRect)).dy) ); - final Offset topDividerCenter = Offset( - secondButtonBox.size.width / 2.0, - firstButtonBox.size.height + (0.5 * dividerThickness), + await expectLater( + find.byType(CupertinoAlertDialog), + matchesGoldenFile('cupertinoAlertDialog.long-overscroll.0.png'), ); - final Offset bottomDividerCenter = Offset( - secondButtonBox.size.width / 2.0, - firstButtonBox.size.height + - dividerThickness + - secondButtonBox.size.height + - (0.5 * dividerThickness), - ); - - // Before pressing the button, verify following expectations: - // - Background includes the button that will be pressed - // - Background excludes the divider above and below the button that will be pressed - // - Pressed button background does NOT include the button that will be pressed - expect(actionsSectionBox, paints - ..path( - color: normalButtonBackgroundColor, - includes: [ - pressedButtonCenter, - ], - excludes: [ - topDividerCenter, - bottomDividerCenter, - ], - ) - ..path( - color: pressedButtonBackgroundColor, - excludes: [ - pressedButtonCenter, - ], - ), - ); - - // Press down on the button. - final TestGesture gesture = await tester.press(find.widgetWithText(CupertinoDialogAction, 'Option 2')); - await tester.pump(); - - // While pressing the button, verify following expectations: - // - Background excludes the pressed button - // - Background includes the divider above and below the pressed button - // - Pressed button background includes the pressed - expect(actionsSectionBox, paints - ..path( - color: normalButtonBackgroundColor, - // The background should contain the divider above and below the pressed - // button. While pressed, surrounding dividers disappear, which means - // they become part of the background. - includes: [ - topDividerCenter, - bottomDividerCenter, - ], - // The background path should not include the tapped button background... - excludes: [ - pressedButtonCenter, - ], - ) - // For a pressed button, a dedicated path is painted with a pressed button - // background color... - ..path( - color: pressedButtonBackgroundColor, - includes: [ - pressedButtonCenter, - ], - ), - ); - - // We must explicitly cause an "up" gesture to avoid a crash. - // TODO(mattcarroll): remove this call, https://github.com/flutter/flutter/issues/19540 - await gesture.up(); }); testWidgets('ScaleTransition animation for showCupertinoDialog()', (WidgetTester tester) async {