Skip to content

Instantly share code, notes, and snippets.

@davidhicks980
Created October 18, 2025 17:11
Show Gist options
  • Save davidhicks980/8c6bba779b6a00e95582b61b132292bc to your computer and use it in GitHub Desktop.
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.
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/physics.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:flutter/cupertino.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(
onAnimationStatusChange: (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 ***
// 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 size at which text scales linearly on the iOS 18.5 simulator.
const double _kCupertinoMobileBaseFontSize = 17.0;
/// 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... (6 units)
/// * Min "accessible" scale factor ≈ 28/17 ≈ 1.647... (11 units)
///
/// 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 CupertinoDialog.
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;
/// 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';
/// 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.
///
/// On iOS, the base text scale is 17.0 pt, meaning each "unit" represents an
/// increase or decrease of 1/17th (≈5.88%) of the base font size.
///
/// The equation to calculate the normalized text scale is:
///
/// ```dart
/// final normalizedScale = MediaQuery.of(context).scale(baseFontSize) - baseFontSize
/// ```
///
/// 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;
}
// 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 < _kMenuWidthMobileWidthThreshold;
return switch ((isMobile, isAccessibilityModeEnabled)) {
(false, false) => _CupertinoMenuWidth.iPadOS,
(false, true) => _CupertinoMenuWidth.iPadOSAccessible,
(true, false) => _CupertinoMenuWidth.iOS,
(true, true) => _CupertinoMenuWidth.iOSAccessible,
};
}
static const double _kMenuWidthMobileWidthThreshold = 768;
final double points;
}
// TODO(davidhicks980): DynamicType should be moved to the Cupertino theming library when available.
// Obtained from https://developer.apple.com/design/human-interface-guidelines/typography#Specifications
enum _DynamicTypeStyle {
body(
xSmall: TextStyle(
fontSize: 14,
height: 19 / 14,
letterSpacing: -0.41,
fontFamily: _kBodyFont,
),
small: TextStyle(
fontSize: 15,
height: 20 / 15,
letterSpacing: -0.41,
fontFamily: _kBodyFont,
),
medium: TextStyle(
fontSize: 16,
height: 21 / 16,
letterSpacing: -0.41,
fontFamily: _kBodyFont,
),
large: TextStyle(
fontSize: 17,
height: 22 / 17,
letterSpacing: -0.41,
fontFamily: _kBodyFont,
),
xLarge: TextStyle(
fontSize: 19,
height: 24 / 19,
letterSpacing: -0.41,
fontFamily: _kBodyFont,
),
xxLarge: TextStyle(
fontSize: 21,
height: 26 / 21,
letterSpacing: -0.8,
fontFamily: _kBodyFont,
),
xxxLarge: TextStyle(
fontSize: 23,
height: 29 / 23,
letterSpacing: 0.38,
fontFamily: _kDisplayFont,
),
ax1: TextStyle(
fontSize: 28,
height: 34 / 28,
letterSpacing: 0.38,
fontFamily: _kDisplayFont,
),
ax2: TextStyle(
fontSize: 33,
height: 40 / 33,
letterSpacing: 0.38,
fontFamily: _kDisplayFont,
),
ax3: TextStyle(
fontSize: 40,
height: 48 / 40,
letterSpacing: 0.38,
fontFamily: _kDisplayFont,
),
ax4: TextStyle(
fontSize: 47,
height: 56 / 47,
letterSpacing: 0.38,
fontFamily: _kDisplayFont,
),
ax5: TextStyle(
fontSize: 53,
height: 62 / 53,
letterSpacing: 0.38,
fontFamily: _kDisplayFont,
),
),
subhead(
xSmall: TextStyle(
fontSize: 12,
height: 16 / 12,
letterSpacing: -0.025,
fontFamily: _kBodyFont,
),
small: TextStyle(
fontSize: 13,
height: 18 / 13,
letterSpacing: -0.025,
fontFamily: _kBodyFont,
),
medium: TextStyle(
fontSize: 14,
height: 19 / 14,
letterSpacing: -0.025,
fontFamily: _kBodyFont,
),
large: TextStyle(
fontSize: 15,
height: 20 / 15,
letterSpacing: -0.2,
fontFamily: _kBodyFont,
),
xLarge: TextStyle(
fontSize: 17,
height: 22 / 17,
letterSpacing: -0.41,
fontFamily: _kBodyFont,
),
xxLarge: TextStyle(
fontSize: 19,
height: 24 / 19,
letterSpacing: -0.68,
fontFamily: _kBodyFont,
),
xxxLarge: TextStyle(
fontSize: 21,
height: 28 / 21,
letterSpacing: -0.68,
fontFamily: _kBodyFont,
),
ax1: TextStyle(
fontSize: 25,
height: 31 / 25,
letterSpacing: 0.38,
fontFamily: _kDisplayFont,
),
ax2: TextStyle(
fontSize: 30,
height: 37 / 30,
letterSpacing: 0.38,
fontFamily: _kDisplayFont,
),
ax3: TextStyle(
fontSize: 36,
height: 43 / 36,
letterSpacing: 0.38,
fontFamily: _kDisplayFont,
),
ax4: TextStyle(
fontSize: 42,
height: 50 / 42,
letterSpacing: 0.38,
fontFamily: _kDisplayFont,
),
ax5: TextStyle(
fontSize: 49,
height: 58 / 49,
letterSpacing: 0.38,
fontFamily: _kDisplayFont,
),
);
const _DynamicTypeStyle({
required this.xSmall,
required this.small,
required this.medium,
required this.large,
required this.xLarge,
required this.xxLarge,
required this.xxxLarge,
required this.ax1,
required this.ax2,
required this.ax3,
required this.ax4,
required this.ax5,
});
final TextStyle xSmall;
final TextStyle small;
final TextStyle medium;
final TextStyle large;
final TextStyle xLarge;
final TextStyle xxLarge;
final TextStyle xxxLarge;
final TextStyle ax1;
final TextStyle ax2;
final TextStyle ax3;
final TextStyle ax4;
final TextStyle ax5;
double _interpolateUnits(double units, int minimum, int maximum) {
final double t = (units - minimum) / (maximum - minimum);
return ui.lerpDouble(0, 1, t)!;
}
// The following units were measured from the iOS 18.5 simulator in points.
TextStyle resolveTextStyle(TextScaler textScaler) {
final double units =
textScaler.scale(_kCupertinoMobileBaseFontSize) -
_kCupertinoMobileBaseFontSize;
return switch (units) {
<= -3 => xSmall,
< -2 => TextStyle.lerp(xSmall, small, _interpolateUnits(units, -3, -2))!,
< -1 => TextStyle.lerp(small, medium, _interpolateUnits(units, -2, -1))!,
< 0 => TextStyle.lerp(medium, large, _interpolateUnits(units, -1, 0))!,
< 2 => TextStyle.lerp(large, xLarge, _interpolateUnits(units, 0, 2))!,
< 4 => TextStyle.lerp(xLarge, xxLarge, _interpolateUnits(units, 2, 4))!,
< 6 => TextStyle.lerp(xxLarge, xxxLarge, _interpolateUnits(units, 4, 6))!,
< 11 => TextStyle.lerp(xxxLarge, ax1, _interpolateUnits(units, 6, 11))!,
< 16 => TextStyle.lerp(ax1, ax2, _interpolateUnits(units, 11, 16))!,
< 23 => TextStyle.lerp(ax2, ax3, _interpolateUnits(units, 16, 23))!,
< 30 => TextStyle.lerp(ax3, ax4, _interpolateUnits(units, 23, 30))!,
< 36 => TextStyle.lerp(ax4, ax5, _interpolateUnits(units, 30, 36))!,
_ => ax5,
};
}
}
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(quantize(3.15, to: 0)); // 3.15
/// print(quantize(3.15, to: 1)); // 3
/// print(quantize(3.15, to: 0.1)); // 3.2
/// print(quantize(3.15, to: 0.01)); // 3.15
/// print(quantize(3.15, to: 0.25)); // 3.25
/// print(quantize(3.15, to: 0.5)); // 3.0
/// print(quantize(-3.15, to: 0.5)); // -3.0
/// print(quantize(-3.15, to: 0.1)); // -3.2
/// ```
double _quantize(double value, {required double to}) {
if (to == 0) {
return value;
}
return (value / to).round() * to;
}
/// Mix [CupertinoMenuEntryMixin] in to define how a menu item should be drawn
/// in a menu.
mixin CupertinoMenuEntryMixin {
/// Whether this menu item has a leading widget.
///
/// If 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 get hasLeading;
/// Whether a separator can be drawn above this menu item.
///
/// When [allowLeadingSeparator] is true, a separator will be drawn if the
/// menu item immediately above this item has mixed in
/// [CupertinoMenuEntryMixin] and has set [allowTrailingSeparator] to true.
bool get allowLeadingSeparator;
/// Whether a separator can be drawn below this menu item.
///
/// When [allowTrailingSeparator] is true, a separator will be drawn if the
/// menu item immediately below this item has mixed in
/// [CupertinoMenuEntryMixin] and has set [allowLeadingSeparator] to true.
bool get allowTrailingSeparator;
}
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 position is determined by the [alignment] of the
/// anchor attachment point and the [menuAlignment] of the menu attachment
/// point. The [alignmentOffset] can be used to move the menu position relative
/// to the alignment point. If the menu is opened with an explicit position,
/// then the [alignment] and [alignmentOffset] are ignored.
///
/// The [controller] can be used to open and close the menu from other widgets.
/// 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 [onAnimationStatusChange] callback is
/// invoked every time the [AnimationStatus] of the menu animation changes.
///
/// ## Usage
/// {@tool snippet}
///
/// This sample code shows a [CupertinoMenuAnchor] containing one
/// [CupertinoMenuItem]. The menu item prints `Item 1 pressed!` when pressed.
///
/// ```dart
/// CupertinoMenuAnchor(
/// menuChildren: <Widget>[
/// CupertinoMenuItem(
/// trailing: const Icon(Icons.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}
class CupertinoMenuAnchor extends StatefulWidget {
/// Creates a [CupertinoMenuAnchor].
const CupertinoMenuAnchor({
super.key,
this.controller,
this.onOpen,
this.onClose,
this.onAnimationStatusChange,
this.alignment,
this.alignmentOffset,
this.menuAlignment,
this.constraints,
this.constrainCrossAxis = false,
this.consumeOutsideTaps = false,
this.enableSwipe = true,
this.longPressToOpenDuration = Duration.zero,
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 invoked when the menu is opened while having an
/// [AnimationStatus] of [AnimationStatus.dismissed] or [AnimationStatus.reverse].
final VoidCallback? onOpen;
/// A callback invoked when the menu is closed while having an
/// [AnimationStatus] of [AnimationStatus.complete] or [AnimationStatus.forward].
final VoidCallback? onClose;
/// A callback that is invoked when the status of the menu changes.
///
/// Unlike [onOpen] and [onClose], this callback is invoked for all
/// [AnimationStatus] changes.
final CupertinoMenuAnimationStatusChangedCallback? onAnimationStatusChange;
/// The point on the anchor surface that attaches to the menu.
///
/// This value is ignored if the menu is opened with an explicit position.
final AlignmentGeometry? alignment;
/// The offset of the menu relative to the alignment origin determined by
/// [alignment] and the ambient [Directionality].
///
/// Use this for adjustments of the menu placement.
///
/// Increasing [Offset.dy] values of [alignmentOffset] move the menu position
/// down.
///
/// If the [alignment] is an [AlignmentDirectional] AND the text direction is
/// [TextDirection.rtl], a larger [Offset.dx] component of [alignmentOffset]
/// moves the menu position to the left. Otherwise, a larger [Offset.dx] moves
/// the menu position to the right.
///
/// This value is ignored if the menu is opened with an explicit position.
final ui.Offset? alignmentOffset;
/// The point on the menu surface that attaches to the anchor.
final AlignmentGeometry? menuAlignment;
/// 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 menu button 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;
/// The duration after which a long-press on the anchor button will open the
/// menu.
///
/// When a menu is opened via long-press, the menu can be swiped in the same
/// gesture to select and activate menu items.
///
/// When the inner menu button is disabled, [longPressToOpenDuration] should
/// be set to [Duration.zero] to prevent the menu from opening on long-press.
///
/// Defaults to [Duration.zero], which disables the behavior.
final Duration longPressToOpenDuration;
/// {@macro flutter.widgets.RawMenuAnchor.useRootOverlay}
final bool useRootOverlay;
/// The padding to subtract from the overlay when positioning the menu.
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;
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(
DiagnosticsProperty<AlignmentGeometry?>('menuAlignment', menuAlignment),
);
properties.add(
DiagnosticsProperty<AlignmentGeometry?>('alignment', alignment),
);
properties.add(
DiagnosticsProperty<Offset?>('alignmentOffset', alignmentOffset),
);
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 Tolerance springTolerance = Tolerance(
velocity: 0.1,
distance: 0.1,
);
/// Approximated using settling duration calculation (see
/// https://github.com/flutter/flutter/pull/164411#issuecomment-2691969477)
/// with a settling duration of 500 ms, an initialVelocity of 0.5, and a bounce of 0.2,
/// then tweaked to match iOS
static const SpringDescription forwardSpring = SpringDescription(
mass: 1.0,
stiffness: 349.1,
damping: 29.9,
);
/// Approximated using settling duration calculation (see
/// https://github.com/flutter/flutter/pull/164411#issuecomment-2691969477)
/// with a duration of 500 ms and bounce of 0, then tweaked to match iOS
static const SpringDescription reverseSpring = SpringDescription(
mass: 1.0,
stiffness: 235.1,
damping: 30.7,
);
late final AnimationController _animationController;
final FocusScopeNode _menuScopeNode = FocusScopeNode(
debugLabel: 'Menu Scope',
);
final ValueNotifier<double> _swipeDistanceNotifier = ValueNotifier<double>(0);
bool _hasLeadingWidget = false;
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);
_resolveHasLeading();
}
@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) {
_resolveHasLeading();
}
}
@override
void dispose() {
_menuScopeNode.dispose();
_animationController
..stop()
..dispose();
_internalMenuController = null;
_swipeDistanceNotifier.dispose();
super.dispose();
}
void _resolveHasLeading() {
_hasLeadingWidget = widget.menuChildren.any((Widget element) {
return switch (element) {
CupertinoMenuEntryMixin(hasLeading: true) => true,
_ => false,
};
});
}
void _handleAnimationStatusChange(AnimationStatus status) {
setState(() {
_animationStatus = status;
});
widget.onAnimationStatusChange?.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 widget.anchorPressActivationDuration becomes zero while a press is
// active, do not open the menu.
if (isOpening || widget.longPressToOpenDuration == Duration.zero) {
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,
alignmentOffset: widget.alignmentOffset ?? Offset.zero,
constraints: widget.constraints,
consumeOutsideTaps: widget.consumeOutsideTaps,
alignment: widget.alignment,
menuAlignment: widget.menuAlignment,
overlaySize: info.overlaySize,
anchorRect: info.anchorRect,
anchorPosition: info.position,
tapRegionGroupId: info.tapRegionGroupId,
focusScopeNode: _menuScopeNode,
overlayInsets: 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.longPressToOpenDuration == Duration.zero || !enableSwipe) {
return anchor;
}
return _SwipeSurface(
onStart: _handleAnchorSwipeStart,
delay: widget.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,
),
),
);
}
}
// TODO(davidhicks980): Remove _resolveMotion() when AnimationBehavior accommodates reduced motion.
// (https://github.com/flutter/flutter/issues/173461)
enum _CompatAnimationBehavior {
/// All animations are played as normal, with no reduction in motion.
normal,
/// Corresponds to ui.AccessibilityFeatures.reduceMotion
reduced,
/// Corresponds to ui.AccessibilityFeatures.disableAnimations
none,
}
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.overlayInsets,
required this.anchorRect,
required this.anchorPosition,
required this.tapRegionGroupId,
required this.alignmentOffset,
required this.alignment,
required this.menuAlignment,
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 overlayInsets;
final Rect anchorRect;
final Offset? anchorPosition;
final Object tapRegionGroupId;
final Offset alignmentOffset;
final AlignmentGeometry? alignment;
final AlignmentGeometry? menuAlignment;
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(),
};
final ScrollController _scrollController = ScrollController();
late final AnimationController _swipeAnimationController;
late final ProxyAnimation _scaleAnimation = ProxyAnimation(
kAlwaysCompleteAnimation,
);
final ProxyAnimation _fadeAnimation = ProxyAnimation(
kAlwaysCompleteAnimation,
);
final ProxyAnimation _sizeAnimation = ProxyAnimation(
kAlwaysCompleteAnimation,
);
late Alignment _attachmentPointAlignment;
late ui.Offset _attachmentPoint;
late Alignment _menuAlignment;
List<Widget> _children = <Widget>[];
_CompatAnimationBehavior? _animationBehavior;
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.alignmentOffset != widget.alignmentOffset ||
oldWidget.alignment != widget.alignment ||
oldWidget.menuAlignment != widget.menuAlignment ||
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 List<Widget> children = <Widget>[];
Widget child = widget.children.first;
for (int i = 0; i < widget.children.length; i++) {
children.add(child);
if (child == widget.children.last) {
break;
}
if (child case CupertinoMenuEntryMixin(allowTrailingSeparator: false)) {
child = widget.children[i + 1];
continue;
}
child = widget.children[i + 1];
if (child case CupertinoMenuEntryMixin(allowLeadingSeparator: false)) {
continue;
}
children.add(const _CupertinoMenuDivider());
}
_children = children;
}
void _resolveMotion() {
// Behavior of reduce motion is based on iOS 18.5 simulator. Behavior of
// disable animations could not be determined, so all animations are disabled.
final ui.AccessibilityFeatures accessibilityFeatures = View.of(
context,
).platformDispatcher.accessibilityFeatures;
final _CompatAnimationBehavior newAnimationBehavior =
switch (accessibilityFeatures) {
ui.AccessibilityFeatures(disableAnimations: true) =>
_CompatAnimationBehavior.none,
ui.AccessibilityFeatures(reduceMotion: true) =>
_CompatAnimationBehavior.reduced,
_ => _CompatAnimationBehavior.normal,
};
if (_animationBehavior == newAnimationBehavior) {
return;
}
_animationBehavior = newAnimationBehavior;
switch (_animationBehavior!) {
case _CompatAnimationBehavior.normal:
_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)),
);
case _CompatAnimationBehavior.reduced:
// 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 _CompatAnimationBehavior.none:
_scaleAnimation.parent = kAlwaysCompleteAnimation;
_fadeAnimation.parent = kAlwaysCompleteAnimation;
_sizeAnimation.parent = kAlwaysCompleteAnimation;
}
}
// 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 defaultVerticalAlignment = yMidpointRatio < 0.55 ? 1 : -1;
final double defaultHorizontalAlignment = switch (xMidpointRatio) {
< 0.4 => -1.0, // Left
> 0.6 => 1.0, // Right
_ => 0.0, // Center
};
_menuAlignment =
widget.menuAlignment?.resolve(_textDirection) ??
Alignment(defaultHorizontalAlignment, -defaultVerticalAlignment);
_attachmentPoint = widget.anchorRect.topLeft;
if (widget.anchorPosition != null) {
// If an anchorPosition is provided, then the alignment and the
// alignmentOffset are ignored. The anchorPosition already provides the
// exact point on the anchor surface that attaches to the menu, so no
// further adjustment is needed.
_attachmentPoint += widget.anchorPosition!;
} else {
final Alignment anchorAlignment =
widget.alignment?.resolve(_textDirection) ??
Alignment(defaultHorizontalAlignment, defaultVerticalAlignment);
_attachmentPoint += anchorAlignment.alongSize(widget.anchorRect.size);
if (widget.alignment is AlignmentDirectional) {
_attachmentPoint += switch (_textDirection!) {
ui.TextDirection.ltr => widget.alignmentOffset,
ui.TextDirection.rtl => Offset(
-widget.alignmentOffset.dx,
widget.alignmentOffset.dy,
),
};
} else {
_attachmentPoint += widget.alignmentOffset;
}
}
final double yAttachmentPointRatio =
_attachmentPoint.dy / widget.overlaySize.height;
// The alignment of the menu growth point relative to the screen.
_attachmentPointAlignment = Alignment(
xMidpointRatio * 2 - 1,
yAttachmentPointRatio * 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 double maxVelocity = 20.0;
const double minVelocity = 8;
const double maxSwipeDistance = 150;
const double 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 double remainingDistanceSnapThreshold = 1.0;
// When the user's pointer is within this distance of the menu edges, the
// swipe animation will terminate.
const double 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 _CupertinoMenuWidth 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,
// A custom shadow painter is used to make the underlying colors
// appear more vibrant. This is achieved by removing the shadow
// underlying the popup surface using a save layer combined with a
// clear blend mode.
//
// From my (davidhicks980) understanding and testing, it is
// impossible to achieve the appearance of an iOS backdrop using
// only Gaussian blur, linear color filter, and shadows, because the
// iOS popup surface does not linearly transform underlying colors.
// A custom shader would need to be used to achieve the same effect.
child: Actions(
actions: _actions,
child: Shortcuts(
shortcuts: _kMenuTraversalShortcuts,
child: FocusScope(
node: widget.focusScopeNode,
descendantsAreFocusable: true,
descendantsAreTraversable: true,
canRequestFocus: true,
child: CustomPaint(
painter: _ShadowPainter(
brightness:
CupertinoTheme.maybeBrightnessOf(context) ??
ui.Brightness.light,
repaint: _fadeAnimation,
),
child: FadeTransition(
opacity: _fadeAnimation,
alwaysIncludeSemantics: true,
child: CupertinoPopupSurface(
// 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: 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,
),
),
),
),
),
),
),
),
),
),
),
);
// 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;
return _MenuLayout(
constrainCrossAxis: widget.constrainCrossAxis,
anchorRect: anchorRect,
padding: widget.overlayInsets.resolve(Directionality.of(context)),
attachmentPoint: _attachmentPoint,
menuAlignment: _menuAlignment,
heightFactor: value,
child: child,
);
},
),
),
);
}
}
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 Offset center = Offset(size.width / 2, size.height / 2);
final ui.RSuperellipse menuRect = RSuperellipse.fromRectAndRadius(
Rect.fromCenter(center: center, width: size.width, height: size.height),
radius,
);
final double opacityMultiplier = switch (brightness) {
ui.Brightness.light => lightShadowOpacity,
ui.Brightness.dark => darkShadowOpacity,
};
final Paint shadowPaint = Paint()
..maskFilter = MaskFilter.blur(BlurStyle.normal, shadowOpacity * 50)
..color = ui.Color.fromRGBO(
0,
0,
10,
shadowOpacity * shadowOpacity * opacityMultiplier,
);
final ui.Paint clearPaint = Paint()..blendMode = BlendMode.clear;
canvas
..saveLayer(Rect.largest, Paint())
..drawRSuperellipse(menuRect.inflate(50), shadowPaint)
..drawRSuperellipse(menuRect, clearPaint)
..restore();
}
@override
bool shouldRepaint(_ShadowPainter oldDelegate) =>
oldDelegate.brightness != brightness || oldDelegate.repaint != repaint;
@override
bool shouldRebuildSemantics(_ShadowPainter oldDelegate) => false;
}
// Positions the menu in the view while trying to keep as much as possible
// visible in the view.
class _MenuLayout extends SingleChildRenderObjectWidget {
const _MenuLayout({
required this.anchorRect,
required this.menuAlignment,
required this.constrainCrossAxis,
required this.padding,
required this.attachmentPoint,
required this.heightFactor,
required super.child,
});
// Rectangle anchoring the menu. If the menu was opened at a specific point,
// this will be a zero-size rect at that point.
final ui.Rect anchorRect;
// Whether to constrain the menu surface to the cross axis.
final bool constrainCrossAxis;
// The padding to subtract from the overlay when positioning the menu.
final EdgeInsets padding;
// The resolved alignment of the menu attachment point relative to the menu surface.
final Alignment menuAlignment;
// The offset of the menu from the top-left corner of the overlay.
final ui.Offset attachmentPoint;
// The factor by which to multiply the height of the child.
final double heightFactor;
Set<ui.Rect> avoidBounds(List<ui.DisplayFeature> displayFeatures) {
return displayFeatures
.where((ui.DisplayFeature d) {
return d.bounds.shortestSide > 0 ||
d.state == ui.DisplayFeatureState.postureHalfOpened;
})
.map((ui.DisplayFeature d) => d.bounds)
.toSet();
}
@override
RenderObject createRenderObject(BuildContext context) {
final List<ui.DisplayFeature>? displayFeatures =
MediaQuery.maybeDisplayFeaturesOf(context);
return _RenderMenuLayout(
anchorRect: anchorRect,
menuAlignment: menuAlignment,
avoidBounds: displayFeatures != null
? avoidBounds(displayFeatures)
: <Rect>{},
constrainCrossAxis: constrainCrossAxis,
padding: padding,
attachmentPoint: attachmentPoint,
heightFactor: heightFactor,
);
}
@override
void updateRenderObject(
BuildContext context,
_RenderMenuLayout renderObject,
) {
final List<ui.DisplayFeature>? displayFeatures =
MediaQuery.maybeDisplayFeaturesOf(context);
renderObject
..anchorRect = anchorRect
..menuAlignment = menuAlignment
..avoidBounds = displayFeatures != null
? avoidBounds(displayFeatures)
: <Rect>{}
..constrainCrossAxis = constrainCrossAxis
..padding = padding
..attachmentPoint = attachmentPoint
..heightFactor = heightFactor;
}
}
class _RenderMenuLayout extends RenderShiftedBox {
_RenderMenuLayout({
required Rect anchorRect,
required ui.Offset attachmentPoint,
required Set<Rect> avoidBounds,
required bool constrainCrossAxis,
required EdgeInsets padding,
required Alignment menuAlignment,
required double heightFactor,
RenderBox? child,
}) : _anchorRect = anchorRect,
_attachmentPoint = attachmentPoint,
_avoidBounds = avoidBounds,
_constrainCrossAxis = constrainCrossAxis,
_padding = padding,
_menuAlignment = menuAlignment,
_heightFactor = heightFactor,
super(child);
Rect get anchorRect => _anchorRect;
Rect _anchorRect;
set anchorRect(Rect value) {
if (_anchorRect == value) {
return;
}
_anchorRect = value;
markNeedsLayout();
}
ui.Offset get attachmentPoint => _attachmentPoint;
ui.Offset _attachmentPoint;
set attachmentPoint(ui.Offset value) {
if (_attachmentPoint == value) {
return;
}
_attachmentPoint = value;
markNeedsLayout();
}
Set<Rect> get avoidBounds => _avoidBounds;
Set<Rect> _avoidBounds;
set avoidBounds(Set<Rect> value) {
if (setEquals(_avoidBounds, value)) {
return;
}
_avoidBounds = value;
markNeedsLayout();
}
bool get constrainCrossAxis => _constrainCrossAxis;
bool _constrainCrossAxis;
set constrainCrossAxis(bool value) {
if (_constrainCrossAxis == value) {
return;
}
_constrainCrossAxis = value;
markNeedsLayout();
}
EdgeInsets get padding => _padding;
EdgeInsets _padding;
set padding(EdgeInsets value) {
if (_padding == value) {
return;
}
_padding = value;
markNeedsLayout();
}
Alignment get menuAlignment => _menuAlignment;
Alignment _menuAlignment;
set menuAlignment(Alignment value) {
if (_menuAlignment == value) {
return;
}
_menuAlignment = value;
markNeedsLayout();
}
double get heightFactor => _heightFactor;
double _heightFactor;
set heightFactor(double value) {
if (_heightFactor == value) {
return;
}
_heightFactor = value;
markNeedsLayout();
}
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
// The menu can be at most the size of the overlay minus the view padding
// in each direction.
return BoxConstraints.loose(constraints.biggest);
}
@override
double? computeDryBaseline(
BoxConstraints constraints,
TextBaseline baseline,
) {
final RenderBox? child = this.child;
if (child == null) {
return null;
}
final BoxConstraints childConstraints = getConstraintsForChild(constraints);
final double? result = child.getDryBaseline(childConstraints, baseline);
if (result == null) {
return null;
}
final Size drySize = childConstraints.isTight
? childConstraints.smallest
: child.getDryLayout(childConstraints);
final ui.Offset position =
attachmentPoint - menuAlignment.alongSize(drySize);
return result +
_positionChild(
padding.deflateRect(Offset.zero & constraints.biggest),
drySize,
position,
anchorRect,
).dy;
}
@override
double computeMinIntrinsicWidth(double height) {
final double width = BoxConstraints.tightForFinite(height: height).maxWidth;
if (width.isFinite) {
return width;
}
return 0.0;
}
@override
double computeMaxIntrinsicWidth(double height) {
final double width = BoxConstraints.tightForFinite(height: height).maxWidth;
if (width.isFinite) {
return width;
}
return 0.0;
}
@override
double computeMinIntrinsicHeight(double width) {
final double height = BoxConstraints.tightForFinite(width: width).maxHeight;
if (height.isFinite) {
return height;
}
return 0.0;
}
@override
double computeMaxIntrinsicHeight(double width) {
final double height = BoxConstraints.tightForFinite(width: width).maxHeight;
if (height.isFinite) {
return height;
}
return 0.0;
}
@override
Size computeDryLayout(covariant BoxConstraints constraints) {
return constraints.biggest;
}
@override
void performLayout() {
final BoxConstraints constraints = this.constraints;
if (child != null) {
size = constraints.biggest;
final BoxConstraints childConstraints = getConstraintsForChild(
constraints,
);
assert(childConstraints.debugAssertIsValid(isAppliedConstraint: true));
final Size drySize = child!.getDryLayout(childConstraints);
child!.layout(
childConstraints.copyWith(maxHeight: drySize.height * _heightFactor),
parentUsesSize: true,
);
final ui.Offset position =
attachmentPoint - menuAlignment.alongSize(drySize);
final ui.Rect screen = _findClosestScreen(
size,
anchorRect.center,
avoidBounds,
);
final Rect paddedScreen = padding.deflateRect(screen);
final ui.Offset startPosition = _positionChild(
paddedScreen,
Size(drySize.width, 0),
position,
anchorRect,
);
final ui.Offset endPosition = _positionChild(
paddedScreen,
drySize,
position,
anchorRect,
);
final ui.Offset lerpPosition = Offset.lerp(
startPosition,
endPosition,
_heightFactor,
)!;
final BoxParentData childParentData = child!.parentData! as BoxParentData;
childParentData.offset = lerpPosition;
} else {
size = constraints.smallest;
}
}
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;
bool overRightEdge(double x) => x > screen.right - childSize.width;
bool overTopEdge(double y) => y < screen.top;
bool overBottomEdge(double y) => y > screen.bottom - childSize.height;
// 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;
} 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;
} 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;
} 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);
}
// 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;
} 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;
} else {
y = flipY;
}
}
return Offset(x, y);
}
// Finds the closest screen to the anchor point.
//
// This is slightly 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 ui.Rect 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!;
}
}
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 used to separate [CupertinoMenuItem]s
///
/// The default thickness of the divider is 1 physical pixel.
///
// This is class may be made public in the future, but is currently private to
// avoid API churn.
class _CupertinoMenuDivider extends StatelessWidget {
/// Draws a [_CupertinoMenuDivider] below a [child].
const _CupertinoMenuDivider();
/// The default color applied to the [_CupertinoMenuDivider] 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 give the appearance of the divider
/// cutting into the 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 [_CupertinoMenuDivider], 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: _AliasedLinePainter(
overlayColor: CupertinoDynamicColor.resolve(overlayColor, context),
border: BorderSide(
width: 0.0,
color: CupertinoDynamicColor.resolve(color, context),
),
// Only anti-alias on devices with a low pixel density.
antiAlias: pixelRatio < 1.0,
),
);
}
}
// Draws an aliased line that approximates the appearance of an iOS 18.5 menu
// divider using blend modes.
class _AliasedLinePainter extends CustomPainter {
const _AliasedLinePainter({
required this.border,
required this.overlayColor,
this.antiAlias = false,
});
final BorderSide border;
final Color overlayColor;
final bool antiAlias;
@override
void paint(Canvas canvas, Size size) {
final Offset p1 = size.bottomLeft(Offset.zero);
final Offset p2 = size.bottomRight(Offset.zero);
// BlendMode.overlay is not supported on the web.
if (!kIsWeb) {
final Paint overlayPainter = border.toPaint()
..color = overlayColor
..isAntiAlias = antiAlias
..blendMode = BlendMode.overlay;
canvas.drawLine(p1, p2, overlayPainter);
}
final Paint colorPainter = border.toPaint()..isAntiAlias = antiAlias;
canvas.drawLine(p1, p2, colorPainter);
}
@override
bool shouldRepaint(_AliasedLinePainter oldDelegate) {
return border != oldDelegate.border ||
antiAlias != oldDelegate.antiAlias ||
overlayColor != oldDelegate.overlayColor;
}
}
/// 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(Icons.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] are applied
/// **above** [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 with CupertinoMenuEntryMixin {
/// 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 get hasLeading => leading != null;
@override
bool get allowLeadingSeparator => true;
@override
bool get allowTrailingSeparator => true;
static final WidgetStateProperty<MouseCursor> defaultCursor =
WidgetStateProperty.resolveWith<MouseCursor>((Set<WidgetState> states) {
return !states.contains(WidgetState.disabled) && kIsWeb
? SystemMouseCursors.click
: MouseCursor.defer;
});
// Obtained 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 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 maximum number of lines for the [child] widget when
/// [MediaQuery.textScalerOf] returns a [TextScaler] that is less than or
/// equal to 1.25.
// Observed on the iOS and iPadOS 18.5 simulators.
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.
///
// Observed on the iOS and iPadOS 18.5 simulators.
static const int defaultAccessibilityModeMaxLines = 100;
/// The base font size multiplier for the [trailing] widget when
/// [MediaQuery.textScalerOf] returns a [TextScaler] that is less than or
/// equal to 1.25.
static const double _trailingIconFontSizeMultiplier = 1.24;
/// 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 bool 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: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
child: IconTheme.merge(
data: const IconThemeData(
size: 15,
weight: 600,
applyTextScaling: true,
),
child: leading!,
),
);
}
if (trailing != null && !isAccessibilityModeEnabled) {
final Widget child = DefaultTextStyle.merge(
style: const TextStyle(fontSize: 17),
child: IconTheme.merge(
data: const IconThemeData(size: 17, applyTextScaling: true),
child: trailing!,
),
);
trailingWidget = Builder(
builder: (BuildContext context) {
return MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaler: TextScaler.linear(
textScaler.scale(_trailingIconFontSizeMultiplier),
),
),
child: child,
);
},
);
}
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));
}
}
}
// TODO(davidhicks980): Update layout when Flutter adds support for last
// baseline (https://github.com/flutter/flutter/issues/4614)
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;
// Values were obtained from the iOS 18.5 simulator.
static const double _defaultHorizontalWidth = 16;
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;
// Tested across all iOS dynamic type sizes on iOS and iPadOS 18.5 simulators.
// Expected values deviate by no more than 1 physical pixel.
double _resolveLeadingWidth(
TextScaler textScaler,
double pixelRatio,
double lineHeight,
) {
final double units = _normalizeTextScale(textScaler);
final double value = _leadingWidthSlope * units + _leadingWidthYIntercept;
return _quantize(value + lineHeight, to: 1 / pixelRatio);
}
// Tested across all iOS dynamic type sizes on iOS and iPadOS 18.5 simulators.
// Expected values deviate by no more than 1 physical pixel.
double _resolveTrailingWidth(
TextScaler textScaler,
double pixelRatio,
double lineHeight,
) {
final double units = _normalizeTextScale(textScaler);
final double value = _trailingWidthSlope * units + _trailingWidthYIntercept;
return _quantize(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 _quantize(lineHeight * _firstBaselineToTopSlope, to: 1 / pixelRatio);
}
double _resolveLastBaselineToBottom(double lineHeight, double pixelRatio) {
return _quantize(
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.
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 BoxParentData 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);
}
@override
void debugPaintSize(PaintingContext context, Offset offset) {
assert(() {
final Paint paint;
if (child != null && !child!.size.isEmpty) {
paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.0
..color = const Color(0xFFFFFF00);
final BoxParentData childParentData =
child!.parentData! as BoxParentData;
// vertical alignment arrows
final double headSize = math.min(childParentData.offset.dy * 0.2, 10.0);
final ui.Size childSize = child!.size;
final double horizontalMidpoint =
offset.dx + childParentData.offset.dx + childSize.width / 2;
final double verticalMidpoint =
offset.dy + childParentData.offset.dy + childSize.height / 2;
final ui.Path path = Path()
// Top arrow
..moveTo(horizontalMidpoint, offset.dy)
..relativeLineTo(0.0, childParentData.offset.dy - headSize)
..relativeLineTo(headSize, 0.0)
..relativeLineTo(-headSize, headSize)
..relativeLineTo(-headSize, -headSize)
..relativeLineTo(headSize, 0.0)
// Bottom arrow
..moveTo(horizontalMidpoint, offset.dy + size.height + headSize)
..relativeLineTo(
0.0,
-size.height + childSize.height + childParentData.offset.dy,
)
..relativeLineTo(headSize, 0)
..relativeLineTo(-headSize, -headSize)
..relativeLineTo(-headSize, headSize)
..relativeLineTo(headSize, 0)
// Left arrow
..moveTo(offset.dx, verticalMidpoint)
..relativeLineTo(childParentData.offset.dx - headSize, 0.0)
..relativeLineTo(0.0, headSize)
..relativeLineTo(headSize, -headSize)
..relativeLineTo(-headSize, -headSize)
..relativeLineTo(0.0, headSize)
// Right arrow
..moveTo(offset.dx + size.width, verticalMidpoint)
..relativeLineTo(
-size.width +
childSize.width +
childParentData.offset.dx +
headSize,
0.0,
)
..relativeLineTo(0.0, headSize)
..relativeLineTo(-headSize, -headSize)
..relativeLineTo(headSize, -headSize)
..relativeLineTo(0.0, headSize);
context.canvas.drawPath(path, paint);
} else {
paint = Paint()..color = const Color(0x90909090);
context.canvas.drawRect(offset & size, paint);
}
return true;
}());
}
}
/// 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.
/// * [CupertinoMenuEntryMixin], a mixin that can be used to control whether
/// dividers are shown before or after a menu item.
class CupertinoLargeMenuDivider extends StatelessWidget
with CupertinoMenuEntryMixin {
/// Creates a large horizontal divider for a [CupertinoMenuAnchor].
const CupertinoLargeMenuDivider({super.key, this.color = defaultColor});
/// The color of the divider.
///
/// Defaults to [CupertinoLargeMenuDivider.defaultColor].
final Color color;
@override
bool get allowTrailingSeparator => false;
@override
bool get allowLeadingSeparator => false;
@override
bool get hasLeading => false;
/// Color for a transparent [CupertinoLargeMenuDivider].
// 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),
);
}
}
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<BoxDecoration> decoration;
final WidgetStateProperty<MouseCursor> mouseCursor;
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,
),
};
// If a focus node isn't given to the widget, then we have to manage our own.
FocusNode get _focusNode => widget.focusNode ?? _internalFocusNode!;
FocusNode? _internalFocusNode;
WidgetStatesController? _internalStatesController;
WidgetStatesController get _statesController {
return widget.statesController ?? _internalStatesController!;
}
Map<Type, GestureRecognizerFactory>? gestures;
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 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 == null) {
final DeviceGestureSettings? gestureSettings =
MediaQuery.maybeGestureSettingsOf(context);
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,
),
),
),
),
),
),
);
}
}
abstract class _SwipeSurfaceData {
ui.Rect computeRect();
}
abstract class _SwipeRegionProvider {
void attachSurface(_SwipeSurfaceData surface);
void detachSurface(_SwipeSurfaceData surface);
void beginSwipe(
PointerDownEvent event, {
Duration delay = Duration.zero,
VoidCallback? onStart,
});
}
class _SwipeScope extends InheritedWidget {
const _SwipeScope({required super.child, required this.state});
final _SwipeRegionProvider 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 _SwipeRegionProvider of(BuildContext context) {
final _SwipeScope? scope = context
.dependOnInheritedWidgetOfExactType<_SwipeScope>();
assert(scope != null, 'No SwipeRegion found in context');
return scope!.state;
}
@override
State<_SwipeRegion> createState() => _SwipeRegionState();
}
class _SwipeRegionState extends State<_SwipeRegion>
implements _SwipeRegionProvider {
final Set<_SwipeSurfaceData> _surfaces = <_SwipeSurfaceData>{};
MultiDragGestureRecognizer? _recognizer;
bool get _isSwiping => _position != null;
ui.Offset? _position;
@override
void attachSurface(_SwipeSurfaceData surface) {
_surfaces.add(surface);
}
@override
void detachSurface(_SwipeSurfaceData surface) {
_surfaces.remove(surface);
}
@override
void beginSwipe(
PointerDownEvent event, {
Duration delay = Duration.zero,
VoidCallback? onStart,
}) {
if (_isSwiping) {
assert(_recognizer != null);
return;
}
if (_recognizer != null && _recognizer!.onStart == onStart) {
bool delayMatches = true;
if (_recognizer case final DelayedMultiDragGestureRecognizer recognizer) {
delayMatches = recognizer.delay == delay;
}
if (delayMatches) {
_recognizer!.addPointer(event);
return;
}
}
_recognizer?.dispose();
_recognizer = null;
Drag handleStart(Offset position) {
onStart?.call();
return _createSwipeHandle(position);
}
// Use a MultiDragGestureRecognizer instead of a SwipeGestureRecognizer
// 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) {
if (_isSwiping) {
_position = null;
widget.onDistanceChanged(0);
}
_recognizer?.dispose();
_recognizer = null;
}
}
}
@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 merge rects because the root menu anchor may not be contiguous.
double minimumSquaredDistance = double.maxFinite;
for (final _SwipeSurfaceData 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) {
setState(() {
// Rebuild to notify that the swipe has ended.
});
} else {
// 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
_RenderSwipenableSurface createRenderObject(BuildContext context) {
return _RenderSwipenableSurface(
region: _SwipeRegion.of(context),
delay: delay,
onStart: onStart,
);
}
@override
void updateRenderObject(
BuildContext context,
_RenderSwipenableSurface renderObject,
) {
renderObject
..region = _SwipeRegion.of(context)
..delay = delay
..onStart = onStart;
}
}
class _RenderSwipenableSurface extends RenderProxyBox
implements _SwipeSurfaceData {
_RenderSwipenableSurface({
required _SwipeRegionProvider region,
required this.delay,
required this.onStart,
}) : _region = region;
_SwipeRegionProvider get region => _region;
_SwipeRegionProvider _region;
set region(_SwipeRegionProvider value) {
if (_region != value) {
_region.detachSurface(this);
_region = value;
_region.attachSurface(this);
}
}
Duration delay;
VoidCallback? onStart;
@override
ui.Rect computeRect() => localToGlobal(Offset.zero) & size;
@override
void attach(PipelineOwner owner) {
super.attach(owner);
_region.attachSurface(this);
}
@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);
}
}
}
/// Mix into [State] to receive callbacks when a pointer enters or leaves while
/// down. The [StatefulWidget] this class is mixed into must be a descendant of
/// a [_SwipeRegion].
@optionalTypeArgs
abstract 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});
}
/// Handles swiping events for a [_SwipeRegion].
// This class was adapted from _DragAvatar.
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 HitTestResult result = HitTestResult();
WidgetsBinding.instance.hitTestInView(result, _position, viewId);
// Look for the RenderBoxes that corresponds to the hit target
final List<_SwipeTarget> 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) {
bool listsMatch = true;
for (int 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 _SwipeTarget target in targets) {
_enteredTargets.add(target);
if (target.didSwipeEnter()) {
return;
}
}
}
void _leaveAllEntered({bool pointerUp = false}) {
for (int 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