Last active
February 1, 2026 08:49
-
-
Save davidhicks980/8c6bba779b6a00e95582b61b132292bc to your computer and use it in GitHub Desktop.
CupertinoMenuAnchor
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // 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'; | |
| library; | |
| import 'dart:collection'; | |
| import 'dart:math' as math; | |
| import 'dart:ui' as ui; | |
| import 'package:flutter/cupertino.dart'; | |
| import 'package:flutter/foundation.dart'; | |
| import 'package:flutter/gestures.dart'; | |
| import 'package:flutter/physics.dart'; | |
| import 'package:flutter/rendering.dart'; | |
| import 'package:flutter/scheduler.dart'; | |
| import 'package:flutter/services.dart'; | |
| /// Flutter code sample for a [CupertinoMenuAnchor] that shows a menu with 3 | |
| /// items. | |
| void main() => runApp(const CupertinoMenuAnchorApp()); | |
| class CupertinoMenuAnchorApp extends StatelessWidget { | |
| const CupertinoMenuAnchorApp({super.key}); | |
| @override | |
| Widget build(BuildContext context) { | |
| return const CupertinoApp( | |
| home: CupertinoPageScaffold( | |
| navigationBar: CupertinoNavigationBar( | |
| middle: Text('CupertinoMenuAnchor Example'), | |
| ), | |
| child: CupertinoMenuAnchorExample(), | |
| ), | |
| ); | |
| } | |
| } | |
| class CupertinoMenuAnchorExample extends StatefulWidget { | |
| const CupertinoMenuAnchorExample({super.key}); | |
| @override | |
| State<CupertinoMenuAnchorExample> createState() => | |
| _CupertinoMenuAnchorExampleState(); | |
| } | |
| class _CupertinoMenuAnchorExampleState | |
| extends State<CupertinoMenuAnchorExample> { | |
| // Optional: Create a focus node to allow focus traversal between the menu | |
| // button and the menu overlay. | |
| final FocusNode _buttonFocusNode = FocusNode(debugLabel: 'Menu Button'); | |
| String _pressedItem = ''; | |
| AnimationStatus _status = AnimationStatus.dismissed; | |
| @override | |
| void dispose() { | |
| _buttonFocusNode.dispose(); | |
| super.dispose(); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| return Center( | |
| child: Column( | |
| spacing: 20, | |
| mainAxisAlignment: MainAxisAlignment.center, | |
| children: <Widget>[ | |
| CupertinoMenuAnchor( | |
| onAnimationStatusChanged: (AnimationStatus status) { | |
| _status = status; | |
| }, | |
| childFocusNode: _buttonFocusNode, | |
| menuChildren: <Widget>[ | |
| CupertinoMenuItem( | |
| onPressed: () { | |
| setState(() { | |
| _pressedItem = 'Regular Item'; | |
| }); | |
| }, | |
| subtitle: const Text('Subtitle'), | |
| child: const Text('Regular Item'), | |
| ), | |
| CupertinoMenuItem( | |
| onPressed: () { | |
| setState(() { | |
| _pressedItem = 'Colorful Item'; | |
| }); | |
| }, | |
| decoration: const WidgetStateProperty<BoxDecoration>.fromMap(< | |
| WidgetStatesConstraint, | |
| BoxDecoration | |
| >{ | |
| WidgetState.dragged: BoxDecoration(color: Color(0xAEE48500)), | |
| WidgetState.pressed: BoxDecoration(color: Color(0xA6E3002A)), | |
| WidgetState.hovered: BoxDecoration(color: Color(0xA90069DA)), | |
| WidgetState.focused: BoxDecoration(color: Color(0x9B00C8BE)), | |
| WidgetState.any: BoxDecoration(color: Color(0x00000000)), | |
| }), | |
| child: const Text('Colorful Item'), | |
| ), | |
| CupertinoMenuItem( | |
| trailing: const Icon(CupertinoIcons.delete), | |
| isDestructiveAction: true, | |
| child: const Text('Destructive Item'), | |
| onPressed: () { | |
| setState(() { | |
| _pressedItem = 'Destructive Item'; | |
| }); | |
| }, | |
| ), | |
| ], | |
| builder: | |
| ( | |
| BuildContext context, | |
| MenuController controller, | |
| Widget? child, | |
| ) { | |
| return CupertinoButton( | |
| sizeStyle: CupertinoButtonSize.large, | |
| focusNode: _buttonFocusNode, | |
| onPressed: () { | |
| if (_status.isForwardOrCompleted) { | |
| controller.close(); | |
| } else { | |
| controller.open(); | |
| } | |
| }, | |
| child: Text( | |
| _status.isForwardOrCompleted ? 'Close Menu' : 'Open Menu', | |
| ), | |
| ); | |
| }, | |
| ), | |
| Text( | |
| _pressedItem.isEmpty | |
| ? 'No items pressed' | |
| : 'You Pressed: $_pressedItem', | |
| style: CupertinoTheme.of(context).textTheme.textStyle, | |
| ), | |
| ], | |
| ), | |
| ); | |
| } | |
| } | |
| // *** MENU ANCHOR IMPLEMENTATION *** | |
| // Examples can assume: | |
| // late BuildContext context; | |
| // AnimationStatus animationStatus = AnimationStatus.dismissed; | |
| // late double Function(double value, {required double to}) _roundToDivisible; | |
| // late TextScaler textScaler; | |
| // Dismiss is handled by RawMenuAnchor | |
| const Map<ShortcutActivator, Intent> _kMenuTraversalShortcuts = <ShortcutActivator, Intent>{ | |
| SingleActivator(LogicalKeyboardKey.gameButtonA): ActivateIntent(), | |
| SingleActivator(LogicalKeyboardKey.enter): ActivateIntent(), | |
| SingleActivator(LogicalKeyboardKey.space): ActivateIntent(), | |
| SingleActivator(LogicalKeyboardKey.arrowUp): _FocusUpIntent(), | |
| SingleActivator(LogicalKeyboardKey.arrowDown): _FocusDownIntent(), | |
| SingleActivator(LogicalKeyboardKey.home): _FocusFirstIntent(), | |
| SingleActivator(LogicalKeyboardKey.end): _FocusLastIntent(), | |
| }; | |
| bool get _isCupertino { | |
| switch (defaultTargetPlatform) { | |
| case TargetPlatform.iOS: | |
| case TargetPlatform.macOS: | |
| return true; | |
| case TargetPlatform.android: | |
| case TargetPlatform.fuchsia: | |
| case TargetPlatform.linux: | |
| case TargetPlatform.windows: | |
| return false; | |
| } | |
| } | |
| /// The font family for menu items at smaller text scales. | |
| const String _kBodyFont = 'CupertinoSystemText'; | |
| /// The font family for menu items at larger text scales. | |
| const String _kDisplayFont = 'CupertinoSystemDisplay'; | |
| /// Base font size used for text-scaling calculations. | |
| /// | |
| /// On iOS the text scale changes in increments of 1/17 (≈5.88%), as | |
| /// observed on the iOS 18.5 simulator. Each step (1/17 of the base font size) | |
| /// is referred to as one "unit" in the documentation for [CupertinoMenuAnchor] | |
| const double _kCupertinoMobileBaseFontSize = 17.0; | |
| /// Returns an integer that represents the current text scale factor normalized | |
| /// to the base font size. | |
| /// | |
| /// Normalizing to the base font size simplifies storage of nonlinear layout | |
| /// spacing that depends on the text scale factor. | |
| /// | |
| /// The equation to calculate the normalized text scale is: | |
| /// | |
| /// ```dart | |
| /// final normalizedScale = textScaler.scale(17.0) - 17.0; | |
| /// ``` | |
| /// | |
| /// The returned value is positive when the text scale factor is larger than the | |
| /// base font size, negative when smaller, and zero when equal. | |
| double _normalizeTextScale(TextScaler textScaler) { | |
| if (textScaler == TextScaler.noScaling) { | |
| return 0; | |
| } | |
| return textScaler.scale(_kCupertinoMobileBaseFontSize) - _kCupertinoMobileBaseFontSize; | |
| } | |
| /// The CupertinoMenuAnchor 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 menu is laid out differently. | |
| /// | |
| /// Empirically, the jump from one policy to the other occurs at the following text | |
| /// scale factors: | |
| /// * Max "regular" scale factor ≈ 23/17 ≈ 1.352... (normalized text scale: 6) | |
| /// * Min "accessible" scale factor ≈ 28/17 ≈ 1.647... (normalized text scale: 11) | |
| /// | |
| /// The following constant represents a division in text scale factor beyond which | |
| /// we want to change how the menu is laid out. | |
| /// | |
| /// This explanation was ported from cupertino/dialog.dart. | |
| const double _kMinimumAccessibleNormalizedTextScale = 11; | |
| /// The minimum normalized text scale factor supported on iOS. | |
| const double _kMinimumTextScaleFactor = 1 - 3 / _kCupertinoMobileBaseFontSize; | |
| /// The minimum normalized text scale factor supported on iOS. | |
| const double _kMaximumTextScaleFactor = 1 + 36 / _kCupertinoMobileBaseFontSize; | |
| // Accessibility mode on iOS is determined by the text scale factor that the | |
| // user has selected. | |
| bool _isAccessibilityModeEnabled(BuildContext context) { | |
| final TextScaler? textScaler = MediaQuery.maybeTextScalerOf(context); | |
| if (textScaler == null) { | |
| return false; | |
| } | |
| return _normalizeTextScale(textScaler) >= _kMinimumAccessibleNormalizedTextScale; | |
| } | |
| /// The width of a Cupertino menu | |
| // Measured on: | |
| // - iPadOS 18.5 Simulator | |
| // - iPad Pro 11-inch | |
| // - iPad Pro 13-inch | |
| // - iOS 18.5 Simulator | |
| // - iPhone 16 Pro | |
| enum _CupertinoMenuWidth { | |
| iPadOS(points: 262), | |
| iPadOSAccessible(points: 343), | |
| iOS(points: 250), | |
| iOSAccessible(points: 370); | |
| const _CupertinoMenuWidth({required this.points}); | |
| // Determines the appropriate menu width based on screen width and | |
| // accessibility mode. | |
| // | |
| // A screen width threshold of 768 points is used to differentiate between | |
| // mobile and tablet devices. | |
| factory _CupertinoMenuWidth.fromScreenWidth({ | |
| required double screenWidth, | |
| required bool isAccessibilityModeEnabled, | |
| }) { | |
| final bool isMobile = screenWidth < _kTabletWidthThreshold; | |
| return switch ((isMobile, isAccessibilityModeEnabled)) { | |
| (false, false) => _CupertinoMenuWidth.iPadOS, | |
| (false, true) => _CupertinoMenuWidth.iPadOSAccessible, | |
| (true, false) => _CupertinoMenuWidth.iOS, | |
| (true, true) => _CupertinoMenuWidth.iOSAccessible, | |
| }; | |
| } | |
| final double points; | |
| static const double _kTabletWidthThreshold = 768.0; | |
| } | |
| // TODO(davidhicks980): DynamicType should be moved to text_theme.dart when all | |
| // styles are implemented. https://github.com/flutter/flutter/issues/179828 | |
| // | |
| // After that, we should deduplicate the same table in menu_anchor_test.dart | |
| // | |
| // Obtained from | |
| // https://developer.apple.com/design/human-interface-guidelines/typography#Specifications | |
| // | |
| // Note: SF Display doesn't have tracking values on HID guidelines, so the | |
| // tracking values for SF Pro were used | |
| enum _DynamicTypeStyle { | |
| body(<TextStyle>[ | |
| TextStyle(fontSize: 14, height: 19 / 14, letterSpacing: -0.15, fontFamily: _kBodyFont), | |
| TextStyle(fontSize: 15, height: 20 / 15, letterSpacing: -0.23, fontFamily: _kBodyFont), | |
| TextStyle(fontSize: 16, height: 21 / 16, letterSpacing: -0.31, fontFamily: _kBodyFont), | |
| TextStyle(fontSize: 17, height: 22 / 17, letterSpacing: -0.43, fontFamily: _kBodyFont), | |
| TextStyle(fontSize: 19, height: 24 / 19, letterSpacing: -0.44, fontFamily: _kBodyFont), | |
| TextStyle(fontSize: 21, height: 26 / 21, letterSpacing: -0.36, fontFamily: _kBodyFont), | |
| TextStyle(fontSize: 23, height: 29 / 23, letterSpacing: -0.10, fontFamily: _kDisplayFont), | |
| TextStyle(fontSize: 28, height: 34 / 28, letterSpacing: 0.38, fontFamily: _kDisplayFont), | |
| TextStyle(fontSize: 33, height: 40 / 33, letterSpacing: 0.40, fontFamily: _kDisplayFont), | |
| TextStyle(fontSize: 40, height: 48 / 40, letterSpacing: 0.37, fontFamily: _kDisplayFont), | |
| TextStyle(fontSize: 47, height: 56 / 47, letterSpacing: 0.37, fontFamily: _kDisplayFont), | |
| TextStyle(fontSize: 53, height: 62 / 53, letterSpacing: 0.31, fontFamily: _kDisplayFont), | |
| ]), | |
| subhead(<TextStyle>[ | |
| TextStyle(fontSize: 12, height: 16 / 12, letterSpacing: 0, fontFamily: _kBodyFont), | |
| TextStyle(fontSize: 13, height: 18 / 13, letterSpacing: -0.08, fontFamily: _kBodyFont), | |
| TextStyle(fontSize: 14, height: 19 / 14, letterSpacing: -0.15, fontFamily: _kBodyFont), | |
| TextStyle(fontSize: 15, height: 20 / 15, letterSpacing: -0.23, fontFamily: _kBodyFont), | |
| TextStyle(fontSize: 17, height: 22 / 17, letterSpacing: -0.43, fontFamily: _kBodyFont), | |
| TextStyle(fontSize: 19, height: 24 / 19, letterSpacing: -0.45, fontFamily: _kBodyFont), | |
| TextStyle(fontSize: 21, height: 28 / 21, letterSpacing: -0.36, fontFamily: _kBodyFont), | |
| TextStyle(fontSize: 25, height: 31 / 25, letterSpacing: 0.15, fontFamily: _kDisplayFont), | |
| TextStyle(fontSize: 30, height: 37 / 30, letterSpacing: 0.40, fontFamily: _kDisplayFont), | |
| TextStyle(fontSize: 36, height: 43 / 36, letterSpacing: 0.37, fontFamily: _kDisplayFont), | |
| TextStyle(fontSize: 42, height: 50 / 42, letterSpacing: 0.37, fontFamily: _kDisplayFont), | |
| TextStyle(fontSize: 49, height: 58 / 49, letterSpacing: 0.33, fontFamily: _kDisplayFont), | |
| ]); | |
| const _DynamicTypeStyle(this.styles); | |
| // A list of text style for iOS's various scales, which are: xSmall, small, | |
| // medium, large, xLarge, xxLarge, xxxLarge, ax1, ax2, ax3, ax4, ax5. | |
| final List<TextStyle> styles; | |
| TextStyle resolveTextStyle(TextScaler textScaler) { | |
| // Assert the length here instead of in the constructor since .length isn't | |
| // accessible there. | |
| assert(styles.length == _kScaleCount); | |
| final double units = _normalizeTextScale(textScaler); | |
| for (var i = 0; i < styles.length; i++) { | |
| final int bodyUnits = _normalizedBodyScales[i]; | |
| if (units > bodyUnits) { | |
| continue; | |
| } | |
| if (units == bodyUnits) { | |
| return styles[i]; | |
| } | |
| if (i == 0) { | |
| return styles.first; | |
| } | |
| return TextStyle.lerp( | |
| styles[i - 1], | |
| styles[i], | |
| _interpolateUnits(units, _normalizedBodyScales[i - 1], bodyUnits), | |
| )!; | |
| } | |
| return styles.last; | |
| } | |
| static const int _kScaleCount = 12; | |
| static final List<int> _normalizedBodyScales = UnmodifiableListView<int>(<int>[ | |
| for (final TextStyle style in _DynamicTypeStyle.body.styles) | |
| (style.fontSize! - _kCupertinoMobileBaseFontSize).toInt(), | |
| ]); | |
| static double _interpolateUnits(double units, int minimum, int maximum) { | |
| final double t = (units - minimum) / (maximum - minimum); | |
| return ui.lerpDouble(0, 1, t)!; | |
| } | |
| } | |
| double _computeSquaredDistanceToRect(Offset point, Rect rect) { | |
| final double dx = point.dx - ui.clampDouble(point.dx, rect.left, rect.right); | |
| final double dy = point.dy - ui.clampDouble(point.dy, rect.top, rect.bottom); | |
| return dx * dx + dy * dy; | |
| } | |
| /// Returns the nearest multiple of `to` to `value`. | |
| /// | |
| /// ```dart | |
| /// print(_roundToDivisible(3.15, to: 0)); // 3.15 | |
| /// print(_roundToDivisible(3.15, to: 1)); // 3 | |
| /// print(_roundToDivisible(3.15, to: 0.1)); // 3.2 | |
| /// print(_roundToDivisible(3.15, to: 0.01)); // 3.15 | |
| /// print(_roundToDivisible(3.15, to: 0.25)); // 3.25 | |
| /// print(_roundToDivisible(3.15, to: 0.5)); // 3.0 | |
| /// print(_roundToDivisible(-3.15, to: 0.5)); // -3.0 | |
| /// print(_roundToDivisible(-3.15, to: 0.1)); // -3.2 | |
| /// ``` | |
| double _roundToDivisible(double value, {required double to}) { | |
| if (to == 0) { | |
| return value; | |
| } | |
| return (value / to).round() * to; | |
| } | |
| /// Implement [CupertinoMenuEntry] to define how a menu item should be drawn in | |
| /// a menu. | |
| abstract interface class CupertinoMenuEntry { | |
| /// Whether this menu item has a leading widget. | |
| /// | |
| /// If [hasLeading] returns true, siblings of this menu item that are missing | |
| /// a leading widget will have leading space added to align the leading edges | |
| /// of all menu items. | |
| bool hasLeading(BuildContext context); | |
| /// Whether this menu item is a divider. | |
| /// | |
| /// When true, a divider will not be drawn above or below this menu item. | |
| /// Otherwise, adjacent menu items will be separated by a divider. | |
| bool get isDivider; | |
| } | |
| class _AnchorScope extends InheritedWidget { | |
| const _AnchorScope({required this.hasLeading, required super.child}); | |
| final bool hasLeading; | |
| @override | |
| bool updateShouldNotify(_AnchorScope oldWidget) { | |
| return hasLeading != oldWidget.hasLeading; | |
| } | |
| } | |
| /// Signature for the callback called in response to a [CupertinoMenuAnchor] | |
| /// changing its [AnimationStatus]. | |
| typedef CupertinoMenuAnimationStatusChangedCallback = void Function(AnimationStatus status); | |
| /// A widget used to mark the "anchor" for a menu, defining the rectangle used | |
| /// to position the menu, which can be done with an explicit location, or | |
| /// with an alignment. | |
| /// | |
| /// The [CupertinoMenuAnchor] is typically used to wrap a button that opens a | |
| /// menu when pressed. The menu is displayed as a popup overlay that is positioned | |
| /// relative to the anchor rectangle, and will automatically reposition itself to remain | |
| /// fully visible within the screen bounds. | |
| /// | |
| /// A [MenuController] must be used to open and close the menu, and can be | |
| /// obtained from the [builder] callback, or provided to [controller] parameter. | |
| /// Calling [MenuController.open] will open the menu, and calling | |
| /// [MenuController.close] will close the menu. The [onOpen] callback is invoked | |
| /// when the menu popup is mounted and the menu status changes _from_ | |
| /// [AnimationStatus.dismissed]. The [onClose] callback is invoked when the menu | |
| /// popup is unmounted and the menu status changes _to_ | |
| /// [AnimationStatus.dismissed]. The [onAnimationStatusChanged] callback is | |
| /// invoked every time the [AnimationStatus] of the menu animation changes. | |
| /// | |
| /// ## Usage | |
| /// {@tool snippet} | |
| /// | |
| /// This sample creates a [CupertinoMenuAnchor] containing one | |
| /// [CupertinoMenuItem]. The menu item prints `Item 1 pressed!` when pressed. | |
| /// | |
| /// ```dart | |
| /// CupertinoMenuAnchor( | |
| /// menuChildren: <Widget>[ | |
| /// CupertinoMenuItem( | |
| /// trailing: const Icon(CupertinoIcons.add), | |
| /// onPressed: () { | |
| /// print('Item 1 pressed!'); | |
| /// }, | |
| /// child: const Text('Item 1'), | |
| /// ) | |
| /// ], | |
| /// builder: (BuildContext context, MenuController controller, Widget? child) { | |
| /// return CupertinoButton.filled( | |
| /// onPressed: () { | |
| /// if (controller.isOpen) { | |
| /// controller.close(); | |
| /// } else { | |
| /// controller.open(); | |
| /// } | |
| /// }, | |
| /// child: const Text('Open'), | |
| /// ); | |
| /// }, | |
| /// ); | |
| /// ``` | |
| /// {@end-tool} | |
| /// | |
| /// {@tool dartpad} | |
| /// This example demonstrates a basic [CupertinoMenuAnchor] that wraps a button. | |
| /// | |
| /// ** See code in examples/api/lib/cupertino/menu_anchor/cupertino_menu_anchor.0.dart ** | |
| /// {@end-tool} | |
| /// | |
| /// See also: | |
| /// | |
| /// * [CupertinoMenuItem], a Cupertino-themed menu item used in a | |
| /// [CupertinoMenuAnchor]. | |
| /// * [CupertinoMenuDivider], a large divider used to separate | |
| /// [CupertinoMenuItem]s. | |
| /// * [CupertinoMenuEntry], an interface that can be implemented to customize | |
| /// the appearance of menu items in a [CupertinoMenuAnchor]. | |
| class CupertinoMenuAnchor extends StatefulWidget { | |
| /// Creates a [CupertinoMenuAnchor]. | |
| const CupertinoMenuAnchor({ | |
| super.key, | |
| this.controller, | |
| this.onOpen, | |
| this.onClose, | |
| this.onAnimationStatusChanged, | |
| this.constraints, | |
| this.constrainCrossAxis = false, | |
| this.consumeOutsideTaps = false, | |
| this.enableSwipe = true, | |
| this.enableLongPressToOpen = false, | |
| this.useRootOverlay = false, | |
| this.overlayPadding = const EdgeInsets.all(8), | |
| required this.menuChildren, | |
| this.builder, | |
| this.child, | |
| this.childFocusNode, | |
| }); | |
| /// An optional controller that allows opening and closing of the menu from | |
| /// other widgets. | |
| final MenuController? controller; | |
| /// A callback that is invoked when the menu begins opening. | |
| /// | |
| /// Defaults to null. | |
| final VoidCallback? onOpen; | |
| /// A callback that is invoked when the menu finishes closing. | |
| /// | |
| /// Defaults to null. | |
| final VoidCallback? onClose; | |
| /// An optional callback that is invoked when the [AnimationStatus] of the | |
| /// menu changes during open and close animations. | |
| /// | |
| /// This callback provides a way to determine when the menu is opening or | |
| /// closing. This is necessary because the [MenuController.isOpen] property | |
| /// remains true throughout the opening, opened, and closing phases, and | |
| /// therefore cannot be used on its own to determine the current animation | |
| /// direction. | |
| /// | |
| /// {@tool snippet} | |
| /// This example shows how to use the [onAnimationStatusChanged] callback to | |
| /// create a [MenuAnchor] that will toggle between opening and closing. | |
| /// | |
| /// ```dart | |
| /// CupertinoMenuAnchor( | |
| /// onAnimationStatusChanged: (AnimationStatus status) { | |
| /// // Typically, animationStatus would be stored in a State object. | |
| /// animationStatus = status; | |
| /// }, | |
| /// menuChildren: <Widget>[ | |
| /// CupertinoMenuItem( | |
| /// onPressed: () {}, | |
| /// child: const Text('Menu Item') | |
| /// ), | |
| /// ], | |
| /// builder: (BuildContext context, MenuController controller, Widget? child) { | |
| /// return CupertinoButton( | |
| /// onPressed: () { | |
| /// if (animationStatus.isForwardOrCompleted) { | |
| /// controller.close(); | |
| /// } else { | |
| /// controller.open(); | |
| /// } | |
| /// }, | |
| /// child: const Icon(Icons.more_vert), | |
| /// ); | |
| /// }, | |
| /// ); | |
| /// ``` | |
| /// {@end-tool} | |
| /// | |
| /// Defaults to null. | |
| final CupertinoMenuAnimationStatusChangedCallback? onAnimationStatusChanged; | |
| /// The constraints to apply to the menu scrollable. | |
| final BoxConstraints? constraints; | |
| /// Whether the menu's cross axis should be constrained by the overlay. | |
| /// | |
| /// If true, when the menu is wider than the overlay, the menu width will | |
| /// shrink to fit the overlay bounds. | |
| /// | |
| /// If false, the menu will grow to fit the size of its contents. If the menu | |
| /// is wider than the overlay, it will be clipped to the overlay's bounds. | |
| /// | |
| /// Defaults to false. | |
| final bool constrainCrossAxis; | |
| /// Whether or not a tap event that closes the menu will be permitted to | |
| /// continue on to the gesture arena. | |
| /// | |
| /// If false, then tapping outside of a menu when the menu is open will both | |
| /// close the menu, and allow the tap to participate in the gesture arena. If | |
| /// true, then it will only close the menu, and the tap event will be | |
| /// consumed. | |
| /// | |
| /// Defaults to false. | |
| final bool consumeOutsideTaps; | |
| /// Whether or not swiping is enabled on the menu. | |
| /// | |
| /// When swiping is enabled, a [MultiDragGestureRecognizer] is added around | |
| /// the widget built by [builder] and menu items. The | |
| /// [MultiDragGestureRecognizer] allows for users to press, move, and activate | |
| /// adjacent menu items in a single gesture. Swiping also scales the menu | |
| /// panel when users drag their pointer away from the menu. | |
| /// | |
| /// Disabling swiping can be useful if the menu swipe effects interfere with | |
| /// another swipe gesture, such as in the case of dragging a menu anchor | |
| /// around the screen. | |
| /// | |
| /// Defaults to true. | |
| final bool enableSwipe; | |
| /// Whether or not the menu should open in response to a long-press on the | |
| /// anchor. | |
| /// | |
| /// When a menu is opened via long-press, the menu can be swiped in the same | |
| /// gesture to select and activate menu items. | |
| /// | |
| /// If the widget built by [builder] is disabled, [longPressToOpenDuration] | |
| /// should be set to false to prevent the menu from opening on long-press. | |
| /// | |
| /// Defaults to false, which disables the behavior. | |
| final bool enableLongPressToOpen; | |
| /// {@macro flutter.widgets.RawMenuAnchor.useRootOverlay} | |
| final bool useRootOverlay; | |
| /// The padding inside the overlay between its boundary and the menu content. | |
| /// | |
| /// If the menu width is larger than the available space in the overlay minus | |
| /// the [overlayPadding] and [constrainCrossAxis] is false, the menu will be | |
| /// positioned against the starting edge of the overlay (left when the ambient | |
| /// [Directionality] is [TextDirection.ltr], and right when the ambient | |
| /// [Directionality] is [TextDirection.rtl]). If [constrainCrossAxis] is true, | |
| /// the menu width will shrink to fit within the overlay bounds minus the | |
| /// [overlayPadding]. | |
| /// | |
| /// Defaults to `EdgeInsets.all(8)`. | |
| final EdgeInsetsGeometry overlayPadding; | |
| /// A list of menu items to display in the menu. | |
| final List<Widget> menuChildren; | |
| /// The widget that this [CupertinoMenuAnchor] surrounds. | |
| /// | |
| /// Typically, this is a button that calls [MenuController.open] when pressed. | |
| /// | |
| /// If null, the [CupertinoMenuAnchor] will be the size that its parent | |
| /// allocates for it. | |
| final RawMenuAnchorChildBuilder? builder; | |
| /// An optional child to be passed to the [builder]. | |
| /// | |
| /// Supply this child if there is a portion of the widget tree built in | |
| /// [builder] that doesn't depend on the `controller` or `context` supplied to | |
| /// the [builder]. It will be more efficient, since Flutter doesn't then need | |
| /// to rebuild this child when those change. | |
| final Widget? child; | |
| /// The [childFocusNode] attribute is the optional [FocusNode] also associated | |
| /// the [child] or [builder] widget that opens the menu. | |
| /// | |
| /// The focus node should be attached to the widget that should receive focus | |
| /// if keyboard focus traversal moves the focus off of the submenu with the | |
| /// arrow keys. | |
| /// | |
| /// If not supplied, then focus will not traverse from the menu to the | |
| /// controlling button after the menu opens. | |
| final FocusNode? childFocusNode; | |
| /// Returns whether any ancestor [CupertinoMenuAnchor] has menu items with | |
| /// leading widgets. | |
| /// | |
| /// This can be used by menu items to determine whether they need to | |
| /// allocate space for a leading widget to align with sibling menu items. | |
| static bool? maybeHasLeadingOf(BuildContext context) { | |
| return context.dependOnInheritedWidgetOfExactType<_AnchorScope>()?.hasLeading; | |
| } | |
| @override | |
| State<CupertinoMenuAnchor> createState() => _CupertinoMenuAnchorState(); | |
| @override | |
| List<DiagnosticsNode> debugDescribeChildren() { | |
| return menuChildren.map<DiagnosticsNode>((Widget child) => child.toDiagnosticsNode()).toList(); | |
| } | |
| @override | |
| void debugFillProperties(DiagnosticPropertiesBuilder properties) { | |
| super.debugFillProperties(properties); | |
| properties.add(DiagnosticsProperty<FocusNode?>('childFocusNode', childFocusNode)); | |
| properties.add(DiagnosticsProperty<BoxConstraints?>('constraints', constraints)); | |
| properties.add( | |
| FlagProperty( | |
| 'constrainCrossAxis', | |
| value: constrainCrossAxis, | |
| ifTrue: 'constrains cross axis', | |
| ), | |
| ); | |
| properties.add( | |
| FlagProperty( | |
| 'enableSwipe', | |
| value: enableSwipe, | |
| ifTrue: 'swipe enabled', | |
| ifFalse: 'swipe disabled', | |
| ), | |
| ); | |
| properties.add( | |
| FlagProperty( | |
| 'consumeOutsideTaps', | |
| value: consumeOutsideTaps, | |
| ifTrue: 'consumes outside taps', | |
| ), | |
| ); | |
| properties.add( | |
| FlagProperty('useRootOverlay', value: useRootOverlay, ifTrue: 'uses root overlay'), | |
| ); | |
| properties.add(DiagnosticsProperty<EdgeInsetsGeometry>('overlayPadding', overlayPadding)); | |
| } | |
| } | |
| class _CupertinoMenuAnchorState extends State<CupertinoMenuAnchor> with TickerProviderStateMixin { | |
| static const Duration longPressToOpenDuration = Duration(milliseconds: 400); | |
| static const Tolerance springTolerance = Tolerance(velocity: 0.1); | |
| // Approximated from the iOS 18.5 Simulator. | |
| static final SpringDescription forwardSpring = SpringDescription.withDurationAndBounce( | |
| duration: const Duration(milliseconds: 337), | |
| bounce: 0.2, | |
| ); | |
| // Approximated from the iOS 18.5 Simulator. | |
| static final SpringDescription reverseSpring = SpringDescription.withDurationAndBounce( | |
| duration: const Duration(milliseconds: 409), | |
| ); | |
| late final AnimationController _animationController; | |
| final FocusScopeNode _menuScopeNode = FocusScopeNode(debugLabel: 'Menu Scope'); | |
| final ValueNotifier<double> _swipeDistanceNotifier = ValueNotifier<double>(0); | |
| bool? _hasLeadingWidget; | |
| MenuController get _menuController => widget.controller ?? _internalMenuController!; | |
| MenuController? _internalMenuController; | |
| bool get isOpening => _animationStatus.isForwardOrCompleted; | |
| bool get enableSwipe => | |
| widget.enableSwipe && | |
| switch (_animationStatus) { | |
| AnimationStatus.forward || AnimationStatus.completed || AnimationStatus.dismissed => true, | |
| AnimationStatus.reverse => false, | |
| }; | |
| AnimationStatus _animationStatus = AnimationStatus.dismissed; | |
| @override | |
| void initState() { | |
| super.initState(); | |
| if (widget.controller == null) { | |
| _internalMenuController = MenuController(); | |
| } | |
| _animationController = AnimationController.unbounded(vsync: this); | |
| _animationController.addStatusListener(_handleAnimationStatusChange); | |
| } | |
| @override | |
| void didUpdateWidget(CupertinoMenuAnchor oldWidget) { | |
| super.didUpdateWidget(oldWidget); | |
| if (oldWidget.controller != widget.controller) { | |
| if (widget.controller != null) { | |
| _internalMenuController = null; | |
| } else { | |
| assert(_internalMenuController == null); | |
| _internalMenuController = MenuController(); | |
| } | |
| } | |
| if (oldWidget.menuChildren != widget.menuChildren) { | |
| _hasLeadingWidget = _resolveHasLeading(); | |
| } | |
| } | |
| @override | |
| void didChangeDependencies() { | |
| super.didChangeDependencies(); | |
| _hasLeadingWidget ??= _resolveHasLeading(); | |
| } | |
| @override | |
| void dispose() { | |
| _menuScopeNode.dispose(); | |
| _animationController | |
| ..stop() | |
| ..dispose(); | |
| _internalMenuController = null; | |
| _swipeDistanceNotifier.dispose(); | |
| super.dispose(); | |
| } | |
| bool _resolveHasLeading() { | |
| return widget.menuChildren.any((Widget element) { | |
| return switch (element) { | |
| final CupertinoMenuEntry entry => entry.hasLeading(context), | |
| _ => false, | |
| }; | |
| }); | |
| } | |
| void _handleAnimationStatusChange(AnimationStatus status) { | |
| setState(() { | |
| _animationStatus = status; | |
| }); | |
| widget.onAnimationStatusChanged?.call(status); | |
| } | |
| void _handleSwipeDistanceChange(double distance) { | |
| if (!_menuController.isOpen) { | |
| return; | |
| } | |
| // Because we are triggering a nested ticker, it's easiest to pass a | |
| // listenable down the tree. Otherwise, it would be more idiomatic to use | |
| // an inherited widget. | |
| _swipeDistanceNotifier.value = distance; | |
| } | |
| void _handleAnchorSwipeStart() { | |
| if (isOpening || !widget.enableLongPressToOpen) { | |
| return; | |
| } | |
| _menuController.open(); | |
| } | |
| void _handleCloseRequested(VoidCallback hideMenu) { | |
| if (_animationStatus case AnimationStatus.reverse || AnimationStatus.dismissed) { | |
| return; | |
| } | |
| _animationController | |
| .animateBackWith( | |
| ClampedSimulation( | |
| SpringSimulation( | |
| reverseSpring, | |
| _animationController.value, | |
| 0.0, | |
| 0.0, | |
| tolerance: springTolerance, | |
| ), | |
| xMin: 0.0, | |
| xMax: 1.0, | |
| ), | |
| ) | |
| .whenComplete(hideMenu); | |
| } | |
| void _handleOpenRequested(ui.Offset? position, VoidCallback showOverlay) { | |
| showOverlay(); | |
| if (_animationStatus case AnimationStatus.completed || AnimationStatus.forward) { | |
| return; | |
| } | |
| _animationController.animateWith( | |
| SpringSimulation(forwardSpring, _animationController.value, 1, 0.5), | |
| ); | |
| FocusScope.of(context).setFirstFocus(_menuScopeNode); | |
| } | |
| Widget _buildMenuOverlay(BuildContext childContext, RawMenuOverlayInfo info) { | |
| return ExcludeSemantics( | |
| excluding: !isOpening, | |
| child: IgnorePointer( | |
| ignoring: !isOpening, | |
| child: ExcludeFocus( | |
| excluding: !isOpening, | |
| child: _MenuOverlay( | |
| constrainCrossAxis: widget.constrainCrossAxis, | |
| visibilityAnimation: _animationController.view, | |
| swipeDistanceListenable: _swipeDistanceNotifier, | |
| constraints: widget.constraints, | |
| consumeOutsideTaps: widget.consumeOutsideTaps, | |
| overlaySize: info.overlaySize, | |
| anchorRect: info.anchorRect, | |
| anchorPosition: info.position, | |
| tapRegionGroupId: info.tapRegionGroupId, | |
| focusScopeNode: _menuScopeNode, | |
| overlayPadding: widget.overlayPadding, | |
| children: widget.menuChildren, | |
| ), | |
| ), | |
| ), | |
| ); | |
| } | |
| Widget _buildChild(BuildContext context, MenuController controller, Widget? child) { | |
| final Widget anchor = | |
| widget.builder?.call(context, _menuController, widget.child) ?? | |
| widget.child ?? | |
| const SizedBox.shrink(); | |
| if (!widget.enableLongPressToOpen || !enableSwipe) { | |
| return anchor; | |
| } | |
| return _SwipeSurface( | |
| onStart: _handleAnchorSwipeStart, | |
| delay: longPressToOpenDuration, | |
| child: anchor, | |
| ); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| return _SwipeRegion( | |
| onDistanceChanged: _handleSwipeDistanceChange, | |
| enabled: enableSwipe, | |
| child: _AnchorScope( | |
| hasLeading: _hasLeadingWidget!, | |
| child: RawMenuAnchor( | |
| useRootOverlay: widget.useRootOverlay, | |
| onCloseRequested: _handleCloseRequested, | |
| onOpenRequested: _handleOpenRequested, | |
| overlayBuilder: _buildMenuOverlay, | |
| builder: _buildChild, | |
| controller: _menuController, | |
| childFocusNode: widget.childFocusNode, | |
| consumeOutsideTaps: widget.consumeOutsideTaps, | |
| onClose: widget.onClose, | |
| onOpen: widget.onOpen, | |
| ), | |
| ), | |
| ); | |
| } | |
| } | |
| class _MenuOverlay extends StatefulWidget { | |
| const _MenuOverlay({ | |
| required this.children, | |
| required this.focusScopeNode, | |
| required this.consumeOutsideTaps, | |
| required this.constrainCrossAxis, | |
| required this.constraints, | |
| required this.overlaySize, | |
| required this.overlayPadding, | |
| required this.anchorRect, | |
| required this.anchorPosition, | |
| required this.tapRegionGroupId, | |
| required this.visibilityAnimation, | |
| required this.swipeDistanceListenable, | |
| }); | |
| final List<Widget> children; | |
| final FocusScopeNode focusScopeNode; | |
| final bool consumeOutsideTaps; | |
| final bool constrainCrossAxis; | |
| final BoxConstraints? constraints; | |
| final Size overlaySize; | |
| final EdgeInsetsGeometry overlayPadding; | |
| final Rect anchorRect; | |
| final Offset? anchorPosition; | |
| final Object tapRegionGroupId; | |
| final Animation<double> visibilityAnimation; | |
| final ValueListenable<double> swipeDistanceListenable; | |
| @override | |
| State<_MenuOverlay> createState() => _MenuOverlayState(); | |
| } | |
| class _MenuOverlayState extends State<_MenuOverlay> | |
| with TickerProviderStateMixin, WidgetsBindingObserver { | |
| static final Map<Type, Action<Intent>> _actions = <Type, Action<Intent>>{ | |
| _FocusDownIntent: _FocusDownAction(), | |
| _FocusUpIntent: _FocusUpAction(), | |
| _FocusFirstIntent: _FocusFirstAction(), | |
| _FocusLastIntent: _FocusLastAction(), | |
| }; | |
| late final AnimationController _swipeAnimationController; | |
| final ScrollController _scrollController = ScrollController(); | |
| final ProxyAnimation _scaleAnimation = ProxyAnimation(); | |
| final ProxyAnimation _fadeAnimation = ProxyAnimation(); | |
| final ProxyAnimation _sizeAnimation = ProxyAnimation(); | |
| late Alignment _attachmentPointAlignment; | |
| late ui.Offset _attachmentPoint; | |
| late Alignment _menuAlignment; | |
| List<Widget> _children = <Widget>[]; | |
| ui.TextDirection? _textDirection; | |
| // The actual distance the user has swiped away from the menu. | |
| double _swipeTargetDistance = 0; | |
| // The effective distance the user has swiped away from the menu, after | |
| // applying velocity and deceleration. | |
| double _swipeCurrentDistance = 0; | |
| // The accumulated velocity of the swipe gesture, used to determine how fast | |
| // the menu scales to _swipeTargetDistance | |
| double _swipeVelocity = 0; | |
| // A ticker used to drive the swipe animation. | |
| Ticker? _swipeTicker; | |
| @override | |
| void initState() { | |
| super.initState(); | |
| WidgetsBinding.instance.addObserver(this); | |
| _swipeAnimationController = AnimationController.unbounded(value: 1, vsync: this); | |
| widget.swipeDistanceListenable.addListener(_handleSwipeDistanceChanged); | |
| _resolveChildren(); | |
| } | |
| @override | |
| void didChangeDependencies() { | |
| super.didChangeDependencies(); | |
| final ui.TextDirection newTextDirection = Directionality.of(context); | |
| if (_textDirection != newTextDirection) { | |
| _textDirection = newTextDirection; | |
| _resolvePosition(); | |
| } | |
| _resolveMotion(); | |
| } | |
| @override | |
| void didUpdateWidget(_MenuOverlay oldWidget) { | |
| super.didUpdateWidget(oldWidget); | |
| if (oldWidget.swipeDistanceListenable != widget.swipeDistanceListenable) { | |
| oldWidget.swipeDistanceListenable.removeListener(_handleSwipeDistanceChanged); | |
| widget.swipeDistanceListenable.addListener(_handleSwipeDistanceChanged); | |
| } | |
| if (oldWidget.visibilityAnimation != widget.visibilityAnimation) { | |
| _resolveMotion(); | |
| } | |
| if (oldWidget.anchorRect != widget.anchorRect || | |
| oldWidget.anchorPosition != widget.anchorPosition || | |
| oldWidget.overlaySize != widget.overlaySize) { | |
| _resolvePosition(); | |
| } | |
| if (oldWidget.children != widget.children) { | |
| _resolveChildren(); | |
| } | |
| } | |
| @override | |
| void didChangeAccessibilityFeatures() { | |
| super.didChangeAccessibilityFeatures(); | |
| _resolveMotion(); | |
| } | |
| @override | |
| void dispose() { | |
| _scrollController.dispose(); | |
| widget.swipeDistanceListenable.removeListener(_handleSwipeDistanceChanged); | |
| _swipeTicker | |
| ?..stop() | |
| ..dispose(); | |
| _swipeAnimationController | |
| ..stop() | |
| ..dispose(); | |
| _scaleAnimation.parent = null; | |
| _fadeAnimation.parent = null; | |
| _sizeAnimation.parent = null; | |
| WidgetsBinding.instance.removeObserver(this); | |
| super.dispose(); | |
| } | |
| void _resolveChildren() { | |
| if (widget.children.isEmpty) { | |
| _children = <Widget>[]; | |
| return; | |
| } | |
| final children = <Widget>[]; | |
| Widget child = widget.children.first; | |
| for (var i = 0; i < widget.children.length; i++) { | |
| children.add(child); | |
| if (child == widget.children.last) { | |
| break; | |
| } | |
| if (child case CupertinoMenuEntry(isDivider: true)) { | |
| child = widget.children[i + 1]; | |
| continue; | |
| } | |
| child = widget.children[i + 1]; | |
| if (child case CupertinoMenuEntry(isDivider: true)) { | |
| continue; | |
| } | |
| children.add(const _CupertinoMenuImplicitDivider()); | |
| } | |
| _children = children; | |
| } | |
| void _resolveMotion() { | |
| // Behavior of reduce motion is based on iOS 18.5 simulator. Because the | |
| // disableAnimations accessibility feature is not present on iOS, all | |
| // animations are disabled when disableAnimations is enabled. | |
| final ui.AccessibilityFeatures accessibilityFeatures = View.of( | |
| context, | |
| ).platformDispatcher.accessibilityFeatures; | |
| switch (accessibilityFeatures) { | |
| case ui.AccessibilityFeatures(disableAnimations: true): | |
| _scaleAnimation.parent = kAlwaysCompleteAnimation; | |
| _fadeAnimation.parent = kAlwaysCompleteAnimation; | |
| _sizeAnimation.parent = kAlwaysCompleteAnimation; | |
| case ui.AccessibilityFeatures(reduceMotion: true): | |
| // Swipe scaling works with reduced motion. | |
| _scaleAnimation.parent = _swipeAnimationController.view.drive( | |
| Tween<double>(begin: 0.8, end: 1), | |
| ); | |
| _sizeAnimation.parent = kAlwaysCompleteAnimation; | |
| _fadeAnimation.parent = widget.visibilityAnimation.drive( | |
| CurveTween(curve: Curves.easeIn).chain(const _ClampTween(begin: 0, end: 1)), | |
| ); | |
| case _: | |
| _scaleAnimation.parent = _AnimationProduct( | |
| first: widget.visibilityAnimation, | |
| next: _swipeAnimationController.view.drive(Tween<double>(begin: 0.8, end: 1)), | |
| ); | |
| _sizeAnimation.parent = widget.visibilityAnimation.drive(Tween<double>(begin: 0.8, end: 1)); | |
| _fadeAnimation.parent = widget.visibilityAnimation.drive( | |
| CurveTween(curve: Curves.easeIn).chain(const _ClampTween(begin: 0, end: 1)), | |
| ); | |
| } | |
| } | |
| // Position was determined using iOS 18.5 simulator (phone + tablet). | |
| // | |
| // Layout needs to be resolved outside of the layout delegate because the | |
| // ScaleTransition widget is dependent on the attachment point alignment. | |
| void _resolvePosition() { | |
| final ui.Offset anchorMidpoint; | |
| if (widget.anchorPosition != null) { | |
| anchorMidpoint = widget.anchorRect.topLeft + widget.anchorPosition!; | |
| } else { | |
| anchorMidpoint = widget.anchorRect.center; | |
| } | |
| final double xMidpointRatio = anchorMidpoint.dx / widget.overlaySize.width; | |
| final double yMidpointRatio = anchorMidpoint.dy / widget.overlaySize.height; | |
| // Slightly favor placing the menu below the anchor when it is near the vertical | |
| // center of the screen. | |
| final double dy = yMidpointRatio < 0.55 ? 1 : -1; | |
| final double dx = switch (xMidpointRatio) { | |
| < 0.4 => -1.0, // Left | |
| > 0.6 => 1.0, // Right | |
| _ => 0.0, // Center | |
| }; | |
| _menuAlignment = Alignment(dx, -dy); | |
| final Offset transformOrigin; | |
| if (widget.anchorPosition != null) { | |
| _attachmentPoint = widget.anchorRect.topLeft + widget.anchorPosition!; | |
| transformOrigin = _attachmentPoint; | |
| } else { | |
| _attachmentPoint = Alignment(dx, dy).withinRect(widget.anchorRect); | |
| transformOrigin = Alignment(0, dy).withinRect(widget.anchorRect); | |
| } | |
| final double xOriginRatio = transformOrigin.dx / widget.overlaySize.width; | |
| final double yOriginRatio = transformOrigin.dy / widget.overlaySize.height; | |
| // The alignment of the menu growth point relative to the overlay. | |
| _attachmentPointAlignment = Alignment(xOriginRatio * 2 - 1, yOriginRatio * 2 - 1); | |
| } | |
| void _handleOutsideTap(PointerDownEvent event) { | |
| MenuController.maybeOf(context)!.close(); | |
| } | |
| void _handleSwipeDistanceChanged() { | |
| _swipeTargetDistance = ui.clampDouble(widget.swipeDistanceListenable.value, 0, 150); | |
| if (_swipeCurrentDistance == _swipeTargetDistance) { | |
| return; | |
| } | |
| _swipeTicker ??= createTicker(_updateSwipeScale); | |
| if (!_swipeTicker!.isActive) { | |
| _swipeTicker!.start(); | |
| } | |
| } | |
| // The menu will scale between 80% and 100% of its size based on the distance | |
| // the user has dragged their pointer away from the menu edges. | |
| void _updateSwipeScale(Duration elapsed) { | |
| const maxVelocity = 20.0; | |
| const double minVelocity = 8; | |
| const double maxSwipeDistance = 150; | |
| const accelerationRate = 0.12; | |
| // The distance below which velocity begins to decelerate. | |
| // | |
| // When the swipe distance to target is less than this value, the animation | |
| // velocity reduces proportionally to create smooth arrival at the target. | |
| // Higher values mean the animation begins to decelerate sooner, resulting to | |
| // a smoother animation curve. | |
| const double decelerationDistanceThreshold = 80; | |
| // The distance at which the animation will snap to the target distance without | |
| // any animation. | |
| const remainingDistanceSnapThreshold = 1.0; | |
| // When the user's pointer is within this distance of the menu edges, the | |
| // swipe animation will terminate. | |
| const terminationDistanceThreshold = 5.0; | |
| final double distance = _swipeTargetDistance - _swipeCurrentDistance; | |
| final double absoluteDistance = distance.abs(); | |
| // As the distance between the current position and the target position increases, | |
| // the proximity factor approaches 1.0, which increases acceleration. | |
| // | |
| // Conversely, as the current position nears the target within the deceleration | |
| // zone, the proximity factor approaches 0.0, which decreases acceleration | |
| // and smoothes the end of the animation. | |
| final double proximityFactor = math.min(absoluteDistance / decelerationDistanceThreshold, 1.0); | |
| _swipeVelocity += accelerationRate * proximityFactor; | |
| _swipeVelocity = ui.clampDouble(_swipeVelocity, minVelocity, maxVelocity); | |
| final double finalVelocity = _swipeVelocity * proximityFactor; | |
| final double distanceReduction = distance.sign * finalVelocity; | |
| _swipeCurrentDistance += distanceReduction; | |
| if (absoluteDistance < remainingDistanceSnapThreshold) { | |
| _swipeCurrentDistance = _swipeTargetDistance; | |
| _swipeVelocity = 0; | |
| if (_swipeTargetDistance < terminationDistanceThreshold) { | |
| _swipeTicker!.stop(); | |
| } | |
| } | |
| _swipeAnimationController.value = 1 - _swipeCurrentDistance / maxSwipeDistance; | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| final BoxConstraints constraints; | |
| if (widget.constraints != null) { | |
| constraints = widget.constraints!; | |
| } else { | |
| final bool isAccessibilityModeEnabled = _isAccessibilityModeEnabled(context); | |
| final double screenWidth = MediaQuery.widthOf(context); | |
| final menuWidth = _CupertinoMenuWidth.fromScreenWidth( | |
| isAccessibilityModeEnabled: isAccessibilityModeEnabled, | |
| screenWidth: screenWidth, | |
| ); | |
| constraints = BoxConstraints.tightFor(width: menuWidth.points); | |
| } | |
| Widget child = _SwipeSurface( | |
| child: TapRegion( | |
| groupId: widget.tapRegionGroupId, | |
| consumeOutsideTaps: widget.consumeOutsideTaps, | |
| onTapOutside: _handleOutsideTap, | |
| child: Actions( | |
| actions: _actions, | |
| child: Shortcuts( | |
| shortcuts: _kMenuTraversalShortcuts, | |
| child: FocusScope( | |
| node: widget.focusScopeNode, | |
| descendantsAreFocusable: true, | |
| descendantsAreTraversable: true, | |
| canRequestFocus: true, | |
| // A custom shadow painter is used to make the underlying colors | |
| // appear more vibrant. | |
| child: CustomPaint( | |
| painter: _ShadowPainter( | |
| brightness: CupertinoTheme.maybeBrightnessOf(context) ?? ui.Brightness.light, | |
| repaint: _fadeAnimation, | |
| ), | |
| // The FadeTransition widget needs to wrap Semantics so | |
| // that the semantics widget senses that the menu is the | |
| // same opacity as the menu items. Otherwise, "a menu | |
| // cannot be empty" error is thrown due to the menu items | |
| // being transparent while the menu semantics are still | |
| // present. | |
| child: FadeTransition( | |
| opacity: _fadeAnimation, | |
| alwaysIncludeSemantics: true, | |
| child: CupertinoPopupSurface( | |
| child: AnimatedBuilder( | |
| animation: _sizeAnimation, | |
| child: Semantics( | |
| explicitChildNodes: true, | |
| scopesRoute: true, | |
| namesRoute: true, | |
| child: ConstrainedBox( | |
| constraints: constraints, | |
| child: SingleChildScrollView( | |
| clipBehavior: Clip.none, | |
| primary: true, | |
| child: Column(mainAxisSize: MainAxisSize.min, children: _children), | |
| ), | |
| ), | |
| ), | |
| builder: (BuildContext context, Widget? child) { | |
| return Align( | |
| heightFactor: _sizeAnimation.value, | |
| widthFactor: 1.0, | |
| alignment: Alignment.topCenter, | |
| child: child, | |
| ); | |
| }, | |
| ), | |
| ), | |
| ), | |
| ), | |
| ), | |
| ), | |
| ), | |
| ), | |
| ); | |
| // The menu content can grow beyond the size of the overlay, but will be | |
| // clipped by the overlay's bounds. | |
| if (!widget.constrainCrossAxis) { | |
| child = UnconstrainedBox( | |
| clipBehavior: Clip.hardEdge, | |
| alignment: AlignmentDirectional.centerStart, | |
| constrainedAxis: Axis.vertical, | |
| child: child, | |
| ); | |
| } | |
| return ConstrainedBox( | |
| constraints: BoxConstraints.loose(widget.overlaySize), | |
| child: ScaleTransition( | |
| scale: _scaleAnimation, | |
| alignment: _attachmentPointAlignment, | |
| child: ValueListenableBuilder<double>( | |
| valueListenable: _sizeAnimation, | |
| child: child, | |
| builder: (BuildContext context, double value, Widget? child) { | |
| final ui.Rect anchorRect = widget.anchorPosition != null | |
| ? _attachmentPoint & Size.zero | |
| : widget.anchorRect; | |
| final List<ui.DisplayFeature>? displayFeatures = MediaQuery.maybeDisplayFeaturesOf( | |
| context, | |
| ); | |
| return CustomSingleChildLayout( | |
| delegate: _MenuLayoutDelegate( | |
| anchorRect: anchorRect, | |
| attachmentPoint: _attachmentPoint, | |
| menuAlignment: _menuAlignment, | |
| overlayPadding: widget.overlayPadding.resolve(_textDirection), | |
| heightFactor: value, | |
| avoidBounds: displayFeatures != null ? avoidBounds(displayFeatures) : <Rect>{}, | |
| ), | |
| child: child, | |
| ); | |
| }, | |
| ), | |
| ), | |
| ); | |
| } | |
| static Set<ui.Rect> avoidBounds(List<ui.DisplayFeature> displayFeatures) { | |
| final bounds = <ui.Rect>{}; | |
| for (final feature in displayFeatures) { | |
| if (feature.bounds.shortestSide > 0 || | |
| feature.state == ui.DisplayFeatureState.postureHalfOpened) { | |
| bounds.add(feature.bounds); | |
| } | |
| } | |
| return bounds; | |
| } | |
| } | |
| class _ShadowPainter extends CustomPainter { | |
| const _ShadowPainter({required this.brightness, required this.repaint}) : super(repaint: repaint); | |
| static const Radius radius = Radius.circular(13); | |
| static const double lightShadowOpacity = 0.12; | |
| static const double darkShadowOpacity = 0.24; | |
| double get shadowOpacity => ui.clampDouble(repaint.value, 0, 1); | |
| final Animation<double> repaint; | |
| final ui.Brightness brightness; | |
| @override | |
| void paint(Canvas canvas, Size size) { | |
| assert(shadowOpacity >= 0 && shadowOpacity <= 1); | |
| final center = Offset(size.width / 2, size.height / 2); | |
| final rect = Rect.fromCenter(center: center, width: size.width, height: size.height); | |
| final roundedRect = RSuperellipse.fromRectAndRadius(rect, radius); | |
| final double opacityMultiplier = switch (brightness) { | |
| ui.Brightness.light => lightShadowOpacity, | |
| ui.Brightness.dark => darkShadowOpacity, | |
| }; | |
| final double blurSigma = shadowOpacity * 50; | |
| final shadowPaint = Paint() | |
| ..maskFilter = MaskFilter.blur(BlurStyle.normal, blurSigma) | |
| ..color = ui.Color.fromRGBO(0, 0, 10, shadowOpacity * shadowOpacity * opacityMultiplier); | |
| final maskPath = Path() | |
| ..fillType = ui.PathFillType.evenOdd | |
| // Extra large rect to ensure the shadow is fully visible. | |
| ..addRect(rect.inflate(200)) | |
| ..addRRect(RRect.fromRectAndRadius(rect, radius)); | |
| canvas | |
| ..save() | |
| ..clipPath(maskPath) | |
| ..drawRSuperellipse(roundedRect.inflate(50), shadowPaint) | |
| ..restore(); | |
| } | |
| @override | |
| bool shouldRepaint(_ShadowPainter oldDelegate) => | |
| oldDelegate.brightness != brightness || oldDelegate.repaint != repaint; | |
| @override | |
| bool shouldRebuildSemantics(_ShadowPainter oldDelegate) => false; | |
| } | |
| class _MenuLayoutDelegate extends SingleChildLayoutDelegate { | |
| const _MenuLayoutDelegate({ | |
| required this.anchorRect, | |
| required this.menuAlignment, | |
| required this.overlayPadding, | |
| required this.attachmentPoint, | |
| required this.heightFactor, | |
| required this.avoidBounds, | |
| }); | |
| // Rectangle anchoring the menu | |
| final ui.Rect anchorRect; | |
| // The offset of the menu from the top-left corner of the overlay. | |
| final ui.Offset attachmentPoint; | |
| // The resolved alignment of the menu attachment point relative to the menu surface. | |
| final Alignment menuAlignment; | |
| // Unsafe bounds used when constraining and positioning the menu. | |
| // | |
| // Used to prevent the menu from being obstructed by system UI. | |
| final EdgeInsets overlayPadding; | |
| // The factor by which to multiply the height of the child. | |
| final double heightFactor; | |
| // List of rectangles that the menu should not overlap. Unusable screen area. | |
| final Set<Rect> avoidBounds; | |
| @override | |
| BoxConstraints getConstraintsForChild(BoxConstraints constraints) { | |
| // The menu can be at most the size of the overlay minus padding. | |
| return BoxConstraints.loose(constraints.biggest).deflate(overlayPadding); | |
| } | |
| @override | |
| Offset getPositionForChild(Size size, Size childSize) { | |
| final double inverseHeightFactor = heightFactor > 0.01 ? 1 / heightFactor : 0; | |
| // size: The size of the overlay. | |
| // childSize: The size of the menu, when fully open, as determined by | |
| // getConstraintsForChild. | |
| final double finalHeight = math.min(childSize.height * inverseHeightFactor, size.height); | |
| final finalSize = Size(childSize.width, finalHeight); | |
| final ui.Offset desiredPosition = attachmentPoint - menuAlignment.alongSize(finalSize); | |
| final ui.Rect screen = _findClosestScreen(size, anchorRect.center, avoidBounds); | |
| final ui.Offset finalPosition = _positionChild(screen, finalSize, desiredPosition, anchorRect); | |
| // If the menu sits above the anchor when fully open, grow upward: | |
| // keep the bottom (attachment) fixed by shifting the top-left during animation. | |
| final bool growsUp = finalPosition.dy + finalSize.height <= anchorRect.center.dy; | |
| if (growsUp) { | |
| final double dy = finalHeight - childSize.height; | |
| return Offset(finalPosition.dx, finalPosition.dy + dy); | |
| } | |
| final initialPosition = Offset(finalPosition.dx, anchorRect.bottom); | |
| return Offset.lerp(initialPosition, finalPosition, heightFactor)!; | |
| } | |
| Offset _positionChild(Rect screen, Size childSize, Offset position, ui.Rect anchor) { | |
| double x = position.dx; | |
| double y = position.dy; | |
| bool overLeftEdge(double x) => x < screen.left + overlayPadding.left; | |
| bool overRightEdge(double x) => x > screen.right - childSize.width - overlayPadding.right; | |
| bool overTopEdge(double y) => y < screen.top + overlayPadding.top; | |
| bool overBottomEdge(double y) => y > screen.bottom - childSize.height - overlayPadding.bottom; | |
| // Layout horizontally first to determine if the menu can be placed on | |
| // either side of the anchor without overlapping. | |
| bool hasHorizontalAnchorOverlap = childSize.width >= screen.width; | |
| if (hasHorizontalAnchorOverlap) { | |
| x = screen.left + overlayPadding.left; | |
| } else { | |
| if (overLeftEdge(x)) { | |
| // Flip the X position across the horizontal midpoint of the anchor so | |
| // that the menu is to the right of the anchor. | |
| final double flipX = anchor.center.dx * 2 - position.dx - childSize.width; | |
| hasHorizontalAnchorOverlap = overRightEdge(flipX); | |
| if (hasHorizontalAnchorOverlap || overLeftEdge(flipX)) { | |
| x = screen.left + overlayPadding.left; | |
| } else { | |
| x = flipX; | |
| } | |
| } else if (overRightEdge(x)) { | |
| // Flip the X position across the horizontal midpoint of the anchor so | |
| // that the menu is to the left of the anchor. | |
| final double flipX = anchor.center.dx * 2 - position.dx - childSize.width; | |
| hasHorizontalAnchorOverlap = overLeftEdge(flipX); | |
| if (hasHorizontalAnchorOverlap || overRightEdge(flipX)) { | |
| x = screen.right - childSize.width - overlayPadding.right; | |
| } else { | |
| x = flipX; | |
| } | |
| } | |
| } | |
| if (childSize.height >= screen.height) { | |
| // Menu is too big to fit on screen. Fit as much as possible. | |
| return Offset(x, screen.top + overlayPadding.top); | |
| } | |
| // Behavior in this scenario could not be determined on iOS 18.5 | |
| // simulator, so this logic is based on what seems most reasonable. | |
| if (hasHorizontalAnchorOverlap && !anchor.isEmpty) { | |
| // If both horizontal screen edges overlap, shift the menu upwards or | |
| // downwards by the minimum amount needed to avoid overlapping the anchor. | |
| // | |
| // NOTE: Menus that are deliberately overlapping the anchor will stop | |
| // overlapping the anchor, but only when the screen's width is smaller | |
| // than the menu's width. | |
| final double below = anchor.bottom - y; | |
| final double above = y + childSize.height - anchor.top; | |
| if (below > 0 && above > 0) { | |
| if (below > above) { | |
| y = anchor.top - childSize.height; | |
| } else { | |
| y = anchor.bottom; | |
| } | |
| } | |
| } | |
| if (overTopEdge(y)) { | |
| // Flip the Y position across the vertical midpoint of the anchor so that | |
| // the menu is below the anchor. | |
| final double flipY = anchor.center.dy * 2 - position.dy - childSize.height; | |
| if (overTopEdge(flipY) || overBottomEdge(flipY)) { | |
| y = screen.top + overlayPadding.top; | |
| } else { | |
| y = flipY; | |
| } | |
| } else if (overBottomEdge(y)) { | |
| // Flip the Y position across the vertical midpoint of the anchor so that | |
| // the menu is above the anchor. | |
| final double flipY = anchor.center.dy * 2 - position.dy - childSize.height; | |
| if (overTopEdge(flipY) || overBottomEdge(flipY)) { | |
| y = screen.bottom - childSize.height - overlayPadding.bottom; | |
| } else { | |
| y = flipY; | |
| } | |
| } | |
| return Offset(x, y); | |
| } | |
| // Finds the closest screen to the anchor point. | |
| // | |
| // This algorithm is different than the algorithms for PopupMenuButton and MenuAnchor, | |
| // since those widgets calculate the closest screen based on the center of the | |
| // overlay. | |
| Rect _findClosestScreen(Size parentSize, Offset point, Set<Rect> avoidBounds) { | |
| final Iterable<ui.Rect> screens = DisplayFeatureSubScreen.subScreensInBounds( | |
| Offset.zero & parentSize, | |
| avoidBounds, | |
| ); | |
| Rect? closest; | |
| double closestSquaredDistance = 0; | |
| for (final screen in screens) { | |
| if (screen.contains(point)) { | |
| return screen; | |
| } | |
| if (closest == null) { | |
| closest = screen; | |
| closestSquaredDistance = _computeSquaredDistanceToRect(point, closest); | |
| continue; | |
| } | |
| final double squaredDistance = _computeSquaredDistanceToRect(point, screen); | |
| if (squaredDistance < closestSquaredDistance) { | |
| closest = screen; | |
| closestSquaredDistance = squaredDistance; | |
| } | |
| } | |
| return closest!; | |
| } | |
| @override | |
| bool shouldRelayout(_MenuLayoutDelegate oldDelegate) { | |
| return menuAlignment != oldDelegate.menuAlignment || | |
| attachmentPoint != oldDelegate.attachmentPoint || | |
| anchorRect != oldDelegate.anchorRect || | |
| overlayPadding != oldDelegate.overlayPadding || | |
| heightFactor != oldDelegate.heightFactor || | |
| !setEquals(avoidBounds, oldDelegate.avoidBounds); | |
| } | |
| } | |
| class _FocusUpIntent extends DirectionalFocusIntent { | |
| const _FocusUpIntent() : super(TraversalDirection.up); | |
| } | |
| class _FocusDownIntent extends DirectionalFocusIntent { | |
| const _FocusDownIntent() : super(TraversalDirection.down); | |
| } | |
| class _FocusUpAction extends ContextAction<DirectionalFocusIntent> { | |
| _FocusUpAction(); | |
| @override | |
| void invoke(DirectionalFocusIntent intent, [BuildContext? context]) { | |
| final FocusTraversalPolicy policy = | |
| FocusTraversalGroup.maybeOf(context!) ?? ReadingOrderTraversalPolicy(); | |
| if (_isCupertino && !kIsWeb) { | |
| // Don't wrap on iOS or macOS. | |
| policy.inDirection(primaryFocus!, intent.direction); | |
| return; | |
| } | |
| final FocusNode? firstFocus = policy.findFirstFocus(primaryFocus!, ignoreCurrentFocus: true); | |
| final FocusNode lastFocus = policy.findLastFocus(primaryFocus!, ignoreCurrentFocus: true); | |
| if (lastFocus.context != null) { | |
| if (primaryFocus == lastFocus.enclosingScope || primaryFocus == firstFocus) { | |
| policy.requestFocusCallback(lastFocus); | |
| return; | |
| } | |
| } | |
| policy.inDirection(primaryFocus!, intent.direction); | |
| } | |
| } | |
| class _FocusDownAction extends ContextAction<DirectionalFocusIntent> { | |
| _FocusDownAction(); | |
| @override | |
| void invoke(DirectionalFocusIntent intent, [BuildContext? context]) { | |
| final FocusTraversalPolicy policy = | |
| FocusTraversalGroup.maybeOf(context!) ?? ReadingOrderTraversalPolicy(); | |
| if (_isCupertino && !kIsWeb) { | |
| // Don't wrap on iOS or macOS. | |
| policy.inDirection(primaryFocus!, intent.direction); | |
| return; | |
| } | |
| final FocusNode? firstFocus = policy.findFirstFocus(primaryFocus!, ignoreCurrentFocus: true); | |
| final FocusNode lastFocus = policy.findLastFocus(primaryFocus!, ignoreCurrentFocus: true); | |
| if (firstFocus?.context != null) { | |
| if (primaryFocus == firstFocus!.enclosingScope || primaryFocus == lastFocus) { | |
| policy.requestFocusCallback(firstFocus); | |
| return; | |
| } | |
| } | |
| policy.inDirection(primaryFocus!, intent.direction); | |
| } | |
| } | |
| class _FocusFirstIntent extends Intent { | |
| const _FocusFirstIntent(); | |
| } | |
| class _FocusFirstAction extends ContextAction<_FocusFirstIntent> { | |
| _FocusFirstAction(); | |
| @override | |
| void invoke(_FocusFirstIntent intent, [BuildContext? context]) { | |
| final FocusTraversalPolicy policy = | |
| FocusTraversalGroup.maybeOf(context!) ?? ReadingOrderTraversalPolicy(); | |
| final FocusNode? firstFocus = policy.findFirstFocus(primaryFocus!, ignoreCurrentFocus: true); | |
| if (firstFocus == null || firstFocus.context == null) { | |
| return; | |
| } | |
| policy.requestFocusCallback(firstFocus); | |
| } | |
| } | |
| class _FocusLastIntent extends Intent { | |
| const _FocusLastIntent(); | |
| } | |
| class _FocusLastAction extends ContextAction<_FocusLastIntent> { | |
| _FocusLastAction(); | |
| @override | |
| void invoke(_FocusLastIntent intent, [BuildContext? context]) { | |
| final FocusTraversalPolicy policy = | |
| FocusTraversalGroup.maybeOf(context!) ?? ReadingOrderTraversalPolicy(); | |
| final FocusNode lastFocus = policy.findLastFocus(primaryFocus!, ignoreCurrentFocus: true); | |
| if (lastFocus.context == null) { | |
| return; | |
| } | |
| policy.requestFocusCallback(lastFocus); | |
| } | |
| } | |
| /// A horizontal divider placed between each menu item in a | |
| /// [CupertinoMenuAnchor] on iOS 18 and before. | |
| /// | |
| /// To create a menu item that does not show an automatic divider, implement | |
| /// [CupertinoMenuEntry] and return true from [CupertinoMenuEntry.isDivider]. | |
| /// | |
| /// The default thickness of the divider is 1 physical pixel. | |
| class _CupertinoMenuImplicitDivider extends StatelessWidget { | |
| /// Draws a [_CupertinoMenuImplicitDivider] below a [child]. | |
| const _CupertinoMenuImplicitDivider(); | |
| /// The default color applied to the [_CupertinoMenuImplicitDivider] with | |
| /// [ui.BlendMode.overlay]. | |
| /// | |
| /// On all platforms except web, this color is applied to the divider before | |
| /// the [color] is applied, and is used to create a subtle translucent effect | |
| /// against the menu background. | |
| // The following colors were measured from the iOS 17.2 simulator, and opacity was | |
| // extrapolated: | |
| // Dark mode on black Color.fromRGBO(97, 97, 97) | |
| // Dark mode on white Color.fromRGBO(132, 132, 132) | |
| // Light mode on black Color.fromRGBO(147, 147, 147) | |
| // Light mode on white Color.fromRGBO(187, 187, 187) | |
| // | |
| // Colors were also compared atop a red, green, and blue backgrounds. | |
| static const CupertinoDynamicColor overlayColor = CupertinoDynamicColor.withBrightness( | |
| color: Color.fromRGBO(140, 140, 140, 0.3), | |
| darkColor: Color.fromRGBO(255, 255, 255, 0.25), | |
| ); | |
| /// The default color applied to the [_CupertinoMenuImplicitDivider], atop the | |
| /// [overlayColor], with [BlendMode.srcOver]. | |
| /// | |
| /// This color is used to make the divider more opaque. | |
| static const CupertinoDynamicColor color = CupertinoDynamicColor.withBrightness( | |
| color: Color.fromRGBO(0, 0, 0, 0.25), | |
| darkColor: Color.fromRGBO(255, 255, 255, 0.25), | |
| ); | |
| @override | |
| Widget build(BuildContext context) { | |
| final double pixelRatio = MediaQuery.maybeDevicePixelRatioOf(context) ?? 1.0; | |
| final double displacement = 1 / pixelRatio; | |
| return CustomPaint( | |
| size: Size(double.infinity, displacement), | |
| painter: _CupertinoDividerPainter( | |
| color: CupertinoDynamicColor.resolve(color, context), | |
| overlayColor: CupertinoDynamicColor.resolve(overlayColor, context), | |
| // Only anti-alias on devices with a low pixel density. | |
| antiAlias: pixelRatio < 1.0, | |
| ), | |
| ); | |
| } | |
| } | |
| /// A large horizontal divider that is used to separate [CupertinoMenuItem]s in | |
| /// a [CupertinoMenuAnchor]. | |
| /// | |
| /// The divider has a height of 8 logical pixels. The [color] parameter can be | |
| /// provided to customize the color of the divider. | |
| /// | |
| /// See also: | |
| /// | |
| /// * [CupertinoMenuItem], a Cupertino-style menu item. | |
| /// * [CupertinoMenuAnchor], a widget that creates a Cupertino-style popup menu. | |
| /// * [CupertinoMenuEntry], an interface that can be used to control whether | |
| /// dividers are shown before or after a menu item. | |
| class CupertinoMenuDivider extends StatelessWidget implements CupertinoMenuEntry { | |
| /// Creates a large horizontal divider for a [CupertinoMenuAnchor]. | |
| const CupertinoMenuDivider({super.key, this.color = defaultColor}); | |
| /// The color of the divider. | |
| /// | |
| /// Defaults to [CupertinoMenuDivider.defaultColor]. | |
| final Color color; | |
| @override | |
| bool get isDivider => true; | |
| @override | |
| bool hasLeading(BuildContext context) => false; | |
| /// Default color for a [CupertinoMenuDivider]. | |
| // The following colors were measured from debug mode on the iOS 18.5 simulator, | |
| static const CupertinoDynamicColor defaultColor = CupertinoDynamicColor.withBrightness( | |
| color: Color.fromRGBO(0, 0, 0, 0.08), | |
| darkColor: Color.fromRGBO(0, 0, 0, 0.16), | |
| ); | |
| static const double _height = 8.0; | |
| @override | |
| Widget build(BuildContext context) { | |
| return ColoredBox( | |
| color: CupertinoDynamicColor.resolve(color, context), | |
| child: const SizedBox(height: _height, width: double.infinity), | |
| ); | |
| } | |
| } | |
| // Draws an aliased line that approximates the appearance of an iOS 18.5 menu | |
| // divider using blend modes. | |
| class _CupertinoDividerPainter extends CustomPainter { | |
| const _CupertinoDividerPainter({ | |
| required this.color, | |
| required this.overlayColor, | |
| this.antiAlias = false, | |
| }); | |
| final Color color; | |
| final Color overlayColor; | |
| final bool antiAlias; | |
| @override | |
| void paint(Canvas canvas, Size size) { | |
| final Offset p1 = size.centerLeft(Offset.zero); | |
| final Offset p2 = size.centerRight(Offset.zero); | |
| // BlendMode.overlay is not supported on the web. | |
| if (!kIsWeb) { | |
| final overlayPainter = Paint() | |
| ..style = PaintingStyle.stroke | |
| ..color = overlayColor | |
| ..isAntiAlias = antiAlias | |
| ..blendMode = BlendMode.overlay; | |
| canvas.drawLine(p1, p2, overlayPainter); | |
| } | |
| final colorPainter = Paint() | |
| ..style = PaintingStyle.stroke | |
| ..color = color | |
| ..isAntiAlias = antiAlias; | |
| canvas.drawLine(p1, p2, colorPainter); | |
| } | |
| @override | |
| bool shouldRepaint(_CupertinoDividerPainter oldDelegate) { | |
| return color != oldDelegate.color || | |
| overlayColor != oldDelegate.overlayColor || | |
| antiAlias != oldDelegate.antiAlias; | |
| } | |
| } | |
| /// A menu item for use in a [CupertinoMenuAnchor]. | |
| /// | |
| /// {@tool snippet} | |
| /// | |
| /// This sample code shows a [CupertinoMenuItem] that prints "Item 1 pressed!" | |
| /// when pressed. | |
| /// | |
| /// ```dart | |
| /// CupertinoMenuAnchor( | |
| /// menuChildren: <Widget>[ | |
| /// CupertinoMenuItem( | |
| /// trailing: const Icon(CupertinoIcons.add), | |
| /// onPressed: () { | |
| /// print('Item 1 pressed!'); | |
| /// }, | |
| /// child: const Text('Item 1'), | |
| /// ) | |
| /// ], | |
| /// builder: ( | |
| /// BuildContext context, | |
| /// MenuController controller, | |
| /// Widget? child, | |
| /// ) { | |
| /// return CupertinoButton.filled( | |
| /// onPressed: () { | |
| /// if (controller.isOpen) { | |
| /// controller.close(); | |
| /// } else { | |
| /// controller.open(); | |
| /// } | |
| /// }, | |
| /// child: const Text('Open'), | |
| /// ); | |
| /// }, | |
| /// ); | |
| /// ``` | |
| /// {@end-tool} | |
| /// | |
| /// ## Layout | |
| /// The menu item is unconstrained by default and will grow to fit the size of | |
| /// its container. To constrain the size of a [CupertinoMenuItem], the | |
| /// [constraints] parameter can be set. When set, the [constraints] apply to the | |
| /// total area occupied by the content and its [padding]. This means that | |
| /// [padding] will only affect the size of this menu item if this item's minimum | |
| /// constraints are less than the sum of its [padding] and the size of its | |
| /// contents. | |
| /// | |
| /// The [leading] and [trailing] widgets display before and after the [child] | |
| /// widget, respectively. The [leadingWidth] and [trailingWidth] parameters | |
| /// control the horizontal space that these widgets occupy. The | |
| /// [leadingMidpointAlignment] and [trailingMidpointAlignment] parameters control the alignment | |
| /// of the leading and trailing widgets within their respective spaces. | |
| /// | |
| /// ## Input | |
| /// In order to respond to user input, an [onPressed] callback must be provided. | |
| /// If absent, the [enabled] property will be false and user input callbacks | |
| /// ([onFocusChange], [onHover], and [onPressed]) will be ignored. The | |
| /// [behavior] parameter can be used to control whether hit tests can travel | |
| /// behind the menu item, and the [mouseCursor] parameter can be used to change | |
| /// the cursor that appears when the user hovers over the menu. | |
| /// | |
| /// The [requestCloseOnActivate] parameter can be set to false to prevent the | |
| /// menu from closing when the item is activated. By default, the menu will | |
| /// close when an item is pressed. | |
| /// | |
| /// The [requestFocusOnHover] parameter, when true, focuses the menu item when | |
| /// the item is hovered. | |
| /// | |
| /// ## Visuals | |
| /// The [decoration] parameter can be used to change the background color of the | |
| /// menu item when hovered, focused, pressed, or swiped. If these parameters are | |
| /// not set, the menu item will use [CupertinoMenuItem.defaultDecoration]. | |
| /// | |
| /// The [isDestructiveAction] parameter should be set to true if the menu item | |
| /// will perform a destructive action, and will color the text of the menu item | |
| /// [CupertinoColors.systemRed]. | |
| /// | |
| /// {@tool dartpad} | |
| /// This example shows basic usage of a [CupertinoMenuItem] that wraps a button. | |
| /// | |
| /// ** See code in examples/api/lib/cupertino/menu_anchor/cupertino_menu_anchor.0.dart ** | |
| /// {@end-tool} | |
| /// | |
| /// See also: | |
| /// * [CupertinoMenuAnchor], a Cupertino-style widget that shows a menu of | |
| /// actions in a popup | |
| /// * [RawMenuAnchor], a lower-level widget that creates a region with a submenu | |
| /// that is the basis for [CupertinoMenuAnchor]. | |
| /// * [PlatformMenuBar], which creates a menu bar that is rendered by the host | |
| /// platform instead of by Flutter (on macOS, for example). | |
| class CupertinoMenuItem extends StatelessWidget implements CupertinoMenuEntry { | |
| /// Creates a [CupertinoMenuItem] | |
| /// | |
| /// The [child] parameter is required and must not be null. | |
| const CupertinoMenuItem({ | |
| super.key, | |
| required this.child, | |
| this.subtitle, | |
| this.leading, | |
| this.leadingWidth, | |
| this.leadingMidpointAlignment, | |
| this.trailing, | |
| this.trailingWidth, | |
| this.trailingMidpointAlignment, | |
| this.padding, | |
| this.constraints, | |
| this.autofocus = false, | |
| this.focusNode, | |
| this.onFocusChange, | |
| this.onHover, | |
| this.onPressed, | |
| this.decoration, | |
| this.mouseCursor, | |
| this.statesController, | |
| this.behavior = HitTestBehavior.opaque, | |
| this.requestCloseOnActivate = true, | |
| this.requestFocusOnHover = true, | |
| this.isDestructiveAction = false, | |
| }); | |
| /// The widget displayed in the center of this button. | |
| /// | |
| /// Typically this is the button's label, using a [Text] widget. | |
| /// | |
| /// {@macro flutter.widgets.ProxyWidget.child} | |
| final Widget child; | |
| /// The padding applied to this menu item. | |
| final EdgeInsetsGeometry? padding; | |
| /// The widget shown before the label. Typically an [Icon]. | |
| final Widget? leading; | |
| /// The widget shown after the label. Typically an [Icon]. | |
| final Widget? trailing; | |
| /// A widget displayed underneath the [child]. Typically a [Text] widget. | |
| final Widget? subtitle; | |
| /// Called when this menu is tapped or otherwise activated. | |
| /// | |
| /// If a callback is not provided, then the button will be disabled. | |
| final VoidCallback? onPressed; | |
| /// Triggered when a pointer moves into a position within this widget without | |
| /// buttons pressed. | |
| /// | |
| /// Usually this is only fired for pointers which report their location when | |
| /// not down (e.g. mouse pointers). Certain devices also fire this event on | |
| /// single taps in accessibility mode. | |
| /// | |
| /// This callback is not triggered by the movement of the widget. | |
| /// | |
| /// The time that this callback is triggered is during the callback of a | |
| /// pointer event, which is always between frames. | |
| final ValueChanged<bool>? onHover; | |
| /// {@macro flutter.material.inkwell.onFocusChange} | |
| final ValueChanged<bool>? onFocusChange; | |
| /// Whether hovering should request focus for this widget. | |
| /// | |
| /// Defaults to true. | |
| final bool requestFocusOnHover; | |
| /// {@macro flutter.widgets.Focus.autofocus} | |
| final bool autofocus; | |
| /// {@macro flutter.widgets.Focus.focusNode} | |
| final FocusNode? focusNode; | |
| /// The decoration to paint behind the menu item. | |
| /// | |
| /// If null, defaults to [CupertinoMenuItem.defaultDecoration]. | |
| final WidgetStateProperty<BoxDecoration>? decoration; | |
| /// The mouse cursor to display on hover. | |
| final WidgetStateProperty<MouseCursor>? mouseCursor; | |
| /// {@macro flutter.material.inkwell.statesController} | |
| final WidgetStatesController? statesController; | |
| /// How the menu item should respond to hit tests. | |
| final HitTestBehavior behavior; | |
| /// Determines if the menu will be closed when a [CupertinoMenuItem] is pressed. | |
| /// | |
| /// Defaults to true. | |
| final bool requestCloseOnActivate; | |
| /// Whether pressing this item will perform a destructive action | |
| /// | |
| /// Defaults to false. If true, the default color of this item's label and | |
| /// icon will be [CupertinoColors.systemRed]. | |
| final bool isDestructiveAction; | |
| /// The horizontal space in which the [leading] widget can be placed. | |
| final double? leadingWidth; | |
| /// The horizontal space in which the [trailing] widget can be placed. | |
| final double? trailingWidth; | |
| /// The alignment of the center point of the leading widget within the | |
| /// [leadingWidth] of the menu item. | |
| final AlignmentGeometry? leadingMidpointAlignment; | |
| /// The alignment of the center point of the trailing widget within the | |
| /// [trailingWidth] of the menu item. | |
| final AlignmentGeometry? trailingMidpointAlignment; | |
| /// The [BoxConstraints] to apply to the menu item. | |
| /// | |
| /// Because [padding] is applied to the menu item prior to [constraints], the [padding] | |
| /// will only affect the size of the menu item if the vertical [padding] | |
| /// plus the height of the menu item's children exceeds the | |
| /// [BoxConstraints.minHeight]. | |
| final BoxConstraints? constraints; | |
| @override | |
| bool hasLeading(BuildContext context) => leading != null; | |
| @override | |
| bool get isDivider => false; | |
| /// The decoration of a [CupertinoMenuItem] when pressed. | |
| // Pressed colors were sampled from the iOS simulator and are based on the | |
| // following: | |
| // | |
| // Dark mode on white background rgb(111, 111, 111) | |
| // Dark mode on black rgb(61, 61, 61) | |
| // Light mode on black rgb(177, 177, 177) | |
| // Light mode on white rgb(225, 225, 225) | |
| // | |
| // Blend mode is used to mimic the visual effect of the iOS | |
| // menu item. As a result, the default pressed color does not match the | |
| // reported colors on the iOS 18.5 simulator. | |
| static const WidgetStateProperty<BoxDecoration> defaultDecoration = | |
| WidgetStateProperty<BoxDecoration>.fromMap(<WidgetStatesConstraint, BoxDecoration>{ | |
| WidgetState.dragged: BoxDecoration( | |
| color: CupertinoDynamicColor.withBrightness( | |
| color: Color.fromRGBO(50, 50, 50, 0.1), | |
| darkColor: Color.fromRGBO(255, 255, 255, 0.1), | |
| ), | |
| ), | |
| WidgetState.pressed: BoxDecoration( | |
| color: CupertinoDynamicColor.withBrightness( | |
| color: Color.fromRGBO(50, 50, 50, 0.1), | |
| darkColor: Color.fromRGBO(255, 255, 255, 0.1), | |
| ), | |
| ), | |
| WidgetState.focused: BoxDecoration( | |
| color: CupertinoDynamicColor.withBrightness( | |
| color: Color.fromRGBO(50, 50, 50, 0.075), | |
| darkColor: Color.fromRGBO(255, 255, 255, 0.075), | |
| ), | |
| ), | |
| WidgetState.hovered: BoxDecoration( | |
| color: CupertinoDynamicColor.withBrightness( | |
| color: Color.fromRGBO(50, 50, 50, 0.05), | |
| darkColor: Color.fromRGBO(255, 255, 255, 0.05), | |
| ), | |
| ), | |
| WidgetState.any: BoxDecoration(), | |
| }); | |
| /// The default mouse cursor for a [CupertinoMenuItem]. | |
| static final WidgetStateProperty<MouseCursor> _defaultCursor = | |
| WidgetStateProperty.resolveWith<MouseCursor>((Set<WidgetState> states) { | |
| return !states.contains(WidgetState.disabled) && kIsWeb | |
| ? SystemMouseCursors.click | |
| : MouseCursor.defer; | |
| }); | |
| // Measured from the iOS 18.5 simulator debug view. | |
| static const Color _defaultTextColor = CupertinoDynamicColor.withBrightness( | |
| color: Color.from(alpha: 0.96, red: 0, green: 0, blue: 0), | |
| darkColor: Color.from(alpha: 0.96, red: 1, green: 1, blue: 1), | |
| ); | |
| /// The default [Color] applied to a [CupertinoMenuItem]'s [subtitle] | |
| /// widget, if a subtitle is provided. | |
| // A custom blend mode is applied to the subtitle to mimic the visual effect | |
| // of the iOS menu subtitle. As a result, the defaultSubtitleStyle color does | |
| // not match the reported color on the iOS 18.5 simulator. | |
| static const Color _defaultSubtitleTextColor = CupertinoDynamicColor.withBrightness( | |
| color: Color.from(alpha: 0.55, red: 0, green: 0, blue: 0), | |
| darkColor: Color.from(alpha: 0.4, red: 1, green: 1, blue: 1), | |
| ); | |
| /// The maximum number of lines for the [child] widget when | |
| /// [MediaQuery.textScalerOf] returns a [TextScaler] that is less than or | |
| /// equal to 1.25. | |
| // Measured from the iOS 18.5 simulator debug view. | |
| static const int _defaultMaxLines = 2; | |
| /// The maximum number of lines for the [child] widget when | |
| /// [MediaQuery.textScalerOf] returns a [TextScaler] that is greater than | |
| /// 1.25. | |
| static const int _defaultAccessibilityModeMaxLines = 100; | |
| static const TextStyle _leadingDefaultTextStyle = TextStyle( | |
| fontSize: 15, | |
| fontWeight: FontWeight.w600, | |
| ); | |
| static const IconThemeData _leadingDefaultIconTheme = IconThemeData( | |
| size: 15, | |
| weight: 600, | |
| applyTextScaling: true, | |
| ); | |
| static const TextStyle _trailingDefaultTextStyle = TextStyle(fontSize: 21); | |
| static const IconThemeData _trailingDefaultIconTheme = IconThemeData( | |
| size: 21, | |
| applyTextScaling: true, | |
| ); | |
| /// Resolves the title [TextStyle] in response to | |
| /// [CupertinoThemeData.brightness], [isDestructiveAction], and [enabled]. | |
| // | |
| // Approximated from the iOS and iPadOS 18.5 simulators. | |
| TextStyle _resolveDefaultTextStyle(BuildContext context, TextScaler textScaler) { | |
| Color color; | |
| if (onPressed == null) { | |
| color = CupertinoColors.systemGrey; | |
| } else if (isDestructiveAction) { | |
| color = CupertinoColors.systemRed; | |
| } else { | |
| color = _defaultTextColor; | |
| } | |
| return _DynamicTypeStyle.body | |
| .resolveTextStyle(textScaler) | |
| .copyWith( | |
| // Font size will be scaled by TextScaler. | |
| fontSize: 17, | |
| color: CupertinoDynamicColor.resolve(color, context), | |
| ); | |
| } | |
| TextStyle _resolveDefaultSubtitleStyle(BuildContext context, TextScaler textScaler) { | |
| final isDark = CupertinoTheme.maybeBrightnessOf(context) == Brightness.dark; | |
| return _DynamicTypeStyle.subhead | |
| .resolveTextStyle(textScaler) | |
| .copyWith( | |
| // Font size will be scaled by TextScaler. | |
| fontSize: 15, | |
| textBaseline: TextBaseline.alphabetic, | |
| foreground: Paint() | |
| // Per iOS 18.5 simulator: | |
| // Dark mode: linearDodge is used on iOS to achieve a lighter color. | |
| // This is approximated with BlendMode.plus. | |
| // For light mode: plusDarker is used on iOS to achieve a darker color. | |
| // HardLight is used as an approximation. | |
| ..blendMode = isDark ? BlendMode.plus : BlendMode.hardLight | |
| ..color = CupertinoDynamicColor.resolve(_defaultSubtitleTextColor, context), | |
| ); | |
| } | |
| void _handleSelect(BuildContext context) { | |
| if (requestCloseOnActivate) { | |
| MenuController.maybeOf(context)?.close(); | |
| } | |
| onPressed?.call(); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| final TextScaler textScaler = | |
| MediaQuery.maybeTextScalerOf(context) ?? | |
| TextScaler.linear(MediaQuery.maybeTextScaleFactorOf(context) ?? 1); | |
| final TextStyle defaultTextStyle = _resolveDefaultTextStyle(context, textScaler); | |
| final bool isAccessibilityModeEnabled = _isAccessibilityModeEnabled(context); | |
| Widget? leadingWidget; | |
| Widget? trailingWidget; | |
| if (leading != null) { | |
| leadingWidget = DefaultTextStyle.merge( | |
| style: _leadingDefaultTextStyle, | |
| child: IconTheme.merge(data: _leadingDefaultIconTheme, child: leading!), | |
| ); | |
| } | |
| if (trailing != null && !isAccessibilityModeEnabled) { | |
| trailingWidget = DefaultTextStyle.merge( | |
| style: _trailingDefaultTextStyle, | |
| child: IconTheme.merge(data: _trailingDefaultIconTheme, child: trailing!), | |
| ); | |
| } | |
| return MediaQuery.withClampedTextScaling( | |
| minScaleFactor: _kMinimumTextScaleFactor, | |
| maxScaleFactor: _kMaximumTextScaleFactor, | |
| child: _CupertinoMenuItemInteractionHandler( | |
| mouseCursor: mouseCursor ?? _defaultCursor, | |
| requestFocusOnHover: requestFocusOnHover, | |
| onPressed: onPressed != null ? () => _handleSelect(context) : null, | |
| onHover: onHover, | |
| onFocusChange: onFocusChange, | |
| autofocus: autofocus, | |
| focusNode: focusNode, | |
| decoration: decoration ?? defaultDecoration, | |
| statesController: statesController, | |
| behavior: behavior, | |
| child: DefaultTextStyle.merge( | |
| maxLines: isAccessibilityModeEnabled | |
| ? _defaultAccessibilityModeMaxLines | |
| : _defaultMaxLines, | |
| overflow: TextOverflow.ellipsis, | |
| softWrap: true, | |
| style: TextStyle(color: defaultTextStyle.color), | |
| child: IconTheme.merge( | |
| data: IconThemeData(color: defaultTextStyle.color), | |
| child: _CupertinoMenuItemLabel( | |
| padding: padding, | |
| constraints: constraints, | |
| trailing: trailingWidget, | |
| leading: leadingWidget, | |
| leadingMidpointAlignment: leadingMidpointAlignment, | |
| trailingMidpointAlignment: trailingMidpointAlignment, | |
| leadingWidth: leadingWidth, | |
| trailingWidth: trailingWidth, | |
| subtitle: subtitle != null | |
| ? DefaultTextStyle.merge( | |
| style: _resolveDefaultSubtitleStyle(context, textScaler), | |
| child: subtitle!, | |
| ) | |
| : null, | |
| child: DefaultTextStyle.merge(style: defaultTextStyle, child: child), | |
| ), | |
| ), | |
| ), | |
| ), | |
| ); | |
| } | |
| @override | |
| void debugFillProperties(DiagnosticPropertiesBuilder properties) { | |
| super.debugFillProperties(properties); | |
| properties.add(DiagnosticsProperty<Widget?>('child', child)); | |
| properties.add( | |
| FlagProperty( | |
| 'requestCloseOnActivate', | |
| value: requestCloseOnActivate, | |
| ifTrue: 'closes on press', | |
| ifFalse: 'does not close on press', | |
| defaultValue: true, | |
| ), | |
| ); | |
| properties.add( | |
| FlagProperty( | |
| 'requestFocusOnHover', | |
| value: requestFocusOnHover, | |
| ifFalse: 'does not request focus on hover', | |
| ifTrue: 'requests focus on hover', | |
| defaultValue: true, | |
| ), | |
| ); | |
| properties.add(EnumProperty<HitTestBehavior>('hitTestBehavior', behavior)); | |
| properties.add(DiagnosticsProperty<FocusNode?>('focusNode', focusNode, defaultValue: null)); | |
| properties.add(FlagProperty('enabled', value: onPressed != null, ifFalse: 'DISABLED')); | |
| if (subtitle != null) { | |
| properties.add(DiagnosticsProperty<Widget?>('subtitle', subtitle)); | |
| } | |
| if (leading != null) { | |
| properties.add(DiagnosticsProperty<Widget?>('leading', leading)); | |
| } | |
| if (trailing != null) { | |
| properties.add(DiagnosticsProperty<Widget?>('trailing', trailing)); | |
| } | |
| } | |
| } | |
| class _CupertinoMenuItemLabel extends StatelessWidget { | |
| const _CupertinoMenuItemLabel({ | |
| required this.child, | |
| this.subtitle, | |
| this.leading, | |
| this.leadingWidth, | |
| AlignmentGeometry? leadingMidpointAlignment, | |
| this.trailing, | |
| this.trailingWidth, | |
| AlignmentGeometry? trailingMidpointAlignment, | |
| BoxConstraints? constraints, | |
| this.padding, | |
| }) : _leadingAlignment = leadingMidpointAlignment, | |
| _trailingAlignment = trailingMidpointAlignment, | |
| _constraints = constraints; | |
| static const double _defaultHorizontalWidth = 16; | |
| // The leading and trailing widths scale roughly linearly with the normalized | |
| // text scale once quantized to the nearest physical pixel. Each linear | |
| // regression will return a value within 1 physical pixel of the observed | |
| // value at each text scale factor. | |
| // | |
| // This behavior was observed on several iOS and iPadOS 18.5 simulators using | |
| // the debug view. | |
| static const double _leadingWidthSlope = -311 / 1000; | |
| static const double _leadingWidthYIntercept = 10; | |
| static const double _leadingMidpointSlope = 118 / 1000000; | |
| static const double _leadingMidpointYIntercept = 73 / 125; | |
| static const double _trailingWidthSlope = 1 / 10; | |
| static const double _trailingWidthYIntercept = 22; | |
| static const double _firstBaselineToTopSlope = 14 / 11; | |
| static const double _lastBaselineToBottomSlope = 71 / 100; | |
| final Widget? leading; | |
| final double? leadingWidth; | |
| final AlignmentGeometry? _leadingAlignment; | |
| final Widget? trailing; | |
| final double? trailingWidth; | |
| final AlignmentGeometry? _trailingAlignment; | |
| final Widget child; | |
| final Widget? subtitle; | |
| final EdgeInsetsGeometry? padding; | |
| final BoxConstraints? _constraints; | |
| double _resolveLeadingWidth(TextScaler textScaler, double pixelRatio, double lineHeight) { | |
| final double units = _normalizeTextScale(textScaler); | |
| final double value = _leadingWidthSlope * units + _leadingWidthYIntercept; | |
| return _roundToDivisible(value + lineHeight, to: 1 / pixelRatio); | |
| } | |
| double _resolveTrailingWidth(TextScaler textScaler, double pixelRatio, double lineHeight) { | |
| final double units = _normalizeTextScale(textScaler); | |
| final double value = _trailingWidthSlope * units + _trailingWidthYIntercept; | |
| return _roundToDivisible(value + lineHeight, to: 1 / pixelRatio); | |
| } | |
| AlignmentGeometry _resolveTrailingAlignment(double trailingWidth) { | |
| final double horizontalOffset = trailingWidth / 2 + 6; | |
| final double horizontalRatio = (trailingWidth - horizontalOffset) / trailingWidth; | |
| final double horizontalAlignment = (horizontalRatio * 2) - 1; | |
| return AlignmentDirectional(horizontalAlignment, 0.0); | |
| } | |
| AlignmentGeometry _resolveLeadingAlignment(double leadingWidth, TextScaler textScaler) { | |
| final double units = _normalizeTextScale(textScaler); | |
| final double horizontalRatio = _leadingMidpointSlope * units + _leadingMidpointYIntercept; | |
| final double horizontalAlignment = (horizontalRatio * 2) - 1; | |
| return AlignmentDirectional(horizontalAlignment, 0.0); | |
| } | |
| double _resolveFirstBaselineToTop(double lineHeight, double pixelRatio) { | |
| return _roundToDivisible(lineHeight * _firstBaselineToTopSlope, to: 1 / pixelRatio); | |
| } | |
| double _resolveLastBaselineToBottom(double lineHeight, double pixelRatio) { | |
| return _roundToDivisible(lineHeight * _lastBaselineToBottomSlope, to: 1 / pixelRatio); | |
| } | |
| EdgeInsets _resolvePadding(double minimumHeight, double lineHeight) { | |
| final double padding = math.max(0, minimumHeight - lineHeight); | |
| return EdgeInsets.symmetric(vertical: padding / 2); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| final TextDirection textDirection = Directionality.maybeOf(context) ?? TextDirection.ltr; | |
| final TextScaler textScaler = MediaQuery.maybeTextScalerOf(context) ?? TextScaler.noScaling; | |
| final double pixelRatio = MediaQuery.maybeDevicePixelRatioOf(context) ?? 1.0; | |
| final TextStyle dynamicBodyText = _DynamicTypeStyle.body.resolveTextStyle(textScaler); | |
| assert(dynamicBodyText.fontSize != null && dynamicBodyText.height != null); | |
| final double lineHeight = dynamicBodyText.fontSize! * dynamicBodyText.height!; | |
| final bool showLeadingWidget = | |
| leading != null || (CupertinoMenuAnchor.maybeHasLeadingOf(context) ?? false); | |
| // TODO(davidhicks980): Use last baseline layout when supported. | |
| // (https://github.com/flutter/flutter/issues/4614) | |
| // The actual menu item layout uses first and last baselines to position the | |
| // text, but Flutter does not support last baseline alignment. | |
| // | |
| // To approximate the padding, subtract the default height of a single line | |
| // of text from the height of a single-line menu item, and divide the result | |
| // in half to get an estimated top and bottom padding. The downside to this | |
| // approach is that child and subtitle text with different line heights may | |
| // appear to have uneven padding. | |
| final double minimumHeight = | |
| _resolveFirstBaselineToTop(lineHeight, pixelRatio) + | |
| _resolveLastBaselineToBottom(lineHeight, pixelRatio); | |
| final BoxConstraints constraints = _constraints ?? BoxConstraints(minHeight: minimumHeight); | |
| final EdgeInsetsGeometry resolvedPadding = | |
| padding ?? _resolvePadding(minimumHeight, lineHeight); | |
| final double resolvedLeadingWidth = | |
| leadingWidth ?? | |
| (showLeadingWidget | |
| ? _resolveLeadingWidth(textScaler, pixelRatio, lineHeight) | |
| : _defaultHorizontalWidth); | |
| final double resolvedTrailingWidth = | |
| trailingWidth ?? | |
| (trailing != null | |
| ? _resolveTrailingWidth(textScaler, pixelRatio, lineHeight) | |
| : _defaultHorizontalWidth); | |
| return ConstrainedBox( | |
| constraints: constraints, | |
| child: Padding( | |
| padding: resolvedPadding, | |
| child: Stack( | |
| children: <Widget>[ | |
| if (showLeadingWidget) | |
| Positioned.directional( | |
| textDirection: textDirection, | |
| start: 0, | |
| top: 0, | |
| bottom: 0, | |
| width: resolvedLeadingWidth, | |
| child: _AlignMidpoint( | |
| alignment: | |
| _leadingAlignment ?? | |
| _resolveLeadingAlignment(resolvedLeadingWidth, textScaler), | |
| child: leading, | |
| ), | |
| ), | |
| Padding( | |
| padding: EdgeInsetsDirectional.only( | |
| start: resolvedLeadingWidth, | |
| end: resolvedTrailingWidth, | |
| ), | |
| child: subtitle == null | |
| ? Align(alignment: AlignmentDirectional.centerStart, child: child) | |
| : Column( | |
| mainAxisSize: MainAxisSize.min, | |
| crossAxisAlignment: CrossAxisAlignment.stretch, | |
| mainAxisAlignment: MainAxisAlignment.center, | |
| children: <Widget>[child, const SizedBox(height: 1), subtitle!], | |
| ), | |
| ), | |
| if (trailing != null) | |
| // On iOS, the trailing widget is constrained to a maximum height | |
| // of minimumHeight - 12 and a maximum width of | |
| // resolvedTrailingWidth - 20. These constraints were omitted for | |
| // more flexibility. | |
| Positioned.directional( | |
| textDirection: textDirection, | |
| end: 0, | |
| top: 0, | |
| bottom: 0, | |
| width: resolvedTrailingWidth, | |
| child: _AlignMidpoint( | |
| alignment: _trailingAlignment ?? _resolveTrailingAlignment(resolvedTrailingWidth), | |
| child: trailing, | |
| ), | |
| ), | |
| ], | |
| ), | |
| ), | |
| ); | |
| } | |
| } | |
| /// A widget that positions the midpoint of its child at an alignment within | |
| /// itself. | |
| /// | |
| /// Almost identical to [Align], but aligns the midpoint of the child rather | |
| /// than the top-left corner. | |
| /// | |
| /// This layout behavior was observed on the iOS 18.5 simulator | |
| /// (https://developer.apple.com/documentation/uikit/uiview/centerxanchor) | |
| class _AlignMidpoint extends SingleChildRenderObjectWidget { | |
| /// Creates a widget that positions its child's center point at a specific | |
| /// [alignment]. | |
| /// | |
| /// The [alignment] parameter is required and must not | |
| /// be null. | |
| const _AlignMidpoint({required this.alignment, required super.child}); | |
| /// The alignment for positioning the child's horizontal midpoint. | |
| final AlignmentGeometry alignment; | |
| @override | |
| RenderObject createRenderObject(BuildContext context) { | |
| return _RenderAlignMidpoint( | |
| alignment: alignment, | |
| textDirection: Directionality.maybeOf(context), | |
| ); | |
| } | |
| @override | |
| void updateRenderObject(BuildContext context, _RenderAlignMidpoint renderObject) { | |
| renderObject | |
| ..alignment = alignment | |
| ..textDirection = Directionality.maybeOf(context); | |
| } | |
| } | |
| class _RenderAlignMidpoint extends RenderPositionedBox { | |
| _RenderAlignMidpoint({super.alignment, super.textDirection}); | |
| @override | |
| void alignChild() { | |
| assert(child != null); | |
| assert(!child!.debugNeedsLayout); | |
| assert(child!.hasSize); | |
| assert(hasSize); | |
| final childParentData = child!.parentData! as BoxParentData; | |
| final ui.Offset offset = resolvedAlignment.alongSize(size) - child!.size.center(Offset.zero); | |
| final double dx = offset.dx.clamp(0.0, size.width - child!.size.width); | |
| final double dy = offset.dy.clamp(0.0, size.height - child!.size.height); | |
| childParentData.offset = Offset(dx, dy); | |
| } | |
| } | |
| class _CupertinoMenuItemInteractionHandler extends StatefulWidget { | |
| const _CupertinoMenuItemInteractionHandler({ | |
| required this.onHover, | |
| required this.onPressed, | |
| required this.onFocusChange, | |
| required this.focusNode, | |
| required this.autofocus, | |
| required this.requestFocusOnHover, | |
| required this.behavior, | |
| required this.statesController, | |
| required this.mouseCursor, | |
| required this.decoration, | |
| required this.child, | |
| }); | |
| final ValueChanged<bool>? onHover; | |
| final VoidCallback? onPressed; | |
| final ValueChanged<bool>? onFocusChange; | |
| final FocusNode? focusNode; | |
| final bool autofocus; | |
| final bool requestFocusOnHover; | |
| final HitTestBehavior behavior; | |
| final WidgetStatesController? statesController; | |
| final WidgetStateProperty<MouseCursor> mouseCursor; | |
| final WidgetStateProperty<BoxDecoration> decoration; | |
| final Widget child; | |
| @override | |
| State<_CupertinoMenuItemInteractionHandler> createState() => | |
| _CupertinoMenuItemInteractionHandlerState(); | |
| } | |
| class _CupertinoMenuItemInteractionHandlerState extends State<_CupertinoMenuItemInteractionHandler> | |
| implements _SwipeTarget { | |
| late final Map<Type, Action<Intent>> _actionMap = <Type, Action<Intent>>{ | |
| ActivateIntent: CallbackAction<ActivateIntent>(onInvoke: _handleActivation), | |
| ButtonActivateIntent: CallbackAction<ButtonActivateIntent>(onInvoke: _handleActivation), | |
| }; | |
| Map<Type, GestureRecognizerFactory>? _gestures; | |
| DeviceGestureSettings? _gestureSettings; | |
| // If a focus node isn't given to the widget, then we have to manage our own. | |
| FocusNode? _internalFocusNode; | |
| FocusNode get _focusNode => widget.focusNode ?? _internalFocusNode!; | |
| WidgetStatesController? _internalStatesController; | |
| WidgetStatesController get _statesController { | |
| return widget.statesController ?? _internalStatesController!; | |
| } | |
| bool get isHovered => _isHovered; | |
| bool _isHovered = false; | |
| set isHovered(bool value) { | |
| if (_isHovered != value) { | |
| _isHovered = value; | |
| _statesController.update(WidgetState.hovered, value); | |
| } | |
| } | |
| bool get isPressed => _isPressed; | |
| bool _isPressed = false; | |
| set isPressed(bool value) { | |
| if (_isPressed != value) { | |
| _isPressed = value; | |
| _statesController.update(WidgetState.pressed, value); | |
| } | |
| } | |
| bool get isSwiped => _isSwiped; | |
| bool _isSwiped = false; | |
| set isSwiped(bool value) { | |
| if (_isSwiped != value) { | |
| _isSwiped = value; | |
| _statesController.update(WidgetState.dragged, value); | |
| } | |
| } | |
| bool get isFocused => _isFocused; | |
| bool _isFocused = false; | |
| set isFocused(bool value) { | |
| if (_isFocused != value) { | |
| _isFocused = value; | |
| _statesController.update(WidgetState.focused, value); | |
| } | |
| } | |
| bool get isEnabled => _isEnabled; | |
| bool _isEnabled = false; | |
| set isEnabled(bool value) { | |
| if (_isEnabled != value) { | |
| _isEnabled = value; | |
| _statesController.update(WidgetState.disabled, !value); | |
| } | |
| } | |
| @override | |
| void initState() { | |
| super.initState(); | |
| if (widget.focusNode == null) { | |
| _internalFocusNode = FocusNode(); | |
| } | |
| if (widget.statesController == null) { | |
| _internalStatesController = WidgetStatesController(); | |
| } | |
| isEnabled = widget.onPressed != null; | |
| isFocused = _focusNode.hasPrimaryFocus; | |
| } | |
| @override | |
| void didChangeDependencies() { | |
| super.didChangeDependencies(); | |
| final DeviceGestureSettings? newGestureSettings = MediaQuery.maybeGestureSettingsOf(context); | |
| if (_gestureSettings != newGestureSettings) { | |
| _gestureSettings = newGestureSettings; | |
| _gestures = null; | |
| } | |
| } | |
| @override | |
| void didUpdateWidget(_CupertinoMenuItemInteractionHandler oldWidget) { | |
| super.didUpdateWidget(oldWidget); | |
| if (widget.focusNode != oldWidget.focusNode) { | |
| if (widget.focusNode != null) { | |
| _internalFocusNode?.dispose(); | |
| _internalFocusNode = null; | |
| } else { | |
| assert(_internalFocusNode == null); | |
| _internalFocusNode = FocusNode(); | |
| } | |
| isFocused = _focusNode.hasPrimaryFocus; | |
| } | |
| if (widget.statesController != oldWidget.statesController) { | |
| if (widget.statesController != null) { | |
| _internalStatesController?.dispose(); | |
| _internalStatesController = null; | |
| } else { | |
| assert(_internalStatesController == null); | |
| _internalStatesController = WidgetStatesController(); | |
| } | |
| } | |
| if (widget.onPressed != oldWidget.onPressed) { | |
| if (widget.onPressed == null) { | |
| isEnabled = isHovered = isPressed = isSwiped = isFocused = false; | |
| } else { | |
| isEnabled = true; | |
| } | |
| } | |
| } | |
| @override | |
| bool didSwipeEnter() { | |
| if (!isEnabled) { | |
| return false; | |
| } | |
| switch (defaultTargetPlatform) { | |
| case TargetPlatform.iOS: | |
| case TargetPlatform.android: | |
| HapticFeedback.selectionClick(); | |
| case TargetPlatform.fuchsia: | |
| case TargetPlatform.linux: | |
| case TargetPlatform.windows: | |
| case TargetPlatform.macOS: | |
| break; | |
| } | |
| isSwiped = true; | |
| return true; | |
| } | |
| @override | |
| void didSwipeLeave({bool pointerUp = false}) { | |
| if (isEnabled && pointerUp) { | |
| _handleActivation(); | |
| } | |
| isSwiped = false; | |
| } | |
| @override | |
| void dispose() { | |
| _internalStatesController?.dispose(); | |
| _internalStatesController = null; | |
| _internalFocusNode?.dispose(); | |
| _internalFocusNode = null; | |
| super.dispose(); | |
| } | |
| void _handleFocusChange([bool? focused]) { | |
| isFocused = _focusNode.hasPrimaryFocus; | |
| widget.onFocusChange?.call(isFocused); | |
| } | |
| void _handleActivation([Intent? intent]) { | |
| isSwiped = isPressed = false; | |
| widget.onPressed?.call(); | |
| } | |
| void _handleTapDown(TapDownDetails details) { | |
| isPressed = true; | |
| } | |
| void _handleTapUp(TapUpDetails? details) { | |
| isPressed = false; | |
| widget.onPressed?.call(); | |
| } | |
| void _handleTapCancel() { | |
| isPressed = false; | |
| } | |
| void _handlePointerExit(PointerExitEvent event) { | |
| if (isHovered) { | |
| isHovered = isFocused = false; | |
| widget.onHover?.call(false); | |
| } | |
| } | |
| // TextButton.onHover and MouseRegion.onHover can't be used without triggering | |
| // focus on scroll. | |
| void _handlePointerHover(PointerHoverEvent event) { | |
| if (!isHovered) { | |
| isHovered = true; | |
| widget.onHover?.call(true); | |
| if (widget.requestFocusOnHover) { | |
| _focusNode.requestFocus(); | |
| // Without invalidating the focus policy, switching to directional focus | |
| // may not originate at this node. | |
| FocusTraversalGroup.of(context).invalidateScopeData(FocusScope.of(context)); | |
| } | |
| } | |
| } | |
| void _handleDismissMenu() { | |
| Actions.invoke(context, const DismissIntent()); | |
| } | |
| Widget _buildStatefulWrapper(BuildContext context, Set<WidgetState> value, Widget? child) { | |
| final MouseCursor cursor = widget.mouseCursor.resolve(value); | |
| final BoxDecoration decoration = widget.decoration.resolve(value); | |
| final bool hasBackground = decoration.color != null || decoration.gradient != null; | |
| return MouseRegion( | |
| onHover: isEnabled ? _handlePointerHover : null, | |
| onExit: isEnabled ? _handlePointerExit : null, | |
| hitTestBehavior: HitTestBehavior.deferToChild, | |
| cursor: cursor, | |
| child: DecoratedBox( | |
| decoration: decoration.copyWith( | |
| color: CupertinoDynamicColor.maybeResolve(decoration.color, context), | |
| backgroundBlendMode: kIsWeb || !hasBackground || decoration.backgroundBlendMode != null | |
| ? decoration.backgroundBlendMode | |
| : CupertinoTheme.maybeBrightnessOf(context) == Brightness.light | |
| ? BlendMode.multiply | |
| : BlendMode.plus, | |
| ), | |
| child: child, | |
| ), | |
| ); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| if (isEnabled) { | |
| _gestures ??= <Type, GestureRecognizerFactory>{ | |
| TapGestureRecognizer: GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>( | |
| () => TapGestureRecognizer(), | |
| (TapGestureRecognizer instance) { | |
| instance | |
| ..onTapDown = _handleTapDown | |
| ..onTapUp = _handleTapUp | |
| ..onTapCancel = _handleTapCancel | |
| ..gestureSettings = _gestureSettings; | |
| }, | |
| ), | |
| }; | |
| } else { | |
| _gestures = null; | |
| } | |
| return MergeSemantics( | |
| child: Semantics.fromProperties( | |
| properties: SemanticsProperties( | |
| enabled: isEnabled, | |
| onDismiss: isEnabled ? _handleDismissMenu : null, | |
| ), | |
| child: MetaData( | |
| metaData: this, | |
| child: Actions( | |
| actions: isEnabled ? _actionMap : <Type, Action<Intent>>{}, | |
| child: Focus( | |
| autofocus: isEnabled && widget.autofocus, | |
| focusNode: _focusNode, | |
| canRequestFocus: isEnabled, | |
| skipTraversal: !isEnabled, | |
| onFocusChange: _handleFocusChange, | |
| child: ValueListenableBuilder<Set<WidgetState>>( | |
| valueListenable: _statesController, | |
| builder: _buildStatefulWrapper, | |
| child: RawGestureDetector( | |
| behavior: widget.behavior, | |
| gestures: _gestures ?? const <Type, GestureRecognizerFactory>{}, | |
| child: widget.child, | |
| ), | |
| ), | |
| ), | |
| ), | |
| ), | |
| ), | |
| ); | |
| } | |
| } | |
| /// Implement to receive callbacks when a pointer enters or leaves while down. | |
| /// | |
| /// An ancestor [_SwipeRegion] must be present in order to receive these | |
| /// callbacks. | |
| abstract interface class _SwipeTarget { | |
| /// Called when a pointer enters the [_SwipeTarget]. Return true if the pointer | |
| /// should be considered "on" the [_SwipeTarget], and false otherwise (for | |
| /// example, when the [_SwipeTarget] is disabled). | |
| bool didSwipeEnter(); | |
| /// Called when the swipe is ended or canceled. If `pointerUp` is true, | |
| /// then the pointer was removed from the screen while over this [_SwipeTarget]. | |
| void didSwipeLeave({required bool pointerUp}); | |
| } | |
| class _SwipeScope extends InheritedWidget { | |
| const _SwipeScope({required super.child, required this.state}); | |
| final _SwipeRegionState state; | |
| @override | |
| bool updateShouldNotify(_SwipeScope oldWidget) { | |
| return state != oldWidget.state; | |
| } | |
| } | |
| class _SwipeRegion extends StatefulWidget { | |
| const _SwipeRegion({this.enabled = true, required this.onDistanceChanged, required this.child}); | |
| final bool enabled; | |
| final ValueChanged<double> onDistanceChanged; | |
| final Widget child; | |
| static _SwipeRegionState? of(BuildContext context) { | |
| final _SwipeScope? scope = context.dependOnInheritedWidgetOfExactType<_SwipeScope>(); | |
| return scope?.state; | |
| } | |
| @override | |
| State<_SwipeRegion> createState() => _SwipeRegionState(); | |
| } | |
| class _SwipeRegionState extends State<_SwipeRegion> { | |
| final Set<_RenderSwipeSurface> _surfaces = <_RenderSwipeSurface>{}; | |
| MultiDragGestureRecognizer? _recognizer; | |
| bool get isSwiping => _position != null; | |
| ui.Offset? _position; | |
| void attachSurface(_RenderSwipeSurface surface) { | |
| _surfaces.add(surface); | |
| } | |
| void detachSurface(_RenderSwipeSurface surface) { | |
| _surfaces.remove(surface); | |
| } | |
| void beginSwipe(PointerDownEvent event, {Duration delay = Duration.zero, VoidCallback? onStart}) { | |
| if (isSwiping || !widget.enabled) { | |
| return; | |
| } | |
| _recognizer?.dispose(); | |
| _recognizer = null; | |
| Drag handleStart(Offset position) { | |
| onStart?.call(); | |
| return _createSwipeHandle(position); | |
| } | |
| // Use a MultiDragGestureRecognizer instead of a PanGestureRecognizer | |
| // since the latter does not support delayed recognition. | |
| if (delay == Duration.zero) { | |
| _recognizer = ImmediateMultiDragGestureRecognizer( | |
| allowedButtonsFilter: (int button) => button == kPrimaryButton, | |
| )..onStart = handleStart; | |
| } else { | |
| _recognizer = DelayedMultiDragGestureRecognizer( | |
| delay: delay, | |
| allowedButtonsFilter: (int button) => button == kPrimaryButton, | |
| )..onStart = handleStart; | |
| } | |
| _recognizer!.gestureSettings = MediaQuery.maybeGestureSettingsOf(context); | |
| _recognizer!.addPointer(event); | |
| } | |
| @override | |
| void didUpdateWidget(_SwipeRegion oldWidget) { | |
| super.didUpdateWidget(oldWidget); | |
| if (widget.enabled != oldWidget.enabled) { | |
| if (!widget.enabled) { | |
| _position = null; | |
| widget.onDistanceChanged(0); | |
| _recognizer?.dispose(); | |
| _recognizer = null; | |
| } | |
| } | |
| } | |
| @override | |
| void didChangeDependencies() { | |
| super.didChangeDependencies(); | |
| _recognizer?.gestureSettings = MediaQuery.maybeGestureSettingsOf(context); | |
| } | |
| @override | |
| void dispose() { | |
| assert(_surfaces.isEmpty); | |
| _disposeInactiveRecognizer(); | |
| super.dispose(); | |
| } | |
| void _handleSwipeEnd(DragEndDetails position) { | |
| _completeSwipe(); | |
| } | |
| void _handleSwipeCancel() { | |
| _completeSwipe(); | |
| } | |
| void _handleSwipeUpdate(DragUpdateDetails updateDetails, {bool onTarget = false}) { | |
| _position = _position! + updateDetails.delta; | |
| // We can't used expandToInclude() because the total menu area may not be | |
| // rectangular. | |
| double minimumSquaredDistance = double.maxFinite; | |
| for (final _RenderSwipeSurface surface in _surfaces) { | |
| final double squaredDistance = _computeSquaredDistanceToRect( | |
| _position!, | |
| surface.computeRect(), | |
| ); | |
| if (squaredDistance.floor() == 0) { | |
| widget.onDistanceChanged(0); | |
| return; | |
| } | |
| minimumSquaredDistance = math.min(squaredDistance, minimumSquaredDistance); | |
| } | |
| final double distance = minimumSquaredDistance == 0 ? 0 : math.sqrt(minimumSquaredDistance); | |
| widget.onDistanceChanged(distance); | |
| } | |
| Drag _createSwipeHandle(ui.Offset position) { | |
| assert(!isSwiping, 'A new swipe should not begin while a swipe is active.'); | |
| _position = position; | |
| return _SwipeHandle( | |
| router: this, | |
| viewId: View.of(context).viewId, | |
| initialPosition: position, | |
| onSwipeUpdate: _handleSwipeUpdate, | |
| onSwipeEnd: _handleSwipeEnd, | |
| onSwipeCanceled: _handleSwipeCancel, | |
| ); | |
| } | |
| void _disposeInactiveRecognizer() { | |
| if (!isSwiping && _recognizer != null) { | |
| _recognizer!.dispose(); | |
| _recognizer = null; | |
| } | |
| } | |
| void _completeSwipe() { | |
| _position = null; | |
| widget.onDistanceChanged(0); | |
| if (!mounted) { | |
| // If the widget is not mounted, safely dispose of the recognizer. | |
| _disposeInactiveRecognizer(); | |
| } | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| return _SwipeScope(state: this, child: widget.child); | |
| } | |
| } | |
| /// An area that can initiate swiping. | |
| /// | |
| /// This widget registers with the nearest [_SwipeRegion] and exposes its position | |
| /// as a [ui.Rect]. This [_SwipeSurface] will route [PointerDownEvent]s to its | |
| /// [_SwipeRegion]. If a routed [PointerDownEvent] results in a swipe gesture, the | |
| /// [_SwipeRegion] will use the combined [ui.Rect] of all registered [_SwipeSurface]s | |
| /// to calculate the swiping distance. | |
| class _SwipeSurface extends SingleChildRenderObjectWidget { | |
| /// Creates a swipe surface that registers with a parent [_SwipeRegion]. | |
| const _SwipeSurface({required super.child, this.delay = Duration.zero, this.onStart}); | |
| /// The delay before recognizing a swipe gesture. | |
| final Duration delay; | |
| final VoidCallback? onStart; | |
| @override | |
| _RenderSwipeSurface createRenderObject(BuildContext context) { | |
| return _RenderSwipeSurface(region: _SwipeRegion.of(context)!, delay: delay, onStart: onStart); | |
| } | |
| @override | |
| void updateRenderObject(BuildContext context, _RenderSwipeSurface renderObject) { | |
| renderObject | |
| ..region = _SwipeRegion.of(context)! | |
| ..delay = delay | |
| ..onStart = onStart; | |
| } | |
| } | |
| class _RenderSwipeSurface extends RenderProxyBoxWithHitTestBehavior { | |
| _RenderSwipeSurface({ | |
| required _SwipeRegionState region, | |
| required this.delay, | |
| required this.onStart, | |
| }) : _region = region, | |
| super(behavior: HitTestBehavior.opaque) { | |
| _region.attachSurface(this); | |
| } | |
| _SwipeRegionState get region => _region; | |
| _SwipeRegionState _region; | |
| set region(_SwipeRegionState value) { | |
| if (_region != value) { | |
| _region.detachSurface(this); | |
| _region = value; | |
| _region.attachSurface(this); | |
| } | |
| } | |
| Duration delay; | |
| VoidCallback? onStart; | |
| ui.Rect computeRect() => localToGlobal(Offset.zero) & size; | |
| @override | |
| void detach() { | |
| _region.detachSurface(this); | |
| super.detach(); | |
| } | |
| @override | |
| void dispose() { | |
| _region.detachSurface(this); | |
| super.dispose(); | |
| } | |
| @override | |
| void handleEvent(PointerEvent event, BoxHitTestEntry entry) { | |
| assert(debugHandleEvent(event, entry)); | |
| if (event is PointerDownEvent) { | |
| _region.beginSwipe(event, delay: delay, onStart: onStart); | |
| } | |
| } | |
| } | |
| /// Handles swiping events for a [_SwipeRegion]. | |
| class _SwipeHandle extends Drag { | |
| /// Creates a [_SwipeHandle] that handles swiping events for a [_SwipeRegion]. | |
| _SwipeHandle({ | |
| required Offset initialPosition, | |
| required this.viewId, | |
| required this.router, | |
| required this.onSwipeEnd, | |
| required this.onSwipeUpdate, | |
| required this.onSwipeCanceled, | |
| }) : _position = initialPosition { | |
| _updateSwipe(); | |
| } | |
| final int viewId; | |
| final List<_SwipeTarget> _enteredTargets = <_SwipeTarget>[]; | |
| final GestureDragUpdateCallback onSwipeUpdate; | |
| final GestureDragEndCallback onSwipeEnd; | |
| final GestureDragCancelCallback onSwipeCanceled; | |
| final _SwipeRegionState router; | |
| Offset _position; | |
| @override | |
| void update(DragUpdateDetails details) { | |
| final Offset oldPosition = _position; | |
| _position += details.delta; | |
| if (_position != oldPosition) { | |
| _updateSwipe(); | |
| onSwipeUpdate.call(details); | |
| } | |
| } | |
| @override | |
| void end(DragEndDetails details) { | |
| _leaveAllEntered(pointerUp: true); | |
| onSwipeEnd.call(details); | |
| } | |
| @override | |
| void cancel() { | |
| _leaveAllEntered(); | |
| onSwipeCanceled(); | |
| } | |
| void _updateSwipe() { | |
| final result = HitTestResult(); | |
| WidgetsBinding.instance.hitTestInView(result, _position, viewId); | |
| // Look for the RenderBoxes that corresponds to the hit target | |
| final targets = <_SwipeTarget>[]; | |
| for (final HitTestEntry entry in result.path) { | |
| if (entry.target case RenderMetaData(:final _SwipeTarget metaData)) { | |
| targets.add(metaData); | |
| } | |
| } | |
| if (_enteredTargets.isNotEmpty && targets.length >= _enteredTargets.length) { | |
| var listsMatch = true; | |
| for (var i = 0; i < _enteredTargets.length; i++) { | |
| if (targets[i] != _enteredTargets[i]) { | |
| listsMatch = false; | |
| break; | |
| } | |
| } | |
| if (listsMatch) { | |
| return; | |
| } | |
| } | |
| // Leave old targets. | |
| _leaveAllEntered(); | |
| // Enter new targets. | |
| for (final target in targets) { | |
| _enteredTargets.add(target); | |
| if (target.didSwipeEnter()) { | |
| return; | |
| } | |
| } | |
| } | |
| void _leaveAllEntered({bool pointerUp = false}) { | |
| for (var i = 0; i < _enteredTargets.length; i += 1) { | |
| _enteredTargets[i].didSwipeLeave(pointerUp: pointerUp); | |
| } | |
| _enteredTargets.clear(); | |
| } | |
| } | |
| // Multiplies the values of two animations. | |
| // | |
| // This class is used to animate the scale of the menu when the user drags | |
| // outside of the menu area. | |
| class _AnimationProduct extends CompoundAnimation<double> { | |
| _AnimationProduct({required super.first, required super.next}); | |
| @override | |
| double get value => super.first.value * super.next.value; | |
| } | |
| class _ClampTween extends Animatable<double> { | |
| const _ClampTween({required this.begin, required this.end}); | |
| final double begin; | |
| final double end; | |
| @override | |
| double transform(double t) { | |
| if (t < begin) { | |
| return begin; | |
| } | |
| if (t > end) { | |
| return end; | |
| } | |
| return t; | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment