| // 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/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'; |
| import 'package:flutter/widgets.dart'; |
| |
| import 'colors.dart'; |
| import 'dialog.dart'; |
| import 'theme.dart'; |
| |
| // Dismiss is handled by RawMenuAnchor |
| const Map<ShortcutActivator, Intent> _kMenuTraversalShortcuts = <ShortcutActivator, Intent>{ |
| 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; |
| } |
| } |
| |
| // TODO(davidhicks980): Move typography constants to a more appropriate |
| // location. https://github.com/flutter/flutter/issues/182933 |
| |
| /// 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 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 "large" 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 _kMinimumNormalizedLargeTextScale = 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; |
| |
| // Large text mode on iOS is determined by the text scale factor that the |
| // user has selected. |
| bool _largeTextModeEnabled(BuildContext context) { |
| final TextScaler? textScaler = MediaQuery.maybeTextScalerOf(context); |
| if (textScaler == null) { |
| return false; |
| } |
| |
| return _normalizeTextScale(textScaler) >= _kMinimumNormalizedLargeTextScale; |
| } |
| |
| /// 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 |
| // the large text mode setting. |
| // |
| // A screen width threshold of 768 points is used to differentiate between |
| // mobile and tablet devices. |
| factory _CupertinoMenuWidth.fromScreenWidth({ |
| required double screenWidth, |
| required bool isLargeTextModeEnabled, |
| }) { |
| final bool isMobile = screenWidth < _kTabletWidthThreshold; |
| return switch ((isMobile, isLargeTextModeEnabled)) { |
| (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`. |
| 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 sample} |
| /// This example demonstrates a simple [CupertinoMenuAnchor] that wraps |
| /// a button. |
| /// |
| /// ** See code in examples/api/lib/cupertino/menu_anchor/menu_anchor.0.dart ** |
| /// {@end-tool} |
| /// |
| /// {@tool dartpad} |
| /// This example demonstrates a [CupertinoMenuAnchor] that wraps a button and |
| /// shows a menu with three [CupertinoMenuItem]s and one [CupertinoMenuDivider]. |
| /// |
| /// ** See code in examples/api/lib/cupertino/menu_anchor/menu_anchor.1.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, |
| }) : assert( |
| enableSwipe || !enableLongPressToOpen, |
| 'enableLongPressToOpen cannot be true if enableSwipe is false', |
| ); |
| |
| /// 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. |
| /// |
| /// 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. |
| /// |
| /// 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. |
| /// |
| /// Because long-press-to-open relies on the swipe gesture, [enableSwipe] must |
| /// be true if [enableLongPressToOpen] is true. |
| /// |
| /// If the widget built by [builder] is disabled, [enableLongPressToOpen] |
| /// 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. |
| /// |
| /// The [builder] will rebuild when the menu's [AnimationStatus] changes. |
| /// |
| /// 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 _kLongPressToOpenDuration = Duration(milliseconds: 400); |
| static const Tolerance _kSpringTolerance = 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 isOpenOrOpening => _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 (isOpenOrOpening || !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: _kSpringTolerance, |
| ), |
| 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: !isOpenOrOpening, |
| child: IgnorePointer( |
| ignoring: !isOpenOrOpening, |
| child: ExcludeFocus( |
| excluding: !isOpenOrOpening, |
| 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: _kLongPressToOpenDuration, |
| 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 const _kAttachmentOffset = Offset(0, 8); |
| static final Map<Type, Action<Intent>> _kActions = <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 { |
| final ui.Offset offset = _kAttachmentOffset * dy; |
| _attachmentPoint = Alignment(dx, dy).withinRect(widget.anchorRect) + offset; |
| transformOrigin = Alignment(0, dy).withinRect(widget.anchorRect) + offset; |
| } |
| |
| 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 minVelocity = 8.0; |
| const maxSwipeDistance = 150.0; |
| 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 decelerationDistanceThreshold = 80.0; |
| |
| // 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; |
| } |
| |
| Widget _buildAlign(BuildContext context, Widget? child) { |
| return Align( |
| heightFactor: _sizeAnimation.value, |
| widthFactor: 1.0, |
| alignment: Alignment.topCenter, |
| child: child, |
| ); |
| } |
| |
| @override |
| Widget build(BuildContext context) { |
| final BoxConstraints constraints; |
| if (widget.constraints != null) { |
| constraints = widget.constraints!; |
| } else { |
| final bool isLargeTextModeEnabled = _largeTextModeEnabled(context); |
| final double screenWidth = MediaQuery.widthOf(context); |
| final menuWidth = _CupertinoMenuWidth.fromScreenWidth( |
| isLargeTextModeEnabled: isLargeTextModeEnabled, |
| screenWidth: screenWidth, |
| ); |
| constraints = BoxConstraints.tightFor(width: menuWidth.points); |
| } |
| |
| Widget child = _SwipeSurface( |
| child: TapRegion( |
| groupId: widget.tapRegionGroupId, |
| consumeOutsideTaps: widget.consumeOutsideTaps, |
| onTapOutside: _handleOutsideTap, |
| child: Actions( |
| actions: _kActions, |
| 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, |
| builder: _buildAlign, |
| child: Semantics( |
| explicitChildNodes: true, |
| scopesRoute: true, |
| child: ConstrainedBox( |
| constraints: constraints, |
| child: SingleChildScrollView( |
| clipBehavior: Clip.none, |
| child: Column(mainAxisSize: MainAxisSize.min, children: _children), |
| ), |
| ), |
| ), |
| ), |
| ), |
| ), |
| ), |
| ), |
| ), |
| ), |
| ), |
| ); |
| |
| // 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 effectiveAnchorRect = widget.anchorPosition != null |
| ? _attachmentPoint & Size.zero |
| : widget.anchorRect; |
| final List<ui.DisplayFeature>? displayFeatures = MediaQuery.maybeDisplayFeaturesOf( |
| context, |
| ); |
| return CustomSingleChildLayout( |
| delegate: _MenuLayoutDelegate( |
| anchorRect: effectiveAnchorRect, |
| attachmentPoint: _attachmentPoint, |
| avoidBounds: displayFeatures != null ? avoidBounds(displayFeatures) : <Rect>{}, |
| heightFactor: value, |
| menuAlignment: _menuAlignment, |
| overlayPadding: widget.overlayPadding.resolve(_textDirection), |
| ), |
| 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 _kRadius = Radius.circular(13); |
| static const double _kShadowOpacity = 0.12; |
| double get shadowAnimation => ui.clampDouble(repaint.value, 0, 1); |
| final Animation<double> repaint; |
| final ui.Brightness brightness; |
| |
| @override |
| void paint(Canvas canvas, Size size) { |
| assert(shadowAnimation >= 0 && shadowAnimation <= 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, _kRadius); |
| |
| final double blurSigma = shadowAnimation * 50; |
| final shadowPaint = Paint() |
| ..maskFilter = MaskFilter.blur(BlurStyle.normal, blurSigma) |
| ..color = ui.Color.fromRGBO(0, 0, 10, shadowAnimation * shadowAnimation * _kShadowOpacity); |
| |
| 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, _kRadius)); |
| |
| // Clip the shadow underneath the menu shape to make the shadow appear more |
| // vibrant. |
| canvas |
| ..save() |
| ..clipPath(maskPath) |
| ..drawRSuperellipse(roundedRect.inflate(50), shadowPaint) |
| ..restore(); |
| } |
| |
| @override |
| bool shouldRepaint(_ShadowPainter oldDelegate) { |
| return oldDelegate.brightness != brightness || oldDelegate.repaint != repaint; |
| } |
| |
| @override |
| bool shouldRebuildSemantics(_ShadowPainter oldDelegate) => false; |
| } |
| |
| class _MenuLayoutDelegate extends SingleChildLayoutDelegate { |
| const _MenuLayoutDelegate({ |
| required this.anchorRect, |
| required this.attachmentPoint, |
| required this.avoidBounds, |
| required this.heightFactor, |
| required this.menuAlignment, |
| required this.overlayPadding, |
| }); |
| |
| // 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; |
| |
| // List of rectangles that the menu should not overlap. Unusable screen area. |
| final Set<Rect> avoidBounds; |
| |
| // The factor by which to multiply the height of the child. |
| final double heightFactor; |
| |
| // 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; |
| |
| @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 anchorRect != oldDelegate.anchorRect || |
| attachmentPoint != oldDelegate.attachmentPoint || |
| !setEquals(avoidBounds, oldDelegate.avoidBounds) || |
| heightFactor != oldDelegate.heightFactor || |
| menuAlignment != oldDelegate.menuAlignment || |
| overlayPadding != oldDelegate.overlayPadding; |
| } |
| } |
| |
| 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 [kDividerColor] 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 kOverlayColor = 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 |
| /// [kOverlayColor], with [BlendMode.srcOver]. |
| /// |
| /// This color is used to make the divider more opaque. |
| static const CupertinoDynamicColor kDividerColor = 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(kDividerColor, context), |
| overlayColor: CupertinoDynamicColor.resolve(kOverlayColor, 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 = kDefaultColor}); |
| |
| /// The color of the divider. |
| /// |
| /// Defaults to [CupertinoMenuDivider.kDefaultColor]. |
| 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 kDefaultColor = CupertinoDynamicColor.withBrightness( |
| color: Color.fromRGBO(0, 0, 0, 0.08), |
| darkColor: Color.fromRGBO(0, 0, 0, 0.16), |
| ); |
| |
| static const double _kDividerHeight = 8.0; |
| |
| @override |
| Widget build(BuildContext context) { |
| return ColoredBox( |
| color: CupertinoDynamicColor.resolve(color, context), |
| child: const SizedBox(height: _kDividerHeight, 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]. |
| /// |
| /// ## 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, 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.kDefaultDecoration]. |
| /// |
| /// 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 sample} |
| /// This example demonstrates a simple [CupertinoMenuAnchor] that wraps |
| /// a button. |
| /// |
| /// ** See code in examples/api/lib/cupertino/menu_anchor/menu_anchor.0.dart ** |
| /// {@end-tool} |
| /// |
| /// {@tool dartpad} |
| /// This example demonstrates a [CupertinoMenuAnchor] that wraps a button and |
| /// shows a menu with three [CupertinoMenuItem]s and one [CupertinoMenuDivider]. |
| /// |
| /// ** See code in examples/api/lib/cupertino/menu_anchor/menu_anchor.1.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.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.kDefaultDecoration]. |
| final WidgetStateProperty<BoxDecoration>? decoration; |
| |
| /// The mouse cursor to display on hover. |
| final WidgetStateProperty<MouseCursor>? mouseCursor; |
| |
| /// 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> kDefaultDecoration = |
| 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(), |
| }); |
| |
| static final WidgetStateProperty<MouseCursor> _kDefaultCursor = |
| 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 _kDefaultTextColor = 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 _kDefaultSubtitleTextColor does |
| /// not match the reported color on the iOS 18.5 simulator. |
| static const Color _kDefaultSubtitleTextColor = 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 _kDefaultMaxLines = 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 _kDefaultLargeTextModeMaxLines = 100; |
| |
| static const TextStyle _kLeadingDefaultTextStyle = TextStyle( |
| fontSize: 15, |
| fontWeight: FontWeight.w600, |
| ); |
| |
| static const IconThemeData _kLeadingDefaultIconTheme = IconThemeData( |
| size: 15, |
| weight: 600, |
| applyTextScaling: true, |
| ); |
| |
| static const TextStyle _kTrailingDefaultTextStyle = TextStyle(fontSize: 21); |
| |
| static const IconThemeData _kTrailingDefaultIconTheme = 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 = _kDefaultTextColor; |
| } |
| |
| 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(_kDefaultSubtitleTextColor, 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 isLargeTextModeEnabled = _largeTextModeEnabled(context); |
| Widget? leadingWidget; |
| Widget? trailingWidget; |
| if (leading != null) { |
| leadingWidget = DefaultTextStyle.merge( |
| style: _kLeadingDefaultTextStyle, |
| child: IconTheme.merge(data: _kLeadingDefaultIconTheme, child: leading!), |
| ); |
| } |
| |
| if (trailing != null && !isLargeTextModeEnabled) { |
| trailingWidget = DefaultTextStyle.merge( |
| style: _kTrailingDefaultTextStyle, |
| child: IconTheme.merge(data: _kTrailingDefaultIconTheme, child: trailing!), |
| ); |
| } |
| |
| return MediaQuery.withClampedTextScaling( |
| minScaleFactor: _kMinimumTextScaleFactor, |
| maxScaleFactor: _kMaximumTextScaleFactor, |
| child: _CupertinoMenuItemInteractionHandler( |
| mouseCursor: mouseCursor ?? _kDefaultCursor, |
| requestFocusOnHover: requestFocusOnHover, |
| onPressed: onPressed != null ? () => _handleSelect(context) : null, |
| onHover: onHover, |
| onFocusChange: onFocusChange, |
| autofocus: autofocus, |
| focusNode: focusNode, |
| decoration: decoration ?? kDefaultDecoration, |
| behavior: behavior, |
| child: DefaultTextStyle.merge( |
| maxLines: isLargeTextModeEnabled ? _kDefaultLargeTextModeMaxLines : _kDefaultMaxLines, |
| 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 _kDefaultHorizontalWidth = 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 measured on several iOS and iPadOS 18.5 simulators using |
| // the debug view. |
| static const double _kLeadingWidthSlope = -311 / 1000; |
| static const double _kLeadingWidthYIntercept = 10; |
| |
| static const double _kLeadingMidpointSlope = 118 / 1000000; |
| static const double _kLeadingMidpointYIntercept = 73 / 125; |
| |
| static const double _kTrailingWidthSlope = 1 / 10; |
| static const double _kTrailingWidthYIntercept = 22; |
| |
| static const double _kFirstBaselineToTopSlope = 14 / 11; |
| static const double _kLastBaselineToBottomSlope = 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 = _kLeadingWidthSlope * units + _kLeadingWidthYIntercept; |
| return _roundToDivisible(value + lineHeight, to: 1 / pixelRatio); |
| } |
| |
| double _resolveTrailingWidth(TextScaler textScaler, double pixelRatio, double lineHeight) { |
| final double units = _normalizeTextScale(textScaler); |
| final double value = _kTrailingWidthSlope * units + _kTrailingWidthYIntercept; |
| 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 = _kLeadingMidpointSlope * units + _kLeadingMidpointYIntercept; |
| final double horizontalAlignment = (horizontalRatio * 2) - 1; |
| return AlignmentDirectional(horizontalAlignment, 0.0); |
| } |
| |
| double _resolveFirstBaselineToTop(double lineHeight, double pixelRatio) { |
| return _roundToDivisible(lineHeight * _kFirstBaselineToTopSlope, to: 1 / pixelRatio); |
| } |
| |
| double _resolveLastBaselineToBottom(double lineHeight, double pixelRatio) { |
| return _roundToDivisible(lineHeight * _kLastBaselineToBottomSlope, 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) |
| : _kDefaultHorizontalWidth); |
| |
| final double resolvedTrailingWidth = |
| trailingWidth ?? |
| (trailing != null |
| ? _resolveTrailingWidth(textScaler, pixelRatio, lineHeight) |
| : _kDefaultHorizontalWidth); |
| |
| 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 = ui.clampDouble(offset.dx, 0.0, size.width - child!.size.width); |
| final double dy = ui.clampDouble(offset.dy, 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.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 WidgetStateProperty<MouseCursor> mouseCursor; |
| final WidgetStateProperty<BoxDecoration> decoration; |
| final Widget child; |
| |
| @override |
| State<_CupertinoMenuItemInteractionHandler> createState() => |
| _CupertinoMenuItemInteractionHandlerState(); |
| } |
| |
| class _CupertinoMenuItemInteractionHandlerState |
| extends State<_CupertinoMenuItemInteractionHandler> { |
| late final Map<Type, Action<Intent>> _actions = <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!; |
| final WidgetStatesController _statesController = WidgetStatesController(); |
| |
| bool get isHovered => _statesController.value.contains(WidgetState.hovered); |
| set isHovered(bool value) { |
| _statesController.update(WidgetState.hovered, value); |
| } |
| |
| bool get isPressed => _statesController.value.contains(WidgetState.pressed); |
| set isPressed(bool value) { |
| _statesController.update(WidgetState.pressed, value); |
| } |
| |
| bool get isSwiped => _statesController.value.contains(WidgetState.dragged); |
| set isSwiped(bool value) { |
| _statesController.update(WidgetState.dragged, value); |
| } |
| |
| bool get isFocused => _statesController.value.contains(WidgetState.focused); |
| set isFocused(bool value) { |
| _statesController.update(WidgetState.focused, value); |
| } |
| |
| bool get isEnabled => !_statesController.value.contains(WidgetState.disabled); |
| set isEnabled(bool value) { |
| _statesController.update(WidgetState.disabled, !value); |
| } |
| |
| @override |
| void initState() { |
| super.initState(); |
| if (widget.focusNode == null) { |
| _internalFocusNode = FocusNode(); |
| } |
| |
| isEnabled = widget.onPressed != null; |
| isFocused = _focusNode.hasPrimaryFocus; |
| } |
| |
| @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.onPressed != oldWidget.onPressed) { |
| if (widget.onPressed == null) { |
| isEnabled = isHovered = isPressed = isSwiped = isFocused = false; |
| } else { |
| isEnabled = true; |
| } |
| } |
| } |
| |
| @override |
| void dispose() { |
| _statesController.dispose(); |
| _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()); |
| } |
| |
| void _handleSwipeEnter() { |
| if (!isEnabled) { |
| return; |
| } |
| |
| 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; |
| } |
| |
| void _handleSwipeExit() { |
| if (mounted) { |
| isSwiped = false; |
| } |
| } |
| |
| void _handleSwipeCompleted() { |
| if (mounted && isEnabled) { |
| _handleActivation(); |
| } |
| } |
| |
| Widget _buildStatefulAppearance(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) { |
| final DeviceGestureSettings? newGestureSettings = MediaQuery.maybeGestureSettingsOf(context); |
| if (_gestureSettings != newGestureSettings) { |
| _gestureSettings = newGestureSettings; |
| _gestures = null; |
| } |
| |
| _gestures ??= <Type, GestureRecognizerFactory>{ |
| TapGestureRecognizer: GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>( |
| () => TapGestureRecognizer(debugOwner: this), |
| (TapGestureRecognizer instance) { |
| instance |
| ..onTapDown = _handleTapDown |
| ..onTapUp = _handleTapUp |
| ..onTapCancel = _handleTapCancel |
| ..gestureSettings = _gestureSettings; |
| }, |
| ), |
| }; |
| |
| return MergeSemantics( |
| child: Semantics.fromProperties( |
| properties: SemanticsProperties( |
| enabled: isEnabled, |
| onDismiss: isEnabled ? _handleDismissMenu : null, |
| ), |
| child: Actions( |
| actions: isEnabled ? _actions : <Type, Action<Intent>>{}, |
| child: Focus( |
| autofocus: isEnabled && widget.autofocus, |
| focusNode: _focusNode, |
| canRequestFocus: isEnabled, |
| skipTraversal: !isEnabled, |
| onFocusChange: _handleFocusChange, |
| child: _SwipeTarget( |
| onEnter: _handleSwipeEnter, |
| onExit: _handleSwipeExit, |
| onCompletion: _handleSwipeCompleted, |
| child: ValueListenableBuilder<Set<WidgetState>>( |
| valueListenable: _statesController, |
| builder: _buildStatefulAppearance, |
| child: RawGestureDetector( |
| behavior: widget.behavior, |
| gestures: isEnabled ? _gestures! : const <Type, GestureRecognizerFactory>{}, |
| child: widget.child, |
| ), |
| ), |
| ), |
| ), |
| ), |
| ), |
| ); |
| } |
| } |
| |
| /// A widget that triggers callbacks when a pointer enters or leaves while down. |
| /// |
| /// An ancestor [_SwipeRegion] must be present in order for callbacks to be triggered. |
| class _SwipeTarget extends StatelessWidget { |
| const _SwipeTarget({ |
| required this.onEnter, |
| required this.onExit, |
| required this.onCompletion, |
| required this.child, |
| }); |
| |
| /// A pointer has entered this region while down. |
| /// |
| /// This includes: |
| /// |
| /// * The pointer has moved into this region from outside. |
| /// * The point has contacted the screen in this region. In this case, this |
| /// method is called as soon as the pointer down event occurs regardless of |
| /// whether the gesture wins the arena immediately. |
| final VoidCallback? onEnter; |
| |
| /// A pointer has exited this region while down. |
| /// |
| /// This includes: |
| /// * The pointer has moved out of this region. |
| /// * The pointer is no longer in contact with the screen. |
| /// * The pointer is canceled. |
| /// * The gesture loses the arena. |
| /// * The gesture ends. In this case, this method is called immediately |
| /// before [onCompletion]. |
| final VoidCallback? onExit; |
| |
| /// The drag gesture completed in this region. |
| /// |
| /// This method is called immediately after [onExit]. |
| final VoidCallback? onCompletion; |
| |
| /// The widget below this widget in the tree. |
| final Widget child; |
| |
| /// Whether this target stops underlying widgets from being swiped. |
| /// |
| /// When true, targets that are obscured by this widget will not receive |
| /// swipe enter or exit events. When false, swipe events will continue |
| /// to propagate to targets behind this one. |
| bool get isOpaque => true; |
| |
| @override |
| Widget build(BuildContext context) { |
| return MetaData(metaData: this, child: child); |
| } |
| } |
| |
| 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; |
| |
| @override |
| void didUpdateWidget(_SwipeRegion oldWidget) { |
| super.didUpdateWidget(oldWidget); |
| if (widget.enabled != oldWidget.enabled) { |
| if (!widget.enabled) { |
| _recognizer?.dispose(); |
| _recognizer = null; |
| _position = null; |
| widget.onDistanceChanged(0); |
| } |
| } |
| } |
| |
| @override |
| void didChangeDependencies() { |
| super.didChangeDependencies(); |
| _recognizer?.gestureSettings = MediaQuery.maybeGestureSettingsOf(context); |
| } |
| |
| @override |
| void dispose() { |
| assert(_surfaces.isEmpty); |
| _recognizer?.dispose(); |
| _recognizer = null; |
| super.dispose(); |
| } |
| |
| 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); |
| } |
| |
| Drag _createSwipeHandle(ui.Offset position) { |
| assert(!isSwiping, 'A new swipe should not begin while a swipe is active.'); |
| _position = position; |
| return _SwipeHandle( |
| viewId: View.of(context).viewId, |
| initialPosition: position, |
| onSwipeUpdate: _handleSwipeUpdate, |
| onSwipeEnd: _handleSwipeEnd, |
| onSwipeCanceled: _handleSwipeCancel, |
| ); |
| } |
| |
| void _handleSwipeUpdate(DragUpdateDetails updateDetails) { |
| _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); |
| } |
| |
| void _handleSwipeEnd(DragEndDetails updateDetails) { |
| _completeSwipe(); |
| } |
| |
| void _handleSwipeCancel() { |
| _completeSwipe(); |
| } |
| |
| void _completeSwipe() { |
| _recognizer?.dispose(); |
| _recognizer = null; |
| _position = null; |
| if (mounted) { |
| widget.onDistanceChanged(0); |
| } |
| } |
| |
| @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.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; |
| 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); |
| } |
| } |
| |
| _enteredTargets.removeWhere((target) { |
| if (!targets.contains(target)) { |
| target.onExit?.call(); |
| return true; |
| } |
| return false; |
| }); |
| |
| final hitTargets = <_SwipeTarget>{}; |
| final newlyEnteredTargets = <_SwipeTarget>[]; |
| var hitExistingTarget = false; |
| for (final target in targets) { |
| if (_enteredTargets.contains(target)) { |
| hitTargets.add(target); |
| hitExistingTarget = true; |
| continue; |
| } |
| |
| if (!hitExistingTarget) { |
| hitTargets.add(target); |
| newlyEnteredTargets.add(target); |
| } |
| |
| if (target.isOpaque) { |
| break; |
| } |
| } |
| |
| // Leave old targets. |
| // |
| // Disjoint siblings (1 -> 2) were removed above to preserve the expected |
| // "Leave 1" -> "Enter 2" order. For nested items (1 -> 1.1 -> 1.1.1), |
| // entering a nested item (1.1) that obscures a parent item (1) will result |
| // in "Leave 1" -> "Enter 1.1". Leaving the nested item will behave in the |
| // opposite order: "Leave 1.1" -> "Enter 1". |
| for (final _SwipeTarget target in _enteredTargets.reversed) { |
| if (!hitTargets.contains(target)) { |
| target.onExit?.call(); |
| } |
| } |
| |
| for (final _SwipeTarget target in newlyEnteredTargets.reversed) { |
| target.onEnter?.call(); |
| } |
| |
| _enteredTargets |
| ..clear() |
| ..addAll(hitTargets); |
| } |
| |
| void _leaveAllEntered({bool pointerUp = false}) { |
| for (var i = 0; i < _enteredTargets.length; i += 1) { |
| final _SwipeTarget target = _enteredTargets[i]; |
| target.onExit?.call(); |
| if (pointerUp) { |
| target.onCompletion?.call(); |
| } |
| } |
| _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; |
| } |
| } |