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 {