blob: f2f6db5c6509c5417dfc96a573e5a3db78eba40c [file] [log] [blame] [edit]
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/// @docImport 'package:flutter/material.dart';
///
/// @docImport 'button.dart';
/// @docImport 'route.dart';
library;
import 'dart:math' as math;
import 'dart:ui' show ImageFilter, SemanticsRole, lerpDouble;
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'button.dart';
import 'colors.dart';
import 'constants.dart';
import 'cupertino_focus_halo.dart';
import 'interface_level.dart';
import 'localizations.dart';
import 'scrollbar.dart';
import 'theme.dart';
// TODO(abarth): These constants probably belong somewhere more general.
// Used XD to flutter plugin(https://github.com/AdobeXD/xd-to-flutter-plugin/)
// to derive values of TextStyle(height and letterSpacing) from
// Adobe XD template for iOS 13, which can be found in
// Apple Design Resources(https://developer.apple.com/design/resources/).
// However the values are not exactly the same as native, so eyeballing is needed.
const TextStyle _kCupertinoDialogTitleStyle = TextStyle(
fontFamily: 'CupertinoSystemText',
inherit: false,
fontSize: 17.0,
fontWeight: FontWeight.w600,
height: 1.3,
letterSpacing: -0.5,
textBaseline: TextBaseline.alphabetic,
);
const TextStyle _kCupertinoDialogContentStyle = TextStyle(
fontFamily: 'CupertinoSystemText',
inherit: false,
fontSize: 13.0,
fontWeight: FontWeight.w400,
height: 1.35,
letterSpacing: -0.2,
textBaseline: TextBaseline.alphabetic,
);
const TextStyle _kCupertinoDialogActionStyle = TextStyle(
fontFamily: 'CupertinoSystemText',
inherit: false,
fontSize: 16.8,
fontWeight: FontWeight.w400,
textBaseline: TextBaseline.alphabetic,
);
// CupertinoActionSheet-specific text styles.
const TextStyle _kActionSheetActionStyle = TextStyle(
// The fontSize and fontWeight may be adjusted when the text is rendered.
fontFamily: 'CupertinoSystemDisplay',
inherit: false,
fontSize: 17.0,
fontWeight: FontWeight.w400,
textBaseline: TextBaseline.alphabetic,
);
const TextStyle _kActionSheetContentStyle = TextStyle(
fontFamily: 'CupertinoSystemText',
inherit: false,
fontSize: 13.0,
fontWeight: FontWeight.w400,
textBaseline: TextBaseline.alphabetic,
// The `color` is configured by _kActionSheetContentTextColor to be dynamic on
// context.
);
// Generic constants shared between Dialog and ActionSheet.
const double _kCornerRadius = 14.0;
const double _kDividerThickness = 0.3;
// Dialog specific constants.
// iOS dialogs have a normal display width and another display width that is
// used when the device is in accessibility mode. Each of these widths are
// listed below.
const double _kCupertinoDialogWidth = 270.0;
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;
const double _kActionSheetCancelButtonPadding = 8.0;
const double _kActionSheetContentHorizontalPadding = 16.0;
const double _kActionSheetContentVerticalPadding = 13.5;
const double _kActionSheetActionsSectionMinHeight = 84.0;
const double _kActionSheetButtonHorizontalPadding = 10.0;
// According to experimenting on the simulator, the height of action sheet
// buttons is proportional to the font size down to a minimal height.
const double _kActionSheetButtonMinHeight = 57.17;
const double _kActionSheetButtonVerticalPaddingFactor = 0.4;
const double _kActionSheetButtonVerticalPaddingBase = 1.8;
// A translucent color that is painted on top of the blurred backdrop as the
// dialog's background color
// Extracted from https://developer.apple.com/design/resources/.
const Color _kDialogColor = CupertinoDynamicColor.withBrightness(
color: Color(0xCCF2F2F2),
darkColor: Color(0xCC2D2D2D),
);
// Translucent light gray that is painted on top of the blurred backdrop as the
// background color of a pressed button.
// Eyeballed from iOS 13 beta simulator.
const Color _kDialogPressedColor = CupertinoDynamicColor.withBrightness(
color: Color(0xFFE1E1E1),
darkColor: Color(0xFF404040),
);
// Translucent light gray that is painted on top of the blurred backdrop as the
// background color of a pressed button.
// Eyeballed from iOS 17 simulator.
const Color _kActionSheetPressedColor = CupertinoDynamicColor.withBrightness(
color: Color(0xCAE0E0E0),
darkColor: Color(0xC1515151),
);
const Color _kActionSheetCancelColor = CupertinoDynamicColor.withBrightness(
color: Color(0xFFFFFFFF),
darkColor: Color(0xFF2C2C2C),
);
const Color _kActionSheetCancelPressedColor = CupertinoDynamicColor.withBrightness(
color: Color(0xFFECECEC),
darkColor: Color(0xFF494949),
);
// Translucent, very light gray that is painted on top of the blurred backdrop
// as the action sheet's background color.
// TODO(LongCatIsLooong): https://github.com/flutter/flutter/issues/39272. Use
// System Materials once we have them.
// Eyeballed from iOS 17 simulator.
const Color _kActionSheetBackgroundColor = CupertinoDynamicColor.withBrightness(
color: Color(0xC8FCFCFC),
darkColor: Color(0xBE292929),
);
// The gray color used for text that appears in the title area.
// Eyeballed from iOS 17 simulator.
const Color _kActionSheetContentTextColor = CupertinoDynamicColor.withBrightness(
color: Color(0x851D1D1D),
darkColor: Color(0x96F1F1F1),
);
// Translucent gray that is painted on top of the blurred backdrop in the gap
// areas between the content section and actions section, as well as between
// buttons.
// Eyeballed from iOS 17 simulator.
const Color _kActionSheetButtonDividerColor = CupertinoDynamicColor.withBrightness(
color: Color(0xD4C9C9C9),
darkColor: Color(0xD57D7D7D),
);
// The alert dialog layout policy changes depending on whether the user is using
// a "regular" font size vs a "large" font size. This is a spectrum. There are
// many "regular" font sizes and many "large" font sizes. But depending on which
// policy is currently being used, a dialog is laid out differently.
//
// Empirically, the jump from one policy to the other occurs at the following text
// scale factors:
// Largest regular scale factor: 1.3529411764705883
// Smallest large scale factor: 1.6470588235294117
//
// The following constant represents a division in text scale factor beyond which
// we want to change how the dialog is laid out.
const double _kMaxRegularTextScaleFactor = 1.4;
// Accessibility mode on iOS is determined by the text scale factor that the
// user has selected.
bool _isInAccessibilityMode(BuildContext context) {
const defaultFontSize = 14.0;
final double? scaledFontSize = MediaQuery.maybeTextScalerOf(context)?.scale(defaultFontSize);
return scaledFontSize != null && scaledFontSize > defaultFontSize * _kMaxRegularTextScaleFactor;
}
/// An iOS-style alert dialog.
///
/// {@youtube 560 315 https://www.youtube.com/watch?v=75CsnyRXf5I}
///
/// An alert dialog informs the user about situations that require
/// acknowledgment. An alert dialog has an optional title, optional content,
/// and an optional list of actions. The title is displayed above the content
/// and the actions are displayed below the content.
///
/// This dialog styles its title and content (typically a message) to match the
/// standard iOS title and message dialog text style. These default styles can
/// be overridden by explicitly defining [TextStyle]s for [Text] widgets that
/// are part of the title or content.
///
/// To display action buttons that look like standard iOS dialog buttons,
/// provide [CupertinoDialogAction]s for the [actions] given to this dialog.
///
/// Typically passed as the child widget to [showDialog], which displays the
/// dialog.
///
/// {@tool dartpad}
/// This sample shows how to use a [CupertinoAlertDialog].
/// The [CupertinoAlertDialog] shows an alert with a set of two choices
/// when [CupertinoButton] is pressed.
///
/// ** See code in examples/api/lib/cupertino/dialog/cupertino_alert_dialog.0.dart **
/// {@end-tool}
///
/// See also:
///
/// * [CupertinoPopupSurface], which is a generic iOS-style popup surface that
/// holds arbitrary content to create custom popups.
/// * [CupertinoDialogAction], which is an iOS-style dialog button.
/// * [AlertDialog], a Material Design alert dialog.
/// * <https://developer.apple.com/design/human-interface-guidelines/alerts/>
class CupertinoAlertDialog extends StatefulWidget {
/// Creates an iOS-style alert dialog.
const CupertinoAlertDialog({
super.key,
this.title,
this.content,
this.actions = const <Widget>[],
this.scrollController,
this.actionScrollController,
this.insetAnimationDuration = const Duration(milliseconds: 100),
this.insetAnimationCurve = Curves.decelerate,
});
/// The (optional) title of the dialog is displayed in a large font at the top
/// of the dialog.
///
/// Typically a [Text] widget.
final Widget? title;
/// The (optional) content of the dialog is displayed in the center of the
/// dialog in a lighter font.
///
/// Typically a [Text] widget.
final Widget? content;
/// The (optional) set of actions that are displayed at the bottom of the
/// dialog.
///
/// Typically this is a list of [CupertinoDialogAction] widgets.
final List<Widget> actions;
/// A scroll controller that can be used to control the scrolling of the
/// [content] in the dialog.
///
/// Defaults to null, which means the [CupertinoDialogAction] will create a
/// scroll controller internally.
///
/// See also:
///
/// * [actionScrollController], which can be used for controlling the actions
/// section when there are many actions.
final ScrollController? scrollController;
/// A scroll controller that can be used to control the scrolling of the
/// actions in the dialog.
///
/// Defaults to null, which means the [CupertinoDialogAction] will create an
/// action scroll controller internally.
///
/// See also:
///
/// * [scrollController], which can be used for controlling the [content]
/// section when it is long.
final ScrollController? actionScrollController;
/// {@macro flutter.material.dialog.insetAnimationDuration}
final Duration insetAnimationDuration;
/// {@macro flutter.material.dialog.insetAnimationCurve}
final Curve insetAnimationCurve;
@override
State<CupertinoAlertDialog> createState() => _CupertinoAlertDialogState();
}
class _CupertinoAlertDialogState extends State<CupertinoAlertDialog> {
// 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;
ScrollController get _effectiveScrollController =>
widget.scrollController ?? (_backupScrollController ??= ScrollController());
ScrollController get _effectiveActionScrollController =>
widget.actionScrollController ?? (_backupActionScrollController ??= ScrollController());
Widget? _buildContent(BuildContext context) {
final bool hasContent = widget.title != null || widget.content != null;
if (!hasContent) {
return null;
}
const defaultFontSize = 14.0;
final double effectiveTextScaleFactor =
MediaQuery.textScalerOf(context).scale(defaultFontSize) / defaultFontSize;
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: child);
}
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,
actions: widget.actions,
pressedIndex: _pressedIndex,
onPressedUpdate: _onPressedUpdate,
);
}
}
Widget _buildBody(BuildContext context) {
final Color backgroundColor = CupertinoDynamicColor.resolve(_kDialogColor, context);
final Color dividerColor = CupertinoDynamicColor.resolve(CupertinoColors.separator, context);
// 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(
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: <Widget>[
SizedBox(
width: double.infinity,
child: _Divider(
dividerColor: dividerColor,
hiddenColor: backgroundColor,
hidden: false,
),
),
Flexible(child: scrolledActionsSection),
],
),
bottomMinHeight: actionsMinHeight,
);
},
),
);
}
@override
Widget build(BuildContext context) {
final CupertinoLocalizations localizations = CupertinoLocalizations.of(context);
final bool isInAccessibilityMode = _isInAccessibilityMode(context);
return CupertinoUserInterfaceLevel(
data: CupertinoUserInterfaceLevelData.elevated,
child: MediaQuery.withClampedTextScaling(
// iOS does not shrink dialog content below a 1.0 scale factor
minScaleFactor: 1.0,
child: ScrollConfiguration(
// A CupertinoScrollbar is built-in below.
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return AnimatedPadding(
padding:
MediaQuery.viewInsetsOf(context) +
const EdgeInsets.symmetric(horizontal: 40.0, vertical: 24.0),
duration: widget.insetAnimationDuration,
curve: widget.insetAnimationCurve,
child: MediaQuery.removeViewInsets(
removeLeft: true,
removeTop: true,
removeRight: true,
removeBottom: true,
context: context,
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: _kDialogEdgePadding),
child: SizedBox(
width: isInAccessibilityMode
? _kAccessibilityCupertinoDialogWidth
: _kCupertinoDialogWidth,
child: _ActionSheetGestureDetector(
child: CupertinoPopupSurface(
isSurfacePainted: false,
child: Semantics(
role: SemanticsRole.alertDialog,
namesRoute: true,
scopesRoute: true,
explicitChildNodes: true,
label: localizations.alertDialogLabel,
child: _buildBody(context),
),
),
),
),
),
),
),
);
},
),
),
),
);
}
@override
void dispose() {
_backupScrollController?.dispose();
_backupActionScrollController?.dispose();
super.dispose();
}
}
/// An iOS-style component for creating modal overlays like dialogs and action
/// sheets.
///
/// By default, [CupertinoPopupSurface] generates a rounded rectangle surface
/// that applies two effects to the background content:
///
/// 1. Background filter: Saturates and then blurs content behind the surface.
/// 2. Overlay color: Covers the filtered background with a transparent
/// surface color. The color adapts to the CupertinoTheme's brightness:
/// light gray when the ambient [CupertinoTheme] brightness is
/// [Brightness.light], and dark gray when [Brightness.dark].
///
/// The blur strength can be changed by setting [blurSigma] to a positive value,
/// or removed by setting the [blurSigma] to 0.
///
/// The saturation effect can be removed for debugging by setting
/// [debugIsVibrancePainted] to false. The saturation effect is not supported on
/// web with the skwasm renderer and will not be applied regardless of the value
/// of [debugIsVibrancePainted].
///
/// The surface color can be disabled by setting [isSurfacePainted] to false,
/// which is useful for more complicated layouts, such as rendering divider gaps
/// in [CupertinoAlertDialog] or rendering custom surface colors.
///
/// {@tool dartpad}
/// This sample shows how to use a [CupertinoPopupSurface]. The [CupertinoPopupSurface]
/// shows a modal popup from the bottom of the screen.
/// Toggle the switch to configure its surface color.
///
/// ** See code in examples/api/lib/cupertino/dialog/cupertino_popup_surface.0.dart **
/// {@end-tool}
///
/// See also:
///
/// * [CupertinoAlertDialog], which is a dialog with a title, content, and
/// actions.
/// * <https://developer.apple.com/design/human-interface-guidelines/alerts/>
class CupertinoPopupSurface extends StatelessWidget {
/// Creates an iOS-style rounded rectangle popup surface.
const CupertinoPopupSurface({
super.key,
this.blurSigma = defaultBlurSigma,
this.isSurfacePainted = true,
required this.child,
}) : assert(blurSigma >= 0, 'CupertinoPopupSurface requires a non-negative blur sigma.');
/// The strength of the gaussian blur applied to the area beneath this
/// surface.
///
/// Defaults to [defaultBlurSigma]. Setting [blurSigma] to 0 will remove the
/// blur filter.
final double blurSigma;
/// Whether or not to paint a translucent white on top of this surface's
/// blurred background. [isSurfacePainted] should be true for a typical popup
/// that contains content without any dividers. A popup that requires dividers
/// should set [isSurfacePainted] to false and then paint its own surface area.
///
/// Some popups, like iOS's volume control popup, choose to render a blurred
/// area without any white paint covering it. To achieve this effect,
/// [isSurfacePainted] should be set to false.
///
/// Defaults to true.
final bool isSurfacePainted;
/// The widget below this widget in the tree.
// Because [CupertinoPopupSurface] is composed of proxy boxes, which mimic
// the size of their child, a [child] is required to ensure that this surface
// has a size.
final Widget child;
/// The default strength of the blur applied to widgets underlying a
/// [CupertinoPopupSurface].
///
/// Eyeballed from the iOS 17 simulator.
static const double defaultBlurSigma = 30.0;
/// The default corner radius of a [CupertinoPopupSurface].
static const BorderRadius _clipper = BorderRadius.all(Radius.circular(13));
// The [ColorFilter] matrix used to saturate widgets underlying a
// [CupertinoPopupSurface] when the ambient [CupertinoThemeData.brightness] is
// [Brightness.light].
//
// To derive this matrix, the saturation matrix was taken from
// https://docs.rainmeter.net/tips/colormatrix-guide/ and was tweaked to
// resemble the iOS 17 simulator.
//
// The matrix can be derived from the following function:
// static List<double> get _lightSaturationMatrix {
// const double lightLumR = 0.26;
// const double lightLumG = 0.4;
// const double lightLumB = 0.17;
// const double saturation = 2.0;
// const double sr = (1 - saturation) * lightLumR;
// const double sg = (1 - saturation) * lightLumG;
// const double sb = (1 - saturation) * lightLumB;
// return <double>[
// sr + saturation, sg, sb, 0.0, 0.0,
// sr, sg + saturation, sb, 0.0, 0.0,
// sr, sg, sb + saturation, 0.0, 0.0,
// 0.0, 0.0, 0.0, 1.0, 0.0,
// ];
// }
static const List<double> _lightSaturationMatrix = <double>[
1.74,
-0.40,
-0.17,
0.00,
0.00,
-0.26,
1.60,
-0.17,
0.00,
0.00,
-0.26,
-0.40,
1.83,
0.00,
0.00,
0.00,
0.00,
0.00,
1.00,
0.00,
];
// The [ColorFilter] matrix used to saturate widgets underlying a
// [CupertinoPopupSurface] when the ambient [CupertinoThemeData.brightness] is
// [Brightness.dark].
//
// To derive this matrix, the saturation matrix was taken from
// https://docs.rainmeter.net/tips/colormatrix-guide/ and was tweaked to
// resemble the iOS 17 simulator.
//
// The matrix can be derived from the following function:
// static List<double> get _darkSaturationMatrix {
// const double additive = 0.3;
// const double darkLumR = 0.45;
// const double darkLumG = 0.8;
// const double darkLumB = 0.16;
// const double saturation = 1.7;
// const double sr = (1 - saturation) * darkLumR;
// const double sg = (1 - saturation) * darkLumG;
// const double sb = (1 - saturation) * darkLumB;
// return <double>[
// sr + saturation, sg, sb, 0.0, additive,
// sr, sg + saturation, sb, 0.0, additive,
// sr, sg, sb + saturation, 0.0, additive,
// 0.0, 0.0, 0.0, 1.0, 0.0,
// ];
// }
static const List<double> _darkSaturationMatrix = <double>[
1.39,
-0.56,
-0.11,
0.00,
0.30,
-0.32,
1.14,
-0.11,
0.00,
0.30,
-0.32,
-0.56,
1.59,
0.00,
0.30,
0.00,
0.00,
0.00,
1.00,
0.00,
];
/// Whether or not the area beneath this surface should be saturated with a
/// [ColorFilter].
///
/// The appearance of the [ColorFilter] is determined by the [Brightness]
/// value obtained from the ambient [CupertinoTheme].
///
/// The vibrance is always painted if asserts are disabled.
///
/// Defaults to true.
static bool debugIsVibrancePainted = true;
ImageFilterConfig? _buildFilter(Brightness? brightness) {
var isVibrancePainted = true;
assert(() {
isVibrancePainted = debugIsVibrancePainted;
return true;
}());
if (!isVibrancePainted) {
if (blurSigma == 0) {
return null;
}
return ImageFilterConfig.blur(sigmaX: blurSigma, sigmaY: blurSigma, bounded: true);
}
final colorFilter = ImageFilterConfig(switch (brightness) {
Brightness.dark => const ColorFilter.matrix(_darkSaturationMatrix),
Brightness.light || null => const ColorFilter.matrix(_lightSaturationMatrix),
});
if (blurSigma == 0) {
return colorFilter;
}
return ImageFilterConfig.compose(
inner: colorFilter,
outer: ImageFilterConfig.blur(sigmaX: blurSigma, sigmaY: blurSigma, bounded: true),
);
}
@override
Widget build(BuildContext context) {
final ImageFilterConfig? filter = _buildFilter(CupertinoTheme.maybeBrightnessOf(context));
Widget contents = child;
if (isSurfacePainted) {
contents = ColoredBox(
color: CupertinoDynamicColor.resolve(_kDialogColor, context),
child: contents,
);
}
if (filter != null) {
return ClipRSuperellipse(
borderRadius: _clipper,
child: BackdropFilter(filterConfig: filter, child: contents),
);
}
return ClipRSuperellipse(borderRadius: _clipper, child: contents);
}
}
typedef _HitTester = HitTestResult Function(Offset location);
// Recognizes taps with possible sliding during the tap.
//
// This recognizer only tracks one pointer at a time (called the primary
// pointer), and other pointers added while the primary pointer is alive are
// ignored and can not be used by other gestures either. After the primary
// pointer ends, the pointer added next becomes the new primary pointer (which
// starts a new gesture sequence).
//
// This recognizer only allows [kPrimaryMouseButton].
class _SlidingTapGestureRecognizer extends VerticalDragGestureRecognizer {
_SlidingTapGestureRecognizer({super.debugOwner}) {
dragStartBehavior = DragStartBehavior.down;
}
/// Called whenever the primary pointer moves regardless of whether drag has
/// started.
///
/// The parameter is the global position of the primary pointer.
///
/// This is similar to `onUpdate`, but allows the caller to track the primary
/// pointer's location before the drag starts, which is useful to enhance
/// responsiveness.
ValueSetter<Offset>? onResponsiveUpdate;
/// Called whenever the primary pointer is lifted regardless of whether drag
/// has started.
///
/// The parameter is the global position of the primary pointer.
///
/// This is similar to `onEnd`, but allows know the primary pointer's final
/// location even if the drag never started, which is useful to enhance
/// responsiveness.
ValueSetter<Offset>? onResponsiveEnd;
int? _primaryPointer;
@override
void addAllowedPointer(PointerDownEvent event) {
_primaryPointer ??= event.pointer;
super.addAllowedPointer(event);
}
@override
void rejectGesture(int pointer) {
if (pointer == _primaryPointer) {
_primaryPointer = null;
}
super.rejectGesture(pointer);
}
@override
void handleEvent(PointerEvent event) {
if (event.pointer == _primaryPointer) {
if (event is PointerMoveEvent) {
onResponsiveUpdate?.call(event.position);
}
// Sliding tap needs to handle 'up' events differently compared to typical
// drag gestures. If there's another gesture recognizer (like scrolling)
// competing and the pointer hasn't moved beyond the tolerance limit
// (slop), this gesture must still be accepted.
//
// Simply calling `accept()` here to handle this won't work because it
// would break backward compatibility with legacy buttons (see
// https://github.com/flutter/flutter/issues/150980 for more details).
// Legacy buttons recognize taps using `GestureDetector.onTap`, which
// neither accepts nor rejects for short taps. Instead, they wait for the
// default resolution as the last contender in the gesture arena.
//
// Therefore, this gesture should also follow the same strategy of not
// immediately accepting or rejecting. This allows tap gestures to take
// precedence for being inner, while sliding taps can take precedence over
// scroll gestures when the latter give up.
if (event is PointerUpEvent) {
stopTrackingPointer(_primaryPointer!);
onResponsiveEnd?.call(event.position);
_primaryPointer = null;
// Do not call `super.handleEvent`, which gives up the pointer and thus
// rejects the gesture.
return;
}
if (event is PointerCancelEvent) {
_primaryPointer = null;
}
}
super.handleEvent(event);
}
@override
String get debugDescription => 'tap slide';
}
// A region (typically a button) that can receive entering, exiting, and
// updating events of a "sliding tap" gesture.
//
// Some Cupertino widgets, such as action sheets or dialogs, allow the user to
// select buttons using "sliding taps", where the user can drag around after
// pressing on the screen, and whichever button the drag ends in is selected.
//
// This class is used to define the regions that sliding taps recognize. This
// class must be provided to a `MetaData` widget as `data`, and is typically
// implemented by a widget state class. When an eligible dragging gesture
// enters, leaves, or ends this `MetaData` widget, corresponding methods of this
// class will be called.
//
// Multiple `_SlideTarget`s might be nested.
// `_TargetSelectionGestureRecognizer` uses a simple algorithm that only
// compares if the inner-most slide target has changed (which suffices our use
// case). Semantically, this means that all outer targets will be treated as
// having the identical area as the inner-most one, i.e. when the pointer enters
// or leaves a slide target, the corresponding method will be called on all
// targets that nest it.
abstract class _SlideTarget {
// A pointer has entered this region.
//
// This includes:
//
// * The pointer has moved into this region from outside.
// * The point has contacted the screen in this region. In this case, this
// method is called as soon as the pointer down event occurs regardless of
// whether the gesture wins the arena immediately.
//
// The `fromPointerDown` should be true if this callback is triggered by a
// PointerDownEvent, i.e. the second case from the list above.
//
// The return value of this method is used as the `innerEnabled` for the next
// target, while `innerEnabled` of the innermost target is true.
bool didEnter({required bool fromPointerDown, required bool innerEnabled});
// A pointer has exited this region.
//
// This includes:
// * The pointer has moved out of this region.
// * The pointer is no longer in contact with the screen.
// * The pointer is canceled.
// * The gesture loses the arena.
// * The gesture ends. In this case, this method is called immediately
// before [didConfirm].
void didLeave();
// The drag gesture is completed in this region.
//
// This method is called immediately after a [didLeave].
void didConfirm();
}
// Recognizes sliding taps and thereupon interacts with
// `_SlideTarget`s.
//
// TODO(dkwingsmt): It should recompute hit testing when the app is updated,
// or better, share code with `MouseTracker`.
// https://github.com/flutter/flutter/issues/155266
class _TargetSelectionGestureRecognizer extends GestureRecognizer {
_TargetSelectionGestureRecognizer({super.debugOwner, required this.hitTest})
: _slidingTap = _SlidingTapGestureRecognizer(debugOwner: debugOwner) {
_slidingTap
..onDown = _onDown
..onResponsiveUpdate = _onUpdate
..onResponsiveEnd = _onEnd
..onCancel = _onCancel;
}
final _HitTester hitTest;
final List<_SlideTarget> _currentTargets = <_SlideTarget>[];
final _SlidingTapGestureRecognizer _slidingTap;
@override
void acceptGesture(int pointer) {
_slidingTap.acceptGesture(pointer);
}
@override
void rejectGesture(int pointer) {
_slidingTap.rejectGesture(pointer);
}
@override
void addPointer(PointerDownEvent event) {
_slidingTap.addPointer(event);
}
@override
void addPointerPanZoom(PointerPanZoomStartEvent event) {
_slidingTap.addPointerPanZoom(event);
}
@override
void dispose() {
_slidingTap.dispose();
super.dispose();
}
// Collect the `_SlideTarget`s that are currently hit by the
// pointer, check whether the current target have changed, and invoke their
// methods if necessary.
//
// The `fromPointerDown` should be true if this update is triggered by a
// PointerDownEvent.
void _updateDrag(Offset pointerPosition, {required bool fromPointerDown}) {
final HitTestResult result = hitTest(pointerPosition);
// A slide target might nest other targets, therefore multiple targets might
// be found.
final foundTargets = <_SlideTarget>[];
for (final HitTestEntry entry in result.path) {
if (entry.target case final RenderMetaData target) {
if (target.metaData is _SlideTarget) {
foundTargets.add(target.metaData as _SlideTarget);
}
}
}
// Compare whether the active target has changed by simply comparing the
// first (inner-most) avatar of the nest, ignoring the cases where
// _currentTargets intersect with foundTargets (see _SlideTarget's
// document for more explanation).
if (_currentTargets.firstOrNull != foundTargets.firstOrNull) {
for (final _SlideTarget target in _currentTargets) {
target.didLeave();
}
_currentTargets
..clear()
..addAll(foundTargets);
var enabled = true;
for (final _SlideTarget target in _currentTargets) {
enabled = target.didEnter(fromPointerDown: fromPointerDown, innerEnabled: enabled);
}
}
}
void _onDown(DragDownDetails details) {
_updateDrag(details.globalPosition, fromPointerDown: true);
}
void _onUpdate(Offset globalPosition) {
_updateDrag(globalPosition, fromPointerDown: false);
}
void _onEnd(Offset globalPosition) {
_updateDrag(globalPosition, fromPointerDown: false);
for (final _SlideTarget target in _currentTargets) {
target.didConfirm();
}
_currentTargets.clear();
}
void _onCancel() {
for (final _SlideTarget target in _currentTargets) {
target.didLeave();
}
_currentTargets.clear();
}
@override
String get debugDescription => 'target selection';
}
// The gesture detector used by action sheets.
//
// This gesture detector only recognizes one gesture,
// `_TargetSelectionGestureRecognizer`.
//
// This widget's child might contain another VerticalDragGestureRecognizer if
// the actions section or the content section scrolls. Conveniently, Flutter's
// gesture algorithm makes the inner gesture take priority.
class _ActionSheetGestureDetector extends StatelessWidget {
const _ActionSheetGestureDetector({this.child});
final Widget? child;
HitTestResult _hitTest(BuildContext context, Offset globalPosition) {
final int viewId = View.of(context).viewId;
final result = HitTestResult();
WidgetsBinding.instance.hitTestInView(result, globalPosition, viewId);
return result;
}
@override
Widget build(BuildContext context) {
final gestures = <Type, GestureRecognizerFactory>{};
gestures[_TargetSelectionGestureRecognizer] =
GestureRecognizerFactoryWithHandlers<_TargetSelectionGestureRecognizer>(
() => _TargetSelectionGestureRecognizer(
debugOwner: this,
hitTest: (Offset globalPosition) => _hitTest(context, globalPosition),
),
(_TargetSelectionGestureRecognizer instance) {},
);
return RawGestureDetector(excludeFromSemantics: true, gestures: gestures, child: child);
}
}
/// An iOS-style action sheet.
///
/// {@youtube 560 315 https://www.youtube.com/watch?v=U-ao8p4A82k}
///
/// An action sheet is a specific style of alert that presents the user
/// with a set of two or more choices related to the current context.
/// An action sheet can have a title, an additional message, and a list
/// of actions. The title is displayed above the message and the actions
/// are displayed below this content.
///
/// This action sheet styles its title and message to match standard iOS action
/// sheet title and message text style.
///
/// To display action buttons that look like standard iOS action sheet buttons,
/// provide [CupertinoActionSheetAction]s for the [actions] given to this action
/// sheet.
///
/// To include a iOS-style cancel button separate from the other buttons,
/// provide an [CupertinoActionSheetAction] for the [cancelButton] given to this
/// action sheet.
///
/// An action sheet is typically passed as the child widget to
/// [showCupertinoModalPopup], which displays the action sheet by sliding it up
/// from the bottom of the screen.
///
/// {@tool dartpad}
/// This sample shows how to use a [CupertinoActionSheet].
/// The [CupertinoActionSheet] shows a modal popup that slides in from the
/// bottom when [CupertinoButton] is pressed.
///
/// ** See code in examples/api/lib/cupertino/dialog/cupertino_action_sheet.0.dart **
/// {@end-tool}
///
/// See also:
///
/// * [CupertinoActionSheetAction], which is an iOS-style action sheet button.
/// * <https://developer.apple.com/design/human-interface-guidelines/ios/views/action-sheets/>
class CupertinoActionSheet extends StatefulWidget {
/// Creates an iOS-style action sheet.
///
/// An action sheet must have a non-null value for at least one of the
/// following arguments: [actions], [title], [message], or [cancelButton].
///
/// Generally, action sheets are used to give the user a choice between
/// two or more choices for the current context.
const CupertinoActionSheet({
super.key,
this.title,
this.message,
this.actions,
this.messageScrollController,
this.actionScrollController,
this.cancelButton,
}) : assert(
actions != null || title != null || message != null || cancelButton != null,
'An action sheet must have a non-null value for at least one of the following arguments: '
'actions, title, message, or cancelButton',
);
/// An optional title of the action sheet. When the [message] is non-null,
/// the font of the [title] is bold.
///
/// Typically a [Text] widget.
final Widget? title;
/// An optional descriptive message that provides more details about the
/// reason for the alert.
///
/// Typically a [Text] widget.
final Widget? message;
/// The set of actions that are displayed for the user to select.
///
/// This must be a list of [CupertinoActionSheetAction] widgets.
final List<Widget>? actions;
/// A scroll controller that can be used to control the scrolling of the
/// [message] in the action sheet.
///
/// Defaults to null, which means the [CupertinoActionSheet] will create a
/// scroll controller internally.
final ScrollController? messageScrollController;
/// A scroll controller that can be used to control the scrolling of the
/// [actions] in the action sheet.
///
/// Defaults to null, which means the [CupertinoActionSheet] will create an
/// action scroll controller internally.
final ScrollController? actionScrollController;
/// The optional cancel button that is grouped separately from the other
/// actions.
///
/// This must be a [CupertinoActionSheetAction] widget.
final Widget? cancelButton;
@override
State<CupertinoActionSheet> createState() => _CupertinoActionSheetState();
}
class _CupertinoActionSheetState extends State<CupertinoActionSheet> {
int? _pressedIndex;
static const int _kCancelButtonIndex = -1;
ScrollController? _backupMessageScrollController;
ScrollController? _backupActionScrollController;
ScrollController get _effectiveMessageScrollController =>
widget.messageScrollController ?? (_backupMessageScrollController ??= ScrollController());
ScrollController get _effectiveActionScrollController =>
widget.actionScrollController ?? (_backupActionScrollController ??= ScrollController());
@override
void dispose() {
_backupMessageScrollController?.dispose();
_backupActionScrollController?.dispose();
super.dispose();
}
bool get hasContent => widget.title != null || widget.message != null;
Widget? _buildContent(BuildContext context) {
if (!hasContent) {
return null;
}
final TextStyle textStyle = _kActionSheetContentStyle.copyWith(
color: CupertinoDynamicColor.resolve(_kActionSheetContentTextColor, context),
);
return ColoredBox(
color: CupertinoDynamicColor.resolve(_kActionSheetBackgroundColor, context),
child: _CupertinoAlertContentSection(
title: widget.title,
message: widget.message,
scrollController: _effectiveMessageScrollController,
titlePadding: EdgeInsets.only(
left: _kActionSheetContentHorizontalPadding,
right: _kActionSheetContentHorizontalPadding,
bottom: widget.message == null ? _kActionSheetContentVerticalPadding : 0.0,
top: _kActionSheetContentVerticalPadding,
),
messagePadding: EdgeInsets.only(
left: _kActionSheetContentHorizontalPadding,
right: _kActionSheetContentHorizontalPadding,
bottom: _kActionSheetContentVerticalPadding,
top: widget.title == null ? _kActionSheetContentVerticalPadding : 0.0,
),
titleTextStyle: widget.message == null
? textStyle
: textStyle.copyWith(fontWeight: FontWeight.w600),
messageTextStyle: widget.title == null
? textStyle.copyWith(fontWeight: FontWeight.w600)
: textStyle,
additionalPaddingBetweenTitleAndMessage: const EdgeInsets.only(top: 4.0),
),
);
}
void _onPressedUpdate(int actionIndex, bool state) {
if (!state) {
if (_pressedIndex == actionIndex) {
setState(() {
_pressedIndex = null;
});
}
} else {
setState(() {
_pressedIndex = actionIndex;
});
}
}
Widget _buildCancelButton() {
assert(widget.cancelButton != null);
final double cancelPadding =
(widget.actions != null || widget.message != null || widget.title != null)
? _kActionSheetCancelButtonPadding
: 0.0;
return Padding(
padding: EdgeInsets.only(top: cancelPadding),
child: CupertinoFocusHalo.withRRect(
borderRadius: kCupertinoButtonSizeBorderRadius[CupertinoButtonSize.large]!,
child: _ActionSheetButtonBackground(
isCancel: true,
pressed: _pressedIndex == _kCancelButtonIndex,
onPressStateChange: (bool state) {
_onPressedUpdate(_kCancelButtonIndex, state);
},
child: widget.cancelButton!,
),
),
);
}
// Given data point (x1, y1) and (x2, y2), derive the y corresponding to x
// using linear interpolation between the two data points, and extrapolates
// flatly beyond these points.
//
// (x2, y2)
// _____________
// /
// /
// _________/
// (x1, y1)
static double _lerp(double x, double x1, double y1, double x2, double y2) {
if (x <= x1) {
return y1;
} else if (x >= x2) {
return y2;
} else {
return lerpDouble(y1, y2, (x - x1) / (x2 - x1))!;
}
}
// Derive the top padding, which is the distance between the top of a
// full-height action sheet and the top of the safe area.
//
// The algorithm and its values are derived from measuring on the simulator.
double _topPadding(BuildContext context) {
if (MediaQuery.orientationOf(context) == Orientation.landscape) {
return _kActionSheetEdgePadding;
}
// The top padding in portrait mode is in general close to the top view
// padding, but not always equal:
//
// | view padding | action sheet padding | ratio
// No notch (eg. iPhone SE) | 20.0 | 20.0 | 1.0
// Notch (eg. iPhone 13) | 47.0 | 47.0 | 1.0
// Capsule (eg. iPhone 15) | 59.0 | 54.0 | 0.915
//
// Currently, we cannot determine why the result changes on "capsules."
// Therefore, we'll hard code this rule, given the limited types of actual
// devices. To provide an algorithm that accepts arbitrary view padding, this
// function calculates the ratio as a continuous curve with linear
// interpolation.
// The x for lerp is the top view padding, while the y is ratio of
// action sheet padding versus top view padding.
const viewPaddingData1 = 47.0;
const paddingRatioData1 = 1.0;
const viewPaddingData2 = 59.0;
const double paddingRatioData2 = 54.0 / 59.0;
final double currentViewPadding = MediaQuery.viewPaddingOf(context).top;
final double currentPaddingRatio = _lerp(
/* x= */ currentViewPadding,
/* x1, y1= */ viewPaddingData1,
paddingRatioData1,
/* x2, y2= */ viewPaddingData2,
paddingRatioData2,
);
final double padding = (currentPaddingRatio * currentViewPadding).roundToDouble();
// In case there is no view padding, there should still be some space
// between the action sheet and the edge.
return math.max(padding, _kDialogEdgePadding);
}
@override
Widget build(BuildContext context) {
assert(debugCheckHasMediaQuery(context));
/*
* ╭─────────────────╮ ↑ ↑
* │ The title │ Content section |
* │ The message │ ↓ |
* ├─────────────────┤ ↑ Main sheet
* │ Action 1 │ | |
* ├─────────────────┤ Actions section |
* │ Action 2 │ | |
* ╰─────────────────╯ ↓ ↓
* ╭─────────────────╮
* │ Cancel │
* ╰─────────────────╯
*/
final children = <Widget>[
Flexible(
child: ClipRSuperellipse(
borderRadius: const BorderRadius.all(Radius.circular(12.0)),
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: CupertinoPopupSurface.defaultBlurSigma,
sigmaY: CupertinoPopupSurface.defaultBlurSigma,
),
child: _ActionSheetMainSheet(
pressedIndex: _pressedIndex,
onPressedUpdate: _onPressedUpdate,
scrollController: _effectiveActionScrollController,
contentSection: _buildContent(context),
actions: widget.actions ?? List<Widget>.empty(),
dividerColor: CupertinoDynamicColor.resolve(_kActionSheetButtonDividerColor, context),
),
),
),
),
if (widget.cancelButton != null) _buildCancelButton(),
];
final double actionSheetWidth = switch (MediaQuery.orientationOf(context)) {
Orientation.portrait => MediaQuery.widthOf(context),
Orientation.landscape => MediaQuery.heightOf(context),
};
return SafeArea(
minimum: const EdgeInsets.only(bottom: _kActionSheetEdgePadding),
child: ScrollConfiguration(
// A CupertinoScrollbar is built-in below
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
child: Semantics(
namesRoute: true,
scopesRoute: true,
explicitChildNodes: true,
role: SemanticsRole.dialog,
label: 'Alert',
child: CupertinoUserInterfaceLevel(
data: CupertinoUserInterfaceLevelData.elevated,
child: Padding(
padding: EdgeInsets.only(
left: _kActionSheetEdgePadding,
right: _kActionSheetEdgePadding,
top: _topPadding(context),
// The bottom padding is set on SafeArea.minimum, allowing it to
// be consumed by bottom view padding.
),
child: SizedBox(
width: actionSheetWidth - _kActionSheetEdgePadding * 2,
child: _ActionSheetGestureDetector(
child: Semantics(
explicitChildNodes: true,
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children,
),
),
),
),
),
),
),
),
);
}
}
/// The content of a typical action button in a [CupertinoActionSheet].
///
/// This widget draws the content of a button, i.e. the text, while the
/// background of the button is drawn by [CupertinoActionSheet]. When
/// [focusNode] has focus, this widget will draw the background of color
/// [focusColor].
///
/// See also:
///
/// * [CupertinoActionSheet], an alert that presents the user with a set of two or
/// more choices related to the current context.
class CupertinoActionSheetAction extends StatefulWidget {
/// Creates an action for an iOS-style action sheet.
const CupertinoActionSheetAction({
super.key,
required this.onPressed,
this.isDefaultAction = false,
this.isDestructiveAction = false,
this.mouseCursor,
this.focusNode,
this.focusColor,
required this.child,
});
/// The callback that is called when the button is selected.
///
/// The button can be selected by either by tapping on this button or by
/// pressing elsewhere and sliding onto this button before releasing.
final VoidCallback onPressed;
/// Whether this action is the default choice in the action sheet.
///
/// Default buttons have bold text.
final bool isDefaultAction;
/// Whether this action might change or delete data.
///
/// Destructive buttons have red text.
final bool isDestructiveAction;
/// The cursor that will be shown when hovering over the button.
///
/// If null, defaults to [SystemMouseCursors.click] on web and
/// [MouseCursor.defer] on other platforms.
final MouseCursor? mouseCursor;
/// {@macro flutter.widgets.Focus.focusNode}
final FocusNode? focusNode;
/// The color of the background that highlights active focus.
///
/// A transparency of [kCupertinoButtonTintedOpacityLight] (light mode) or
/// [kCupertinoButtonTintedOpacityDark] (dark mode) is automatically applied to this color.
///
/// When [focusColor] is null, defaults to [CupertinoColors.activeBlue].
final Color? focusColor;
/// The widget below this widget in the tree.
///
/// Typically a [Text] widget.
final Widget child;
@override
State<CupertinoActionSheetAction> createState() => _CupertinoActionSheetActionState();
}
class _CupertinoActionSheetActionState extends State<CupertinoActionSheetAction>
implements _SlideTarget {
bool _showHighlight = false;
// |_SlideTarget|
@override
bool didEnter({required bool fromPointerDown, required bool innerEnabled}) {
return innerEnabled;
}
// |_SlideTarget|
@override
void didLeave() {}
// |_SlideTarget|
@override
void didConfirm() {
widget.onPressed();
}
void _onShowFocusHighlight(bool showHighlight) {
setState(() {
_showHighlight = showHighlight;
});
}
late final Map<Type, Action<Intent>> _actionMap = <Type, Action<Intent>>{
ActivateIntent: CallbackAction<ActivateIntent>(onInvoke: _handleTap),
};
void _handleTap([Intent? _]) {
widget.onPressed();
context.findRenderObject()!.sendSemanticsEvent(const TapSemanticEvent());
}
Color get effectiveFocusBackgroundColor => HSLColor.fromColor(
(widget.focusColor ?? CupertinoColors.activeBlue).withOpacity(
CupertinoTheme.brightnessOf(context) == Brightness.light
? kCupertinoButtonTintedOpacityLight
: kCupertinoButtonTintedOpacityDark,
),
).toColor();
@override
Widget build(BuildContext context) {
return MouseRegion(
cursor: widget.mouseCursor ?? (kIsWeb ? SystemMouseCursors.click : MouseCursor.defer),
child: MetaData(
metaData: this,
behavior: HitTestBehavior.opaque,
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: _kActionSheetButtonMinHeight),
child: FocusableActionDetector(
actions: _actionMap,
focusNode: widget.focusNode,
onShowFocusHighlight: _onShowFocusHighlight,
child: Semantics(
button: true,
onTap: widget.onPressed,
child: _showHighlight
? DecoratedBox(
decoration: BoxDecoration(color: effectiveFocusBackgroundColor),
child: _ActionSheetActionContent(
isDestructiveAction: widget.isDestructiveAction,
isDefaultAction: widget.isDefaultAction,
child: widget.child,
),
)
: _ActionSheetActionContent(
isDestructiveAction: widget.isDestructiveAction,
isDefaultAction: widget.isDefaultAction,
child: widget.child,
),
),
),
),
),
);
}
}
class _ActionSheetActionContent extends StatelessWidget {
const _ActionSheetActionContent({
required this.isDestructiveAction,
required this.isDefaultAction,
required this.child,
});
final bool isDestructiveAction;
final bool isDefaultAction;
final Widget child;
// Calculates the font size for action sheet buttons.
//
// The `contextBodySize` is the body font size specified by context. The
// return value is the button font size, including the effect of context font
// scale factor. Divide by context font scale factor before using in a `Text`.
static double _buttonFontSize(double contextBodySize) {
// It is observed that the native action sheet buttons use font sizes that
// deviate from standard HIG specifications in a non-linear way. The following
// table shows the regular body font size vs the button font size:
//
// Text scale | xs | s | m | l | xl | xxl | xxxl | ax1 | ax2 | ax3 | ax4 | ax5
// Body font | 14 | 15 | 16 | 17 | 19 | 21 | 23 | 28 | 33 | 40 | 47 | 53
// Button font | 21 | 21 | 21 | 21 | 23 | 24 | 24 | 28 | 33 | 40 | 47 | 53
// For very small or very large text, simple rules can be observed.
// For mid-sized text, piecewise linear interpolation is used.
return switch (contextBodySize) {
<= 17 => 21.0,
<= 19 => lerpDouble(21.0, 23.0, (contextBodySize - 17.0) / (19.0 - 17.0))!,
<= 21 => lerpDouble(23.0, 24.0, (contextBodySize - 19.0) / (21.0 - 19.0))!,
<= 24 => 24.0,
_ => contextBodySize,
};
}
@override
Widget build(BuildContext context) {
// The context scale factor is derived from the current body size and the
// standard body size in "large".
const higLargeBodySize = 17.0;
final double contextBodySize = MediaQuery.textScalerOf(context).scale(higLargeBodySize);
final double contextScaleFactor = contextBodySize / higLargeBodySize;
final double fontSize = _buttonFontSize(contextBodySize);
TextStyle style = _kActionSheetActionStyle.copyWith(
// `Text` will scale the provided font size inside, so its parameter is
// unscaled first.
fontSize: fontSize / contextScaleFactor,
color: isDestructiveAction
? CupertinoDynamicColor.resolve(CupertinoColors.systemRed, context)
: CupertinoTheme.of(context).primaryColor,
);
if (isDefaultAction) {
style = style.copyWith(fontWeight: FontWeight.w600);
}
final double verticalPadding =
_kActionSheetButtonVerticalPaddingBase +
fontSize * _kActionSheetButtonVerticalPaddingFactor;
return Padding(
padding: EdgeInsets.fromLTRB(
_kActionSheetButtonHorizontalPadding,
verticalPadding,
_kActionSheetButtonHorizontalPadding,
verticalPadding,
),
child: DefaultTextStyle(
style: style,
textAlign: TextAlign.center,
child: Center(child: child),
),
);
}
}
// Renders the background of a button (both the pressed background and the idle
// background) and reports its state to the parent with `onPressStateChange`.
//
// Although this class doesn't keep any states, it's still a stateful widget
// because the state is used as a persistent object across rebuilds to provide
// to [MetaData.data].
class _ActionSheetButtonBackground extends StatefulWidget {
const _ActionSheetButtonBackground({
this.isCancel = false,
required this.pressed,
this.onPressStateChange,
required this.child,
});
final bool isCancel;
/// 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<bool>? onPressStateChange;
/// The widget below this widget in the tree.
///
/// Typically a [Text] widget.
final Widget child;
@override
_ActionSheetButtonBackgroundState createState() => _ActionSheetButtonBackgroundState();
}
class _ActionSheetButtonBackgroundState extends State<_ActionSheetButtonBackground>
implements _SlideTarget {
void _emitVibration() {
switch (defaultTargetPlatform) {
case TargetPlatform.iOS:
case TargetPlatform.android:
HapticFeedback.selectionClick();
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.macOS:
case TargetPlatform.windows:
break;
}
}
// |_SlideTarget|
@override
bool didEnter({required bool fromPointerDown, required bool innerEnabled}) {
// Action sheet doesn't support disabled buttons, therefore `innerEnabled`
// is always true.
assert(innerEnabled);
widget.onPressStateChange?.call(true);
if (!fromPointerDown) {
_emitVibration();
}
return innerEnabled;
}
// |_SlideTarget|
@override
void didLeave() {
widget.onPressStateChange?.call(false);
}
// |_SlideTarget|
@override
void didConfirm() {
widget.onPressStateChange?.call(false);
}
@override
Widget build(BuildContext context) {
late final Widget child;
if (!widget.isCancel) {
child = ColoredBox(
color: CupertinoDynamicColor.resolve(
widget.pressed ? _kActionSheetPressedColor : _kActionSheetBackgroundColor,
context,
),
child: widget.child,
);
} else {
const borderRadius = BorderRadius.all(Radius.circular(_kCornerRadius));
child = ClipRSuperellipse(
borderRadius: borderRadius,
child: DecoratedBox(
decoration: BoxDecoration(
color: CupertinoDynamicColor.resolve(
widget.pressed ? _kActionSheetCancelPressedColor : _kActionSheetCancelColor,
context,
),
),
child: widget.child,
),
);
}
return MetaData(metaData: this, child: child);
}
}
// 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.
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) {
// 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.child});
// The color for the overscroll part.
//
// This value must be a resolved color instead of, for example, a
// CupertinoDynamicColor.
final Color color;
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: <Widget>[
DecoratedBox(
decoration: BoxDecoration(color: widget.color),
child: SizedBox(height: _topOverscroll),
),
DecoratedBox(
decoration: BoxDecoration(color: widget.color),
child: SizedBox(height: _bottomOverscroll),
),
],
);
return Stack(
children: <Widget>[
Positioned.fill(child: overscroll),
NotificationListener<ScrollUpdateNotification>(
onNotification: _onScrollUpdate,
child: widget.child,
),
],
);
}
}
typedef _PressedUpdateHandler = void Function(int actionIndex, bool state);
// The list of actions in an action sheet.
//
// This excludes the divider between the action section and the content section.
class _ActionSheetActionSection extends StatelessWidget {
const _ActionSheetActionSection({
required this.actions,
required this.pressedIndex,
required this.dividerColor,
required this.backgroundColor,
required this.onPressedUpdate,
required this.scrollController,
});
final List<Widget>? actions;
final _PressedUpdateHandler onPressedUpdate;
final int? pressedIndex;
final Color dividerColor;
final Color backgroundColor;
final ScrollController scrollController;
@override
Widget build(BuildContext context) {
if (actions == null || actions!.isEmpty) {
return const LimitedBox(maxWidth: 0, child: SizedBox(width: double.infinity, height: 0));
}
final column = <Widget>[];
for (var actionIndex = 0; actionIndex < actions!.length; actionIndex += 1) {
if (actionIndex != 0) {
column.add(
_Divider(
dividerColor: dividerColor,
hiddenColor: _kActionSheetBackgroundColor,
hidden: pressedIndex == actionIndex - 1 || pressedIndex == actionIndex,
),
);
}
column.add(
_ActionSheetButtonBackground(
pressed: pressedIndex == actionIndex,
onPressStateChange: (bool state) {
onPressedUpdate(actionIndex, state);
},
child: actions![actionIndex],
),
);
}
return CupertinoScrollbar(
controller: scrollController,
child: SingleChildScrollView(
controller: scrollController,
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: column),
),
);
}
}
// The part of an action sheet without the cancel button.
class _ActionSheetMainSheet extends StatelessWidget {
const _ActionSheetMainSheet({
required this.pressedIndex,
required this.onPressedUpdate,
required this.scrollController,
required this.actions,
required this.contentSection,
required this.dividerColor,
});
final int? pressedIndex;
final _PressedUpdateHandler onPressedUpdate;
final ScrollController scrollController;
final List<Widget> actions;
final Widget? contentSection;
final Color dividerColor;
Widget _scrolledActionsSection(BuildContext context) {
final Color backgroundColor = CupertinoDynamicColor.resolve(
_kActionSheetBackgroundColor,
context,
);
return _OverscrollBackground(
color: backgroundColor,
child: CupertinoFocusHalo.withRRect(
borderRadius: kCupertinoButtonSizeBorderRadius[CupertinoButtonSize.large]!.copyWith(
topLeft: Radius.zero,
topRight: Radius.zero,
),
child: _ActionSheetActionSection(
actions: actions,
scrollController: scrollController,
dividerColor: dividerColor,
backgroundColor: backgroundColor,
pressedIndex: pressedIndex,
onPressedUpdate: onPressedUpdate,
),
),
);
}
Widget _dividerAndActionsSection(BuildContext context) {
final Color backgroundColor = CupertinoDynamicColor.resolve(
_kActionSheetBackgroundColor,
context,
);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
_Divider(dividerColor: dividerColor, hiddenColor: backgroundColor, hidden: false),
Flexible(child: _scrolledActionsSection(context)),
],
);
}
@override
Widget build(BuildContext context) {
if (actions.isEmpty) {
return contentSection ?? _empty;
}
if (contentSection == null) {
return _scrolledActionsSection(context);
}
return _PriorityColumn(
top: contentSection!,
bottom: _dividerAndActionsSection(context),
bottomMinHeight: _kActionSheetActionsSectionMinHeight + _kDividerThickness,
);
}
static const Widget _empty = LimitedBox(
maxWidth: 0,
child: SizedBox(width: double.infinity, height: 0),
);
}
// The "content section" of a CupertinoAlertDialog.
//
// If title is missing, then only content is added. If content is
// missing, then only title is added. If both are missing, then it returns
// a SingleChildScrollView with a zero-sized Container.
class _CupertinoAlertContentSection extends StatelessWidget {
const _CupertinoAlertContentSection({
this.title,
this.message,
required this.scrollController,
this.titlePadding,
this.messagePadding,
this.titleTextStyle,
this.messageTextStyle,
this.additionalPaddingBetweenTitleAndMessage,
}) : assert(title == null || titlePadding != null && titleTextStyle != null),
assert(message == null || messagePadding != null && messageTextStyle != null);
// The (optional) title of the dialog is displayed in a large font at the top
// of the dialog.
//
// Typically a Text widget.
final Widget? title;
// The (optional) message of the dialog is displayed in the center of the
// dialog in a lighter font.
//
// Typically a Text widget.
final Widget? message;
// A scroll controller that can be used to control the scrolling of the
// content in the dialog.
final ScrollController scrollController;
// Paddings used around title and message.
// CupertinoAlertDialog and CupertinoActionSheet have different paddings.
final EdgeInsets? titlePadding;
final EdgeInsets? messagePadding;
// Additional padding to be inserted between title and message.
// Only used for CupertinoActionSheet.
final EdgeInsets? additionalPaddingBetweenTitleAndMessage;
// Text styles used for title and message.
// CupertinoAlertDialog and CupertinoActionSheet have different text styles.
final TextStyle? titleTextStyle;
final TextStyle? messageTextStyle;
@override
Widget build(BuildContext context) {
if (title == null && message == null) {
return SingleChildScrollView(controller: scrollController, child: const SizedBox.shrink());
}
final titleContentGroup = <Widget>[
if (title != null)
Padding(
padding: titlePadding!,
child: DefaultTextStyle(
style: titleTextStyle!,
textAlign: TextAlign.center,
child: title!,
),
),
if (message != null)
Padding(
padding: messagePadding!,
child: DefaultTextStyle(
style: messageTextStyle!,
textAlign: TextAlign.center,
child: message!,
),
),
];
// Add padding between the widgets if necessary.
if (additionalPaddingBetweenTitleAndMessage != null && titleContentGroup.length > 1) {
titleContentGroup.insert(1, Padding(padding: additionalPaddingBetweenTitleAndMessage!));
}
return CupertinoScrollbar(
controller: scrollController,
child: SingleChildScrollView(
controller: scrollController,
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: titleContentGroup),
),
);
}
}
// The "actions section" of a [CupertinoAlertDialog].
//
// The `actions` must not be empty.
class _CupertinoAlertActionSection extends StatelessWidget {
const _CupertinoAlertActionSection({
required this.actions,
required this.onPressedUpdate,
required this.pressedIndex,
required this.scrollController,
}) : assert(actions.length != 0);
// 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<Widget> actions;
final _PressedUpdateHandler onPressedUpdate;
final int? pressedIndex;
// A scroll controller that can be used to control the scrolling of the
// actions in the dialog.
final ScrollController scrollController;
@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 column = <Widget>[];
for (var 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: _AlertDialogActionsLayout(dividerThickness: _kDividerThickness, children: column),
),
);
}
}
// 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 StatefulWidget {
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<bool>? onPressStateChange;
final Color idleColor;
final Color pressedColor;
/// The widget below this widget in the tree.
///
/// Typically a [Text] widget.
final Widget child;
@override
_AlertDialogButtonBackgroundState createState() => _AlertDialogButtonBackgroundState();
}
class _AlertDialogButtonBackgroundState extends State<_AlertDialogButtonBackground>
implements _SlideTarget {
void _emitVibration() {
switch (defaultTargetPlatform) {
case TargetPlatform.iOS:
case TargetPlatform.android:
HapticFeedback.selectionClick();
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.macOS:
case TargetPlatform.windows:
break;
}
}
// |_SlideTarget|
@override
bool didEnter({required bool fromPointerDown, required bool innerEnabled}) {
widget.onPressStateChange?.call(innerEnabled);
if (innerEnabled && !fromPointerDown) {
_emitVibration();
}
return innerEnabled;
}
// |_SlideTarget|
@override
void didLeave() {
widget.onPressStateChange?.call(false);
}
// |_SlideTarget|
@override
void didConfirm() {
widget.onPressStateChange?.call(false);
}
@override
Widget build(BuildContext context) {
final Color backgroundColor = widget.pressed ? widget.pressedColor : widget.idleColor;
return MetaData(
metaData: this,
child: MergeSemantics(
child: Container(
decoration: BoxDecoration(color: CupertinoDynamicColor.resolve(backgroundColor, context)),
child: widget.child,
),
),
);
}
}
/// A button typically used in a [CupertinoAlertDialog].
///
/// See also:
///
/// * [CupertinoAlertDialog], a dialog that informs the user about situations
/// that require acknowledgment.
class CupertinoDialogAction extends StatefulWidget {
/// Creates an action for an iOS-style dialog.
const CupertinoDialogAction({
super.key,
this.onPressed,
this.isDefaultAction = false,
this.isDestructiveAction = false,
this.textStyle,
this.mouseCursor,
required this.child,
});
/// The callback that is called when the button is tapped or otherwise
/// activated.
///
/// If this is set to null, the button will be disabled.
final VoidCallback? onPressed;
/// Set to true if button is the default choice in the dialog.
///
/// Default buttons have bold text. Similar to
/// [UIAlertController.preferredAction](https://developer.apple.com/documentation/uikit/uialertcontroller/1620102-preferredaction),
/// but more than one action can have this attribute set to true in the same
/// [CupertinoAlertDialog].
///
/// This parameters defaults to false.
final bool isDefaultAction;
/// Whether this action destroys an object.
///
/// For example, an action that deletes an email is destructive.
///
/// Defaults to false.
final bool isDestructiveAction;
/// [TextStyle] to apply to any text that appears in this button.
///
/// Dialog actions have a built-in text resizing policy for long text. To
/// ensure that this resizing policy always works as expected, [textStyle]
/// must be used if a text size is desired other than that specified in
/// [_kCupertinoDialogActionStyle].
final TextStyle? textStyle;
/// The cursor that will be shown when hovering over the button.
///
/// If null, defaults to [SystemMouseCursors.click] on web and
/// [MouseCursor.defer] on other platforms.
final MouseCursor? mouseCursor;
/// The widget below this widget in the tree.
///
/// Typically a [Text] widget.
final Widget child;
@override
State<CupertinoDialogAction> createState() => _CupertinoDialogActionState();
}
class _CupertinoDialogActionState extends State<CupertinoDialogAction> implements _SlideTarget {
// The button is enabled when it has [onPressed].
bool get enabled => widget.onPressed != null;
// |_SlideTarget|
@override
bool didEnter({required bool fromPointerDown, required bool innerEnabled}) {
return enabled;
}
// |_SlideTarget|
@override
void didLeave() {}
// |_SlideTarget|
@override
void didConfirm() {
widget.onPressed?.call();
}
// Dialog action content shrinks to fit, up to a certain point, and if it still
// cannot fit at the minimum size, the text content is ellipsized.
//
// This policy only applies when the device is not in accessibility mode.
Widget _buildContentWithRegularSizingPolicy({
required BuildContext context,
required TextStyle textStyle,
required Widget content,
required double padding,
}) {
final bool isInAccessibilityMode = _isInAccessibilityMode(context);
final double dialogWidth = isInAccessibilityMode
? _kAccessibilityCupertinoDialogWidth
: _kCupertinoDialogWidth;
// The fontSizeRatio is the ratio of the current text size (including any
// iOS scale factor) vs the minimum text size that we allow in action
// buttons. This ratio information is used to automatically scale down action
// button text to fit the available space.
final double fontSizeRatio =
MediaQuery.textScalerOf(context).scale(textStyle.fontSize!) / _kDialogMinButtonFontSize;
return FittedBox(
fit: BoxFit.scaleDown,
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: fontSizeRatio * (dialogWidth - (2 * padding))),
child: Semantics(
button: true,
onTap: widget.onPressed,
child: DefaultTextStyle(
style: textStyle,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
maxLines: 1,
child: content,
),
),
),
);
}
// Dialog action content is permitted to be as large as it wants when in
// accessibility mode. If text is used as the content, the text wraps instead
// of ellipsizing.
Widget _buildContentWithAccessibilitySizingPolicy({
required TextStyle textStyle,
required Widget content,
}) {
return DefaultTextStyle(style: textStyle, textAlign: TextAlign.center, child: content);
}
@override
Widget build(BuildContext context) {
TextStyle style = _kCupertinoDialogActionStyle
.copyWith(
color: CupertinoDynamicColor.resolve(
widget.isDestructiveAction
? CupertinoColors.systemRed
: CupertinoTheme.of(context).primaryColor,
context,
),
)
.merge(widget.textStyle);
if (widget.isDefaultAction) {
style = style.copyWith(fontWeight: FontWeight.w600);
}
if (!enabled) {
style = style.copyWith(color: style.color!.withOpacity(0.5));
}
final double fontSize = style.fontSize ?? kDefaultFontSize;
final double fontSizeToScale = fontSize == 0.0 ? kDefaultFontSize : fontSize;
final double effectiveTextScale =
MediaQuery.textScalerOf(context).scale(fontSizeToScale) / fontSizeToScale;
final double padding = 8.0 * effectiveTextScale;
// Apply a sizing policy to the action button's content based on whether or
// not the device is in accessibility mode.
// TODO(mattcarroll): The following logic is not entirely correct. It is also
// the case that if content text does not contain a space, it should also
// wrap instead of ellipsizing. We are consciously not implementing that
// now due to complexity.
final Widget sizedContent = _isInAccessibilityMode(context)
? _buildContentWithAccessibilitySizingPolicy(textStyle: style, content: widget.child)
: _buildContentWithRegularSizingPolicy(
context: context,
textStyle: style,
content: widget.child,
padding: padding,
);
return MouseRegion(
cursor:
widget.mouseCursor ?? (enabled && kIsWeb ? SystemMouseCursors.click : MouseCursor.defer),
child: MetaData(
metaData: this,
behavior: HitTestBehavior.opaque,
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: _kDialogMinButtonHeight),
child: Padding(
padding: EdgeInsets.all(padding),
child: Center(child: sizedContent),
),
),
),
);
}
}
// iOS style dialog action button layout.
//
// [_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.
//
// 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;
@override
RenderObject createRenderObject(BuildContext context) {
return _RenderAlertDialogActionsLayout(
dividerThickness: _dividerThickness,
textDirection: Directionality.of(context),
);
}
@override
void updateRenderObject(BuildContext context, _RenderAlertDialogActionsLayout renderObject) {
renderObject
..dividerThickness = _dividerThickness
..textDirection = Directionality.of(context);
}
}
class _RenderAlertDialogActionsLayout extends RenderFlex {
_RenderAlertDialogActionsLayout({
List<RenderBox>? children,
required double dividerThickness,
super.textDirection,
}) : _dividerThickness = dividerThickness,
super(
direction: Axis.vertical,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
) {
addAll(children);
}
// The thickness of the divider between buttons.
double get dividerThickness => _dividerThickness;
double _dividerThickness;
set dividerThickness(double newValue) {
if (newValue != _dividerThickness) {
_dividerThickness = newValue;
markNeedsLayout();
}
}
double horizontalSlotWidthFor({required double overallWidth}) =>
(overallWidth - dividerThickness) / 2;
@override
double computeMinIntrinsicHeight(double width) {
if (!_useHorizontalLayout(width)) {
return super.computeMinIntrinsicHeight(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 (!_useHorizontalLayout(width)) {
return super.computeMaxIntrinsicHeight(width);
}
final double slotWidth = horizontalSlotWidthFor(overallWidth: width);
double height = 0;
_forEachSlot((RenderBox slot) {
height = math.max(height, slot.getMaxIntrinsicHeight(slotWidth));
});
return height;
}
@override
@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() {
if (firstChild == null) {
size = constraints.smallest;
return;
}
if (!_debugHasValidConstraints(constraints)) {
size = constraints.smallest;
return;
}
final double overallWidth = constraints.maxWidth;
if (!_useHorizontalLayout(overallWidth)) {
return super.performLayout();
}
final double slotWidth = horizontalSlotWidthFor(overallWidth: overallWidth);
final double height = getMinIntrinsicHeight(overallWidth);
size = Size(overallWidth, height);
final ltr = textDirection == TextDirection.ltr;
RenderBox slot = firstChild!;
double x = ltr ? 0 : (overallWidth - slotWidth);
while (true) {
slot.layout(BoxConstraints.tight(Size(slotWidth, height)), parentUsesSize: true);
(slot.parentData! as FlexParentData).offset = Offset(x, 0);
if (ltr) {
x += slot.size.width;
} else {
x -= slot.size.width;
}
final RenderBox? divider = childAfter(slot);
if (divider == null) {
break;
}
divider.layout(BoxConstraints.tight(Size(dividerThickness, height)));
(divider.parentData! as FlexParentData).offset = Offset(x, 0);
if (ltr) {
x += dividerThickness;
} else {
x -= dividerThickness;
}
slot = childAfter(divider)!;
}
}
bool _debugHasValidConstraints(BoxConstraints constraints) {
assert(() {
ErrorSummary? errorSummary;
if (constraints.maxWidth == double.infinity) {
errorSummary = ErrorSummary('The incoming width constraints are unbounded.');
}
if (errorSummary != null) {
throw FlutterError.fromParts(<DiagnosticsNode>[
errorSummary,
ErrorDescription('The incoming constraints are: $constraints'),
]);
}
return true;
}());
return true;
}
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;
}
void _forEachSlot(ValueSetter<RenderBox> action) {
assert(childCount.isOdd);
RenderBox slot = firstChild!;
while (true) {
action(slot);
final RenderBox? divider = childAfter(slot);
if (divider == null) {
break;
}
slot = childAfter(divider)!;
}
}
}
typedef _TwoChildrenHeights = ({double topChildHeight, double bottomChildHeight});
// A column layout with two widgets, where the top widget expands vertically as
// needed, and the bottom widget has a minimum height.
//
// Both child widgets stretch horizontally to the parent's maximum width
// constraint, with vertical space allocated in this priority:
//
// 1. The `bottom` widget receives its requested height, up to a
// `bottomMaxHeight` limit and the container's constraint.
// 2. The `top` widget receives its requested height, up to the remaining space
// in the container.
// 3. The `bottom` widget receives its requested height, up to any remaining
// space in the container.
//
// This mirrors the behavior seen in iOS components like action sheets and
// alerts.
//
// Implementing this layout with simple compositing widgets is challenging
// because:
//
// * The bottom widget should take more than `bottomMinHeight` if the top
// widget is short.
// * The bottom widget should take less than `bottomMinHeight` if it is
// naturally shorter.
class _PriorityColumn extends MultiChildRenderObjectWidget {
_PriorityColumn({required Widget top, required Widget bottom, required this.bottomMinHeight})
: super(children: <Widget>[top, bottom]);
final double bottomMinHeight;
@override
RenderObject createRenderObject(BuildContext context) {
return _RenderPriorityColumn(bottomMinHeight: bottomMinHeight);
}
@override
void updateRenderObject(BuildContext context, _RenderPriorityColumn renderObject) {
renderObject.bottomMinHeight = bottomMinHeight;
}
}
class _RenderPriorityColumn extends RenderFlex {
_RenderPriorityColumn({List<RenderBox>? children, required double bottomMinHeight})
: _bottomMinHeight = bottomMinHeight,
super(
direction: Axis.vertical,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
) {
addAll(children);
}
double get bottomMinHeight => _bottomMinHeight;
double _bottomMinHeight;
set bottomMinHeight(double newValue) {
if (newValue != _bottomMinHeight) {
_bottomMinHeight = newValue;
markNeedsLayout();
}
}
@override
double computeMinIntrinsicHeight(double width) {
assert(childCount == 2);
return firstChild!.getMinIntrinsicHeight(width) + lastChild!.getMinIntrinsicHeight(width);
}
@override
double computeMaxIntrinsicHeight(double width) {
assert(childCount == 2);
return firstChild!.getMaxIntrinsicHeight(width) + lastChild!.getMaxIntrinsicHeight(width);
}
@override
@protected
Size computeDryLayout(covariant BoxConstraints constraints) {
final double width = constraints.maxWidth;
final double maxHeight = constraints.maxHeight;
final (:double topChildHeight, :double bottomChildHeight) = _childrenHeights(width, maxHeight);
return Size(width, topChildHeight + bottomChildHeight);
}
@override
void performLayout() {
final double width = constraints.maxWidth;
final double maxHeight = constraints.maxHeight;
final (:double topChildHeight, :double bottomChildHeight) = _childrenHeights(width, maxHeight);
size = Size(width, topChildHeight + bottomChildHeight);
firstChild!.layout(BoxConstraints.tight(Size(width, topChildHeight)), parentUsesSize: true);
(firstChild!.parentData! as FlexParentData).offset = Offset.zero;
lastChild!.layout(BoxConstraints.tight(Size(width, bottomChildHeight)), parentUsesSize: true);
(lastChild!.parentData! as FlexParentData).offset = Offset(0, topChildHeight);
}
_TwoChildrenHeights _childrenHeights(double width, double maxHeight) {
assert(childCount == 2);
final double topIntrinsic = firstChild!.getMinIntrinsicHeight(width);
final double bottomIntrinsic = lastChild!.getMinIntrinsicHeight(width);
// Try to layout both children as their intrinsic height.
if (topIntrinsic + bottomIntrinsic <= maxHeight) {
return (topChildHeight: topIntrinsic, bottomChildHeight: bottomIntrinsic);
}
// _bottomMinHeight is only effective when bottom actually needs that much.
final double effectiveBottomMinHeight = math.min(_bottomMinHeight, bottomIntrinsic);
// Try to layout top as intrinsics, as long as the bottom has at least
// effectiveBottomMinHeight.
if (maxHeight - topIntrinsic >= effectiveBottomMinHeight) {
return (topChildHeight: topIntrinsic, bottomChildHeight: maxHeight - topIntrinsic);
}
// Try to layout bottom as effectiveBottomMinHeight, as long as top has at
// least 0.
if (maxHeight >= effectiveBottomMinHeight) {
return (
topChildHeight: maxHeight - effectiveBottomMinHeight,
bottomChildHeight: effectiveBottomMinHeight,
);
}
return (topChildHeight: 0, bottomChildHeight: maxHeight);
}
}