| // 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); |
| } |
| } |