Skip to content

Instantly share code, notes, and snippets.

@davidhicks980
Last active February 1, 2026 08:49
Show Gist options
  • Select an option

  • Save davidhicks980/8c6bba779b6a00e95582b61b132292bc to your computer and use it in GitHub Desktop.

Select an option

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