Skip to content

Instantly share code, notes, and snippets.

@davidhicks980
Last active February 10, 2026 14:15
Show Gist options
  • Select an option

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

Select an option

Save davidhicks980/e608917541daecf4e55738a783c684a6 to your computer and use it in GitHub Desktop.
CupertinoMenuAnchor reland changes
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/// @docImport 'package:flutter/material.dart';
library;
import 'dart:collection';
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/physics.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'colors.dart';
import 'theme.dart';
// Dismiss is handled by RawMenuAnchor
const Map<ShortcutActivator, Intent> _kMenuTraversalShortcuts = <ShortcutActivator, Intent>{
SingleActivator(LogicalKeyboardKey.arrowUp): _FocusUpIntent(),
SingleActivator(LogicalKeyboardKey.arrowDown): _FocusDownIntent(),
SingleActivator(LogicalKeyboardKey.home): _FocusFirstIntent(),
SingleActivator(LogicalKeyboardKey.end): _FocusLastIntent(),
};
bool get _isCupertino {
switch (defaultTargetPlatform) {
case TargetPlatform.iOS:
case TargetPlatform.macOS:
return true;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
return false;
}
}
const Color _kMenuSurfaceColor = CupertinoDynamicColor.withBrightness(
color: Color(0xCCF2F2F2),
darkColor: Color(0xCC2D2D2D),
);
/// The font family for menu items at smaller text scales.
const String _kBodyFont = 'CupertinoSystemText';
/// The font family for menu items at larger text scales.
const String _kDisplayFont = 'CupertinoSystemDisplay';
/// Base font size used for text-scaling calculations.
///
/// On iOS the text scale changes in increments of 1/17 (≈5.88%), as
/// observed on the iOS 18.5 simulator. Each step (1/17 of the base font size)
/// is referred to as one "unit" in the documentation for [CupertinoMenuAnchor]
const double _kCupertinoMobileBaseFontSize = 17.0;
/// Returns an integer that represents the current text scale factor normalized
/// to the base font size.
///
/// Normalizing to the base font size simplifies storage of nonlinear layout
/// spacing that depends on the text scale factor.
///
/// The returned value is positive when the text scale factor is larger than the
/// base font size, negative when smaller, and zero when equal.
double _normalizeTextScale(TextScaler textScaler) {
if (textScaler == TextScaler.noScaling) {
return 0;
}
return textScaler.scale(_kCupertinoMobileBaseFontSize) - _kCupertinoMobileBaseFontSize;
}
/// The CupertinoMenuAnchor layout policy changes depending on whether the user is using
/// a "regular" font size vs a "large" font size. This is a spectrum. There are
/// many "regular" font sizes and many "large" font sizes. But depending on which
/// policy is currently being used, a menu is laid out differently.
///
/// Empirically, the jump from one policy to the other occurs at the following text
/// scale factors:
/// * Max "regular" scale factor ≈ 23/17 ≈ 1.352... (normalized text scale: 6)
/// * Min "accessible" scale factor ≈ 28/17 ≈ 1.647... (normalized text scale: 11)
///
/// The following constant represents a division in text scale factor beyond which
/// we want to change how the menu is laid out.
///
/// This explanation was ported from cupertino/dialog.dart.
const double _kMinimumAccessibleNormalizedTextScale = 11;
/// The minimum normalized text scale factor supported on iOS.
const double _kMinimumTextScaleFactor = 1 - 3 / _kCupertinoMobileBaseFontSize;
/// The minimum normalized text scale factor supported on iOS.
const double _kMaximumTextScaleFactor = 1 + 36 / _kCupertinoMobileBaseFontSize;
// Accessibility mode on iOS is determined by the text scale factor that the
// user has selected.
bool _isAccessibilityModeEnabled(BuildContext context) {
final TextScaler? textScaler = MediaQuery.maybeTextScalerOf(context);
if (textScaler == null) {
return false;
}
return _normalizeTextScale(textScaler) >= _kMinimumAccessibleNormalizedTextScale;
}
/// The width of a Cupertino menu
// Measured on:
// - iPadOS 18.5 Simulator
// - iPad Pro 11-inch
// - iPad Pro 13-inch
// - iOS 18.5 Simulator
// - iPhone 16 Pro
enum _CupertinoMenuWidth {
iPadOS(points: 262),
iPadOSAccessible(points: 343),
iOS(points: 250),
iOSAccessible(points: 370);
const _CupertinoMenuWidth({required this.points});
// Determines the appropriate menu width based on screen width and
// accessibility mode.
//
// A screen width threshold of 768 points is used to differentiate between
// mobile and tablet devices.
factory _CupertinoMenuWidth.fromScreenWidth({
required double screenWidth,
required bool isAccessibilityModeEnabled,
}) {
final bool isMobile = screenWidth < _kTabletWidthThreshold;
return switch ((isMobile, isAccessibilityModeEnabled)) {
(false, false) => _CupertinoMenuWidth.iPadOS,
(false, true) => _CupertinoMenuWidth.iPadOSAccessible,
(true, false) => _CupertinoMenuWidth.iOS,
(true, true) => _CupertinoMenuWidth.iOSAccessible,
};
}
final double points;
static const double _kTabletWidthThreshold = 768.0;
}
// TODO(davidhicks980): DynamicType should be moved to text_theme.dart when all
// styles are implemented. https://github.com/flutter/flutter/issues/179828
//
// After that, we should deduplicate the same table in menu_anchor_test.dart
//
// Obtained from
// https://developer.apple.com/design/human-interface-guidelines/typography#Specifications
//
// Note: SF Display doesn't have tracking values on HID guidelines, so the
// tracking values for SF Pro were used
enum _DynamicTypeStyle {
body(<TextStyle>[
TextStyle(fontSize: 14, height: 19 / 14, letterSpacing: -0.15, fontFamily: _kBodyFont),
TextStyle(fontSize: 15, height: 20 / 15, letterSpacing: -0.23, fontFamily: _kBodyFont),
TextStyle(fontSize: 16, height: 21 / 16, letterSpacing: -0.31, fontFamily: _kBodyFont),
TextStyle(fontSize: 17, height: 22 / 17, letterSpacing: -0.43, fontFamily: _kBodyFont),
TextStyle(fontSize: 19, height: 24 / 19, letterSpacing: -0.44, fontFamily: _kBodyFont),
TextStyle(fontSize: 21, height: 26 / 21, letterSpacing: -0.36, fontFamily: _kBodyFont),
TextStyle(fontSize: 23, height: 29 / 23, letterSpacing: -0.10, fontFamily: _kDisplayFont),
TextStyle(fontSize: 28, height: 34 / 28, letterSpacing: 0.38, fontFamily: _kDisplayFont),
TextStyle(fontSize: 33, height: 40 / 33, letterSpacing: 0.40, fontFamily: _kDisplayFont),
TextStyle(fontSize: 40, height: 48 / 40, letterSpacing: 0.37, fontFamily: _kDisplayFont),
TextStyle(fontSize: 47, height: 56 / 47, letterSpacing: 0.37, fontFamily: _kDisplayFont),
TextStyle(fontSize: 53, height: 62 / 53, letterSpacing: 0.31, fontFamily: _kDisplayFont),
]),
subhead(<TextStyle>[
TextStyle(fontSize: 12, height: 16 / 12, letterSpacing: 0, fontFamily: _kBodyFont),
TextStyle(fontSize: 13, height: 18 / 13, letterSpacing: -0.08, fontFamily: _kBodyFont),
TextStyle(fontSize: 14, height: 19 / 14, letterSpacing: -0.15, fontFamily: _kBodyFont),
TextStyle(fontSize: 15, height: 20 / 15, letterSpacing: -0.23, fontFamily: _kBodyFont),
TextStyle(fontSize: 17, height: 22 / 17, letterSpacing: -0.43, fontFamily: _kBodyFont),
TextStyle(fontSize: 19, height: 24 / 19, letterSpacing: -0.45, fontFamily: _kBodyFont),
TextStyle(fontSize: 21, height: 28 / 21, letterSpacing: -0.36, fontFamily: _kBodyFont),
TextStyle(fontSize: 25, height: 31 / 25, letterSpacing: 0.15, fontFamily: _kDisplayFont),
TextStyle(fontSize: 30, height: 37 / 30, letterSpacing: 0.40, fontFamily: _kDisplayFont),
TextStyle(fontSize: 36, height: 43 / 36, letterSpacing: 0.37, fontFamily: _kDisplayFont),
TextStyle(fontSize: 42, height: 50 / 42, letterSpacing: 0.37, fontFamily: _kDisplayFont),
TextStyle(fontSize: 49, height: 58 / 49, letterSpacing: 0.33, fontFamily: _kDisplayFont),
]);
const _DynamicTypeStyle(this.styles);
// A list of text style for iOS's various scales, which are: xSmall, small,
// medium, large, xLarge, xxLarge, xxxLarge, ax1, ax2, ax3, ax4, ax5.
final List<TextStyle> styles;
TextStyle resolveTextStyle(TextScaler textScaler) {
// Assert the length here instead of in the constructor since .length isn't
// accessible there.
assert(styles.length == _kScaleCount);
final double units = _normalizeTextScale(textScaler);
for (var i = 0; i < styles.length; i++) {
final int bodyUnits = _normalizedBodyScales[i];
if (units > bodyUnits) {
continue;
}
if (units == bodyUnits) {
return styles[i];
}
if (i == 0) {
return styles.first;
}
return TextStyle.lerp(
styles[i - 1],
styles[i],
_interpolateUnits(units, _normalizedBodyScales[i - 1], bodyUnits),
)!;
}
return styles.last;
}
static const int _kScaleCount = 12;
static final List<int> _normalizedBodyScales = UnmodifiableListView<int>(<int>[
for (final TextStyle style in _DynamicTypeStyle.body.styles)
(style.fontSize! - _kCupertinoMobileBaseFontSize).toInt(),
]);
static double _interpolateUnits(double units, int minimum, int maximum) {
final double t = (units - minimum) / (maximum - minimum);
return ui.lerpDouble(0, 1, t)!;
}
}
double _computeSquaredDistanceToRect(Offset point, Rect rect) {
final double dx = point.dx - ui.clampDouble(point.dx, rect.left, rect.right);
final double dy = point.dy - ui.clampDouble(point.dy, rect.top, rect.bottom);
return dx * dx + dy * dy;
}
/// Returns the nearest multiple of `to` to `value`.
double _roundToDivisible(double value, {required double to}) {
if (to == 0) {
return value;
}
return (value / to).round() * to;
}
/// Implement [CupertinoMenuEntry] to define how a menu item should be drawn in
/// a menu.
abstract interface class CupertinoMenuEntry {
/// Whether this menu item has a leading widget.
///
/// If [hasLeading] returns true, siblings of this menu item that are missing
/// a leading widget will have leading space added to align the leading edges
/// of all menu items.
bool hasLeading(BuildContext context);
/// Whether this menu item is a divider.
///
/// When true, a divider will not be drawn above or below this menu item.
/// Otherwise, adjacent menu items will be separated by a divider.
bool get isDivider;
}
class _AnchorScope extends InheritedWidget {
const _AnchorScope({required this.hasLeading, required super.child});
final bool hasLeading;
@override
bool updateShouldNotify(_AnchorScope oldWidget) {
return hasLeading != oldWidget.hasLeading;
}
}
/// Signature for the callback called in response to a [CupertinoMenuAnchor]
/// changing its [AnimationStatus].
typedef CupertinoMenuAnimationStatusChangedCallback = void Function(AnimationStatus status);
/// A widget used to mark the "anchor" for a menu, defining the rectangle used
/// to position the menu, which can be done with an explicit location, or
/// with an alignment.
///
/// The [CupertinoMenuAnchor] is typically used to wrap a button that opens a
/// menu when pressed. The menu is displayed as a popup overlay that is positioned
/// relative to the anchor rectangle, and will automatically reposition itself to remain
/// fully visible within the screen bounds.
///
/// A [MenuController] must be used to open and close the menu, and can be
/// obtained from the [builder] callback, or provided to [controller] parameter.
/// Calling [MenuController.open] will open the menu, and calling
/// [MenuController.close] will close the menu. The [onOpen] callback is invoked
/// when the menu popup is mounted and the menu status changes _from_
/// [AnimationStatus.dismissed]. The [onClose] callback is invoked when the menu
/// popup is unmounted and the menu status changes _to_
/// [AnimationStatus.dismissed]. The [onAnimationStatusChanged] callback is
/// invoked every time the [AnimationStatus] of the menu animation changes.
///
/// ## Usage
/// {@tool sample}
/// This example demonstrates a simple [CupertinoMenuAnchor] that wraps
/// a button.
///
/// ** See code in examples/api/lib/cupertino/menu_anchor/menu_anchor.0.dart **
/// {@end-tool}
///
/// {@tool dartpad}
/// This example demonstrates a [CupertinoMenuAnchor] that wraps a button and
/// shows a menu with three [CupertinoMenuItem]s and one [CupertinoMenuDivider].
///
/// ** See code in examples/api/lib/cupertino/menu_anchor/menu_anchor.1.dart **
/// {@end-tool}
///
/// See also:
///
/// * [CupertinoMenuItem], a Cupertino-themed menu item used in a
/// [CupertinoMenuAnchor].
/// * [CupertinoMenuDivider], a large divider used to separate
/// [CupertinoMenuItem]s.
/// * [CupertinoMenuEntry], an interface that can be implemented to customize
/// the appearance of menu items in a [CupertinoMenuAnchor].
class CupertinoMenuAnchor extends StatefulWidget {
/// Creates a [CupertinoMenuAnchor].
const CupertinoMenuAnchor({
super.key,
this.controller,
this.onOpen,
this.onClose,
this.onAnimationStatusChanged,
this.constraints,
this.constrainCrossAxis = false,
this.consumeOutsideTaps = false,
this.enableSwipe = true,
this.enableLongPressToOpen = false,
this.useRootOverlay = false,
this.overlayPadding = const EdgeInsets.all(8),
required this.menuChildren,
this.builder,
this.child,
this.childFocusNode,
}) : assert(
enableSwipe || !enableLongPressToOpen,
'enableLongPressToOpen cannot be true if enableSwipe is false',
);
/// An optional controller that allows opening and closing of the menu from
/// other widgets.
final MenuController? controller;
/// A callback that is invoked when the menu begins opening.
///
/// Defaults to null.
final VoidCallback? onOpen;
/// A callback that is invoked when the menu finishes closing.
///
/// Defaults to null.
final VoidCallback? onClose;
/// An optional callback that is invoked when the [AnimationStatus] of the
/// menu changes.
///
/// This callback provides a way to determine when the menu is opening or
/// closing. This is necessary because the [MenuController.isOpen] property
/// remains true throughout the opening, opened, and closing phases, and
/// therefore cannot be used on its own to determine the current animation
/// direction.
///
/// Defaults to null.
final CupertinoMenuAnimationStatusChangedCallback? onAnimationStatusChanged;
/// The constraints to apply to the menu scrollable.
final BoxConstraints? constraints;
/// Whether the menu's cross axis should be constrained by the overlay.
///
/// If true, when the menu is wider than the overlay, the menu width will
/// shrink to fit the overlay bounds.
///
/// If false, the menu will grow to fit the size of its contents. If the menu
/// is wider than the overlay, it will be clipped to the overlay's bounds.
///
/// Defaults to false.
final bool constrainCrossAxis;
/// Whether or not a tap event that closes the menu will be permitted to
/// continue on to the gesture arena.
///
/// If false, then tapping outside of a menu when the menu is open will both
/// close the menu, and allow the tap to participate in the gesture arena. If
/// true, then it will only close the menu, and the tap event will be
/// consumed.
///
/// Defaults to false.
final bool consumeOutsideTaps;
/// Whether or not swiping is enabled on the menu.
///
/// When swiping is enabled, a [MultiDragGestureRecognizer] is added around
/// the widget built by [builder] and menu items. The
/// [MultiDragGestureRecognizer] allows for users to press, move, and activate
/// adjacent menu items in a single gesture. Swiping also scales the menu
/// panel when users drag their pointer away from the menu.
///
/// Disabling swiping can be useful if the menu swipe effects interfere with
/// another swipe gesture, such as in the case of dragging a menu anchor
/// around the screen.
///
/// Defaults to true.
final bool enableSwipe;
/// Whether or not the menu should open in response to a long-press on the
/// anchor.
///
/// When a menu is opened via long-press, the menu can be swiped in the same
/// gesture to select and activate menu items.
///
/// Because long-press-to-open relies on the swipe gesture, [enableSwipe] must
/// be true if [enableLongPressToOpen] is true.
///
/// If the widget built by [builder] is disabled, [enableLongPressToOpen]
/// should be set to false to prevent the menu from opening on long-press.
///
/// Defaults to false, which disables the behavior.
final bool enableLongPressToOpen;
/// {@macro flutter.widgets.RawMenuAnchor.useRootOverlay}
final bool useRootOverlay;
/// The padding inside the overlay between its boundary and the menu content.
///
/// If the menu width is larger than the available space in the overlay minus
/// the [overlayPadding] and [constrainCrossAxis] is false, the menu will be
/// positioned against the starting edge of the overlay (left when the ambient
/// [Directionality] is [TextDirection.ltr], and right when the ambient
/// [Directionality] is [TextDirection.rtl]). If [constrainCrossAxis] is true,
/// the menu width will shrink to fit within the overlay bounds minus the
/// [overlayPadding].
///
/// Defaults to `EdgeInsets.all(8)`.
final EdgeInsetsGeometry overlayPadding;
/// A list of menu items to display in the menu.
final List<Widget> menuChildren;
/// The widget that this [CupertinoMenuAnchor] surrounds.
///
/// Typically, this is a button that calls [MenuController.open] when pressed.
///
/// If null, the [CupertinoMenuAnchor] will be the size that its parent
/// allocates for it.
final RawMenuAnchorChildBuilder? builder;
/// An optional child to be passed to the [builder].
///
/// Supply this child if there is a portion of the widget tree built in
/// [builder] that doesn't depend on the `controller` or `context` supplied to
/// the [builder]. It will be more efficient, since Flutter doesn't then need
/// to rebuild this child when those change.
final Widget? child;
/// The [childFocusNode] attribute is the optional [FocusNode] also associated
/// the [child] or [builder] widget that opens the menu.
///
/// The focus node should be attached to the widget that should receive focus
/// if keyboard focus traversal moves the focus off of the submenu with the
/// arrow keys.
///
/// If not supplied, then focus will not traverse from the menu to the
/// controlling button after the menu opens.
final FocusNode? childFocusNode;
/// Returns whether any ancestor [CupertinoMenuAnchor] has menu items with
/// leading widgets.
///
/// This can be used by menu items to determine whether they need to
/// allocate space for a leading widget to align with sibling menu items.
static bool? maybeHasLeadingOf(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<_AnchorScope>()?.hasLeading;
}
@override
State<CupertinoMenuAnchor> createState() => _CupertinoMenuAnchorState();
@override
List<DiagnosticsNode> debugDescribeChildren() {
return menuChildren.map<DiagnosticsNode>((Widget child) => child.toDiagnosticsNode()).toList();
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<FocusNode?>('childFocusNode', childFocusNode));
properties.add(DiagnosticsProperty<BoxConstraints?>('constraints', constraints));
properties.add(
FlagProperty(
'constrainCrossAxis',
value: constrainCrossAxis,
ifTrue: 'constrains cross axis',
),
);
properties.add(
FlagProperty(
'enableSwipe',
value: enableSwipe,
ifTrue: 'swipe enabled',
ifFalse: 'swipe disabled',
),
);
properties.add(
FlagProperty(
'consumeOutsideTaps',
value: consumeOutsideTaps,
ifTrue: 'consumes outside taps',
),
);
properties.add(
FlagProperty('useRootOverlay', value: useRootOverlay, ifTrue: 'uses root overlay'),
);
properties.add(DiagnosticsProperty<EdgeInsetsGeometry>('overlayPadding', overlayPadding));
}
}
class _CupertinoMenuAnchorState extends State<CupertinoMenuAnchor> with TickerProviderStateMixin {
static const Duration longPressToOpenDuration = Duration(milliseconds: 400);
static const Tolerance springTolerance = Tolerance(velocity: 0.1);
// Approximated from the iOS 18.5 Simulator.
static final SpringDescription forwardSpring = SpringDescription.withDurationAndBounce(
duration: const Duration(milliseconds: 337),
bounce: 0.2,
);
// Approximated from the iOS 18.5 Simulator.
static final SpringDescription reverseSpring = SpringDescription.withDurationAndBounce(
duration: const Duration(milliseconds: 409),
);
late final AnimationController _animationController;
final FocusScopeNode _menuScopeNode = FocusScopeNode(debugLabel: 'Menu Scope');
final ValueNotifier<double> _swipeDistanceNotifier = ValueNotifier<double>(0);
bool? _hasLeadingWidget;
MenuController get _menuController => widget.controller ?? _internalMenuController!;
MenuController? _internalMenuController;
bool get isOpening => _animationStatus.isForwardOrCompleted;
bool get enableSwipe =>
widget.enableSwipe &&
switch (_animationStatus) {
AnimationStatus.forward || AnimationStatus.completed || AnimationStatus.dismissed => true,
AnimationStatus.reverse => false,
};
AnimationStatus _animationStatus = AnimationStatus.dismissed;
@override
void initState() {
super.initState();
if (widget.controller == null) {
_internalMenuController = MenuController();
}
_animationController = AnimationController.unbounded(vsync: this);
_animationController.addStatusListener(_handleAnimationStatusChange);
}
@override
void didUpdateWidget(CupertinoMenuAnchor oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.controller != widget.controller) {
if (widget.controller != null) {
_internalMenuController = null;
} else {
assert(_internalMenuController == null);
_internalMenuController = MenuController();
}
}
if (oldWidget.menuChildren != widget.menuChildren) {
_hasLeadingWidget = _resolveHasLeading();
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_hasLeadingWidget ??= _resolveHasLeading();
}
@override
void dispose() {
_menuScopeNode.dispose();
_animationController
..stop()
..dispose();
_internalMenuController = null;
_swipeDistanceNotifier.dispose();
super.dispose();
}
bool _resolveHasLeading() {
return widget.menuChildren.any((Widget element) {
return switch (element) {
final CupertinoMenuEntry entry => entry.hasLeading(context),
_ => false,
};
});
}
void _handleAnimationStatusChange(AnimationStatus status) {
setState(() {
_animationStatus = status;
});
widget.onAnimationStatusChanged?.call(status);
}
void _handleSwipeDistanceChange(double distance) {
if (!_menuController.isOpen) {
return;
}
// Because we are triggering a nested ticker, it's easiest to pass a
// listenable down the tree. Otherwise, it would be more idiomatic to use
// an inherited widget.
_swipeDistanceNotifier.value = distance;
}
void _handleAnchorSwipeStart() {
if (isOpening || !widget.enableLongPressToOpen) {
return;
}
_menuController.open();
}
void _handleCloseRequested(VoidCallback hideMenu) {
if (_animationStatus case AnimationStatus.reverse || AnimationStatus.dismissed) {
return;
}
_animationController
.animateBackWith(
ClampedSimulation(
SpringSimulation(
reverseSpring,
_animationController.value,
0.0,
0.0,
tolerance: springTolerance,
),
xMin: 0.0,
xMax: 1.0,
),
)
.whenComplete(hideMenu);
}
void _handleOpenRequested(ui.Offset? position, VoidCallback showOverlay) {
showOverlay();
if (_animationStatus case AnimationStatus.completed || AnimationStatus.forward) {
return;
}
_animationController.animateWith(
SpringSimulation(forwardSpring, _animationController.value, 1, 0.5),
);
FocusScope.of(context).setFirstFocus(_menuScopeNode);
}
Widget _buildMenuOverlay(BuildContext childContext, RawMenuOverlayInfo info) {
return ExcludeSemantics(
excluding: !isOpening,
child: IgnorePointer(
ignoring: !isOpening,
child: ExcludeFocus(
excluding: !isOpening,
child: _MenuOverlay(
constrainCrossAxis: widget.constrainCrossAxis,
visibilityAnimation: _animationController.view,
swipeDistanceListenable: _swipeDistanceNotifier,
constraints: widget.constraints,
consumeOutsideTaps: widget.consumeOutsideTaps,
overlaySize: info.overlaySize,
anchorRect: info.anchorRect,
anchorPosition: info.position,
tapRegionGroupId: info.tapRegionGroupId,
focusScopeNode: _menuScopeNode,
overlayPadding: widget.overlayPadding,
children: widget.menuChildren,
),
),
),
);
}
Widget _buildChild(BuildContext context, MenuController controller, Widget? child) {
final Widget anchor =
widget.builder?.call(context, _menuController, widget.child) ??
widget.child ??
const SizedBox.shrink();
if (!widget.enableLongPressToOpen || !enableSwipe) {
return anchor;
}
return _SwipeSurface(
onStart: _handleAnchorSwipeStart,
delay: longPressToOpenDuration,
child: anchor,
);
}
@override
Widget build(BuildContext context) {
return _SwipeRegion(
onDistanceChanged: _handleSwipeDistanceChange,
enabled: enableSwipe,
child: _AnchorScope(
hasLeading: _hasLeadingWidget!,
child: RawMenuAnchor(
useRootOverlay: widget.useRootOverlay,
onCloseRequested: _handleCloseRequested,
onOpenRequested: _handleOpenRequested,
overlayBuilder: _buildMenuOverlay,
builder: _buildChild,
controller: _menuController,
childFocusNode: widget.childFocusNode,
consumeOutsideTaps: widget.consumeOutsideTaps,
onClose: widget.onClose,
onOpen: widget.onOpen,
),
),
);
}
}
class _MenuOverlay extends StatefulWidget {
const _MenuOverlay({
required this.children,
required this.focusScopeNode,
required this.consumeOutsideTaps,
required this.constrainCrossAxis,
required this.constraints,
required this.overlaySize,
required this.overlayPadding,
required this.anchorRect,
required this.anchorPosition,
required this.tapRegionGroupId,
required this.visibilityAnimation,
required this.swipeDistanceListenable,
});
final List<Widget> children;
final FocusScopeNode focusScopeNode;
final bool consumeOutsideTaps;
final bool constrainCrossAxis;
final BoxConstraints? constraints;
final Size overlaySize;
final EdgeInsetsGeometry overlayPadding;
final Rect anchorRect;
final Offset? anchorPosition;
final Object tapRegionGroupId;
final Animation<double> visibilityAnimation;
final ValueListenable<double> swipeDistanceListenable;
@override
State<_MenuOverlay> createState() => _MenuOverlayState();
}
class _MenuOverlayState extends State<_MenuOverlay>
with TickerProviderStateMixin, WidgetsBindingObserver {
static final Map<Type, Action<Intent>> _actions = <Type, Action<Intent>>{
_FocusDownIntent: _FocusDownAction(),
_FocusUpIntent: _FocusUpAction(),
_FocusFirstIntent: _FocusFirstAction(),
_FocusLastIntent: _FocusLastAction(),
};
late final AnimationController _swipeAnimationController;
final ScrollController _scrollController = ScrollController();
final ProxyAnimation _scaleAnimation = ProxyAnimation();
final ProxyAnimation _fadeAnimation = ProxyAnimation();
final ProxyAnimation _sizeAnimation = ProxyAnimation();
late Alignment _attachmentPointAlignment;
late ui.Offset _attachmentPoint;
late Alignment _menuAlignment;
List<Widget> _children = <Widget>[];
ui.TextDirection? _textDirection;
// The actual distance the user has swiped away from the menu.
double _swipeTargetDistance = 0;
// The effective distance the user has swiped away from the menu, after
// applying velocity and deceleration.
double _swipeCurrentDistance = 0;
// The accumulated velocity of the swipe gesture, used to determine how fast
// the menu scales to _swipeTargetDistance
double _swipeVelocity = 0;
// A ticker used to drive the swipe animation.
Ticker? _swipeTicker;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_swipeAnimationController = AnimationController.unbounded(value: 1, vsync: this);
widget.swipeDistanceListenable.addListener(_handleSwipeDistanceChanged);
_resolveChildren();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final ui.TextDirection newTextDirection = Directionality.of(context);
if (_textDirection != newTextDirection) {
_textDirection = newTextDirection;
_resolvePosition();
}
_resolveMotion();
}
@override
void didUpdateWidget(_MenuOverlay oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.swipeDistanceListenable != widget.swipeDistanceListenable) {
oldWidget.swipeDistanceListenable.removeListener(_handleSwipeDistanceChanged);
widget.swipeDistanceListenable.addListener(_handleSwipeDistanceChanged);
}
if (oldWidget.visibilityAnimation != widget.visibilityAnimation) {
_resolveMotion();
}
if (oldWidget.anchorRect != widget.anchorRect ||
oldWidget.anchorPosition != widget.anchorPosition ||
oldWidget.overlaySize != widget.overlaySize) {
_resolvePosition();
}
if (oldWidget.children != widget.children) {
_resolveChildren();
}
}
@override
void didChangeAccessibilityFeatures() {
super.didChangeAccessibilityFeatures();
_resolveMotion();
}
@override
void dispose() {
_scrollController.dispose();
widget.swipeDistanceListenable.removeListener(_handleSwipeDistanceChanged);
_swipeTicker
?..stop()
..dispose();
_swipeAnimationController
..stop()
..dispose();
_scaleAnimation.parent = null;
_fadeAnimation.parent = null;
_sizeAnimation.parent = null;
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
void _resolveChildren() {
if (widget.children.isEmpty) {
_children = <Widget>[];
return;
}
final children = <Widget>[];
Widget child = widget.children.first;
for (var i = 0; i < widget.children.length; i++) {
children.add(child);
if (child == widget.children.last) {
break;
}
if (child case CupertinoMenuEntry(isDivider: true)) {
child = widget.children[i + 1];
continue;
}
child = widget.children[i + 1];
if (child case CupertinoMenuEntry(isDivider: true)) {
continue;
}
children.add(const _CupertinoMenuImplicitDivider());
}
_children = children;
}
void _resolveMotion() {
// Behavior of reduce motion is based on iOS 18.5 simulator. Because the
// disableAnimations accessibility feature is not present on iOS, all
// animations are disabled when disableAnimations is enabled.
final ui.AccessibilityFeatures accessibilityFeatures = View.of(
context,
).platformDispatcher.accessibilityFeatures;
switch (accessibilityFeatures) {
case ui.AccessibilityFeatures(disableAnimations: true):
_scaleAnimation.parent = kAlwaysCompleteAnimation;
_fadeAnimation.parent = kAlwaysCompleteAnimation;
_sizeAnimation.parent = kAlwaysCompleteAnimation;
case ui.AccessibilityFeatures(reduceMotion: true):
// Swipe scaling works with reduced motion.
_scaleAnimation.parent = _swipeAnimationController.view.drive(
Tween<double>(begin: 0.8, end: 1),
);
_sizeAnimation.parent = kAlwaysCompleteAnimation;
_fadeAnimation.parent = widget.visibilityAnimation.drive(
CurveTween(curve: Curves.easeIn).chain(const _ClampTween(begin: 0, end: 1)),
);
case _:
_scaleAnimation.parent = _AnimationProduct(
first: widget.visibilityAnimation,
next: _swipeAnimationController.view.drive(Tween<double>(begin: 0.8, end: 1)),
);
_sizeAnimation.parent = widget.visibilityAnimation.drive(Tween<double>(begin: 0.8, end: 1));
_fadeAnimation.parent = widget.visibilityAnimation.drive(
CurveTween(curve: Curves.easeIn).chain(const _ClampTween(begin: 0, end: 1)),
);
}
}
// Position was determined using iOS 18.5 simulator (phone + tablet).
//
// Layout needs to be resolved outside of the layout delegate because the
// ScaleTransition widget is dependent on the attachment point alignment.
void _resolvePosition() {
final ui.Offset anchorMidpoint;
if (widget.anchorPosition != null) {
anchorMidpoint = widget.anchorRect.topLeft + widget.anchorPosition!;
} else {
anchorMidpoint = widget.anchorRect.center;
}
final double xMidpointRatio = anchorMidpoint.dx / widget.overlaySize.width;
final double yMidpointRatio = anchorMidpoint.dy / widget.overlaySize.height;
// Slightly favor placing the menu below the anchor when it is near the vertical
// center of the screen.
final double dy = yMidpointRatio < 0.55 ? 1 : -1;
final double dx = switch (xMidpointRatio) {
< 0.4 => -1.0, // Left
> 0.6 => 1.0, // Right
_ => 0.0, // Center
};
_menuAlignment = Alignment(dx, -dy);
final Offset transformOrigin;
if (widget.anchorPosition != null) {
_attachmentPoint = widget.anchorRect.topLeft + widget.anchorPosition!;
transformOrigin = _attachmentPoint;
} else {
_attachmentPoint = Alignment(dx, dy).withinRect(widget.anchorRect);
transformOrigin = Alignment(0, dy).withinRect(widget.anchorRect);
}
final double xOriginRatio = transformOrigin.dx / widget.overlaySize.width;
final double yOriginRatio = transformOrigin.dy / widget.overlaySize.height;
// The alignment of the menu growth point relative to the overlay.
_attachmentPointAlignment = Alignment(xOriginRatio * 2 - 1, yOriginRatio * 2 - 1);
}
void _handleOutsideTap(PointerDownEvent event) {
MenuController.maybeOf(context)!.close();
}
void _handleSwipeDistanceChanged() {
_swipeTargetDistance = ui.clampDouble(widget.swipeDistanceListenable.value, 0, 150);
if (_swipeCurrentDistance == _swipeTargetDistance) {
return;
}
_swipeTicker ??= createTicker(_updateSwipeScale);
if (!_swipeTicker!.isActive) {
_swipeTicker!.start();
}
}
// The menu will scale between 80% and 100% of its size based on the distance
// the user has dragged their pointer away from the menu edges.
void _updateSwipeScale(Duration elapsed) {
const maxVelocity = 20.0;
const double minVelocity = 8;
const double maxSwipeDistance = 150;
const accelerationRate = 0.12;
// The distance below which velocity begins to decelerate.
//
// When the swipe distance to target is less than this value, the animation
// velocity reduces proportionally to create smooth arrival at the target.
// Higher values mean the animation begins to decelerate sooner, resulting to
// a smoother animation curve.
const double decelerationDistanceThreshold = 80;
// The distance at which the animation will snap to the target distance without
// any animation.
const remainingDistanceSnapThreshold = 1.0;
// When the user's pointer is within this distance of the menu edges, the
// swipe animation will terminate.
const terminationDistanceThreshold = 5.0;
final double distance = _swipeTargetDistance - _swipeCurrentDistance;
final double absoluteDistance = distance.abs();
// As the distance between the current position and the target position increases,
// the proximity factor approaches 1.0, which increases acceleration.
//
// Conversely, as the current position nears the target within the deceleration
// zone, the proximity factor approaches 0.0, which decreases acceleration
// and smoothes the end of the animation.
final double proximityFactor = math.min(absoluteDistance / decelerationDistanceThreshold, 1.0);
_swipeVelocity += accelerationRate * proximityFactor;
_swipeVelocity = ui.clampDouble(_swipeVelocity, minVelocity, maxVelocity);
final double finalVelocity = _swipeVelocity * proximityFactor;
final double distanceReduction = distance.sign * finalVelocity;
_swipeCurrentDistance += distanceReduction;
if (absoluteDistance < remainingDistanceSnapThreshold) {
_swipeCurrentDistance = _swipeTargetDistance;
_swipeVelocity = 0;
if (_swipeTargetDistance < terminationDistanceThreshold) {
_swipeTicker!.stop();
}
}
_swipeAnimationController.value = 1 - _swipeCurrentDistance / maxSwipeDistance;
}
Widget _buildAlign(BuildContext context, Widget? child) {
return Align(
heightFactor: _sizeAnimation.value,
widthFactor: 1.0,
alignment: Alignment.topCenter,
child: child,
);
}
@override
Widget build(BuildContext context) {
final BoxConstraints constraints;
if (widget.constraints != null) {
constraints = widget.constraints!;
} else {
final bool isAccessibilityModeEnabled = _isAccessibilityModeEnabled(context);
final double screenWidth = MediaQuery.widthOf(context);
final menuWidth = _CupertinoMenuWidth.fromScreenWidth(
isAccessibilityModeEnabled: isAccessibilityModeEnabled,
screenWidth: screenWidth,
);
constraints = BoxConstraints.tightFor(width: menuWidth.points);
}
Widget child = _SwipeSurface(
child: TapRegion(
groupId: widget.tapRegionGroupId,
consumeOutsideTaps: widget.consumeOutsideTaps,
onTapOutside: _handleOutsideTap,
child: Actions(
actions: _actions,
child: Shortcuts(
shortcuts: _kMenuTraversalShortcuts,
child: FocusScope(
node: widget.focusScopeNode,
descendantsAreFocusable: true,
descendantsAreTraversable: true,
canRequestFocus: true,
// A custom shadow painter is used to make the underlying colors
// appear more vibrant.
child: CustomPaint(
painter: _ShadowPainter(
brightness: CupertinoTheme.maybeBrightnessOf(context) ?? ui.Brightness.light,
repaint: _fadeAnimation,
),
// The FadeTransition widget needs to wrap Semantics so
// that the semantics widget senses that the menu is the
// same opacity as the menu items. Otherwise, "a menu
// cannot be empty" error is thrown due to the menu items
// being transparent while the menu semantics are still
// present.
child: FadeTransition(
opacity: _fadeAnimation,
alwaysIncludeSemantics: true,
// TODO(davidhicks980): Use CupertinoPopupSurface when
// appearance is fixed on Impeller.
// https://github.com/flutter/flutter/issues/182066
child: ClipRSuperellipse(
borderRadius: const BorderRadius.all(Radius.circular(13)),
child: BackdropFilter(
filter: ui.ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: ColoredBox(
color: CupertinoDynamicColor.resolve(_kMenuSurfaceColor, context),
child: AnimatedBuilder(
animation: _sizeAnimation,
builder: _buildAlign,
child: Semantics(
explicitChildNodes: true,
scopesRoute: true,
child: ConstrainedBox(
constraints: constraints,
child: SingleChildScrollView(
clipBehavior: Clip.none,
child: Column(mainAxisSize: MainAxisSize.min, children: _children),
),
),
),
),
),
),
),
),
),
),
),
),
),
);
// The menu content can grow beyond the size of the overlay, but will be
// clipped by the overlay's bounds.
if (!widget.constrainCrossAxis) {
child = UnconstrainedBox(
clipBehavior: Clip.hardEdge,
alignment: AlignmentDirectional.centerStart,
constrainedAxis: Axis.vertical,
child: child,
);
}
return ConstrainedBox(
constraints: BoxConstraints.loose(widget.overlaySize),
child: ScaleTransition(
scale: _scaleAnimation,
alignment: _attachmentPointAlignment,
child: ValueListenableBuilder<double>(
valueListenable: _sizeAnimation,
child: child,
builder: (BuildContext context, double value, Widget? child) {
final ui.Rect anchorRect = widget.anchorPosition != null
? _attachmentPoint & Size.zero
: widget.anchorRect;
final List<ui.DisplayFeature>? displayFeatures = MediaQuery.maybeDisplayFeaturesOf(
context,
);
return CustomSingleChildLayout(
delegate: _MenuLayoutDelegate(
anchorRect: anchorRect,
attachmentPoint: _attachmentPoint,
menuAlignment: _menuAlignment,
overlayPadding: widget.overlayPadding.resolve(_textDirection),
heightFactor: value,
avoidBounds: displayFeatures != null ? avoidBounds(displayFeatures) : <Rect>{},
),
child: child,
);
},
),
),
);
}
static Set<ui.Rect> avoidBounds(List<ui.DisplayFeature> displayFeatures) {
final bounds = <ui.Rect>{};
for (final feature in displayFeatures) {
if (feature.bounds.shortestSide > 0 ||
feature.state == ui.DisplayFeatureState.postureHalfOpened) {
bounds.add(feature.bounds);
}
}
return bounds;
}
}
class _ShadowPainter extends CustomPainter {
const _ShadowPainter({required this.brightness, required this.repaint}) : super(repaint: repaint);
static const Radius radius = Radius.circular(13);
static const double shadowOpacity = 0.12;
double get shadowAnimation => ui.clampDouble(repaint.value, 0, 1);
final Animation<double> repaint;
final ui.Brightness brightness;
@override
void paint(Canvas canvas, Size size) {
assert(shadowAnimation >= 0 && shadowAnimation <= 1);
final center = Offset(size.width / 2, size.height / 2);
final rect = Rect.fromCenter(center: center, width: size.width, height: size.height);
final roundedRect = RSuperellipse.fromRectAndRadius(rect, radius);
final double blurSigma = shadowAnimation * 50;
final shadowPaint = Paint()
..maskFilter = MaskFilter.blur(BlurStyle.normal, blurSigma)
..color = ui.Color.fromRGBO(0, 0, 10, shadowAnimation * shadowAnimation * shadowOpacity);
final maskPath = Path()
..fillType = ui.PathFillType.evenOdd
// Extra large rect to ensure the shadow is fully visible.
..addRect(rect.inflate(200))
..addRRect(RRect.fromRectAndRadius(rect, radius));
// Clip the shadow underneath the menu shape to make the shadow appear more
// vibrant.
canvas
..save()
..clipPath(maskPath)
..drawRSuperellipse(roundedRect.inflate(50), shadowPaint)
..restore();
}
@override
bool shouldRepaint(_ShadowPainter oldDelegate) =>
oldDelegate.brightness != brightness || oldDelegate.repaint != repaint;
@override
bool shouldRebuildSemantics(_ShadowPainter oldDelegate) => false;
}
class _MenuLayoutDelegate extends SingleChildLayoutDelegate {
const _MenuLayoutDelegate({
required this.anchorRect,
required this.menuAlignment,
required this.overlayPadding,
required this.attachmentPoint,
required this.heightFactor,
required this.avoidBounds,
});
// Rectangle anchoring the menu
final ui.Rect anchorRect;
// The offset of the menu from the top-left corner of the overlay.
final ui.Offset attachmentPoint;
// The resolved alignment of the menu attachment point relative to the menu surface.
final Alignment menuAlignment;
// Unsafe bounds used when constraining and positioning the menu.
//
// Used to prevent the menu from being obstructed by system UI.
final EdgeInsets overlayPadding;
// The factor by which to multiply the height of the child.
final double heightFactor;
// List of rectangles that the menu should not overlap. Unusable screen area.
final Set<Rect> avoidBounds;
@override
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
// The menu can be at most the size of the overlay minus padding.
return BoxConstraints.loose(constraints.biggest).deflate(overlayPadding);
}
@override
Offset getPositionForChild(Size size, Size childSize) {
final double inverseHeightFactor = heightFactor > 0.01 ? 1 / heightFactor : 0;
// size: The size of the overlay.
// childSize: The size of the menu, when fully open, as determined by
// getConstraintsForChild.
final double finalHeight = math.min(childSize.height * inverseHeightFactor, size.height);
final finalSize = Size(childSize.width, finalHeight);
final ui.Offset desiredPosition = attachmentPoint - menuAlignment.alongSize(finalSize);
final ui.Rect screen = _findClosestScreen(size, anchorRect.center, avoidBounds);
final ui.Offset finalPosition = _positionChild(screen, finalSize, desiredPosition, anchorRect);
// If the menu sits above the anchor when fully open, grow upward:
// keep the bottom (attachment) fixed by shifting the top-left during animation.
final bool growsUp = finalPosition.dy + finalSize.height <= anchorRect.center.dy;
if (growsUp) {
final double dy = finalHeight - childSize.height;
return Offset(finalPosition.dx, finalPosition.dy + dy);
}
final initialPosition = Offset(finalPosition.dx, anchorRect.bottom);
return Offset.lerp(initialPosition, finalPosition, heightFactor)!;
}
Offset _positionChild(Rect screen, Size childSize, Offset position, ui.Rect anchor) {
double x = position.dx;
double y = position.dy;
bool overLeftEdge(double x) => x < screen.left + overlayPadding.left;
bool overRightEdge(double x) => x > screen.right - childSize.width - overlayPadding.right;
bool overTopEdge(double y) => y < screen.top + overlayPadding.top;
bool overBottomEdge(double y) => y > screen.bottom - childSize.height - overlayPadding.bottom;
// Layout horizontally first to determine if the menu can be placed on
// either side of the anchor without overlapping.
bool hasHorizontalAnchorOverlap = childSize.width >= screen.width;
if (hasHorizontalAnchorOverlap) {
x = screen.left + overlayPadding.left;
} else {
if (overLeftEdge(x)) {
// Flip the X position across the horizontal midpoint of the anchor so
// that the menu is to the right of the anchor.
final double flipX = anchor.center.dx * 2 - position.dx - childSize.width;
hasHorizontalAnchorOverlap = overRightEdge(flipX);
if (hasHorizontalAnchorOverlap || overLeftEdge(flipX)) {
x = screen.left + overlayPadding.left;
} else {
x = flipX;
}
} else if (overRightEdge(x)) {
// Flip the X position across the horizontal midpoint of the anchor so
// that the menu is to the left of the anchor.
final double flipX = anchor.center.dx * 2 - position.dx - childSize.width;
hasHorizontalAnchorOverlap = overLeftEdge(flipX);
if (hasHorizontalAnchorOverlap || overRightEdge(flipX)) {
x = screen.right - childSize.width - overlayPadding.right;
} else {
x = flipX;
}
}
}
if (childSize.height >= screen.height) {
// Menu is too big to fit on screen. Fit as much as possible.
return Offset(x, screen.top + overlayPadding.top);
}
// Behavior in this scenario could not be determined on iOS 18.5
// simulator, so this logic is based on what seems most reasonable.
if (hasHorizontalAnchorOverlap && !anchor.isEmpty) {
// If both horizontal screen edges overlap, shift the menu upwards or
// downwards by the minimum amount needed to avoid overlapping the anchor.
//
// NOTE: Menus that are deliberately overlapping the anchor will stop
// overlapping the anchor, but only when the screen's width is smaller
// than the menu's width.
final double below = anchor.bottom - y;
final double above = y + childSize.height - anchor.top;
if (below > 0 && above > 0) {
if (below > above) {
y = anchor.top - childSize.height;
} else {
y = anchor.bottom;
}
}
}
if (overTopEdge(y)) {
// Flip the Y position across the vertical midpoint of the anchor so that
// the menu is below the anchor.
final double flipY = anchor.center.dy * 2 - position.dy - childSize.height;
if (overTopEdge(flipY) || overBottomEdge(flipY)) {
y = screen.top + overlayPadding.top;
} else {
y = flipY;
}
} else if (overBottomEdge(y)) {
// Flip the Y position across the vertical midpoint of the anchor so that
// the menu is above the anchor.
final double flipY = anchor.center.dy * 2 - position.dy - childSize.height;
if (overTopEdge(flipY) || overBottomEdge(flipY)) {
y = screen.bottom - childSize.height - overlayPadding.bottom;
} else {
y = flipY;
}
}
return Offset(x, y);
}
// Finds the closest screen to the anchor point.
//
// This algorithm is different than the algorithms for PopupMenuButton and MenuAnchor,
// since those widgets calculate the closest screen based on the center of the
// overlay.
Rect _findClosestScreen(Size parentSize, Offset point, Set<Rect> avoidBounds) {
final Iterable<ui.Rect> screens = DisplayFeatureSubScreen.subScreensInBounds(
Offset.zero & parentSize,
avoidBounds,
);
Rect? closest;
double closestSquaredDistance = 0;
for (final screen in screens) {
if (screen.contains(point)) {
return screen;
}
if (closest == null) {
closest = screen;
closestSquaredDistance = _computeSquaredDistanceToRect(point, closest);
continue;
}
final double squaredDistance = _computeSquaredDistanceToRect(point, screen);
if (squaredDistance < closestSquaredDistance) {
closest = screen;
closestSquaredDistance = squaredDistance;
}
}
return closest!;
}
@override
bool shouldRelayout(_MenuLayoutDelegate oldDelegate) {
return menuAlignment != oldDelegate.menuAlignment ||
attachmentPoint != oldDelegate.attachmentPoint ||
anchorRect != oldDelegate.anchorRect ||
overlayPadding != oldDelegate.overlayPadding ||
heightFactor != oldDelegate.heightFactor ||
!setEquals(avoidBounds, oldDelegate.avoidBounds);
}
}
class _FocusUpIntent extends DirectionalFocusIntent {
const _FocusUpIntent() : super(TraversalDirection.up);
}
class _FocusDownIntent extends DirectionalFocusIntent {
const _FocusDownIntent() : super(TraversalDirection.down);
}
class _FocusUpAction extends ContextAction<DirectionalFocusIntent> {
_FocusUpAction();
@override
void invoke(DirectionalFocusIntent intent, [BuildContext? context]) {
final FocusTraversalPolicy policy =
FocusTraversalGroup.maybeOf(context!) ?? ReadingOrderTraversalPolicy();
if (_isCupertino && !kIsWeb) {
// Don't wrap on iOS or macOS.
policy.inDirection(primaryFocus!, intent.direction);
return;
}
final FocusNode? firstFocus = policy.findFirstFocus(primaryFocus!, ignoreCurrentFocus: true);
final FocusNode lastFocus = policy.findLastFocus(primaryFocus!, ignoreCurrentFocus: true);
if (lastFocus.context != null) {
if (primaryFocus == lastFocus.enclosingScope || primaryFocus == firstFocus) {
policy.requestFocusCallback(lastFocus);
return;
}
}
policy.inDirection(primaryFocus!, intent.direction);
}
}
class _FocusDownAction extends ContextAction<DirectionalFocusIntent> {
_FocusDownAction();
@override
void invoke(DirectionalFocusIntent intent, [BuildContext? context]) {
final FocusTraversalPolicy policy =
FocusTraversalGroup.maybeOf(context!) ?? ReadingOrderTraversalPolicy();
if (_isCupertino && !kIsWeb) {
// Don't wrap on iOS or macOS.
policy.inDirection(primaryFocus!, intent.direction);
return;
}
final FocusNode? firstFocus = policy.findFirstFocus(primaryFocus!, ignoreCurrentFocus: true);
final FocusNode lastFocus = policy.findLastFocus(primaryFocus!, ignoreCurrentFocus: true);
if (firstFocus?.context != null) {
if (primaryFocus == firstFocus!.enclosingScope || primaryFocus == lastFocus) {
policy.requestFocusCallback(firstFocus);
return;
}
}
policy.inDirection(primaryFocus!, intent.direction);
}
}
class _FocusFirstIntent extends Intent {
const _FocusFirstIntent();
}
class _FocusFirstAction extends ContextAction<_FocusFirstIntent> {
_FocusFirstAction();
@override
void invoke(_FocusFirstIntent intent, [BuildContext? context]) {
final FocusTraversalPolicy policy =
FocusTraversalGroup.maybeOf(context!) ?? ReadingOrderTraversalPolicy();
final FocusNode? firstFocus = policy.findFirstFocus(primaryFocus!, ignoreCurrentFocus: true);
if (firstFocus == null || firstFocus.context == null) {
return;
}
policy.requestFocusCallback(firstFocus);
}
}
class _FocusLastIntent extends Intent {
const _FocusLastIntent();
}
class _FocusLastAction extends ContextAction<_FocusLastIntent> {
_FocusLastAction();
@override
void invoke(_FocusLastIntent intent, [BuildContext? context]) {
final FocusTraversalPolicy policy =
FocusTraversalGroup.maybeOf(context!) ?? ReadingOrderTraversalPolicy();
final FocusNode lastFocus = policy.findLastFocus(primaryFocus!, ignoreCurrentFocus: true);
if (lastFocus.context == null) {
return;
}
policy.requestFocusCallback(lastFocus);
}
}
/// A horizontal divider placed between each menu item in a
/// [CupertinoMenuAnchor] on iOS 18 and before.
///
/// To create a menu item that does not show an automatic divider, implement
/// [CupertinoMenuEntry] and return true from [CupertinoMenuEntry.isDivider].
///
/// The default thickness of the divider is 1 physical pixel.
class _CupertinoMenuImplicitDivider extends StatelessWidget {
/// Draws a [_CupertinoMenuImplicitDivider] below a [child].
const _CupertinoMenuImplicitDivider();
/// The default color applied to the [_CupertinoMenuImplicitDivider] with
/// [ui.BlendMode.overlay].
///
/// On all platforms except web, this color is applied to the divider before
/// the [color] is applied, and is used to create a subtle translucent effect
/// against the menu background.
// The following colors were measured from the iOS 17.2 simulator, and opacity was
// extrapolated:
// Dark mode on black Color.fromRGBO(97, 97, 97)
// Dark mode on white Color.fromRGBO(132, 132, 132)
// Light mode on black Color.fromRGBO(147, 147, 147)
// Light mode on white Color.fromRGBO(187, 187, 187)
//
// Colors were also compared atop a red, green, and blue backgrounds.
static const CupertinoDynamicColor overlayColor = CupertinoDynamicColor.withBrightness(
color: Color.fromRGBO(140, 140, 140, 0.3),
darkColor: Color.fromRGBO(255, 255, 255, 0.25),
);
/// The default color applied to the [_CupertinoMenuImplicitDivider], atop the
/// [overlayColor], with [BlendMode.srcOver].
///
/// This color is used to make the divider more opaque.
static const CupertinoDynamicColor color = CupertinoDynamicColor.withBrightness(
color: Color.fromRGBO(0, 0, 0, 0.25),
darkColor: Color.fromRGBO(255, 255, 255, 0.25),
);
@override
Widget build(BuildContext context) {
final double pixelRatio = MediaQuery.maybeDevicePixelRatioOf(context) ?? 1.0;
final double displacement = 1 / pixelRatio;
return CustomPaint(
size: Size(double.infinity, displacement),
painter: _CupertinoDividerPainter(
color: CupertinoDynamicColor.resolve(color, context),
overlayColor: CupertinoDynamicColor.resolve(overlayColor, context),
// Only anti-alias on devices with a low pixel density.
antiAlias: pixelRatio < 1.0,
),
);
}
}
/// A large horizontal divider that is used to separate [CupertinoMenuItem]s in
/// a [CupertinoMenuAnchor].
///
/// The divider has a height of 8 logical pixels. The [color] parameter can be
/// provided to customize the color of the divider.
///
/// See also:
///
/// * [CupertinoMenuItem], a Cupertino-style menu item.
/// * [CupertinoMenuAnchor], a widget that creates a Cupertino-style popup menu.
/// * [CupertinoMenuEntry], an interface that can be used to control whether
/// dividers are shown before or after a menu item.
class CupertinoMenuDivider extends StatelessWidget implements CupertinoMenuEntry {
/// Creates a large horizontal divider for a [CupertinoMenuAnchor].
const CupertinoMenuDivider({super.key, this.color = defaultColor});
/// The color of the divider.
///
/// Defaults to [CupertinoMenuDivider.defaultColor].
final Color color;
@override
bool get isDivider => true;
@override
bool hasLeading(BuildContext context) => false;
/// Default color for a [CupertinoMenuDivider].
// The following colors were measured from debug mode on the iOS 18.5 simulator,
static const CupertinoDynamicColor defaultColor = CupertinoDynamicColor.withBrightness(
color: Color.fromRGBO(0, 0, 0, 0.08),
darkColor: Color.fromRGBO(0, 0, 0, 0.16),
);
static const double _height = 8.0;
@override
Widget build(BuildContext context) {
return ColoredBox(
color: CupertinoDynamicColor.resolve(color, context),
child: const SizedBox(height: _height, width: double.infinity),
);
}
}
// Draws an aliased line that approximates the appearance of an iOS 18.5 menu
// divider using blend modes.
class _CupertinoDividerPainter extends CustomPainter {
const _CupertinoDividerPainter({
required this.color,
required this.overlayColor,
this.antiAlias = false,
});
final Color color;
final Color overlayColor;
final bool antiAlias;
@override
void paint(Canvas canvas, Size size) {
final Offset p1 = size.centerLeft(Offset.zero);
final Offset p2 = size.centerRight(Offset.zero);
// BlendMode.overlay is not supported on the web.
if (!kIsWeb) {
final overlayPainter = Paint()
..style = PaintingStyle.stroke
..color = overlayColor
..isAntiAlias = antiAlias
..blendMode = BlendMode.overlay;
canvas.drawLine(p1, p2, overlayPainter);
}
final colorPainter = Paint()
..style = PaintingStyle.stroke
..color = color
..isAntiAlias = antiAlias;
canvas.drawLine(p1, p2, colorPainter);
}
@override
bool shouldRepaint(_CupertinoDividerPainter oldDelegate) {
return color != oldDelegate.color ||
overlayColor != oldDelegate.overlayColor ||
antiAlias != oldDelegate.antiAlias;
}
}
/// A menu item for use in a [CupertinoMenuAnchor].
///
/// ## Layout
/// The menu item is unconstrained by default and will grow to fit the size of
/// its container. To constrain the size of a [CupertinoMenuItem], the
/// [constraints] parameter can be set. When set, the [constraints] apply to the
/// total area occupied by the content and its [padding]. This means that
/// [padding] will only affect the size of this menu item if this item's minimum
/// constraints are less than the sum of its [padding] and the size of its
/// contents.
///
/// The [leading] and [trailing] widgets display before and after the [child]
/// widget, respectively. The [leadingWidth] and [trailingWidth] parameters
/// control the horizontal space that these widgets occupy. The
/// [leadingMidpointAlignment] and [trailingMidpointAlignment] parameters control the alignment
/// of the leading and trailing widgets within their respective spaces.
///
/// ## Input
/// In order to respond to user input, an [onPressed] callback must be provided.
/// If absent, user input callbacks ([onFocusChange], [onHover], and
/// [onPressed]) will be ignored. The [behavior] parameter can be used to
/// control whether hit tests can travel behind the menu item, and the
/// [mouseCursor] parameter can be used to change the cursor that appears when
/// the user hovers over the menu.
///
/// The [requestCloseOnActivate] parameter can be set to false to prevent the
/// menu from closing when the item is activated. By default, the menu will
/// close when an item is pressed.
///
/// The [requestFocusOnHover] parameter, when true, focuses the menu item when
/// the item is hovered.
///
/// ## Visuals
/// The [decoration] parameter can be used to change the background color of the
/// menu item when hovered, focused, pressed, or swiped. If these parameters are
/// not set, the menu item will use [CupertinoMenuItem.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 sample}
/// This example demonstrates a simple [CupertinoMenuAnchor] that wraps
/// a button.
///
/// ** See code in examples/api/lib/cupertino/menu_anchor/menu_anchor.0.dart **
/// {@end-tool}
///
/// {@tool dartpad}
/// This example demonstrates a [CupertinoMenuAnchor] that wraps a button and
/// shows a menu with three [CupertinoMenuItem]s and one [CupertinoMenuDivider].
///
/// ** See code in examples/api/lib/cupertino/menu_anchor/menu_anchor.1.dart **
/// {@end-tool}
///
/// See also:
/// * [CupertinoMenuAnchor], a Cupertino-style widget that shows a menu of
/// actions in a popup
/// * [RawMenuAnchor], a lower-level widget that creates a region with a submenu
/// that is the basis for [CupertinoMenuAnchor].
/// * [PlatformMenuBar], which creates a menu bar that is rendered by the host
/// platform instead of by Flutter (on macOS, for example).
class CupertinoMenuItem extends StatelessWidget implements CupertinoMenuEntry {
/// Creates a [CupertinoMenuItem]
///
/// The [child] parameter is required and must not be null.
const CupertinoMenuItem({
super.key,
required this.child,
this.subtitle,
this.leading,
this.leadingWidth,
this.leadingMidpointAlignment,
this.trailing,
this.trailingWidth,
this.trailingMidpointAlignment,
this.padding,
this.constraints,
this.autofocus = false,
this.focusNode,
this.onFocusChange,
this.onHover,
this.onPressed,
this.decoration,
this.mouseCursor,
this.statesController,
this.behavior = HitTestBehavior.opaque,
this.requestCloseOnActivate = true,
this.requestFocusOnHover = true,
this.isDestructiveAction = false,
});
/// The widget displayed in the center of this button.
///
/// Typically this is the button's label, using a [Text] widget.
///
/// {@macro flutter.widgets.ProxyWidget.child}
final Widget child;
/// The padding applied to this menu item.
final EdgeInsetsGeometry? padding;
/// The widget shown before the label. Typically an [Icon].
final Widget? leading;
/// The widget shown after the label. Typically an [Icon].
final Widget? trailing;
/// A widget displayed underneath the [child]. Typically a [Text] widget.
final Widget? subtitle;
/// Called when this menu is tapped or otherwise activated.
///
/// If a callback is not provided, then the button will be disabled.
final VoidCallback? onPressed;
/// Triggered when a pointer moves into a position within this widget without
/// buttons pressed.
///
/// Usually this is only fired for pointers which report their location when
/// not down (e.g. mouse pointers). Certain devices also fire this event on
/// single taps in accessibility mode.
///
/// This callback is not triggered by the movement of the widget.
///
/// The time that this callback is triggered is during the callback of a
/// pointer event, which is always between frames.
final ValueChanged<bool>? onHover;
/// {@macro flutter.material.inkwell.onFocusChange}
final ValueChanged<bool>? onFocusChange;
/// Whether hovering should request focus for this widget.
///
/// Defaults to true.
final bool requestFocusOnHover;
/// {@macro flutter.widgets.Focus.autofocus}
final bool autofocus;
/// {@macro flutter.widgets.Focus.focusNode}
final FocusNode? focusNode;
/// The decoration to paint behind the menu item.
///
/// If null, defaults to [CupertinoMenuItem.defaultDecoration].
final WidgetStateProperty<BoxDecoration>? decoration;
/// The mouse cursor to display on hover.
final WidgetStateProperty<MouseCursor>? mouseCursor;
/// {@macro flutter.material.inkwell.statesController}
final WidgetStatesController? statesController;
/// How the menu item should respond to hit tests.
final HitTestBehavior behavior;
/// Determines if the menu will be closed when a [CupertinoMenuItem] is pressed.
///
/// Defaults to true.
final bool requestCloseOnActivate;
/// Whether pressing this item will perform a destructive action
///
/// Defaults to false. If true, the default color of this item's label and
/// icon will be [CupertinoColors.systemRed].
final bool isDestructiveAction;
/// The horizontal space in which the [leading] widget can be placed.
final double? leadingWidth;
/// The horizontal space in which the [trailing] widget can be placed.
final double? trailingWidth;
/// The alignment of the center point of the leading widget within the
/// [leadingWidth] of the menu item.
final AlignmentGeometry? leadingMidpointAlignment;
/// The alignment of the center point of the trailing widget within the
/// [trailingWidth] of the menu item.
final AlignmentGeometry? trailingMidpointAlignment;
/// The [BoxConstraints] to apply to the menu item.
///
/// Because [padding] is applied to the menu item prior to [constraints], the [padding]
/// will only affect the size of the menu item if the vertical [padding]
/// plus the height of the menu item's children exceeds the
/// [BoxConstraints.minHeight].
final BoxConstraints? constraints;
@override
bool hasLeading(BuildContext context) => leading != null;
@override
bool get isDivider => false;
/// The decoration of a [CupertinoMenuItem] when pressed.
// Pressed colors were sampled from the iOS simulator and are based on the
// following:
//
// Dark mode on white background rgb(111, 111, 111)
// Dark mode on black rgb(61, 61, 61)
// Light mode on black rgb(177, 177, 177)
// Light mode on white rgb(225, 225, 225)
//
// Blend mode is used to mimic the visual effect of the iOS
// menu item. As a result, the default pressed color does not match the
// reported colors on the iOS 18.5 simulator.
static const WidgetStateProperty<BoxDecoration> defaultDecoration =
WidgetStateProperty<BoxDecoration>.fromMap(<WidgetStatesConstraint, BoxDecoration>{
WidgetState.dragged: BoxDecoration(
color: CupertinoDynamicColor.withBrightness(
color: Color.fromRGBO(50, 50, 50, 0.1),
darkColor: Color.fromRGBO(255, 255, 255, 0.1),
),
),
WidgetState.pressed: BoxDecoration(
color: CupertinoDynamicColor.withBrightness(
color: Color.fromRGBO(50, 50, 50, 0.1),
darkColor: Color.fromRGBO(255, 255, 255, 0.1),
),
),
WidgetState.focused: BoxDecoration(
color: CupertinoDynamicColor.withBrightness(
color: Color.fromRGBO(50, 50, 50, 0.075),
darkColor: Color.fromRGBO(255, 255, 255, 0.075),
),
),
WidgetState.hovered: BoxDecoration(
color: CupertinoDynamicColor.withBrightness(
color: Color.fromRGBO(50, 50, 50, 0.05),
darkColor: Color.fromRGBO(255, 255, 255, 0.05),
),
),
WidgetState.any: BoxDecoration(),
});
static final WidgetStateProperty<MouseCursor> _defaultCursor =
WidgetStateProperty.resolveWith<MouseCursor>((Set<WidgetState> states) {
return !states.contains(WidgetState.disabled) && kIsWeb
? SystemMouseCursors.click
: MouseCursor.defer;
});
// Measured from the iOS 18.5 simulator debug view.
static const Color _defaultTextColor = CupertinoDynamicColor.withBrightness(
color: Color.from(alpha: 0.96, red: 0, green: 0, blue: 0),
darkColor: Color.from(alpha: 0.96, red: 1, green: 1, blue: 1),
);
/// The default [Color] applied to a [CupertinoMenuItem]'s [subtitle]
/// widget, if a subtitle is provided.
///
/// A custom blend mode is applied to the subtitle to mimic the visual effect
/// of the iOS menu subtitle. As a result, the defaultSubtitleStyle color does
/// not match the reported color on the iOS 18.5 simulator.
static const Color _defaultSubtitleTextColor = CupertinoDynamicColor.withBrightness(
color: Color.from(alpha: 0.55, red: 0, green: 0, blue: 0),
darkColor: Color.from(alpha: 0.4, red: 1, green: 1, blue: 1),
);
/// The maximum number of lines for the [child] widget when
/// [MediaQuery.textScalerOf] returns a [TextScaler] that is less than or
/// equal to 1.25.
///
/// Measured from the iOS 18.5 simulator debug view.
static const int _defaultMaxLines = 2;
/// The maximum number of lines for the [child] widget when
/// [MediaQuery.textScalerOf] returns a [TextScaler] that is greater than
/// 1.25.
static const int _defaultAccessibilityModeMaxLines = 100;
static const TextStyle _leadingDefaultTextStyle = TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
);
static const IconThemeData _leadingDefaultIconTheme = IconThemeData(
size: 15,
weight: 600,
applyTextScaling: true,
);
static const TextStyle _trailingDefaultTextStyle = TextStyle(fontSize: 21);
static const IconThemeData _trailingDefaultIconTheme = IconThemeData(
size: 21,
applyTextScaling: true,
);
/// Resolves the title [TextStyle] in response to
/// [CupertinoThemeData.brightness], [isDestructiveAction], and [enabled].
//
// Approximated from the iOS and iPadOS 18.5 simulators.
TextStyle _resolveDefaultTextStyle(BuildContext context, TextScaler textScaler) {
Color color;
if (onPressed == null) {
color = CupertinoColors.systemGrey;
} else if (isDestructiveAction) {
color = CupertinoColors.systemRed;
} else {
color = _defaultTextColor;
}
return _DynamicTypeStyle.body
.resolveTextStyle(textScaler)
.copyWith(
// Font size will be scaled by TextScaler.
fontSize: 17,
color: CupertinoDynamicColor.resolve(color, context),
);
}
TextStyle _resolveDefaultSubtitleStyle(BuildContext context, TextScaler textScaler) {
final isDark = CupertinoTheme.maybeBrightnessOf(context) == Brightness.dark;
return _DynamicTypeStyle.subhead
.resolveTextStyle(textScaler)
.copyWith(
// Font size will be scaled by TextScaler.
fontSize: 15,
textBaseline: TextBaseline.alphabetic,
foreground: Paint()
// Per iOS 18.5 simulator:
// Dark mode: linearDodge is used on iOS to achieve a lighter color.
// This is approximated with BlendMode.plus.
// For light mode: plusDarker is used on iOS to achieve a darker color.
// HardLight is used as an approximation.
..blendMode = isDark ? BlendMode.plus : BlendMode.hardLight
..color = CupertinoDynamicColor.resolve(_defaultSubtitleTextColor, context),
);
}
void _handleSelect(BuildContext context) {
if (requestCloseOnActivate) {
MenuController.maybeOf(context)?.close();
}
onPressed?.call();
}
@override
Widget build(BuildContext context) {
final TextScaler textScaler =
MediaQuery.maybeTextScalerOf(context) ??
TextScaler.linear(MediaQuery.maybeTextScaleFactorOf(context) ?? 1);
final TextStyle defaultTextStyle = _resolveDefaultTextStyle(context, textScaler);
final bool isAccessibilityModeEnabled = _isAccessibilityModeEnabled(context);
Widget? leadingWidget;
Widget? trailingWidget;
if (leading != null) {
leadingWidget = DefaultTextStyle.merge(
style: _leadingDefaultTextStyle,
child: IconTheme.merge(data: _leadingDefaultIconTheme, child: leading!),
);
}
if (trailing != null && !isAccessibilityModeEnabled) {
trailingWidget = DefaultTextStyle.merge(
style: _trailingDefaultTextStyle,
child: IconTheme.merge(data: _trailingDefaultIconTheme, child: trailing!),
);
}
return MediaQuery.withClampedTextScaling(
minScaleFactor: _kMinimumTextScaleFactor,
maxScaleFactor: _kMaximumTextScaleFactor,
child: _CupertinoMenuItemInteractionHandler(
mouseCursor: mouseCursor ?? _defaultCursor,
requestFocusOnHover: requestFocusOnHover,
onPressed: onPressed != null ? () => _handleSelect(context) : null,
onHover: onHover,
onFocusChange: onFocusChange,
autofocus: autofocus,
focusNode: focusNode,
decoration: decoration ?? defaultDecoration,
statesController: statesController,
behavior: behavior,
child: DefaultTextStyle.merge(
maxLines: isAccessibilityModeEnabled
? _defaultAccessibilityModeMaxLines
: _defaultMaxLines,
overflow: TextOverflow.ellipsis,
softWrap: true,
style: TextStyle(color: defaultTextStyle.color),
child: IconTheme.merge(
data: IconThemeData(color: defaultTextStyle.color),
child: _CupertinoMenuItemLabel(
padding: padding,
constraints: constraints,
trailing: trailingWidget,
leading: leadingWidget,
leadingMidpointAlignment: leadingMidpointAlignment,
trailingMidpointAlignment: trailingMidpointAlignment,
leadingWidth: leadingWidth,
trailingWidth: trailingWidth,
subtitle: subtitle != null
? DefaultTextStyle.merge(
style: _resolveDefaultSubtitleStyle(context, textScaler),
child: subtitle!,
)
: null,
child: DefaultTextStyle.merge(style: defaultTextStyle, child: child),
),
),
),
),
);
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<Widget?>('child', child));
properties.add(
FlagProperty(
'requestCloseOnActivate',
value: requestCloseOnActivate,
ifTrue: 'closes on press',
ifFalse: 'does not close on press',
defaultValue: true,
),
);
properties.add(
FlagProperty(
'requestFocusOnHover',
value: requestFocusOnHover,
ifFalse: 'does not request focus on hover',
ifTrue: 'requests focus on hover',
defaultValue: true,
),
);
properties.add(EnumProperty<HitTestBehavior>('hitTestBehavior', behavior));
properties.add(DiagnosticsProperty<FocusNode?>('focusNode', focusNode, defaultValue: null));
properties.add(FlagProperty('enabled', value: onPressed != null, ifFalse: 'DISABLED'));
if (subtitle != null) {
properties.add(DiagnosticsProperty<Widget?>('subtitle', subtitle));
}
if (leading != null) {
properties.add(DiagnosticsProperty<Widget?>('leading', leading));
}
if (trailing != null) {
properties.add(DiagnosticsProperty<Widget?>('trailing', trailing));
}
}
}
class _CupertinoMenuItemLabel extends StatelessWidget {
const _CupertinoMenuItemLabel({
required this.child,
this.subtitle,
this.leading,
this.leadingWidth,
AlignmentGeometry? leadingMidpointAlignment,
this.trailing,
this.trailingWidth,
AlignmentGeometry? trailingMidpointAlignment,
BoxConstraints? constraints,
this.padding,
}) : _leadingAlignment = leadingMidpointAlignment,
_trailingAlignment = trailingMidpointAlignment,
_constraints = constraints;
static const double _defaultHorizontalWidth = 16;
// The leading and trailing widths scale roughly linearly with the normalized
// text scale once quantized to the nearest physical pixel. Each linear
// regression will return a value within 1 physical pixel of the observed
// value at each text scale factor.
//
// This behavior was measured on several iOS and iPadOS 18.5 simulators using
// the debug view.
static const double _leadingWidthSlope = -311 / 1000;
static const double _leadingWidthYIntercept = 10;
static const double _leadingMidpointSlope = 118 / 1000000;
static const double _leadingMidpointYIntercept = 73 / 125;
static const double _trailingWidthSlope = 1 / 10;
static const double _trailingWidthYIntercept = 22;
static const double _firstBaselineToTopSlope = 14 / 11;
static const double _lastBaselineToBottomSlope = 71 / 100;
final Widget? leading;
final double? leadingWidth;
final AlignmentGeometry? _leadingAlignment;
final Widget? trailing;
final double? trailingWidth;
final AlignmentGeometry? _trailingAlignment;
final Widget child;
final Widget? subtitle;
final EdgeInsetsGeometry? padding;
final BoxConstraints? _constraints;
double _resolveLeadingWidth(TextScaler textScaler, double pixelRatio, double lineHeight) {
final double units = _normalizeTextScale(textScaler);
final double value = _leadingWidthSlope * units + _leadingWidthYIntercept;
return _roundToDivisible(value + lineHeight, to: 1 / pixelRatio);
}
double _resolveTrailingWidth(TextScaler textScaler, double pixelRatio, double lineHeight) {
final double units = _normalizeTextScale(textScaler);
final double value = _trailingWidthSlope * units + _trailingWidthYIntercept;
return _roundToDivisible(value + lineHeight, to: 1 / pixelRatio);
}
AlignmentGeometry _resolveTrailingAlignment(double trailingWidth) {
final double horizontalOffset = trailingWidth / 2 + 6;
final double horizontalRatio = (trailingWidth - horizontalOffset) / trailingWidth;
final double horizontalAlignment = (horizontalRatio * 2) - 1;
return AlignmentDirectional(horizontalAlignment, 0.0);
}
AlignmentGeometry _resolveLeadingAlignment(double leadingWidth, TextScaler textScaler) {
final double units = _normalizeTextScale(textScaler);
final double horizontalRatio = _leadingMidpointSlope * units + _leadingMidpointYIntercept;
final double horizontalAlignment = (horizontalRatio * 2) - 1;
return AlignmentDirectional(horizontalAlignment, 0.0);
}
double _resolveFirstBaselineToTop(double lineHeight, double pixelRatio) {
return _roundToDivisible(lineHeight * _firstBaselineToTopSlope, to: 1 / pixelRatio);
}
double _resolveLastBaselineToBottom(double lineHeight, double pixelRatio) {
return _roundToDivisible(lineHeight * _lastBaselineToBottomSlope, to: 1 / pixelRatio);
}
EdgeInsets _resolvePadding(double minimumHeight, double lineHeight) {
final double padding = math.max(0, minimumHeight - lineHeight);
return EdgeInsets.symmetric(vertical: padding / 2);
}
@override
Widget build(BuildContext context) {
final TextDirection textDirection = Directionality.maybeOf(context) ?? TextDirection.ltr;
final TextScaler textScaler = MediaQuery.maybeTextScalerOf(context) ?? TextScaler.noScaling;
final double pixelRatio = MediaQuery.maybeDevicePixelRatioOf(context) ?? 1.0;
final TextStyle dynamicBodyText = _DynamicTypeStyle.body.resolveTextStyle(textScaler);
assert(dynamicBodyText.fontSize != null && dynamicBodyText.height != null);
final double lineHeight = dynamicBodyText.fontSize! * dynamicBodyText.height!;
final bool showLeadingWidget =
leading != null || (CupertinoMenuAnchor.maybeHasLeadingOf(context) ?? false);
// TODO(davidhicks980): Use last baseline layout when supported.
// (https://github.com/flutter/flutter/issues/4614)
// The actual menu item layout uses first and last baselines to position the
// text, but Flutter does not support last baseline alignment.
//
// To approximate the padding, subtract the default height of a single line
// of text from the height of a single-line menu item, and divide the result
// in half to get an estimated top and bottom padding. The downside to this
// approach is that child and subtitle text with different line heights may
// appear to have uneven padding.
final double minimumHeight =
_resolveFirstBaselineToTop(lineHeight, pixelRatio) +
_resolveLastBaselineToBottom(lineHeight, pixelRatio);
final BoxConstraints constraints = _constraints ?? BoxConstraints(minHeight: minimumHeight);
final EdgeInsetsGeometry resolvedPadding =
padding ?? _resolvePadding(minimumHeight, lineHeight);
final double resolvedLeadingWidth =
leadingWidth ??
(showLeadingWidget
? _resolveLeadingWidth(textScaler, pixelRatio, lineHeight)
: _defaultHorizontalWidth);
final double resolvedTrailingWidth =
trailingWidth ??
(trailing != null
? _resolveTrailingWidth(textScaler, pixelRatio, lineHeight)
: _defaultHorizontalWidth);
return ConstrainedBox(
constraints: constraints,
child: Padding(
padding: resolvedPadding,
child: Stack(
children: <Widget>[
if (showLeadingWidget)
Positioned.directional(
textDirection: textDirection,
start: 0,
top: 0,
bottom: 0,
width: resolvedLeadingWidth,
child: _AlignMidpoint(
alignment:
_leadingAlignment ??
_resolveLeadingAlignment(resolvedLeadingWidth, textScaler),
child: leading,
),
),
Padding(
padding: EdgeInsetsDirectional.only(
start: resolvedLeadingWidth,
end: resolvedTrailingWidth,
),
child: subtitle == null
? Align(alignment: AlignmentDirectional.centerStart, child: child)
: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[child, const SizedBox(height: 1), subtitle!],
),
),
if (trailing != null)
// On iOS, the trailing widget is constrained to a maximum height
// of minimumHeight - 12 and a maximum width of
// resolvedTrailingWidth - 20. These constraints were omitted for
// more flexibility.
Positioned.directional(
textDirection: textDirection,
end: 0,
top: 0,
bottom: 0,
width: resolvedTrailingWidth,
child: _AlignMidpoint(
alignment: _trailingAlignment ?? _resolveTrailingAlignment(resolvedTrailingWidth),
child: trailing,
),
),
],
),
),
);
}
}
/// A widget that positions the midpoint of its child at an alignment within
/// itself.
///
/// Almost identical to [Align], but aligns the midpoint of the child rather
/// than the top-left corner.
///
/// This layout behavior was observed on the iOS 18.5 simulator
/// (https://developer.apple.com/documentation/uikit/uiview/centerxanchor)
class _AlignMidpoint extends SingleChildRenderObjectWidget {
/// Creates a widget that positions its child's center point at a specific
/// [alignment].
///
/// The [alignment] parameter is required and must not
/// be null.
const _AlignMidpoint({required this.alignment, required super.child});
/// The alignment for positioning the child's horizontal midpoint.
final AlignmentGeometry alignment;
@override
RenderObject createRenderObject(BuildContext context) {
return _RenderAlignMidpoint(
alignment: alignment,
textDirection: Directionality.maybeOf(context),
);
}
@override
void updateRenderObject(BuildContext context, _RenderAlignMidpoint renderObject) {
renderObject
..alignment = alignment
..textDirection = Directionality.maybeOf(context);
}
}
class _RenderAlignMidpoint extends RenderPositionedBox {
_RenderAlignMidpoint({super.alignment, super.textDirection});
@override
void alignChild() {
assert(child != null);
assert(!child!.debugNeedsLayout);
assert(child!.hasSize);
assert(hasSize);
final childParentData = child!.parentData! as BoxParentData;
final ui.Offset offset = resolvedAlignment.alongSize(size) - child!.size.center(Offset.zero);
final double dx = ui.clampDouble(offset.dx, 0.0, size.width - child!.size.width);
final double dy = ui.clampDouble(offset.dy, 0.0, size.height - child!.size.height);
childParentData.offset = Offset(dx, dy);
}
}
class _CupertinoMenuItemInteractionHandler extends StatefulWidget {
const _CupertinoMenuItemInteractionHandler({
required this.onHover,
required this.onPressed,
required this.onFocusChange,
required this.focusNode,
required this.autofocus,
required this.requestFocusOnHover,
required this.behavior,
required this.statesController,
required this.mouseCursor,
required this.decoration,
required this.child,
});
final ValueChanged<bool>? onHover;
final VoidCallback? onPressed;
final ValueChanged<bool>? onFocusChange;
final FocusNode? focusNode;
final bool autofocus;
final bool requestFocusOnHover;
final HitTestBehavior behavior;
final WidgetStatesController? statesController;
final WidgetStateProperty<MouseCursor> mouseCursor;
final WidgetStateProperty<BoxDecoration> decoration;
final Widget child;
@override
State<_CupertinoMenuItemInteractionHandler> createState() =>
_CupertinoMenuItemInteractionHandlerState();
}
class _CupertinoMenuItemInteractionHandlerState extends State<_CupertinoMenuItemInteractionHandler>
implements _SwipeTarget {
late final Map<Type, Action<Intent>> _actions = <Type, Action<Intent>>{
ActivateIntent: CallbackAction<ActivateIntent>(onInvoke: _handleActivation),
ButtonActivateIntent: CallbackAction<ButtonActivateIntent>(onInvoke: _handleActivation),
};
Map<Type, GestureRecognizerFactory>? _gestures;
DeviceGestureSettings? _gestureSettings;
// If a focus node isn't given to the widget, then we have to manage our own.
FocusNode? _internalFocusNode;
FocusNode get _focusNode => widget.focusNode ?? _internalFocusNode!;
WidgetStatesController? _internalStatesController;
WidgetStatesController get _statesController {
return widget.statesController ?? _internalStatesController!;
}
bool get isHovered => _isHovered;
bool _isHovered = false;
set isHovered(bool value) {
if (_isHovered != value) {
_isHovered = value;
_statesController.update(WidgetState.hovered, value);
}
}
bool get isPressed => _isPressed;
bool _isPressed = false;
set isPressed(bool value) {
if (_isPressed != value) {
_isPressed = value;
_statesController.update(WidgetState.pressed, value);
}
}
bool get isSwiped => _isSwiped;
bool _isSwiped = false;
set isSwiped(bool value) {
if (_isSwiped != value) {
_isSwiped = value;
_statesController.update(WidgetState.dragged, value);
}
}
bool get isFocused => _isFocused;
bool _isFocused = false;
set isFocused(bool value) {
if (_isFocused != value) {
_isFocused = value;
_statesController.update(WidgetState.focused, value);
}
}
bool get isEnabled => _isEnabled;
bool _isEnabled = false;
set isEnabled(bool value) {
if (_isEnabled != value) {
_isEnabled = value;
_statesController.update(WidgetState.disabled, !value);
}
}
@override
void initState() {
super.initState();
if (widget.focusNode == null) {
_internalFocusNode = FocusNode();
}
if (widget.statesController == null) {
_internalStatesController = WidgetStatesController();
}
isEnabled = widget.onPressed != null;
isFocused = _focusNode.hasPrimaryFocus;
}
@override
void 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 true;
}
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() {
if (mounted) {
isSwiped = false;
}
}
@override
void didSwipeActivate() {
if (mounted && isEnabled) {
_handleActivation();
}
}
@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 _buildStatefulAppearance(BuildContext context, Set<WidgetState> value, Widget? child) {
final MouseCursor cursor = widget.mouseCursor.resolve(value);
final BoxDecoration decoration = widget.decoration.resolve(value);
final bool hasBackground = decoration.color != null || decoration.gradient != null;
return MouseRegion(
onHover: isEnabled ? _handlePointerHover : null,
onExit: isEnabled ? _handlePointerExit : null,
hitTestBehavior: HitTestBehavior.deferToChild,
cursor: cursor,
child: DecoratedBox(
decoration: decoration.copyWith(
color: CupertinoDynamicColor.maybeResolve(decoration.color, context),
backgroundBlendMode: kIsWeb || !hasBackground || decoration.backgroundBlendMode != null
? decoration.backgroundBlendMode
: CupertinoTheme.maybeBrightnessOf(context) == Brightness.light
? BlendMode.multiply
: BlendMode.plus,
),
child: child,
),
);
}
@override
Widget build(BuildContext context) {
final DeviceGestureSettings? newGestureSettings = MediaQuery.maybeGestureSettingsOf(context);
if (_gestureSettings != newGestureSettings) {
_gestureSettings = newGestureSettings;
_gestures = null;
}
_gestures ??= <Type, GestureRecognizerFactory>{
TapGestureRecognizer: GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
() => TapGestureRecognizer(debugOwner: this),
(TapGestureRecognizer instance) {
instance
..onTapDown = _handleTapDown
..onTapUp = _handleTapUp
..onTapCancel = _handleTapCancel
..gestureSettings = _gestureSettings;
},
),
};
return MergeSemantics(
child: Semantics.fromProperties(
properties: SemanticsProperties(
enabled: isEnabled,
onDismiss: isEnabled ? _handleDismissMenu : null,
),
child: MetaData(
metaData: this,
child: Actions(
actions: isEnabled ? _actions : <Type, Action<Intent>>{},
child: Focus(
autofocus: isEnabled && widget.autofocus,
focusNode: _focusNode,
canRequestFocus: isEnabled,
skipTraversal: !isEnabled,
onFocusChange: _handleFocusChange,
child: ValueListenableBuilder<Set<WidgetState>>(
valueListenable: _statesController,
builder: _buildStatefulAppearance,
child: RawGestureDetector(
behavior: widget.behavior,
gestures: isEnabled ? _gestures! : const <Type, GestureRecognizerFactory>{},
child: widget.child,
),
),
),
),
),
),
);
}
}
/// Implement to receive callbacks when a pointer enters or leaves while down.
///
/// An ancestor [_SwipeRegion] must be present in order to receive these
/// callbacks.
abstract interface class _SwipeTarget {
/// A pointer has entered this region while down.
///
/// This includes:
///
/// * The pointer has moved into this region from outside.
/// * The point has contacted the screen in this region. In this case, this
/// method is called as soon as the pointer down event occurs regardless of
/// whether the gesture wins the arena immediately.
///
/// When this function returns true, this [_SwipeTarget] will prevent
/// underlying widgets from being entered by the swipe gesture. Otherwise,
/// underlying widgets may also receive swipe enter events.
bool didSwipeEnter();
/// A pointer has exited this region.
///
/// This includes:
/// * The pointer has moved out of this region.
/// * The pointer is no longer in contact with the screen.
/// * The pointer is canceled.
/// * The gesture loses the arena.
/// * The gesture ends. In this case, this method is called immediately
/// before [didSwipeActivate].
void didSwipeLeave();
/// The drag gesture is completed in this region.
///
/// This method is called immediately after a [didSwipeLeave].
void didSwipeActivate();
}
class _SwipeScope extends InheritedWidget {
const _SwipeScope({required super.child, required this.state});
final _SwipeRegionState state;
@override
bool updateShouldNotify(_SwipeScope oldWidget) {
return state != oldWidget.state;
}
}
class _SwipeRegion extends StatefulWidget {
const _SwipeRegion({this.enabled = true, required this.onDistanceChanged, required this.child});
final bool enabled;
final ValueChanged<double> onDistanceChanged;
final Widget child;
static _SwipeRegionState? of(BuildContext context) {
final _SwipeScope? scope = context.dependOnInheritedWidgetOfExactType<_SwipeScope>();
return scope?.state;
}
@override
State<_SwipeRegion> createState() => _SwipeRegionState();
}
class _SwipeRegionState extends State<_SwipeRegion> {
final Set<_RenderSwipeSurface> _surfaces = <_RenderSwipeSurface>{};
MultiDragGestureRecognizer? _recognizer;
bool get isSwiping => _position != null;
ui.Offset? _position;
@override
void didUpdateWidget(_SwipeRegion oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.enabled != oldWidget.enabled) {
if (!widget.enabled) {
_recognizer?.dispose();
_recognizer = null;
_position = null;
widget.onDistanceChanged(0);
}
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_recognizer?.gestureSettings = MediaQuery.maybeGestureSettingsOf(context);
}
@override
void dispose() {
assert(_surfaces.isEmpty);
_recognizer?.dispose();
_recognizer = null;
super.dispose();
}
void attachSurface(_RenderSwipeSurface surface) {
_surfaces.add(surface);
}
void detachSurface(_RenderSwipeSurface surface) {
_surfaces.remove(surface);
}
void beginSwipe(PointerDownEvent event, {Duration delay = Duration.zero, VoidCallback? onStart}) {
if (isSwiping || !widget.enabled) {
return;
}
_recognizer?.dispose();
_recognizer = null;
Drag handleStart(Offset position) {
onStart?.call();
return _createSwipeHandle(position);
}
// Use a MultiDragGestureRecognizer instead of a PanGestureRecognizer
// since the latter does not support delayed recognition.
if (delay == Duration.zero) {
_recognizer = ImmediateMultiDragGestureRecognizer(
allowedButtonsFilter: (int button) => button == kPrimaryButton,
)..onStart = handleStart;
} else {
_recognizer = DelayedMultiDragGestureRecognizer(
delay: delay,
allowedButtonsFilter: (int button) => button == kPrimaryButton,
)..onStart = handleStart;
}
_recognizer!.gestureSettings = MediaQuery.maybeGestureSettingsOf(context);
_recognizer!.addPointer(event);
}
Drag _createSwipeHandle(ui.Offset position) {
assert(!isSwiping, 'A new swipe should not begin while a swipe is active.');
_position = position;
return _SwipeHandle(
viewId: View.of(context).viewId,
initialPosition: position,
onSwipeUpdate: _handleSwipeUpdate,
onSwipeEnd: _handleSwipeEnd,
onSwipeCanceled: _handleSwipeCancel,
);
}
void _handleSwipeUpdate(DragUpdateDetails updateDetails) {
_position = _position! + updateDetails.delta;
// We can't used expandToInclude() because the total menu area may not be
// rectangular.
double minimumSquaredDistance = double.maxFinite;
for (final _RenderSwipeSurface surface in _surfaces) {
final double squaredDistance = _computeSquaredDistanceToRect(
_position!,
surface.computeRect(),
);
if (squaredDistance.floor() == 0) {
widget.onDistanceChanged(0);
return;
}
minimumSquaredDistance = math.min(squaredDistance, minimumSquaredDistance);
}
final double distance = minimumSquaredDistance == 0 ? 0 : math.sqrt(minimumSquaredDistance);
widget.onDistanceChanged(distance);
}
void _handleSwipeEnd(DragEndDetails position) {
_completeSwipe();
}
void _handleSwipeCancel() {
_completeSwipe();
}
void _completeSwipe() {
_recognizer?.dispose();
_recognizer = null;
_position = null;
if (mounted) {
widget.onDistanceChanged(0);
}
}
@override
Widget build(BuildContext context) {
return _SwipeScope(state: this, child: widget.child);
}
}
/// An area that can initiate swiping.
///
/// This widget registers with the nearest [_SwipeRegion] and exposes its position
/// as a [ui.Rect]. This [_SwipeSurface] will route [PointerDownEvent]s to its
/// [_SwipeRegion]. If a routed [PointerDownEvent] results in a swipe gesture, the
/// [_SwipeRegion] will use the combined [ui.Rect] of all registered [_SwipeSurface]s
/// to calculate the swiping distance.
class _SwipeSurface extends SingleChildRenderObjectWidget {
/// Creates a swipe surface that registers with a parent [_SwipeRegion].
const _SwipeSurface({required super.child, this.delay = Duration.zero, this.onStart});
/// The delay before recognizing a swipe gesture.
final Duration delay;
final VoidCallback? onStart;
@override
_RenderSwipeSurface createRenderObject(BuildContext context) {
return _RenderSwipeSurface(region: _SwipeRegion.of(context)!, delay: delay, onStart: onStart);
}
@override
void updateRenderObject(BuildContext context, _RenderSwipeSurface renderObject) {
renderObject
..region = _SwipeRegion.of(context)!
..delay = delay
..onStart = onStart;
}
}
class _RenderSwipeSurface extends RenderProxyBoxWithHitTestBehavior {
_RenderSwipeSurface({
required _SwipeRegionState region,
required this.delay,
required this.onStart,
}) : _region = region,
super(behavior: HitTestBehavior.opaque) {
_region.attachSurface(this);
}
_SwipeRegionState get region => _region;
_SwipeRegionState _region;
set region(_SwipeRegionState value) {
if (_region != value) {
_region.detachSurface(this);
_region = value;
_region.attachSurface(this);
}
}
Duration delay;
VoidCallback? onStart;
ui.Rect computeRect() => localToGlobal(Offset.zero) & size;
@override
void detach() {
_region.detachSurface(this);
super.detach();
}
@override
void dispose() {
_region.detachSurface(this);
super.dispose();
}
@override
void handleEvent(PointerEvent event, BoxHitTestEntry entry) {
assert(debugHandleEvent(event, entry));
if (event is PointerDownEvent) {
_region.beginSwipe(event, delay: delay, onStart: onStart);
}
}
}
/// Handles swiping events for a [_SwipeRegion].
class _SwipeHandle extends Drag {
/// Creates a [_SwipeHandle] that handles swiping events for a [_SwipeRegion].
_SwipeHandle({
required Offset initialPosition,
required this.viewId,
required this.onSwipeEnd,
required this.onSwipeUpdate,
required this.onSwipeCanceled,
}) : _position = initialPosition {
_updateSwipe();
}
final int viewId;
final List<_SwipeTarget> _enteredTargets = <_SwipeTarget>[];
final GestureDragUpdateCallback onSwipeUpdate;
final GestureDragEndCallback onSwipeEnd;
final GestureDragCancelCallback onSwipeCanceled;
Offset _position;
@override
void update(DragUpdateDetails details) {
final Offset oldPosition = _position;
_position += details.delta;
if (_position != oldPosition) {
_updateSwipe();
onSwipeUpdate.call(details);
}
}
@override
void end(DragEndDetails details) {
_leaveAllEntered(pointerUp: true);
onSwipeEnd.call(details);
}
@override
void cancel() {
_leaveAllEntered();
onSwipeCanceled();
}
void _updateSwipe() {
final result = HitTestResult();
WidgetsBinding.instance.hitTestInView(result, _position, viewId);
// Look for the RenderBoxes that corresponds to the hit target
final targets = <_SwipeTarget>[];
for (final HitTestEntry entry in result.path) {
if (entry.target case RenderMetaData(:final _SwipeTarget metaData)) {
targets.add(metaData);
}
}
// Identify targets that are no longer hit.
//
// This ensures disjoint siblings (1 -> 2) have a "Leave 1" -> "Enter 2" order.
_enteredTargets.removeWhere((target) {
if (!targets.contains(target)) {
target.didSwipeLeave();
return true;
}
return false;
});
final nextEntered = <_SwipeTarget>{};
// If an existing target is encountered, assume the previous chain is effectively
// active and don't add *new* underlying targets that weren't there before
// (preserving "blocking" behavior without calling didSwipeEnter again).
var hasEncounteredExisting = false;
for (final target in targets) {
if (_enteredTargets.contains(target)) {
nextEntered.add(target);
hasEncounteredExisting = true;
} else {
if (hasEncounteredExisting) {
// If we have already hit a target that was previously entered, we perform
// a "sticky" block. We assume the existing target blocked this new underlying
// target previously, so we don't enter it now.
break;
}
nextEntered.add(target);
if (target.didSwipeEnter()) {
break;
}
}
}
// Leave old targets.
//
// Disjoint siblings were removed above (1 -> 2) to preserve the expected
// "Leave 1" -> "Enter 2" order. For nested items (1 -> 1.1 -> 1.1.1), the
// order is less critical, but the most specific item should still be left
// last to preserve the expected behavior of the surface.
//
// This means that entering a nested item (1.1) that obscures a parent item
// (1) will result in "Enter 1.1" -> "Leave 1". Leaving the nested item will
// behave in the opposite order: "Leave 1.1" -> "Enter 1".
for (final _SwipeTarget target in _enteredTargets.reversed) {
if (!nextEntered.contains(target)) {
target.didSwipeLeave();
}
}
_enteredTargets
..clear()
..addAll(nextEntered);
}
void _leaveAllEntered({bool pointerUp = false}) {
for (var i = 0; i < _enteredTargets.length; i += 1) {
final _SwipeTarget target = _enteredTargets[i];
target.didSwipeLeave();
if (pointerUp) {
target.didSwipeActivate();
}
}
_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;
}
}
void main() {
late MenuController controller;
final selected = <Tag>[];
final opened = <Tag>[];
final closed = <Tag>[];
void onPressed(Tag item) {
selected.add(item);
}
void onOpen(Tag item) {
opened.add(item);
}
void onClose(Tag item) {
opened.remove(item);
closed.add(item);
}
setUp(() {
selected.clear();
opened.clear();
closed.clear();
controller = MenuController();
});
Future<void> Function(int frames) createFramePumper(WidgetTester tester) {
return (int frames) async {
for (var i = 0; i < frames; i += 1) {
await tester.pump(const Duration(milliseconds: 16));
}
};
}
Future<void> changeSurfaceSize(WidgetTester tester, Size size) async {
await tester.binding.setSurfaceSize(size);
addTearDown(() async {
await tester.binding.setSurfaceSize(null);
});
}
T findMenuPanelAncestor<T extends Widget>(WidgetTester tester) {
return tester.firstWidget<T>(
find.ancestor(of: find.byType(ClipRSuperellipse), matching: find.byType(T)),
);
}
double getScale(WidgetTester tester) {
return findMenuPanelAncestor<ScaleTransition>(tester).scale.value;
}
List<Widget> findMenuChildren(WidgetTester tester) {
return tester
.firstWidget<Column>(
find.descendant(of: find.byType(ClipRSuperellipse), matching: find.byType(Column)),
)
.children;
}
List<RenderObject> findAncestorRenderTheaters(RenderObject child) {
final results = <RenderObject>[];
RenderObject? node = child;
while (node != null) {
if (node.runtimeType.toString() == '_RenderTheater') {
results.add(node);
}
final RenderObject? parent = node.parent;
node = parent is RenderObject ? parent : null;
}
return results;
}
Matcher sizeCloseTo(Size size, num distance) {
return within(
distance: distance,
from: size,
distanceFunction: (Size a, Size b) {
final double deltaWidth = (a.width - b.width).abs();
final double deltaHeight = (a.height - b.height).abs();
return math.max<double>(deltaWidth, deltaHeight);
},
);
}
RenderParagraph? findDescendantParagraph(WidgetTester tester, Finder finder) {
return find
.descendant(of: finder, matching: find.byType(RichText))
.evaluate()
.first
.renderObject
as RenderParagraph?;
}
testWidgets("MenuController.isOpen is true when a menu's overlay is shown", (
WidgetTester tester,
) async {
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[Text(Tag.a.text)],
child: const AnchorButton(Tag.anchor),
),
),
);
await tester.tap(find.text(Tag.anchor.text));
await tester.pump();
expect(controller.isOpen, isTrue);
expect(find.text(Tag.a.text), findsOneWidget);
await tester.tap(find.text(Tag.anchor.text));
await tester.pump();
expect(controller.isOpen, isFalse);
expect(find.text(Tag.a.text), findsNothing);
});
testWidgets('MenuController.open() and .close() toggle overlay visibility', (
WidgetTester tester,
) async {
final nestedController = MenuController();
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[Text(Tag.a.text)],
child: const AnchorButton(Tag.anchor),
),
),
);
// Create the menu. The menu is closed, so no menu items should be found in
// the widget tree.
expect(controller.isOpen, isFalse);
expect(find.text(Tag.anchor.text), findsOne);
expect(find.text(Tag.a.text), findsNothing);
// Open the menu.
controller.open();
await tester.pump();
await tester.pumpAndSettle();
expect(controller.isOpen, isTrue);
expect(nestedController.isOpen, isFalse);
expect(find.text(Tag.a.text), findsOneWidget);
expect(find.text(Tag.b.a.text), findsNothing);
// Close the menu
controller.close();
await tester.pump();
await tester.pumpAndSettle();
// All menus should be closed.
expect(controller.isOpen, isFalse);
expect(find.text(Tag.a.text), findsNothing);
expect(find.text(Tag.b.a.text), findsNothing);
});
testWidgets('MenuController can be changed', (WidgetTester tester) async {
final controller = MenuController();
final groupController = MenuController();
final newController = MenuController();
final newGroupController = MenuController();
await tester.pumpWidget(
App(
RawMenuAnchorGroup(
controller: controller,
child: CupertinoMenuAnchor(
controller: groupController,
menuChildren: <Widget>[Text(Tag.a.text)],
child: const AnchorButton(Tag.anchor),
),
),
),
);
await tester.tap(find.text(Tag.anchor.text));
await tester.pump();
expect(find.text(Tag.a.text), findsOneWidget);
expect(controller.isOpen, isTrue);
expect(groupController.isOpen, isTrue);
expect(newController.isOpen, isFalse);
expect(newGroupController.isOpen, isFalse);
// Swap the controllers.
await tester.pumpWidget(
App(
RawMenuAnchorGroup(
controller: newController,
child: CupertinoMenuAnchor(
controller: newGroupController,
menuChildren: <Widget>[Text(Tag.a.text)],
child: const AnchorButton(Tag.anchor),
),
),
),
);
expect(find.text(Tag.a.text), findsOneWidget);
expect(controller.isOpen, isFalse);
expect(groupController.isOpen, isFalse);
expect(newController.isOpen, isTrue);
expect(newGroupController.isOpen, isTrue);
// Close the new controller.
newController.close();
await tester.pump();
expect(newController.isOpen, isFalse);
expect(newGroupController.isOpen, isFalse);
expect(find.text(Tag.a.text), findsNothing);
});
testWidgets('MenuController is detached on update', (WidgetTester tester) async {
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: const <Widget>[SizedBox.shrink()],
child: const SizedBox.shrink(),
),
),
);
// Should not throw because the controller is attached to the menu.
controller.closeChildren();
await tester.pumpWidget(
const App(
CupertinoMenuAnchor(menuChildren: <Widget>[SizedBox.shrink()], child: SizedBox.shrink()),
),
);
var serializedException = '';
runZonedGuarded(controller.closeChildren, (Object exception, StackTrace stackTrace) {
serializedException = exception.toString();
});
expect(serializedException, contains('_anchor != null'));
});
testWidgets('MenuController is detached on dispose', (WidgetTester tester) async {
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: const <SizedBox>[],
child: const SizedBox(),
),
),
);
// Should not throw because the controller is attached to the menu.
controller.closeChildren();
await tester.pumpWidget(const App(SizedBox()));
var serializedException = '';
runZonedGuarded(controller.closeChildren, (Object exception, StackTrace stackTrace) {
serializedException = exception.toString();
});
expect(serializedException, contains('_anchor != null'));
});
// Inspired by a test from the Closure Library:
// https://github.com/google/closure-library/blob/b312823ec5f84239ff1db7526f4a75cba0420a33/closure/goog/ui/menubutton_test.js#L392
testWidgets('Intents are not blocked by a closed anchor', (WidgetTester tester) async {
final invokedIntents = <Intent>[];
final anchorFocusNode = FocusNode();
addTearDown(anchorFocusNode.dispose);
await tester.pumpWidget(
App(
Actions(
actions: <Type, Action<Intent>>{
DirectionalFocusIntent: CallbackAction<DirectionalFocusIntent>(
onInvoke: (DirectionalFocusIntent intent) {
invokedIntents.add(intent);
return;
},
),
NextFocusIntent: CallbackAction<NextFocusIntent>(
onInvoke: (NextFocusIntent intent) {
invokedIntents.add(intent);
return;
},
),
PreviousFocusIntent: CallbackAction<PreviousFocusIntent>(
onInvoke: (PreviousFocusIntent intent) {
invokedIntents.add(intent);
return;
},
),
DismissIntent: CallbackAction<DismissIntent>(
onInvoke: (DismissIntent intent) {
invokedIntents.add(intent);
return;
},
),
},
child: CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[Text(Tag.a.text)],
child: AnchorButton(Tag.anchor, focusNode: anchorFocusNode),
),
),
),
);
anchorFocusNode.requestFocus();
await tester.pump();
Actions.invoke(anchorFocusNode.context!, const DirectionalFocusIntent(TraversalDirection.up));
Actions.invoke(anchorFocusNode.context!, const DirectionalFocusIntent(TraversalDirection.down));
Actions.invoke(anchorFocusNode.context!, const DirectionalFocusIntent(TraversalDirection.left));
Actions.invoke(
anchorFocusNode.context!,
const DirectionalFocusIntent(TraversalDirection.right),
);
Actions.invoke(anchorFocusNode.context!, const NextFocusIntent());
Actions.invoke(anchorFocusNode.context!, const PreviousFocusIntent());
Actions.invoke(anchorFocusNode.context!, const DismissIntent());
await tester.pump();
expect(
invokedIntents,
equals(const <Intent>[
DirectionalFocusIntent(TraversalDirection.up),
DirectionalFocusIntent(TraversalDirection.down),
DirectionalFocusIntent(TraversalDirection.left),
DirectionalFocusIntent(TraversalDirection.right),
NextFocusIntent(),
PreviousFocusIntent(),
DismissIntent(),
]),
);
});
testWidgets('Actions that wrap the menu are invoked by the anchor and the overlay', (
WidgetTester tester,
) async {
final anchorFocusNode = FocusNode();
final aFocusNode = FocusNode();
addTearDown(anchorFocusNode.dispose);
addTearDown(aFocusNode.dispose);
var invokedAnchor = false;
var invokedOverlay = false;
await tester.pumpWidget(
App(
Actions(
actions: <Type, Action<Intent>>{
VoidCallbackIntent: CallbackAction<VoidCallbackIntent>(
onInvoke: (VoidCallbackIntent intent) {
intent.callback();
return null;
},
),
},
child: CupertinoMenuAnchor(
childFocusNode: anchorFocusNode,
menuChildren: <Widget>[
CupertinoMenuItem(onPressed: () {}, focusNode: aFocusNode, child: Text(Tag.a.text)),
],
child: AnchorButton(Tag.anchor, focusNode: anchorFocusNode),
),
),
),
);
await tester.tap(find.text(Tag.anchor.text));
await tester.pump();
Actions.invoke(
anchorFocusNode.context!,
VoidCallbackIntent(() {
invokedAnchor = true;
}),
);
Actions.invoke(
aFocusNode.context!,
VoidCallbackIntent(() {
invokedOverlay = true;
}),
);
await tester.pump();
expect(invokedAnchor, isTrue);
expect(invokedOverlay, isTrue);
});
testWidgets('DismissMenuAction closes menu', (WidgetTester tester) async {
final anchorFocusNode = FocusNode();
final aFocusNode = FocusNode();
addTearDown(anchorFocusNode.dispose);
addTearDown(aFocusNode.dispose);
await tester.pumpWidget(
CupertinoApp(
home: CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(onPressed: () {}, focusNode: aFocusNode, child: Text(Tag.a.text)),
],
child: AnchorButton(Tag.anchor, focusNode: anchorFocusNode),
),
),
);
// Test from the anchor.
controller.open();
await tester.pump();
expect(controller.isOpen, isTrue);
anchorFocusNode.requestFocus();
await tester.pump();
const ActionDispatcher().invokeAction(
DismissMenuAction(controller: controller),
const DismissIntent(),
anchorFocusNode.context,
);
await tester.pump();
expect(controller.isOpen, isFalse);
// Test from the menu item.
controller.open();
await tester.pump();
expect(controller.isOpen, isTrue);
aFocusNode.requestFocus();
await tester.pump();
const ActionDispatcher().invokeAction(
DismissMenuAction(controller: controller),
const DismissIntent(),
aFocusNode.context,
);
await tester.pump();
expect(controller.isOpen, isFalse);
});
testWidgets('Menus close and consume tap when consumeOutsideTap is true', (
WidgetTester tester,
) async {
await tester.pumpWidget(
CupertinoApp(
home: Column(
children: <Widget>[
CupertinoButton(
child: Text(Tag.outside.text),
onPressed: () {
selected.add(Tag.outside);
},
),
CupertinoMenuAnchor(
consumeOutsideTaps: true,
onOpen: () {
onOpen(Tag.anchor);
},
onClose: () {
onClose(Tag.anchor);
},
menuChildren: <Widget>[CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text))],
child: AnchorButton(Tag.anchor, onPressed: onPressed),
),
],
),
),
);
expect(opened, isEmpty);
expect(closed, isEmpty);
// Doesn't consume tap when the menu is closed.
await tester.tap(find.text(Tag.outside.text));
await tester.pump();
expect(selected, equals(<NestedTag>[Tag.outside]));
selected.clear();
await tester.tap(find.text(Tag.anchor.text));
await tester.pump();
expect(opened, equals(<NestedTag>[Tag.anchor]));
expect(closed, isEmpty);
expect(selected, equals(<NestedTag>[Tag.anchor]));
opened.clear();
closed.clear();
selected.clear();
await tester.tap(find.text(Tag.outside.text));
await tester.pump();
// When the menu is open, outside taps are consumed. As a result, tapping
// outside the menu will close it and not select the outside button.
expect(selected, isEmpty);
expect(opened, isEmpty);
expect(closed, equals(<NestedTag>[Tag.anchor]));
selected.clear();
opened.clear();
closed.clear();
});
testWidgets('Menus close and do not consume tap when consumeOutsideTaps is false', (
WidgetTester tester,
) async {
await tester.pumpWidget(
CupertinoApp(
home: Column(
children: <Widget>[
CupertinoButton(
child: Text(Tag.outside.text),
onPressed: () {
selected.add(Tag.outside);
},
),
CupertinoMenuAnchor(
onOpen: () {
onOpen(Tag.anchor);
},
onClose: () {
onClose(Tag.anchor);
},
menuChildren: <Widget>[CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text))],
child: AnchorButton(Tag.anchor, onPressed: onPressed),
),
],
),
),
);
expect(opened, isEmpty);
expect(closed, isEmpty);
await tester.tap(find.text(Tag.outside.text));
await tester.pump();
// Doesn't consume tap when the menu is closed.
expect(selected, equals(<Tag>[Tag.outside]));
selected.clear();
await tester.tap(find.text(Tag.anchor.text));
await tester.pump();
await tester.pumpAndSettle();
expect(opened, equals(<Tag>[Tag.anchor]));
expect(closed, isEmpty);
expect(selected, equals(<Tag>[Tag.anchor]));
opened.clear();
closed.clear();
selected.clear();
await tester.tap(find.text(Tag.outside.text));
await tester.pump();
await tester.pumpAndSettle();
// Because consumeOutsideTaps is false, outsideButton is expected to
// receive a tap.
expect(opened, isEmpty);
expect(closed, equals(<Tag>[Tag.anchor]));
expect(selected, equals(<Tag>[Tag.outside]));
selected.clear();
opened.clear();
closed.clear();
});
testWidgets('onOpen is called when the menu starts opening', (WidgetTester tester) async {
var opened = 0;
var closed = 0;
await tester.pumpWidget(
CupertinoApp(
home: CupertinoMenuAnchor(
controller: controller,
onOpen: () {
opened += 1;
},
onClose: () {
closed += 1;
},
menuChildren: const <Widget>[],
child: const AnchorButton(Tag.anchor),
),
),
);
// onOpen is called immediately when the menu starts opening.
await tester.tap(find.text(Tag.anchor.text));
await tester.pump();
expect(opened, equals(1));
await tester.pump(const Duration(milliseconds: 50));
// Start closing the menu.
await tester.tap(find.text(Tag.anchor.text));
await tester.pump();
// Menu is still open because closing animation hasn't finished.
expect(opened, equals(1));
expect(closed, equals(0));
await tester.tap(find.text(Tag.anchor.text));
await tester.pump();
// onOpen doesn't get called again because the menu never closed.
expect(opened, equals(1));
expect(closed, equals(0));
await tester.tap(find.text(Tag.anchor.text));
await tester.pumpAndSettle();
expect(opened, equals(1));
expect(closed, equals(1));
controller.open();
await tester.pump();
expect(opened, equals(2));
expect(closed, equals(1));
await tester.pumpAndSettle();
expect(opened, equals(2));
expect(closed, equals(1));
});
testWidgets('onClose is called when the menu finishes closing', (WidgetTester tester) async {
var closed = true;
await tester.pumpWidget(
CupertinoApp(
home: CupertinoMenuAnchor(
controller: controller,
onOpen: () {
closed = false;
},
onClose: () {
closed = true;
},
menuChildren: const <Widget>[],
child: const AnchorButton(Tag.anchor),
),
),
);
await tester.tap(find.text(Tag.anchor.text));
await tester.pump();
expect(closed, isFalse);
await tester.pumpAndSettle();
await tester.tap(find.text(Tag.anchor.text));
await tester.pump();
expect(closed, isFalse);
await tester.pumpAndSettle();
expect(closed, isTrue);
controller.open();
await tester.pump();
expect(closed, isFalse);
controller.close();
await tester.pump();
expect(closed, isTrue);
});
test('debugFillProperties', () {
final builder = DiagnosticPropertiesBuilder();
final menuAnchor = CupertinoMenuAnchor(
menuChildren: const <Text>[Text('Menu Item')],
constraints: const BoxConstraints.tightFor(width: 200),
overlayPadding: const EdgeInsets.all(12),
useRootOverlay: true,
enableSwipe: false,
consumeOutsideTaps: true,
controller: MenuController(),
onOpen: () {},
onClose: () {},
constrainCrossAxis: true,
child: const Text('Anchor Child'),
);
menuAnchor.debugFillProperties(builder);
final List<String> descriptions = builder.properties
.map((DiagnosticsNode node) => node.toString())
.toList();
expect(
descriptions,
containsAll(<dynamic>[
'constraints: BoxConstraints(w=200.0, 0.0<=h<=Infinity)',
'constrains cross axis',
'swipe disabled',
'consumes outside taps',
'uses root overlay',
'overlayPadding: EdgeInsets.all(12.0)',
]),
);
});
testWidgets('Tab traversal is not handled', (WidgetTester tester) async {
final bFocusNode = FocusNode();
final bbFocusNode = FocusNode();
addTearDown(bFocusNode.dispose);
addTearDown(bbFocusNode.dispose);
final invokedIntents = <Intent>[];
final defaultTraversalShortcuts = <ShortcutActivator, Intent>{
LogicalKeySet(LogicalKeyboardKey.tab): const NextFocusIntent(),
LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.tab): const PreviousFocusIntent(),
};
await tester.pumpWidget(
App(
Row(
children: <Widget>[
Actions(
actions: <Type, Action<Intent>>{
NextFocusIntent: CallbackAction<NextFocusIntent>(
onInvoke: (NextFocusIntent intent) {
invokedIntents.add(intent);
return null;
},
),
PreviousFocusIntent: CallbackAction<PreviousFocusIntent>(
onInvoke: (PreviousFocusIntent intent) {
invokedIntents.add(intent);
return null;
},
),
},
child: Column(
children: <Widget>[
CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text)),
CupertinoMenuAnchor(
menuChildren: <Widget>[
CupertinoMenuItem(onPressed: () {}, child: Text(Tag.b.a.text)),
Shortcuts(
shortcuts: defaultTraversalShortcuts,
child: CupertinoMenuItem(
onPressed: () {},
focusNode: bbFocusNode,
child: Text(Tag.b.b.text),
),
),
CupertinoMenuItem(onPressed: () {}, child: Text(Tag.b.c.text)),
],
child: Shortcuts(
shortcuts: defaultTraversalShortcuts,
child: AnchorButton(Tag.b, focusNode: bFocusNode),
),
),
CupertinoMenuItem(onPressed: () {}, child: Text(Tag.c.text)),
],
),
),
],
),
),
);
bFocusNode.requestFocus();
await tester.pump();
expect(primaryFocus, equals(bFocusNode));
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pump();
expect(primaryFocus, equals(bFocusNode));
await tester.sendKeyDownEvent(LogicalKeyboardKey.shift);
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
await tester.pump();
expect(primaryFocus, equals(bFocusNode));
// Open and move focus to nested menu
await tester.tap(find.text(Tag.b.text));
await tester.pump();
await tester.pumpAndSettle();
bbFocusNode.requestFocus();
await tester.pump();
expect(primaryFocus, equals(bbFocusNode));
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pump();
expect(primaryFocus, equals(bbFocusNode));
await tester.sendKeyDownEvent(LogicalKeyboardKey.shift);
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
await tester.pump();
expect(primaryFocus, equals(bbFocusNode));
expect(
invokedIntents,
equals(const <Intent>[
NextFocusIntent(),
PreviousFocusIntent(),
NextFocusIntent(),
PreviousFocusIntent(),
]),
);
});
testWidgets('Menu closes on view size change', (WidgetTester tester) async {
var opened = false;
var closed = false;
Widget build(Size size) {
return Builder(
builder: (BuildContext context) {
return MediaQuery(
data: MediaQuery.of(context).copyWith(size: size),
child: App(
CupertinoMenuAnchor(
onOpen: () {
opened = true;
closed = false;
},
onClose: () {
opened = false;
closed = true;
},
controller: controller,
menuChildren: <Widget>[Text(Tag.a.text)],
child: const AnchorButton(Tag.anchor),
),
),
);
},
);
}
await tester.pumpWidget(build(const Size(800, 600)));
await tester.tap(find.text(Tag.anchor.text));
await tester.pump();
expect(opened, isTrue);
expect(closed, isFalse);
const smallSize = Size(200, 200);
await tester.pumpWidget(build(smallSize));
await tester.pump();
expect(opened, isFalse);
expect(closed, isTrue);
});
testWidgets('Menu closes on ancestor scroll', (WidgetTester tester) async {
final scrollController = ScrollController();
addTearDown(scrollController.dispose);
await tester.pumpWidget(
App(
SingleChildScrollView(
controller: scrollController,
child: CupertinoMenuAnchor(
onOpen: () {
onOpen(Tag.anchor);
},
onClose: () {
onClose(Tag.anchor);
},
menuChildren: <Widget>[
CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text)),
CupertinoMenuItem(onPressed: () {}, child: Text(Tag.b.text)),
CupertinoMenuItem(onPressed: () {}, child: Text(Tag.c.text)),
CupertinoMenuItem(onPressed: () {}, child: Text(Tag.d.text)),
],
child: const AnchorButton(Tag.anchor),
),
),
),
);
await tester.tap(find.text(Tag.anchor.text));
await tester.pump();
expect(opened, isNotEmpty);
expect(closed, isEmpty);
opened.clear();
scrollController.jumpTo(1000);
await tester.pump();
expect(opened, isEmpty);
expect(closed, isNotEmpty);
});
testWidgets('Menus do not close on root menu internal scroll', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/122168.
final scrollController = ScrollController();
addTearDown(scrollController.dispose);
var rootOpened = false;
const largeButtonConstraints = BoxConstraints.tightFor(width: 200, height: 300);
await tester.pumpWidget(
App(
SingleChildScrollView(
controller: scrollController,
child: Container(
height: 900,
alignment: Alignment.topLeft,
child: CupertinoMenuAnchor(
onOpen: () {
rootOpened = true;
},
onClose: () {
rootOpened = false;
},
menuChildren: <Widget>[
CupertinoMenuItem(
onPressed: () {},
constraints: largeButtonConstraints,
child: Text(Tag.a.text),
),
CupertinoMenuItem(
onPressed: () {},
constraints: largeButtonConstraints,
child: Text(Tag.b.text),
),
CupertinoMenuItem(
onPressed: () {},
constraints: largeButtonConstraints,
child: Text(Tag.c.text),
),
CupertinoMenuItem(
onPressed: () {},
constraints: largeButtonConstraints,
child: Text(Tag.d.text),
),
],
child: const AnchorButton(Tag.anchor),
),
),
),
),
);
await tester.tap(find.text(Tag.anchor.text));
await tester.pump();
await tester.pumpAndSettle();
expect(rootOpened, true);
// Hover the first submenu anchor.
final pointer = TestPointer(tester.nextPointer, ui.PointerDeviceKind.mouse);
await tester.sendEventToBinding(pointer.hover(tester.getCenter(find.text(Tag.a.text))));
await tester.pump();
// Menus do not close on internal scroll.
await tester.sendEventToBinding(pointer.scroll(const Offset(0.0, 30.0)));
await tester.pump();
expect(rootOpened, true);
// Menus close on external scroll.
scrollController.jumpTo(700);
await tester.pump();
await tester.pumpAndSettle();
expect(rootOpened, false);
});
// Copied from MenuAnchor tests.
//
// Regression test for https://github.com/flutter/flutter/issues/157606.
testWidgets('Menu builder rebuilds when isOpen state changes', (WidgetTester tester) async {
var isOpen = false;
var openCount = 0;
var closeCount = 0;
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
menuChildren: <Widget>[CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text))],
builder: (BuildContext context, MenuController controller, Widget? child) {
isOpen = controller.isOpen;
return CupertinoButton.filled(
child: Text(isOpen ? 'close' : 'open'),
onPressed: () {
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
},
);
},
onOpen: () => openCount++,
onClose: () => closeCount++,
),
),
);
expect(find.text('open'), findsOneWidget);
expect(isOpen, false);
expect(openCount, 0);
expect(closeCount, 0);
await tester.tap(find.text('open'));
await tester.pump();
expect(find.text('close'), findsOneWidget);
expect(isOpen, true);
expect(openCount, 1);
expect(closeCount, 0);
await tester.tap(find.text('close'));
await tester.pump();
expect(find.text('open'), findsOneWidget);
expect(isOpen, false);
expect(openCount, 1);
expect(closeCount, 1);
});
// Copied from [MenuAnchor] tests.
//
// Regression test for https://github.com/flutter/flutter/issues/155034.
testWidgets('Content is shown in the root overlay when useRootOverlay is true', (
WidgetTester tester,
) async {
final controller = MenuController();
final overlayKey = UniqueKey();
late final OverlayEntry overlayEntry;
addTearDown(() {
overlayEntry.remove();
overlayEntry.dispose();
});
await tester.pumpWidget(
App(
Overlay(
key: overlayKey,
initialEntries: <OverlayEntry>[
overlayEntry = OverlayEntry(
builder: (BuildContext context) {
return Center(
child: CupertinoMenuAnchor(
useRootOverlay: true,
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text)),
],
child: const AnchorButton(Tag.anchor),
),
);
},
),
],
),
),
);
expect(find.text(Tag.a.text), findsNothing);
// Open the menu.
controller.open();
await tester.pump();
await tester.pumpAndSettle();
expect(find.text(Tag.a.text), findsOneWidget);
// Expect two overlays: the root overlay created by WidgetsApp and the
// overlay created by the boilerplate code.
expect(find.byType(Overlay), findsNWidgets(2));
final Iterable<Overlay> overlays = tester.widgetList<Overlay>(find.byType(Overlay));
final Overlay nonRootOverlay = tester.widget(find.byKey(overlayKey));
final Overlay rootOverlay = overlays.firstWhere((Overlay overlay) => overlay != nonRootOverlay);
final RenderObject menuTheater = findAncestorRenderTheaters(
tester.renderObject(find.text(Tag.a.text)),
).first;
// Check that the ancestor _RenderTheater for the menu item is the one
// from the root overlay.
expect(menuTheater, tester.renderObject(find.byWidget(rootOverlay)));
});
testWidgets('Content is shown in the nearest ancestor overlay when useRootOverlay is false', (
WidgetTester tester,
) async {
final controller = MenuController();
final overlayKey = UniqueKey();
late final OverlayEntry overlayEntry;
addTearDown(() {
overlayEntry.remove();
overlayEntry.dispose();
});
await tester.pumpWidget(
App(
Overlay(
key: overlayKey,
initialEntries: <OverlayEntry>[
overlayEntry = OverlayEntry(
builder: (BuildContext context) {
return Center(
child: CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text)),
],
child: const AnchorButton(Tag.anchor),
),
);
},
),
],
),
),
);
expect(find.text(Tag.a.text), findsNothing);
// Open the menu.
controller.open();
await tester.pump();
await tester.pumpAndSettle();
expect(find.text(Tag.a.text), findsOneWidget);
// Expect two overlays: the root overlay created by WidgetsApp and the
// overlay created by the boilerplate code.
expect(find.byType(Overlay), findsNWidgets(2));
final Overlay nonRootOverlay = tester.widget(find.byKey(overlayKey));
final RenderObject menuTheater = findAncestorRenderTheaters(
tester.renderObject(find.text(Tag.a.text)),
).first;
// Check that the ancestor _RenderTheater for the menu item is the one
// from the nearest overlay.
expect(menuTheater, tester.renderObject(find.byWidget(nonRootOverlay)));
});
testWidgets('Swiping scales the menu', (WidgetTester tester) async {
final Future<void> Function(int frames) pumpFrames = createFramePumper(tester);
await changeSurfaceSize(tester, const Size(2000, 2000));
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text))],
child: const AnchorButton(Tag.anchor),
),
),
);
final TestGesture gesture = await tester.createGesture();
addTearDown(gesture.removePointer);
controller.open();
await tester.pump();
await tester.pumpAndSettle();
final Offset startPosition = tester.getCenter(find.text(Tag.a.text));
await gesture.down(startPosition);
await tester.pump();
final Rect menuRect = tester.getRect(find.byType(ClipRSuperellipse));
// Check that all corners of the menu are not scaled
await gesture.moveTo(menuRect.topLeft);
await tester.pump();
await pumpFrames(10);
expect(getScale(tester), closeTo(1.0, 0.01));
await gesture.moveTo(menuRect.topRight);
await tester.pump();
await pumpFrames(10);
expect(getScale(tester), closeTo(1.0, 0.01));
await gesture.moveTo(menuRect.bottomLeft);
await tester.pump();
await pumpFrames(10);
expect(getScale(tester), closeTo(1.0, 0.01));
await gesture.moveTo(menuRect.bottomRight);
await tester.pump();
await pumpFrames(10);
expect(getScale(tester), closeTo(1.0, 0.01));
// Move outside the menu bounds to trigger scaling
await gesture.moveTo(menuRect.topLeft - const Offset(50, 50));
await pumpFrames(3);
double topLeftScale = getScale(tester);
expect(topLeftScale, closeTo(0.98, 0.1));
await pumpFrames(3);
topLeftScale = getScale(tester);
expect(topLeftScale, closeTo(0.96, 0.1));
await pumpFrames(3);
topLeftScale = getScale(tester);
expect(topLeftScale, closeTo(0.94, 0.1));
await gesture.moveTo(menuRect.bottomRight + const Offset(50, 50));
await pumpFrames(10);
// Check that scale is roughly the same around the menu
expect(getScale(tester), closeTo(topLeftScale, 0.05));
// Test maximum distance scaling
await gesture.moveTo(menuRect.topLeft - const Offset(200, 200));
await pumpFrames(20);
// Check that the minimum scale is 0.8 (20% reduction)
expect(getScale(tester), closeTo(0.8, 0.1));
await gesture.moveTo(menuRect.bottomRight + const Offset(200, 200));
await pumpFrames(10);
expect(getScale(tester), closeTo(0.8, 0.1));
await gesture.up();
await tester.pump();
});
testWidgets('Swiping minimum scale is 80 percent', (WidgetTester tester) async {
final Future<void> Function(int frames) pumpFrames = createFramePumper(tester);
await changeSurfaceSize(tester, const Size(2000, 2000));
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text))],
child: const AnchorButton(Tag.anchor),
),
),
);
final TestGesture gesture = await tester.createGesture();
addTearDown(gesture.removePointer);
controller.open();
await tester.pump();
await tester.pumpAndSettle();
final Offset startPosition = tester.getCenter(find.text(Tag.a.text));
await gesture.down(startPosition);
await tester.pump();
final Rect menuRect = tester.getRect(find.byType(ClipRSuperellipse));
// Move far outside menu bounds to scale to minimum
await gesture.moveTo(menuRect.topLeft - const Offset(500, 500));
await pumpFrames(30);
// Verify minimum scale is exactly 0.8 (80%)
expect(getScale(tester), moreOrLessEquals(0.8, epsilon: 0.01));
// Try different far positions to ensure consistent minimum scale
await gesture.moveTo(menuRect.bottomRight + const Offset(1000, 1000));
await pumpFrames(30);
expect(getScale(tester), moreOrLessEquals(0.8, epsilon: 0.01));
await gesture.up();
await tester.pump();
});
testWidgets('Menu scale rebounds to full size when swipe returns to menu bounds', (
WidgetTester tester,
) async {
final Future<void> Function(int frames) pumpFrames = createFramePumper(tester);
await changeSurfaceSize(tester, const Size(2000, 2000));
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text))],
child: const AnchorButton(Tag.anchor),
),
),
);
final TestGesture gesture = await tester.createGesture(kind: ui.PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
controller.open();
await tester.pump();
await tester.pumpAndSettle();
final Rect child = tester.getRect(find.byType(ClipRSuperellipse));
await gesture.down(child.bottomRight - const Offset(5, 5));
await pumpFrames(15);
expect(getScale(tester), moreOrLessEquals(1.0, epsilon: 0.01));
await gesture.moveBy(const Offset(100, 100));
await pumpFrames(40);
expect(getScale(tester), closeTo(0.85, 0.1));
await gesture.moveBy(-const Offset(100, 100));
await pumpFrames(40);
expect(getScale(tester), closeTo(1.0, 0.01));
await gesture.moveTo(child.topLeft + const Offset(5, 5));
await pumpFrames(15);
expect(getScale(tester), moreOrLessEquals(1.0, epsilon: 0.01));
await gesture.moveBy(const Offset(-100, -100));
await pumpFrames(15);
expect(getScale(tester), closeTo(0.85, 0.1));
await gesture.moveTo(child.center);
await pumpFrames(40);
expect(getScale(tester), closeTo(1.0, 0.01));
});
testWidgets('Menu scale rebounds to full size when swipe gesture ends', (
WidgetTester tester,
) async {
final Future<void> Function(int frames) pumpFrames = createFramePumper(tester);
await changeSurfaceSize(tester, const Size(2000, 2000));
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text))],
child: const AnchorButton(Tag.anchor),
),
),
);
final TestGesture gesture = await tester.createGesture();
addTearDown(gesture.removePointer);
controller.open();
await tester.pump();
await tester.pumpAndSettle();
final Offset startPosition = tester.getCenter(find.text(Tag.a.text));
await gesture.down(startPosition);
await tester.pump();
final Rect menuRect = tester.getRect(find.byType(ClipRSuperellipse));
// Start with full scale
expect(getScale(tester), moreOrLessEquals(1.0, epsilon: 0.01));
// Move outside menu bounds to trigger scaling
await gesture.moveTo(menuRect.topLeft - const Offset(100, 100));
await pumpFrames(15);
final double scaledValue = getScale(tester);
expect(scaledValue, lessThan(1.0));
expect(scaledValue, greaterThan(0.8));
// End the gesture while still outside menu bounds
await gesture.up();
await pumpFrames(25);
// Verify scale rebounds back to full size after gesture ends
expect(getScale(tester), moreOrLessEquals(1.0, epsilon: 0.01));
// Test from a different scaled position
final TestGesture gesture2 = await tester.createGesture();
addTearDown(gesture2.removePointer);
await gesture2.down(startPosition);
await tester.pump();
// Move to maximum scale distance
await gesture2.moveTo(menuRect.bottomRight + const Offset(200, 200));
await pumpFrames(30);
// Should be at minimum scale
expect(getScale(tester), closeTo(0.8, 0.01));
// End gesture at maximum distance
await gesture2.up();
await tester.pump();
// Allow rebound animation to complete
await pumpFrames(25);
// Should rebound to full scale
expect(getScale(tester), moreOrLessEquals(1.0, epsilon: 0.01));
});
testWidgets('Swipe can be disabled', (WidgetTester tester) async {
await changeSurfaceSize(tester, const Size(1000, 1000));
Widget buildWidget({required bool enableSwipe}) {
return App(
CupertinoMenuAnchor(
controller: controller,
enableSwipe: enableSwipe,
menuChildren: <Widget>[CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text))],
child: const AnchorButton(Tag.anchor),
),
);
}
await tester.pumpWidget(buildWidget(enableSwipe: false));
final TestGesture gesture = await tester.createGesture();
addTearDown(gesture.removePointer);
controller.open();
await tester.pump();
await tester.pumpAndSettle();
final Offset startPosition = tester.getCenter(find.text(Tag.a.text));
await gesture.down(startPosition);
await tester.pump();
final Rect menuRect = tester.getRect(find.byType(ClipRSuperellipse));
// Move far outside the menu bounds
await gesture.moveTo(menuRect.topLeft - const Offset(200, 200));
await tester.pump();
// Scale should remain 1.0 when swiping is disabled
expect(getScale(tester), moreOrLessEquals(1.0, epsilon: 0.001));
await gesture.moveTo(menuRect.bottomRight + const Offset(200, 200));
await tester.pump();
// Scale should still remain 1.0
expect(getScale(tester), moreOrLessEquals(1.0, epsilon: 0.001));
// Move to menu item and verify no special swipe behavior occurs
await gesture.moveTo(tester.getCenter(find.text(Tag.a.text)));
await tester.pump(const Duration(milliseconds: 500));
// Menu should still be open since swipe is disabled
expect(controller.isOpen, isTrue);
await gesture.up();
await tester.pump();
});
testWidgets('Mobile menu width (< 768 px)', (WidgetTester tester) async {
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(size: Size(765, 900)),
child: App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text))],
child: const AnchorButton(Tag.anchor),
),
),
),
);
controller.open();
await tester.pump();
await tester.pumpAndSettle();
final Size popupSize = tester.getSize(find.byType(ClipRSuperellipse));
expect(popupSize.width, moreOrLessEquals(250, epsilon: 0.1));
});
testWidgets('Tablet menu width (>= 768 px)', (WidgetTester tester) async {
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(size: Size(768, 400)),
child: App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text))],
child: const AnchorButton(Tag.anchor),
),
),
),
);
controller.open();
await tester.pump();
await tester.pumpAndSettle();
final Size popupSize = tester.getSize(find.byType(ClipRSuperellipse));
expect(popupSize.width, moreOrLessEquals(262, epsilon: 0.1));
});
testWidgets('Accessible mobile menu width (< 768 px)', (WidgetTester tester) async {
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(size: Size(765, 900), textScaleFactor: 1 + 11 / 17),
child: App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text))],
child: const AnchorButton(Tag.anchor),
),
),
),
);
controller.open();
await tester.pump();
await tester.pumpAndSettle();
final Size popupSize = tester.getSize(find.byType(ClipRSuperellipse));
expect(popupSize.width, moreOrLessEquals(370, epsilon: 0.1));
});
testWidgets('Accessible tablet menu width (>= 768 px)', (WidgetTester tester) async {
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(size: Size(768, 400), textScaleFactor: 1 + 11 / 17),
child: App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text))],
child: const AnchorButton(Tag.anchor),
),
),
),
);
controller.open();
await tester.pump();
await tester.pumpAndSettle();
final Size popupSize = tester.getSize(find.byType(ClipRSuperellipse));
expect(popupSize.width, moreOrLessEquals(343, epsilon: 0.1));
});
testWidgets('Menu scale animation respects reduceMotion', (WidgetTester tester) async {
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text))],
child: const AnchorButton(Tag.anchor),
),
),
);
controller.open();
await tester.pump();
final double baselineScale = getScale(tester);
expect(baselineScale, lessThan(1));
controller.close();
await tester.pumpAndSettle();
tester.binding.platformDispatcher.accessibilityFeaturesTestValue =
const FakeAccessibilityFeatures(reduceMotion: true);
addTearDown(tester.binding.platformDispatcher.clearAccessibilityFeaturesTestValue);
await tester.pump();
controller.open();
await tester.pump();
final double reducedScale = getScale(tester);
expect(reducedScale, moreOrLessEquals(1, epsilon: 0.01));
});
testWidgets('Menu fade animation is disabled when animations are off', (
WidgetTester tester,
) async {
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text))],
child: const AnchorButton(Tag.anchor),
),
),
);
controller.open();
await tester.pump();
final FadeTransition baselineFade = tester.widget<FadeTransition>(
find.ancestor(of: find.byType(ClipRSuperellipse), matching: find.byType(FadeTransition)),
);
expect(baselineFade.opacity.value, lessThan(0.5));
controller.close();
await tester.pump();
tester.binding.platformDispatcher.accessibilityFeaturesTestValue =
const FakeAccessibilityFeatures(disableAnimations: true);
addTearDown(tester.binding.platformDispatcher.clearAccessibilityFeaturesTestValue);
await tester.pump();
controller.open();
await tester.pump();
final FadeTransition disabledFade = tester.widget<FadeTransition>(
find.ancestor(of: find.byType(ClipRSuperellipse), matching: find.byType(FadeTransition)),
);
expect(disabledFade.opacity.value, moreOrLessEquals(1, epsilon: 0.01));
});
group('Focus', () {
testWidgets(
'[Browser] Focus wraps on all platforms',
skip: !isBrowser, // [intended] Web wraps focus regardless of platform.
(WidgetTester tester) async {
final anchorFocusNode = FocusNode();
final firstItemFocusNode = FocusNode();
final lastItemFocusNode = FocusNode();
addTearDown(anchorFocusNode.dispose);
addTearDown(firstItemFocusNode.dispose);
addTearDown(lastItemFocusNode.dispose);
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
onPressed: () {},
focusNode: firstItemFocusNode,
child: Text(Tag.a.text),
),
CupertinoMenuItem(onPressed: () {}, child: Text(Tag.b.text)),
CupertinoMenuItem(
onPressed: () {},
focusNode: lastItemFocusNode,
child: Text(Tag.c.text),
),
],
child: AnchorButton(Tag.anchor, focusNode: anchorFocusNode),
),
),
);
controller.open();
await tester.pump();
await tester.pumpAndSettle();
firstItemFocusNode.requestFocus();
await tester.pump();
expect(FocusManager.instance.primaryFocus, firstItemFocusNode);
// Arrow up from first item should wrap to last item
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
await tester.pump();
expect(FocusManager.instance.primaryFocus, lastItemFocusNode);
// Arrow down from last item should wrap to first item
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
expect(FocusManager.instance.primaryFocus, firstItemFocusNode);
},
);
testWidgets(
'[Not Browser] Focus wraps when traversing with arrow keys on non-Apple platforms',
skip: isBrowser, // [intended] Browser behavior is tested above.
variant: const TargetPlatformVariant(<TargetPlatform>{
TargetPlatform.android,
TargetPlatform.fuchsia,
TargetPlatform.linux,
TargetPlatform.windows,
}),
(WidgetTester tester) async {
final anchorFocusNode = FocusNode();
final firstItemFocusNode = FocusNode();
final lastItemFocusNode = FocusNode();
addTearDown(anchorFocusNode.dispose);
addTearDown(firstItemFocusNode.dispose);
addTearDown(lastItemFocusNode.dispose);
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
onPressed: () {},
focusNode: firstItemFocusNode,
child: Text(Tag.a.text),
),
CupertinoMenuItem(onPressed: () {}, child: Text(Tag.b.text)),
CupertinoMenuItem(
onPressed: () {},
focusNode: lastItemFocusNode,
child: Text(Tag.c.text),
),
],
child: AnchorButton(Tag.anchor, focusNode: anchorFocusNode),
),
),
);
controller.open();
await tester.pump();
await tester.pumpAndSettle();
firstItemFocusNode.requestFocus();
await tester.pump();
expect(FocusManager.instance.primaryFocus, firstItemFocusNode);
// Arrow up from first item should wrap to last item
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
await tester.pump();
expect(FocusManager.instance.primaryFocus, lastItemFocusNode);
// Arrow down from last item should wrap to first item
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
expect(FocusManager.instance.primaryFocus, firstItemFocusNode);
},
);
testWidgets(
'[Not Browser] Focus does not wrap when traversing with arrow keys on Apple platforms',
skip: isBrowser, // [intended] Browser behavior is tested above.
variant: const TargetPlatformVariant(<TargetPlatform>{
TargetPlatform.iOS,
TargetPlatform.macOS,
}),
(WidgetTester tester) async {
final anchorFocusNode = FocusNode();
final firstItemFocusNode = FocusNode();
final lastItemFocusNode = FocusNode();
addTearDown(anchorFocusNode.dispose);
addTearDown(firstItemFocusNode.dispose);
addTearDown(lastItemFocusNode.dispose);
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
menuChildren: <Widget>[
CupertinoMenuItem(
onPressed: () {},
focusNode: firstItemFocusNode,
child: Text(Tag.a.text),
),
CupertinoMenuItem(onPressed: () {}, child: Text(Tag.b.text)),
CupertinoMenuItem(
onPressed: () {},
focusNode: lastItemFocusNode,
child: Text(Tag.c.text),
),
],
child: AnchorButton(Tag.anchor, focusNode: anchorFocusNode),
),
),
);
await tester.tap(find.text(Tag.anchor.text));
await tester.pump();
await tester.pumpAndSettle();
firstItemFocusNode.requestFocus();
await tester.pump();
expect(FocusManager.instance.primaryFocus, firstItemFocusNode);
// Arrow up from first item should not move focus on Apple platforms
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
await tester.pump();
expect(FocusManager.instance.primaryFocus, firstItemFocusNode);
lastItemFocusNode.requestFocus();
await tester.pump();
// Arrow down from last item should not move focus on Apple platforms
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
expect(FocusManager.instance.primaryFocus, lastItemFocusNode);
},
);
testWidgets('Menu items can be activated with enter key', (WidgetTester tester) async {
final anchorFocusNode = FocusNode();
final aFocusNode = FocusNode();
var itemActivated = false;
addTearDown(anchorFocusNode.dispose);
addTearDown(aFocusNode.dispose);
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
focusNode: aFocusNode,
onPressed: () {
itemActivated = true;
},
child: Text(Tag.a.text),
),
],
child: AnchorButton(Tag.anchor, focusNode: anchorFocusNode),
),
),
);
controller.open();
await tester.pump();
await tester.pumpAndSettle();
aFocusNode.requestFocus();
await tester.pump();
expect(FocusManager.instance.primaryFocus, aFocusNode);
expect(itemActivated, isFalse);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump();
expect(itemActivated, isTrue);
});
testWidgets('Menu closes with escape key', (WidgetTester tester) async {
final anchorFocusNode = FocusNode();
final aFocusNode = FocusNode();
addTearDown(anchorFocusNode.dispose);
addTearDown(aFocusNode.dispose);
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(onPressed: () {}, focusNode: aFocusNode, child: Text(Tag.a.text)),
],
child: AnchorButton(Tag.anchor, focusNode: anchorFocusNode),
),
),
);
controller.open();
await tester.pump();
await tester.pumpAndSettle();
aFocusNode.requestFocus();
await tester.pump();
expect(controller.isOpen, isTrue);
expect(FocusManager.instance.primaryFocus, aFocusNode);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pump();
await tester.pumpAndSettle();
expect(controller.isOpen, isFalse);
});
testWidgets('Up and down arrow keys move focus between menu items', (
WidgetTester tester,
) async {
final anchorFocusNode = FocusNode();
final aFocusNode = FocusNode();
final bFocusNode = FocusNode();
final cFocusNode = FocusNode();
addTearDown(anchorFocusNode.dispose);
addTearDown(aFocusNode.dispose);
addTearDown(bFocusNode.dispose);
addTearDown(cFocusNode.dispose);
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(onPressed: () {}, focusNode: aFocusNode, child: Text(Tag.a.text)),
CupertinoMenuItem(onPressed: () {}, focusNode: bFocusNode, child: Text(Tag.b.text)),
CupertinoMenuItem(onPressed: () {}, focusNode: cFocusNode, child: Text(Tag.c.text)),
],
child: AnchorButton(Tag.anchor, focusNode: anchorFocusNode),
),
),
);
controller.open();
await tester.pump();
await tester.pumpAndSettle();
aFocusNode.requestFocus();
await tester.pump();
expect(FocusManager.instance.primaryFocus, aFocusNode);
// Arrow down should move to next item
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
expect(FocusManager.instance.primaryFocus, bFocusNode);
// Arrow down should move to next item
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
expect(FocusManager.instance.primaryFocus, cFocusNode);
// Arrow up should move to previous item
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
await tester.pump();
expect(FocusManager.instance.primaryFocus, bFocusNode);
// Arrow up should move to previous item
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
await tester.pump();
expect(FocusManager.instance.primaryFocus, aFocusNode);
});
testWidgets('Focus returns to button after menu closes', (WidgetTester tester) async {
final anchorFocusNode = FocusNode();
final aFocusNode = FocusNode();
addTearDown(anchorFocusNode.dispose);
addTearDown(aFocusNode.dispose);
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
childFocusNode: anchorFocusNode,
menuChildren: <Widget>[
CupertinoMenuItem(onPressed: () {}, focusNode: aFocusNode, child: Text(Tag.a.text)),
],
child: AnchorButton(Tag.anchor, focusNode: anchorFocusNode),
),
),
);
anchorFocusNode.requestFocus();
await tester.pump();
await tester.pumpAndSettle();
controller.open();
await tester.pump();
aFocusNode.requestFocus();
await tester.pump();
expect(FocusManager.instance.primaryFocus, aFocusNode);
// Close menu with escape
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pump();
expect(controller.isOpen, isFalse);
expect(FocusManager.instance.primaryFocus, anchorFocusNode);
});
testWidgets('Left and right arrow keys do not move focus in menu', (WidgetTester tester) async {
final anchorFocusNode = FocusNode();
final aFocusNode = FocusNode();
final bFocusNode = FocusNode();
addTearDown(anchorFocusNode.dispose);
addTearDown(aFocusNode.dispose);
addTearDown(bFocusNode.dispose);
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(onPressed: () {}, focusNode: aFocusNode, child: Text(Tag.a.text)),
CupertinoMenuItem(onPressed: () {}, focusNode: bFocusNode, child: Text(Tag.b.text)),
],
child: AnchorButton(Tag.anchor, focusNode: anchorFocusNode),
),
),
);
controller.open();
await tester.pump();
await tester.pumpAndSettle();
aFocusNode.requestFocus();
await tester.pump();
expect(FocusManager.instance.primaryFocus, aFocusNode);
// Left arrow should not change focus
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
await tester.pump();
expect(FocusManager.instance.primaryFocus, aFocusNode);
// Right arrow should not change focus
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
expect(FocusManager.instance.primaryFocus, aFocusNode);
});
testWidgets('Down key after menu opens focuses the first menu item', (
WidgetTester tester,
) async {
final anchorFocusNode = FocusNode();
final firstItemFocusNode = FocusNode();
final secondItemFocusNode = FocusNode();
addTearDown(anchorFocusNode.dispose);
addTearDown(firstItemFocusNode.dispose);
addTearDown(secondItemFocusNode.dispose);
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
onPressed: () {},
focusNode: firstItemFocusNode,
child: Text(Tag.a.text),
),
CupertinoMenuItem(
onPressed: () {},
focusNode: secondItemFocusNode,
child: Text(Tag.b.text),
),
],
child: AnchorButton(Tag.anchor, focusNode: anchorFocusNode),
),
),
);
// Focus the anchor button first
anchorFocusNode.requestFocus();
await tester.pump();
expect(FocusManager.instance.primaryFocus, anchorFocusNode);
// Open the menu
controller.open();
await tester.pump();
await tester.pumpAndSettle();
// Press down arrow key - should focus first menu item
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
expect(FocusManager.instance.primaryFocus, firstItemFocusNode);
});
testWidgets('Up key after open focuses the last menu item', (WidgetTester tester) async {
final anchorFocusNode = FocusNode();
final firstItemFocusNode = FocusNode();
final lastItemFocusNode = FocusNode();
addTearDown(anchorFocusNode.dispose);
addTearDown(firstItemFocusNode.dispose);
addTearDown(lastItemFocusNode.dispose);
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
onPressed: () {},
focusNode: firstItemFocusNode,
child: Text(Tag.a.text),
),
CupertinoMenuItem(onPressed: () {}, child: Text(Tag.b.text)),
CupertinoMenuItem(
onPressed: () {},
focusNode: lastItemFocusNode,
child: Text(Tag.c.text),
),
],
child: AnchorButton(Tag.anchor, focusNode: anchorFocusNode),
),
),
);
// Focus the anchor button first
anchorFocusNode.requestFocus();
await tester.pump();
expect(FocusManager.instance.primaryFocus, anchorFocusNode);
// Open the menu
controller.open();
await tester.pump();
await tester.pumpAndSettle();
// Press up arrow key - should focus last menu item
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
await tester.pump();
expect(FocusManager.instance.primaryFocus, lastItemFocusNode);
});
testWidgets('Home key moves focus to first menu item', (WidgetTester tester) async {
final anchorFocusNode = FocusNode();
final firstItemFocusNode = FocusNode();
final middleItemFocusNode = FocusNode();
final lastItemFocusNode = FocusNode();
addTearDown(anchorFocusNode.dispose);
addTearDown(firstItemFocusNode.dispose);
addTearDown(middleItemFocusNode.dispose);
addTearDown(lastItemFocusNode.dispose);
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
onPressed: () {},
focusNode: firstItemFocusNode,
child: Text(Tag.a.text),
),
CupertinoMenuItem(
onPressed: () {},
focusNode: middleItemFocusNode,
child: Text(Tag.b.text),
),
CupertinoMenuItem(
onPressed: () {},
focusNode: lastItemFocusNode,
child: Text(Tag.c.text),
),
],
child: AnchorButton(Tag.anchor, focusNode: anchorFocusNode),
),
),
);
controller.open();
await tester.pump();
await tester.pumpAndSettle();
lastItemFocusNode.requestFocus();
await tester.pump();
expect(FocusManager.instance.primaryFocus, lastItemFocusNode);
await tester.sendKeyEvent(LogicalKeyboardKey.home);
await tester.pump();
expect(FocusManager.instance.primaryFocus, firstItemFocusNode);
});
testWidgets('End key moves focus to last menu item', (WidgetTester tester) async {
final anchorFocusNode = FocusNode();
final firstItemFocusNode = FocusNode();
final middleItemFocusNode = FocusNode();
final lastItemFocusNode = FocusNode();
addTearDown(anchorFocusNode.dispose);
addTearDown(firstItemFocusNode.dispose);
addTearDown(middleItemFocusNode.dispose);
addTearDown(lastItemFocusNode.dispose);
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
onPressed: () {},
focusNode: firstItemFocusNode,
child: Text(Tag.a.text),
),
CupertinoMenuItem(
onPressed: () {},
focusNode: middleItemFocusNode,
child: Text(Tag.b.text),
),
CupertinoMenuItem(
onPressed: () {},
focusNode: lastItemFocusNode,
child: Text(Tag.c.text),
),
],
child: AnchorButton(Tag.anchor, focusNode: anchorFocusNode),
),
),
);
controller.open();
await tester.pump();
await tester.pumpAndSettle();
firstItemFocusNode.requestFocus();
await tester.pump();
expect(FocusManager.instance.primaryFocus, firstItemFocusNode);
await tester.sendKeyEvent(LogicalKeyboardKey.end);
await tester.pump();
expect(FocusManager.instance.primaryFocus, lastItemFocusNode);
});
});
group('Layout', () {
/// Returns the rects of the menu's contents. If [clipped] is true, the
/// rect is taken after UnconstrainedBox clips its contents.
List<Rect> collectOverlays({bool clipped = true}) {
final menuRects = <Rect>[];
final Finder finder = clipped
? find.byType(UnconstrainedBox)
: find.byType(ClipRSuperellipse);
for (final Element candidate in finder.evaluate().toList()) {
final box = candidate.renderObject! as RenderBox;
final Offset topLeft = box.localToGlobal(box.size.topLeft(Offset.zero));
menuRects.add(topLeft & box.size);
}
return menuRects;
}
testWidgets('LTR menu default layout', (WidgetTester tester) async {
const size = Size(2000, 2000);
await changeSurfaceSize(tester, size);
Widget buildApp({required AlignmentGeometry alignment}) {
return App(
CupertinoMenuAnchor(
overlayPadding: EdgeInsets.zero,
constraints: BoxConstraints.tight(const Size(50, 50)),
menuChildren: <Widget>[
Container(
width: 50,
height: 50,
color: const Color(0xFF0000FF),
child: Text(Tag.a.text),
),
],
child: const AnchorButton(
Tag.anchor,
constraints: BoxConstraints.tightFor(width: 50, height: 50),
),
),
alignment: alignment,
);
}
await tester.pumpWidget(buildApp(alignment: Alignment.topCenter));
await tester.tap(find.text(Tag.anchor.text));
await tester.pump();
await tester.pumpAndSettle();
for (var horizontal = -0.8; horizontal <= 0.8; horizontal += 0.15) {
for (var vertical = -0.8; vertical <= 0.8; vertical += 0.15) {
await tester.pumpWidget(buildApp(alignment: Alignment(horizontal, vertical)));
final double x = switch (horizontal) {
< -0.2 => -1.0,
> 0.2 => 1.0,
_ => 0.0,
};
final y = vertical < 0.2 ? 1.0 : -1.0;
final alignment = Alignment(x, y);
final menuAlignment = Alignment(x, -y);
final ui.Rect anchorRect = tester.getRect(
find.widgetWithText(CupertinoButton, Tag.anchor.text),
);
final ui.Rect surface = tester.getRect(find.widgetWithText(Container, Tag.a.text));
final ui.Offset position = alignment.withinRect(anchorRect);
expect(
position,
offsetMoreOrLessEquals(menuAlignment.withinRect(surface), epsilon: 0.01),
reason:
'Anchor alignment: ${Alignment(horizontal, vertical)} \n'
'Menu rect: $surface \n',
);
}
}
});
testWidgets('RTL menu default layout', (WidgetTester tester) async {
const size = Size(2000, 2000);
await changeSurfaceSize(tester, size);
Widget buildApp({required AlignmentGeometry alignment}) {
return App(
textDirection: TextDirection.rtl,
CupertinoMenuAnchor(
overlayPadding: EdgeInsets.zero,
constraints: BoxConstraints.tight(const Size(50, 50)),
menuChildren: <Widget>[
Container(
width: 50,
height: 50,
color: const Color(0xFF0000FF),
child: Text(Tag.a.text),
),
],
child: const AnchorButton(
Tag.anchor,
constraints: BoxConstraints.tightFor(width: 50, height: 50),
),
),
alignment: alignment,
);
}
await tester.pumpWidget(buildApp(alignment: Alignment.topCenter));
await tester.tap(find.text(Tag.anchor.text));
await tester.pump();
await tester.pumpAndSettle();
for (var horizontal = -0.8; horizontal <= 0.8; horizontal += 0.15) {
for (var vertical = -0.8; vertical <= 0.8; vertical += 0.15) {
await tester.pumpWidget(buildApp(alignment: Alignment(horizontal, vertical)));
final double x = switch (horizontal) {
< -0.2 => -1.0,
> 0.2 => 1.0,
_ => 0.0,
};
final y = vertical < 0.2 ? 1.0 : -1.0;
final alignment = Alignment(x, y);
final menuAlignment = Alignment(x, -y);
final ui.Rect anchorRect = tester.getRect(
find.widgetWithText(CupertinoButton, Tag.anchor.text),
);
final ui.Rect surface = tester.getRect(find.widgetWithText(Container, Tag.a.text));
final ui.Offset position = alignment.withinRect(anchorRect);
expect(
position,
offsetMoreOrLessEquals(menuAlignment.withinRect(surface), epsilon: 0.01),
reason:
'Anchor alignment: ${Alignment(horizontal, vertical)} \n'
'Menu rect: $surface \n',
);
}
}
});
testWidgets('LTR menu positioned layout', (WidgetTester tester) async {
const size = Size(2000, 2000);
await changeSurfaceSize(tester, size);
Widget buildApp() {
return App(
textDirection: TextDirection.ltr,
CupertinoMenuAnchor(
controller: controller,
overlayPadding: EdgeInsets.zero,
constraints: BoxConstraints.tight(const Size(50, 50)),
menuChildren: <Widget>[
Container(
width: 50,
height: 50,
color: const Color(0xFF0000FF),
child: Text(Tag.a.text),
),
],
child: const Stack(
children: <Widget>[ColoredBox(color: Color(0xFF00FF00), child: SizedBox.expand())],
),
),
);
}
await tester.pumpWidget(buildApp());
controller.open();
await tester.pump();
await tester.pumpAndSettle();
for (var horizontal = -0.8; horizontal <= 0.8; horizontal += 0.15) {
for (var vertical = -0.8; vertical <= 0.8; vertical += 0.15) {
final double x = switch (horizontal) {
< -0.2 => -1.0,
> 0.2 => 1.0,
_ => 0.0,
};
final y = vertical < 0.2 ? 1.0 : -1.0;
final ui.Offset position = Alignment(x, y).alongSize(size);
controller.open(position: position);
await tester.pump();
final menuAlignment = Alignment(x, y);
final ui.Rect surface = tester.getRect(find.widgetWithText(Container, Tag.a.text));
expect(
position,
offsetMoreOrLessEquals(menuAlignment.withinRect(surface), epsilon: 0.01),
reason:
'Anchor alignment: ${Alignment(horizontal, vertical)} \n'
'Menu rect: $surface \n',
);
}
}
});
testWidgets('RTL menu positioned layout', (WidgetTester tester) async {
const size = Size(2000, 2000);
await changeSurfaceSize(tester, size);
Widget buildApp() {
return App(
textDirection: TextDirection.rtl,
CupertinoMenuAnchor(
controller: controller,
overlayPadding: EdgeInsets.zero,
constraints: BoxConstraints.tight(const Size(50, 50)),
menuChildren: <Widget>[
Container(
width: 50,
height: 50,
color: const Color(0xFF0000FF),
child: Text(Tag.a.text),
),
],
child: const Stack(
children: <Widget>[ColoredBox(color: Color(0xFF00FF00), child: SizedBox.expand())],
),
),
);
}
await tester.pumpWidget(buildApp());
controller.open();
await tester.pump();
await tester.pumpAndSettle();
for (var horizontal = -0.8; horizontal <= 0.8; horizontal += 0.15) {
for (var vertical = -0.8; vertical <= 0.8; vertical += 0.15) {
final double x = switch (horizontal) {
< -0.2 => -1.0,
> 0.2 => 1.0,
_ => 0.0,
};
final y = vertical < 0.2 ? 1.0 : -1.0;
final ui.Offset position = Alignment(x, y).alongSize(size);
controller.open(position: position);
await tester.pump();
final menuAlignment = Alignment(x, y);
final ui.Rect surface = tester.getRect(find.widgetWithText(Container, Tag.a.text));
expect(
position,
offsetMoreOrLessEquals(menuAlignment.withinRect(surface), epsilon: 0.01),
reason:
'Anchor alignment: ${Alignment(horizontal, vertical)} \n'
'Menu rect: $surface \n',
);
}
}
});
testWidgets('LTR constrained menu placement with unconstrained crossaxis', (
WidgetTester tester,
) async {
await changeSurfaceSize(tester, const Size(200, 200));
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
overlayPadding: EdgeInsets.zero,
menuChildren: <Widget>[Container(color: const Color(0xFFFF0000), height: 40)],
child: const AnchorButton(Tag.anchor),
),
),
);
await tester.tap(find.text(Tag.anchor.text));
await tester.pump();
await tester.pumpAndSettle();
final List<ui.Rect> overlays = collectOverlays(clipped: false);
expect(overlays, hasLength(1));
// The unclipped menu surface can grow beyond the screen. Since this
// example is LTR, the left edge of the screen should be flush with the
// left edge of the menu surface.
//
// In this demo, the screen width is 200, the surface width is 250, and
// the content width is 300. The surface width should equal 250, starting
// the left edge (0px).
expect(
overlays.first,
rectMoreOrLessEquals(const Rect.fromLTRB(-0.0, 124.5, 262.0, 164.5), epsilon: 0.01),
);
});
testWidgets('RTL constrained menu placement with unconstrained crossaxis', (
WidgetTester tester,
) async {
await changeSurfaceSize(tester, const Size(200, 200));
await tester.pumpWidget(
App(
textDirection: TextDirection.rtl,
CupertinoMenuAnchor(
overlayPadding: EdgeInsets.zero,
menuChildren: <Widget>[Container(color: const Color(0xFFFF0000), height: 40)],
child: const AnchorButton(Tag.anchor),
),
),
);
await tester.tap(find.text(Tag.anchor.text));
await tester.pump();
await tester.pumpAndSettle();
final List<ui.Rect> overlays = collectOverlays(clipped: false);
expect(overlays, hasLength(1));
// The unclipped menu surface can grow beyond the screen. Since we are
// RTL, the right edge of the screen should be flush with the right edge
// of the menu surface.
//
// In this demo, the screen width is 200, the surface width is 250, and
// the content width is 300. The surface width should equal 250, ending
// at the right edge (200px).
expect(
overlays.first,
rectMoreOrLessEquals(const Rect.fromLTRB(-62.0, 124.5, 200.0, 164.5), epsilon: 0.01),
);
});
testWidgets('LTR constrained menu placement with constrained crossaxis', (
WidgetTester tester,
) async {
await changeSurfaceSize(tester, const Size(200, 200));
const constraints = BoxConstraints.tightFor(width: 300, height: 40);
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
constrainCrossAxis: true,
overlayPadding: EdgeInsets.zero,
menuChildren: <Widget>[
CupertinoMenuItem(
onPressed: () {},
constraints: constraints,
child: Text(Tag.a.text),
),
],
child: const AnchorButton(Tag.anchor, constraints: constraints),
),
),
);
await tester.tap(find.text(Tag.anchor.text));
await tester.pump();
await tester.pumpAndSettle();
final List<ui.Rect> overlays = collectOverlays(clipped: false);
expect(overlays, hasLength(1));
// The unclipped menu surface will not grow beyond the screen.
expect(
overlays.first,
rectMoreOrLessEquals(const Rect.fromLTRB(0.0, 124.5, 200.0, 164.5), epsilon: 0.01),
);
});
testWidgets('RTL constrained menu placement with constrained crossaxis', (
WidgetTester tester,
) async {
await changeSurfaceSize(tester, const Size(200, 200));
const constraints = BoxConstraints.tightFor(width: 300, height: 40);
await tester.pumpWidget(
App(
textDirection: TextDirection.rtl,
CupertinoMenuAnchor(
constrainCrossAxis: true,
overlayPadding: EdgeInsets.zero,
menuChildren: <Widget>[
CupertinoMenuItem(
onPressed: () {},
constraints: constraints,
child: Text(Tag.a.text),
),
],
child: const AnchorButton(Tag.anchor, constraints: constraints),
),
),
);
await tester.tap(find.text(Tag.anchor.text));
await tester.pumpAndSettle();
final List<ui.Rect> overlays = collectOverlays(clipped: false);
expect(overlays, hasLength(1));
// The unclipped menu surface will not grow beyond the screen.
expect(
overlays.first,
rectMoreOrLessEquals(const Rect.fromLTRB(0.0, 124.5, 200.0, 164.5), epsilon: 0.01),
);
});
testWidgets('Constraints applied to anchor do not affect overlay', (WidgetTester tester) async {
await tester.pumpWidget(
App(
alignment: Alignment.topLeft,
ConstrainedBox(
constraints: const BoxConstraints.tightFor(width: 60, height: 60),
child: CupertinoMenuAnchor(
overlayPadding: EdgeInsets.zero,
menuChildren: <Widget>[Container(color: const Color(0xFFFF0000), height: 100)],
child: const AnchorButton(Tag.anchor),
),
),
),
);
await tester.tap(find.text(Tag.anchor.text));
await tester.pumpAndSettle();
expect(collectOverlays().first.size, sizeCloseTo(const Size(262.0, 100.0), 0.01));
});
testWidgets('Default overlay padding', (WidgetTester tester) async {
await changeSurfaceSize(tester, const Size(800, 600));
// Default padding is 8 pixels on all sides
await tester.pumpWidget(
App(
textDirection: TextDirection.ltr,
CupertinoMenuAnchor(
controller: controller,
constrainCrossAxis: true,
constraints: BoxConstraints.tight(const Size(200, 200)),
menuChildren: const <Widget>[SizedBox()],
child: const ColoredBox(color: CupertinoColors.systemOrange, child: SizedBox.expand()),
),
),
);
controller.open(position: Offset.zero);
await tester.pump();
await tester.pumpAndSettle();
final Finder overlayFinder = find.byType(ClipRSuperellipse);
expect(
tester.getTopLeft(overlayFinder),
offsetMoreOrLessEquals(const Offset(8, 8), epsilon: 0.01),
);
controller.open(position: const Offset(800, 600));
await tester.pump();
expect(
tester.getBottomRight(overlayFinder),
offsetMoreOrLessEquals(const Offset(800 - 8, 600 - 8), epsilon: 0.01),
);
});
testWidgets('LTR overlay padding', (WidgetTester tester) async {
await changeSurfaceSize(tester, const Size(800, 600));
const overlayPadding = EdgeInsetsDirectional.fromSTEB(21, 11, 650, 400);
// Padding should stack
await tester.pumpWidget(
App(
textDirection: TextDirection.ltr,
CupertinoMenuAnchor(
controller: controller,
constrainCrossAxis: true,
overlayPadding: overlayPadding,
constraints: BoxConstraints.tight(const Size(200, 200)),
menuChildren: const <Widget>[SizedBox()],
child: Container(
padding: overlayPadding - const EdgeInsetsDirectional.all(2),
color: CupertinoColors.systemOrange,
child: const SizedBox.expand(),
),
),
),
);
controller.open(position: Offset.zero);
await tester.pump();
await tester.pumpAndSettle();
final Rect overlay = tester.getRect(find.byType(ClipRSuperellipse));
expect(
overlay.topLeft,
offsetMoreOrLessEquals(Offset(overlayPadding.start, overlayPadding.top), epsilon: 0.01),
);
expect(overlay.size, sizeCloseTo(const Size(129, 189), 0.01));
});
testWidgets('RTL overlay padding', (WidgetTester tester) async {
await changeSurfaceSize(tester, const Size(800, 600));
const overlayPadding = EdgeInsetsDirectional.fromSTEB(21, 11, 650, 400);
// Padding should stack
await tester.pumpWidget(
App(
textDirection: TextDirection.rtl,
CupertinoMenuAnchor(
controller: controller,
constrainCrossAxis: true,
overlayPadding: overlayPadding,
constraints: BoxConstraints.tight(const Size(200, 200)),
menuChildren: const <Widget>[SizedBox()],
child: Container(
padding: overlayPadding - const EdgeInsetsDirectional.all(2),
color: CupertinoColors.systemOrange,
child: const SizedBox.expand(),
),
),
),
);
controller.open(position: Offset.zero);
await tester.pump();
await tester.pumpAndSettle();
final Rect overlay = tester.getRect(find.byType(ClipRSuperellipse));
expect(
overlay.topLeft,
offsetMoreOrLessEquals(
Offset(800 - (overlayPadding.start + 129), overlayPadding.top),
epsilon: 0.1,
),
);
expect(overlay.size, sizeCloseTo(const Size(129, 189), 0.01));
});
testWidgets('App and overlay padding', (WidgetTester tester) async {
await changeSurfaceSize(tester, const Size(800, 600));
const appPadding = EdgeInsetsDirectional.fromSTEB(31, 7, 27, 50);
const overlayPadding = EdgeInsetsDirectional.fromSTEB(21, 11, 600, 400);
// Overlay padding should stack with App padding
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Container(
color: CupertinoColors.systemGrey6,
padding: appPadding,
child: App(
textDirection: TextDirection.ltr,
CupertinoMenuAnchor(
controller: controller,
constrainCrossAxis: true,
overlayPadding: overlayPadding,
constraints: BoxConstraints.tight(const Size(200, 200)),
menuChildren: const <Widget>[SizedBox()],
child: Container(
padding: overlayPadding - const EdgeInsetsDirectional.all(2),
color: CupertinoColors.systemOrange,
child: const SizedBox.expand(),
),
),
),
),
),
);
controller.open(position: Offset.zero);
await tester.pump();
await tester.pumpAndSettle();
final Rect overlay = tester.getRect(find.byType(ClipRSuperellipse));
expect(
overlay.topLeft,
offsetMoreOrLessEquals(
Offset(appPadding.start + overlayPadding.start, appPadding.top + overlayPadding.top),
epsilon: 0.01,
),
);
expect(overlay.size, sizeCloseTo(const Size(121, 132), 0.01));
});
testWidgets('App and anchor padding', (WidgetTester tester) async {
await changeSurfaceSize(tester, const Size(800, 600));
// Out of App:
// - overlay position affected
// - anchor position affected
// In App:
// - anchor position affected
//
// Padding inside the App DOES NOT affect the overlay position but
// DOES affect the anchor position.
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Container(
color: CupertinoColors.systemGrey6,
padding: const EdgeInsets.fromLTRB(31, 7, 550, 0),
child: App(
alignment: Alignment.topLeft,
Container(
color: CupertinoColors.systemGrey3,
padding: const EdgeInsets.fromLTRB(21, 11, 17, 0),
child: const CupertinoMenuAnchor(
constrainCrossAxis: true,
overlayPadding: EdgeInsets.zero,
constraints: BoxConstraints.tightFor(width: 250, height: 250),
menuChildren: <Widget>[SizedBox.square(dimension: 250)],
child: AnchorButton(
Tag.anchor,
constraints: BoxConstraints.tightFor(width: 125, height: 50),
),
),
),
),
),
),
);
await tester.tap(find.text(Tag.anchor.text));
await tester.pump();
await tester.pumpAndSettle();
final Offset overlay = collectOverlays(clipped: false).first.topLeft;
final Offset anchor = tester.getTopLeft(find.widgetWithText(AnchorButton, Tag.anchor.text));
expect(anchor, offsetMoreOrLessEquals(const Offset(31 + 21, 7 + 11), epsilon: 0.01));
expect(overlay, offsetMoreOrLessEquals(const Offset(31, 7 + 11 + 50), epsilon: 0.01));
});
testWidgets('Menu is positioned around display features', (WidgetTester tester) async {
// A 20-pixel wide vertical display feature, similar to a
// foldable with a visible hinge. Splits the display into two
// "virtual screens".
const displayFeature = ui.DisplayFeature(
bounds: Rect.fromLTRB(390, 0, 410, 1000),
type: ui.DisplayFeatureType.cutout,
state: ui.DisplayFeatureState.unknown,
);
await tester.pumpWidget(
App(
MediaQuery(
data: const MediaQueryData(
platformBrightness: Brightness.dark,
displayFeatures: <ui.DisplayFeature>[displayFeature],
),
child: ColoredBox(
color: const Color(0xFF004CFF),
child: Stack(
children: <Widget>[
// Pink box for visualizing the display feature.
Positioned.fromRect(
rect: displayFeature.bounds,
child: const ColoredBox(color: Color(0xF7FF2190)),
),
const Positioned(
left: 400,
top: 0,
child: CupertinoMenuAnchor(
overlayPadding: EdgeInsets.zero,
menuChildren: <Widget>[SizedBox(width: 100, height: 50)],
child: AnchorButton(Tag.anchor),
),
),
],
),
),
),
),
);
await tester.tap(find.text(Tag.anchor.text));
await tester.pump();
await tester.pumpAndSettle();
final double menuLeft = collectOverlays().first.left;
// Since the display feature splits the display into 2 sub-screens, the
// menu should be positioned to fit against the second virtual screen. The
// menu is positioned with its left edge at the right edge of the display
// feature, which is at 410 pixels.
expect(menuLeft, moreOrLessEquals(410, epsilon: 0.01));
});
testWidgets('Menu constraints are applied to menu surface', (WidgetTester tester) async {
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
overlayPadding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 75, maxHeight: 100),
menuChildren: <Widget>[SizedBox(key: Tag.a.key, height: 150, width: 50)],
child: const AnchorButton(Tag.anchor),
),
),
);
await tester.tap(find.text(Tag.anchor.text));
await tester.pump();
await tester.pumpAndSettle();
final ui.Rect overlay = collectOverlays().first;
expect(overlay.size, sizeCloseTo(const Size(75, 100), 1));
// Width and height should be maintained
expect(tester.getSize(find.byKey(Tag.a.key)), sizeCloseTo(const Size(50, 150), 1));
// The container should be centered in the overlay.
expect(
tester.getTopLeft(find.byKey(Tag.a.key)),
offsetMoreOrLessEquals(overlay.topLeft + const Offset(12.5, 0), epsilon: 0.01),
);
});
testWidgets('Menu is positioned in the root overlay when useRootOverlay is true', (
WidgetTester tester,
) async {
// The menu should not overflow the bottom of the root overlay, so the
// menu should be placed below the anchor button.
final entry = OverlayEntry(
builder: (BuildContext context) {
return const Positioned(
bottom: 0,
child: CupertinoMenuAnchor(
overlayPadding: EdgeInsets.zero,
useRootOverlay: true,
menuChildren: <Widget>[SizedBox(height: 100)],
child: AnchorButton(Tag.anchor),
),
);
},
);
// Overlay entries leak if they are not disposed.
addTearDown(() {
entry.remove();
entry.dispose();
});
await tester.pumpWidget(
App(
Stack(
children: <Widget>[
Positioned(
height: 200,
width: 200,
child: ColoredBox(
color: const Color(0xFFFF0000),
child: Overlay(initialEntries: <OverlayEntry>[entry]),
),
),
],
),
),
);
await tester.tap(find.text(Tag.anchor.text));
await tester.pump();
await tester.pumpAndSettle();
final [ui.Rect menu] = collectOverlays();
final Rect anchor = tester.getRect(find.widgetWithText(CupertinoButton, Tag.anchor.text));
expect(menu.topLeft, offsetMoreOrLessEquals(anchor.bottomLeft, epsilon: 0.01));
});
testWidgets(
'Menu is positioned within the closest ancestor overlay when useRootOverlay is false',
(WidgetTester tester) async {
// The menu should overflow the bottom of the nearest ancestor overlay, so
// the menu should be placed above the anchor button.
final entry = OverlayEntry(
builder: (BuildContext context) {
return const Positioned(
bottom: 0,
child: CupertinoMenuAnchor(
overlayPadding: EdgeInsets.zero,
menuChildren: <Widget>[SizedBox(height: 100)],
child: AnchorButton(Tag.anchor),
),
);
},
);
// Overlay entries leak if they are not disposed.
addTearDown(() {
entry.remove();
entry.dispose();
});
await tester.pumpWidget(
App(
Stack(
children: <Widget>[
Positioned(
height: 200,
width: 200,
child: ColoredBox(
color: const Color(0xFFFF0000),
child: Overlay(initialEntries: <OverlayEntry>[entry]),
),
),
],
),
),
);
await tester.tap(find.text(Tag.anchor.text));
await tester.pump();
await tester.pumpAndSettle();
final [ui.Rect menu] = collectOverlays();
final Rect anchor = tester.getRect(find.widgetWithText(CupertinoButton, Tag.anchor.text));
expect(menu.bottomLeft, offsetMoreOrLessEquals(anchor.topLeft, epsilon: 0.01));
},
);
});
group('CupertinoMenuEntryMixin', () {
App buildApp(List<Widget> children) {
return App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: children,
child: const AnchorButton(Tag.anchor),
),
);
}
Widget entry({required bool isDivider, Widget? child}) {
return DebugCupertinoMenuEntry(key: UniqueKey(), isDivider: isDivider, child: child);
}
testWidgets('Implicit dividers are drawn between menu items when isDivider is false ', (
WidgetTester tester,
) async {
await tester.pumpWidget(
buildApp(<Widget>[
entry(isDivider: false, child: Text(Tag.a.text)),
entry(isDivider: false, child: Text(Tag.b.text)),
entry(isDivider: false, child: Text(Tag.c.text)),
]),
);
controller.open();
await tester.pumpAndSettle();
// Borders are drawn below menu items.
List<Widget> children = findMenuChildren(tester);
expect(children.length, 5);
expect(children[0], isA<DebugCupertinoMenuEntry>());
expect(children[2], isA<DebugCupertinoMenuEntry>());
expect(children[4], isA<DebugCupertinoMenuEntry>());
// First item should never have a leading separator and bottom item should
// never have a trailing separator.
await tester.pumpWidget(
buildApp(<Widget>[
entry(isDivider: true, child: Text(Tag.a.text)),
entry(isDivider: false, child: Text(Tag.b.text)),
entry(isDivider: false, child: Text(Tag.c.text)),
]),
);
children = findMenuChildren(tester);
expect(children.length, 4);
expect(children[0], isA<DebugCupertinoMenuEntry>());
expect(children[1], isA<DebugCupertinoMenuEntry>());
expect(children[3], isA<DebugCupertinoMenuEntry>());
await tester.pumpWidget(
buildApp(<Widget>[
entry(isDivider: false, child: Text(Tag.a.text)),
entry(isDivider: true, child: Text(Tag.b.text)),
entry(isDivider: false, child: Text(Tag.c.text)),
]),
);
children = findMenuChildren(tester);
// item 1: leading == false so no separator should be drawn before it
expect(children.length, 3);
expect(children[0], isA<DebugCupertinoMenuEntry>());
expect(children[1], isA<DebugCupertinoMenuEntry>());
expect(children[2], isA<DebugCupertinoMenuEntry>());
await tester.pumpWidget(
buildApp(<Widget>[
entry(isDivider: false, child: Text(Tag.a.text)),
entry(isDivider: false, child: Text(Tag.b.text)),
entry(isDivider: true, child: Text(Tag.c.text)),
]),
);
children = findMenuChildren(tester);
// item 1: trailing == false so no separator should be drawn after it
expect(children.length, 4);
expect(children[0], isA<DebugCupertinoMenuEntry>());
expect(children[2], isA<DebugCupertinoMenuEntry>());
expect(children[3], isA<DebugCupertinoMenuEntry>());
children.clear();
});
testWidgets(
'Implicit dividers are drawn between widgets that do not mixin CupertinoMenuEntryMixin',
(WidgetTester tester) async {
await tester.pumpWidget(
buildApp(<Widget>[
entry(isDivider: false, child: Text(Tag.a.text)),
SizedBox(child: Text(Tag.b.text)),
entry(isDivider: false, child: Text(Tag.c.text)),
]),
);
controller.open();
await tester.pumpAndSettle();
// Borders are drawn below menu items.
final List<Widget> children = findMenuChildren(tester);
expect(children.length, 5);
expect(children[0], isA<DebugCupertinoMenuEntry>());
expect(children[2], isA<SizedBox>());
expect(children[4], isA<DebugCupertinoMenuEntry>());
},
);
testWidgets('hasLeading aligns sibling CupertinoMenuItems', (WidgetTester tester) async {
Widget buildApp({bool hasLeading = false}) {
return App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(key: Tag.a.key, onPressed: () {}, child: Text(Tag.a.text)),
DebugCupertinoMenuEntry(hasLeading: hasLeading),
],
),
);
}
await tester.pumpWidget(buildApp());
controller.open();
await tester.pumpAndSettle();
final Offset childOffsetWithoutLeading = tester.getTopLeft(find.text(Tag.a.text));
await tester.pumpWidget(buildApp(hasLeading: true));
final Offset childOffsetWithLeading = tester.getTopLeft(find.text(Tag.a.text));
expect(
childOffsetWithLeading - childOffsetWithoutLeading,
offsetMoreOrLessEquals(const Offset(16, 0.0), epsilon: 0.01),
);
});
});
group('CupertinoMenuDivider', () {
testWidgets('dimensions', (WidgetTester tester) async {
await tester.pumpWidget(const App(alignment: Alignment.topLeft, CupertinoMenuDivider()));
expect(
tester.getRect(find.byType(CupertinoMenuDivider)),
rectMoreOrLessEquals(const Rect.fromLTRB(0.0, 0.0, 800.0, 8.0), epsilon: 0.01),
);
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(key: Tag.a.key, onPressed: () {}, child: Text(Tag.a.text)),
const CupertinoMenuDivider(),
],
),
),
);
controller.open();
await tester.pumpAndSettle();
final ui.Rect menuItemRect = tester.getRect(find.byType(CupertinoMenuItem));
expect(
tester.getRect(find.byType(CupertinoMenuDivider)),
rectMoreOrLessEquals(
ui.Rect.fromLTWH(menuItemRect.left, menuItemRect.bottom, menuItemRect.width, 8.0),
epsilon: 0.01,
),
);
});
testWidgets('color', (WidgetTester tester) async {
await tester.pumpWidget(
CupertinoApp(
theme: const CupertinoThemeData(brightness: Brightness.light),
home: CupertinoMenuDivider(key: Tag.a.key),
),
);
final Finder coloredBoxFinder = find.descendant(
of: find.byKey(Tag.a.key),
matching: find.byType(ColoredBox),
);
expect(
tester.widget<ColoredBox>(coloredBoxFinder).color,
isSameColorAs(const Color.fromRGBO(0, 0, 0, 0.08)),
);
await tester.pumpWidget(
CupertinoApp(
theme: const CupertinoThemeData(brightness: Brightness.dark),
home: CupertinoMenuDivider(key: Tag.a.key),
),
);
expect(
tester.widget<ColoredBox>(coloredBoxFinder).color,
isSameColorAs(const Color.fromRGBO(0, 0, 0, 0.16)),
);
});
testWidgets('no adjacent borders are drawn', (WidgetTester tester) async {
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
DebugCupertinoMenuEntry(key: UniqueKey(), isDivider: true),
const CupertinoMenuDivider(),
DebugCupertinoMenuEntry(key: UniqueKey(), isDivider: true),
],
),
),
);
controller.open();
await tester.pumpAndSettle();
expect(find.byType(CupertinoMenuDivider), findsOneWidget);
expect(findMenuChildren(tester), hasLength(3));
});
});
group('CupertinoMenuItem', () {
const defaultLightTextColor = ui.Color.from(alpha: 0.96, red: 0, green: 0, blue: 0);
const defaultDarkTextColor = ui.Color.from(alpha: 0.96, red: 1, green: 1, blue: 1);
const defaultSubtitleTextColor = ui.Color.from(alpha: 0.55, red: 0, green: 0, blue: 0);
const defaultSubtitleDarkTextColor = ui.Color.from(alpha: 0.4, red: 1, green: 1, blue: 1);
group('Appearance', () {
testWidgets('leading style', (WidgetTester tester) async {
RenderParagraph? findIcon() =>
findDescendantParagraph(tester, find.byIcon(CupertinoIcons.check_mark));
RenderParagraph? findText() => findDescendantParagraph(tester, find.text(Tag.a.text));
Widget buildApp({
TextScaler textScaler = TextScaler.noScaling,
ui.Brightness brightness = ui.Brightness.light,
}) {
return CupertinoApp(
theme: CupertinoThemeData(brightness: brightness),
home: Builder(
builder: (BuildContext context) {
return MediaQuery(
data: MediaQuery.of(context).copyWith(textScaler: textScaler),
child: CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
onPressed: () {},
leading: Stack(
children: <Widget>[
Text(Tag.a.text),
const Icon(CupertinoIcons.check_mark),
],
),
child: const Text('Menu Item'),
),
],
),
);
},
),
);
}
await tester.pumpWidget(buildApp());
controller.open();
await tester.pumpAndSettle();
final RenderParagraph? icon = findIcon();
final RenderParagraph? text = findText();
final TextStyle iconStyle = icon!.text.style!;
final TextStyle textStyle = text!.text.style!;
expect(icon.textSize, equals(const Size(15.0, 15.0)));
expect(icon.textDirection, equals(TextDirection.ltr));
expect(icon.maxLines, isNull);
expect(iconStyle.color, isSameColorAs(defaultLightTextColor));
expect(iconStyle.fontSize, equals(15.0));
expect(iconStyle.leadingDistribution, equals(TextLeadingDistribution.even));
expect(
iconStyle.fontVariations,
equals(<FontVariation>[
const FontVariation('FILL', 0.0),
const FontVariation.weight(600.0),
const FontVariation('GRAD', 0.0),
const FontVariation.opticalSize(48.0),
]),
);
expect(text.textScaler, equals(TextScaler.noScaling));
expect(text.textDirection, equals(TextDirection.ltr));
expect(text.maxLines, equals(2));
expect(textStyle.fontSize, equals(15));
expect(textStyle.color, isSameColorAs(defaultLightTextColor));
expect(textStyle.fontWeight, equals(FontWeight.w600));
await tester.pumpWidget(
buildApp(textScaler: AccessibilityTextSize.xxxLarge, brightness: ui.Brightness.dark),
);
final RenderParagraph? icon6x = findIcon();
final RenderParagraph? text6x = findText();
final TextStyle iconStyle6x = icon6x!.text.style!;
final TextStyle textStyle6x = text.text.style!;
expect(iconStyle6x.fontSize, closeTo(20, 0.5));
expect(iconStyle6x.color, isSameColorAs(defaultDarkTextColor));
expect(text6x!.textScaler, equals(AccessibilityTextSize.xxxLarge));
expect(textStyle6x.fontSize, equals(15));
expect(textStyle6x.color, isSameColorAs(defaultDarkTextColor));
await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.ax1));
expect(find.byIcon(CupertinoIcons.check_mark), findsOneWidget);
expect(find.text(Tag.a.text), findsOneWidget);
});
testWidgets('trailing style', (WidgetTester tester) async {
RenderParagraph? findIcon() =>
findDescendantParagraph(tester, find.byIcon(CupertinoIcons.trash));
RenderParagraph? findText() => findDescendantParagraph(tester, find.text(Tag.a.text));
Widget buildApp({
TextScaler textScaler = TextScaler.noScaling,
ui.Brightness brightness = ui.Brightness.light,
}) {
return CupertinoApp(
theme: CupertinoThemeData(brightness: brightness),
home: Builder(
builder: (BuildContext context) {
return MediaQuery(
data: MediaQuery.of(context).copyWith(textScaler: textScaler),
child: CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
onPressed: () {},
trailing: Stack(
children: <Widget>[Text(Tag.a.text), const Icon(CupertinoIcons.trash)],
),
child: const Text('Menu Item'),
),
],
),
);
},
),
);
}
await tester.pumpWidget(buildApp());
controller.open();
await tester.pumpAndSettle();
final RenderParagraph? icon = findIcon();
final RenderParagraph? text = findText();
final TextStyle iconStyle = icon!.text.style!;
final TextStyle textStyle = text!.text.style!;
expect(icon.textDirection, equals(TextDirection.ltr));
expect(icon.maxLines, isNull);
expect(iconStyle.color, isSameColorAs(defaultLightTextColor));
expect(iconStyle.fontSize, closeTo(21, 0.5));
expect(iconStyle.leadingDistribution, equals(TextLeadingDistribution.even));
expect(
iconStyle.fontVariations,
equals(<FontVariation>[
const FontVariation('FILL', 0.0),
const FontVariation.weight(400.0),
const FontVariation('GRAD', 0.0),
const FontVariation.opticalSize(48.0),
]),
);
expect(
Offset.zero & text.textSize,
rectMoreOrLessEquals(Offset.zero & const Size(20.6, 21), epsilon: 0.1),
);
expect(text.textScaler, equals(AccessibilityTextSize.large));
expect(text.textDirection, equals(TextDirection.ltr));
expect(text.maxLines, equals(2));
expect(textStyle.fontSize, closeTo(21, 0.5));
expect(textStyle.color, isSameColorAs(defaultLightTextColor));
expect(textStyle.fontWeight, equals(null));
await tester.pumpWidget(
buildApp(textScaler: AccessibilityTextSize.xxxLarge, brightness: ui.Brightness.dark),
);
final RenderParagraph? icon6x = findIcon();
final RenderParagraph? text6x = findText();
final TextStyle iconStyle6x = icon6x!.text.style!;
final TextStyle textStyle6x = text.text.style!;
expect(iconStyle6x.fontSize, closeTo(28.5, 0.5));
expect(iconStyle6x.color, isSameColorAs(defaultDarkTextColor));
expect(text6x!.textScaler, equals(AccessibilityTextSize.xxxLarge));
expect(textStyle6x.fontSize, closeTo(21, 0.5));
expect(textStyle6x.color, isSameColorAs(defaultDarkTextColor));
await tester.pumpWidget(
buildApp(textScaler: AccessibilityTextSize.ax1, brightness: ui.Brightness.dark),
);
expect(find.byIcon(CupertinoIcons.trash), findsNothing);
expect(find.text(Tag.a.text), findsNothing);
});
testWidgets('child style', (WidgetTester tester) async {
RenderParagraph? findText() => findDescendantParagraph(tester, find.text(Tag.a.text));
Widget buildApp({
TextScaler textScaler = TextScaler.noScaling,
ui.Brightness brightness = ui.Brightness.light,
}) {
return CupertinoApp(
theme: CupertinoThemeData(brightness: brightness),
home: Builder(
builder: (BuildContext context) {
return MediaQuery(
data: MediaQuery.of(context).copyWith(textScaler: textScaler),
child: CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text)),
],
),
);
},
),
);
}
await tester.pumpWidget(buildApp());
controller.open();
await tester.pumpAndSettle();
final RenderParagraph? text = findText();
final TextStyle textStyle = text!.text.style!;
expect(text.textScaler, equals(TextScaler.noScaling));
expect(text.textDirection, equals(TextDirection.ltr));
expect(text.maxLines, equals(2));
expect(textStyle.fontSize, equals(17));
expect(textStyle.color, isSameColorAs(defaultLightTextColor));
expect(textStyle.fontWeight, equals(null));
for (final TextScaler size in AccessibilityTextSize.values) {
await tester.pumpWidget(buildApp(textScaler: size));
final TextStyle expectedTextStyle = DynamicTypeStyle.body.resolveTextStyle(size);
final RenderParagraph textSized = findText()!;
final TextStyle textStyle = textSized.text.style!;
expect(textSized.textScaler, equals(size));
expect(textStyle.fontSize, equals(17));
expect(textStyle.letterSpacing, equals(expectedTextStyle.letterSpacing));
expect(textStyle.height, equals(expectedTextStyle.height));
expect(textStyle.fontFamily, equals(expectedTextStyle.fontFamily));
}
await tester.pumpWidget(buildApp(brightness: ui.Brightness.dark));
expect(findText()!.text.style!.color, isSameColorAs(defaultDarkTextColor));
});
testWidgets('subtitle style', (WidgetTester tester) async {
RenderParagraph? findText() => findDescendantParagraph(tester, find.text(Tag.a.text));
Widget buildApp({
TextScaler textScaler = TextScaler.noScaling,
ui.Brightness brightness = ui.Brightness.light,
}) {
return CupertinoApp(
theme: CupertinoThemeData(brightness: brightness),
home: Builder(
builder: (BuildContext context) {
return MediaQuery(
data: MediaQuery.of(context).copyWith(textScaler: textScaler),
child: CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
onPressed: () {},
subtitle: Text(Tag.a.text),
child: const Text('Menu Item'),
),
],
),
);
},
),
);
}
await tester.pumpWidget(buildApp());
controller.open();
await tester.pumpAndSettle();
final RenderParagraph? text = findText();
final TextStyle textStyle = text!.text.style!;
expect(text.textScaler, equals(TextScaler.noScaling));
expect(text.textDirection, equals(TextDirection.ltr));
expect(text.maxLines, equals(2));
expect(textStyle.fontSize, equals(15));
expect(textStyle.fontWeight, isNull);
expect(textStyle.color, isNull);
expect(
textStyle.foreground,
isA<ui.Paint>()
.having(
(ui.Paint paint) => paint.color,
'color',
isSameColorAs(defaultSubtitleTextColor),
)
.having(
(ui.Paint paint) => paint.blendMode,
'blendMode',
equals(BlendMode.hardLight),
),
);
for (final TextScaler size in AccessibilityTextSize.values) {
await tester.pumpWidget(buildApp(textScaler: size));
final TextStyle expectedTextStyle = DynamicTypeStyle.subhead.resolveTextStyle(size);
final RenderParagraph textSized = findText()!;
final TextStyle textStyle = textSized.text.style!;
expect(textSized.textScaler, equals(size));
expect(textStyle.fontSize, equals(15));
expect(textStyle.letterSpacing, equals(expectedTextStyle.letterSpacing));
expect(textStyle.height, equals(expectedTextStyle.height));
expect(textStyle.fontFamily, equals(expectedTextStyle.fontFamily));
}
await tester.pumpWidget(buildApp(brightness: ui.Brightness.dark));
final RenderParagraph? darkText = findText();
final TextStyle darkTextStyle = darkText!.text.style!;
expect(
darkTextStyle.foreground,
isA<ui.Paint>()
.having(
(ui.Paint paint) => paint.color,
'color',
isSameColorAs(defaultSubtitleDarkTextColor),
)
.having((ui.Paint paint) => paint.blendMode, 'blendMode', equals(BlendMode.plus)),
);
});
testWidgets('isDestructiveAction style', (WidgetTester tester) async {
RenderParagraph? findLeading() {
return findDescendantParagraph(tester, find.byIcon(CupertinoIcons.left_chevron));
}
RenderParagraph? findTrailing() {
return findDescendantParagraph(tester, find.byIcon(CupertinoIcons.right_chevron));
}
RenderParagraph? findChild() {
return findDescendantParagraph(tester, find.text(Tag.a.text));
}
RenderParagraph? findSubtitle() {
return findDescendantParagraph(tester, find.text(Tag.b.text));
}
Widget buildApp([ui.Brightness brightness = ui.Brightness.light]) {
return CupertinoApp(
home: CupertinoTheme(
data: CupertinoThemeData(brightness: brightness),
child: CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
onPressed: () {},
isDestructiveAction: true,
subtitle: Text(Tag.b.text),
leading: const Icon(CupertinoIcons.left_chevron),
trailing: const Icon(CupertinoIcons.right_chevron),
child: Text(Tag.a.text),
),
],
),
),
);
}
await tester.pumpWidget(buildApp());
controller.open();
await tester.pumpAndSettle();
expect(findTrailing()!.text.style!.color, isSameColorAs(CupertinoColors.systemRed));
expect(findLeading()!.text.style!.color, isSameColorAs(CupertinoColors.systemRed));
expect(findChild()!.text.style!.color, isSameColorAs(CupertinoColors.systemRed));
expect(
findSubtitle()!.text.style!.foreground!.color,
isSameColorAs(defaultSubtitleTextColor),
);
await tester.pumpWidget(buildApp(ui.Brightness.dark));
expect(
findTrailing()!.text.style!.color,
isSameColorAs(CupertinoColors.systemRed.darkColor),
);
expect(
findLeading()!.text.style!.color,
isSameColorAs(CupertinoColors.systemRed.darkColor),
);
expect(findChild()!.text.style!.color, isSameColorAs(CupertinoColors.systemRed.darkColor));
expect(
findSubtitle()!.text.style!.foreground!.color,
isSameColorAs(defaultSubtitleDarkTextColor),
);
});
testWidgets('allows adjacent borders', (WidgetTester tester) async {
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
DebugCupertinoMenuEntry(key: UniqueKey()),
CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text)),
DebugCupertinoMenuEntry(key: UniqueKey()),
],
),
),
);
controller.open();
await tester.pumpAndSettle();
expect(findMenuChildren(tester), hasLength(5));
});
testWidgets('disabled items should not interact', (WidgetTester tester) async {
// Test various interactions to ensure that disabled items do not
// respond.
var interactions = 0;
final focusNode = FocusNode();
focusNode.addListener(() {
interactions++;
});
addTearDown(focusNode.dispose);
BoxDecoration getItemDecoration() {
return tester
.widget<DecoratedBox>(
find.descendant(
of: find.byType(CupertinoMenuItem),
matching: find.byType(DecoratedBox),
),
)
.decoration
as BoxDecoration;
}
RenderParagraph? findChild() {
return findDescendantParagraph(tester, find.text(Tag.a.text));
}
await tester.pumpWidget(
CupertinoApp(
home: CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
focusNode: focusNode,
onFocusChange: (bool value) {
interactions++;
},
onHover: (bool value) {
interactions++;
},
child: Text(Tag.a.text),
),
],
),
),
);
controller.open();
await tester.pumpAndSettle();
final TestGesture gesture = await tester.createGesture();
addTearDown(gesture.removePointer);
// Test focus
focusNode.requestFocus();
await tester.pump();
void checkAppearance() {
expect(getItemDecoration(), equals(const BoxDecoration()));
expect(findChild()!.text.style!.color, isSameColorAs(CupertinoColors.systemGrey));
}
// Test hover
await gesture.moveTo(tester.getCenter(find.text(Tag.a.text)));
await tester.pump();
checkAppearance();
// Test press
await gesture.down(tester.getCenter(find.text(Tag.a.text)));
await tester.pump();
checkAppearance();
// Test pan
await tester.pumpAndSettle(const Duration(milliseconds: 200));
await gesture.up();
await tester.pump();
checkAppearance();
expect(controller.isOpen, isTrue);
expect(interactions, 0);
});
testWidgets('hover color', (WidgetTester tester) async {
const hoverColor = CupertinoDynamicColor.withBrightnessAndContrast(
color: Color.fromRGBO(50, 50, 50, 0.05),
darkColor: Color.fromRGBO(255, 255, 255, 0.05),
highContrastColor: Color.fromRGBO(50, 50, 50, 0.1),
darkHighContrastColor: Color.fromRGBO(255, 255, 255, 0.1),
);
const customHoverColor = CupertinoDynamicColor.withBrightness(
color: Color.fromRGBO(75, 0, 0, 1),
darkColor: Color.fromRGBO(150, 0, 0, 1),
);
const decoration = WidgetStateProperty<BoxDecoration>.fromMap(
<WidgetStatesConstraint, BoxDecoration>{
WidgetState.hovered: BoxDecoration(color: customHoverColor),
WidgetState.any: BoxDecoration(),
},
);
BoxDecoration getItemDecoration(Tag tag) {
return tester
.widget<DecoratedBox>(
find.descendant(
of: find.widgetWithText(CupertinoMenuItem, tag.text),
matching: find.byType(DecoratedBox),
),
)
.decoration
as BoxDecoration;
}
Widget buildApp(ui.Brightness brightness) {
return CupertinoApp(
theme: CupertinoThemeData(brightness: brightness),
home: CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
onPressed: () {},
requestFocusOnHover: false,
child: Text(Tag.a.text),
),
CupertinoMenuItem(
requestFocusOnHover: false,
onPressed: () {},
decoration: decoration,
child: Text(Tag.b.text),
),
],
),
);
}
await tester.pumpWidget(buildApp(ui.Brightness.light));
controller.open();
await tester.pumpAndSettle();
final TestGesture gesture = await tester.createGesture(kind: ui.PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
expect(getItemDecoration(Tag.a), equals(const BoxDecoration()));
expect(getItemDecoration(Tag.b), equals(const BoxDecoration()));
await gesture.moveTo(tester.getCenter(find.text(Tag.a.text)));
await tester.pump();
expect(getItemDecoration(Tag.a).color, isSameColorAs(hoverColor.color));
expect(getItemDecoration(Tag.b), equals(const BoxDecoration()));
await gesture.moveTo(tester.getCenter(find.text(Tag.b.text)));
await tester.pump();
expect(getItemDecoration(Tag.a), equals(const BoxDecoration()));
expect(getItemDecoration(Tag.b).color, isSameColorAs(customHoverColor.color));
await tester.pumpWidget(buildApp(ui.Brightness.dark));
expect(getItemDecoration(Tag.a), equals(const BoxDecoration()));
expect(getItemDecoration(Tag.b).color, isSameColorAs(customHoverColor.darkColor));
await gesture.moveTo(tester.getCenter(find.text(Tag.a.text)));
await tester.pump();
expect(getItemDecoration(Tag.a).color, isSameColorAs(hoverColor.darkColor));
expect(getItemDecoration(Tag.b), equals(const BoxDecoration()));
});
testWidgets('pressed color', (WidgetTester tester) async {
const pressedColor = CupertinoDynamicColor.withBrightnessAndContrast(
color: Color.fromRGBO(50, 50, 50, 0.1),
darkColor: Color.fromRGBO(255, 255, 255, 0.1),
highContrastColor: Color.fromRGBO(50, 50, 50, 0.2),
darkHighContrastColor: Color.fromRGBO(255, 255, 255, 0.2),
);
const customPressedColor = CupertinoDynamicColor.withBrightness(
color: Color.fromRGBO(75, 0, 0, 1),
darkColor: Color.fromRGBO(150, 0, 0, 1),
);
const decoration =
WidgetStateProperty<BoxDecoration>.fromMap(<WidgetStatesConstraint, BoxDecoration>{
WidgetState.pressed: BoxDecoration(color: customPressedColor),
WidgetState.any: BoxDecoration(),
});
BoxDecoration getItemDecoration(Tag tag) {
return tester
.widget<DecoratedBox>(
find.descendant(
of: find.widgetWithText(CupertinoMenuItem, tag.text),
matching: find.byType(DecoratedBox),
),
)
.decoration
as BoxDecoration;
}
Widget buildApp(ui.Brightness brightness) {
return CupertinoApp(
theme: CupertinoThemeData(brightness: brightness),
home: CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
onPressed: () {},
requestFocusOnHover: false,
requestCloseOnActivate: false,
child: Text(Tag.a.text),
),
CupertinoMenuItem(
onPressed: () {},
requestFocusOnHover: false,
requestCloseOnActivate: false,
decoration: decoration,
child: Text(Tag.b.text),
),
],
),
);
}
await tester.pumpWidget(buildApp(ui.Brightness.light));
controller.open();
await tester.pumpAndSettle();
final TestGesture gesture = await tester.createGesture();
addTearDown(gesture.removePointer);
expect(getItemDecoration(Tag.a), equals(const BoxDecoration()));
expect(getItemDecoration(Tag.b), equals(const BoxDecoration()));
await gesture.down(tester.getCenter(find.text(Tag.a.text)));
await tester.pumpAndSettle();
expect(getItemDecoration(Tag.a).color, isSameColorAs(pressedColor.color));
expect(getItemDecoration(Tag.b), equals(const BoxDecoration()));
// Release the press
await gesture.up();
await tester.pumpAndSettle();
expect(getItemDecoration(Tag.a), equals(const BoxDecoration()));
expect(getItemDecoration(Tag.b), equals(const BoxDecoration()));
// Press the second item with a custom pressed color
await gesture.down(tester.getCenter(find.text(Tag.b.text)));
await tester.pumpAndSettle();
expect(getItemDecoration(Tag.a), equals(const BoxDecoration()));
expect(getItemDecoration(Tag.b).color, isSameColorAs(customPressedColor.color));
await gesture.up();
await tester.pumpAndSettle();
controller.close();
await tester.pumpAndSettle();
await tester.pumpWidget(buildApp(ui.Brightness.dark));
controller.open();
await tester.pumpAndSettle();
expect(controller.isOpen, isTrue);
await gesture.down(tester.getCenter(find.text(Tag.a.text)));
await tester.pumpAndSettle();
expect(getItemDecoration(Tag.a).color, isSameColorAs(pressedColor.darkColor));
});
testWidgets('focused color', (WidgetTester tester) async {
const focusedColor = CupertinoDynamicColor.withBrightnessAndContrast(
color: Color.fromRGBO(50, 50, 50, 0.075),
darkColor: Color.fromRGBO(255, 255, 255, 0.075),
highContrastColor: Color.fromRGBO(50, 50, 50, 0.15),
darkHighContrastColor: Color.fromRGBO(255, 255, 255, 0.15),
);
const customFocusedColor = CupertinoDynamicColor.withBrightness(
color: Color.fromRGBO(0, 75, 0, 1),
darkColor: Color.fromRGBO(0, 150, 0, 1),
);
const decoration =
WidgetStateProperty<BoxDecoration>.fromMap(<WidgetStatesConstraint, BoxDecoration>{
WidgetState.focused: BoxDecoration(color: customFocusedColor),
WidgetState.any: BoxDecoration(),
});
BoxDecoration getItemDecoration(Tag tag) {
return tester
.widget<DecoratedBox>(
find.descendant(
of: find.widgetWithText(CupertinoMenuItem, tag.text),
matching: find.byType(DecoratedBox),
),
)
.decoration
as BoxDecoration;
}
final focusNodeA = FocusNode();
final focusNodeB = FocusNode();
addTearDown(() {
focusNodeA.dispose();
focusNodeB.dispose();
});
Widget buildApp(ui.Brightness brightness) {
return CupertinoApp(
theme: CupertinoThemeData(brightness: brightness),
home: CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(onPressed: () {}, focusNode: focusNodeA, child: Text(Tag.a.text)),
CupertinoMenuItem(
focusNode: focusNodeB,
onPressed: () {},
decoration: decoration,
child: Text(Tag.b.text),
),
],
),
);
}
await tester.pumpWidget(buildApp(ui.Brightness.light));
controller.open();
await tester.pumpAndSettle();
// Verify initial state
expect(getItemDecoration(Tag.a), equals(const BoxDecoration()));
expect(getItemDecoration(Tag.b), equals(const BoxDecoration()));
// Focus the first item
focusNodeA.requestFocus();
await tester.pump();
await tester.pump();
expect(getItemDecoration(Tag.a).color, isSameColorAs(focusedColor.color));
expect(getItemDecoration(Tag.b), equals(const BoxDecoration()));
// Focus the second item with a custom focused color
focusNodeB.requestFocus();
await tester.pump();
await tester.pump();
expect(getItemDecoration(Tag.a), equals(const BoxDecoration()));
expect(getItemDecoration(Tag.b).color, isSameColorAs(customFocusedColor.color));
await tester.pumpWidget(buildApp(ui.Brightness.dark));
// Verify dark mode focused colors
focusNodeA.requestFocus();
await tester.pump();
await tester.pump();
expect(getItemDecoration(Tag.a).color, isSameColorAs(focusedColor.darkColor));
expect(getItemDecoration(Tag.b), equals(const BoxDecoration()));
focusNodeB.requestFocus();
await tester.pump();
await tester.pump();
expect(getItemDecoration(Tag.b).color, isSameColorAs(customFocusedColor.darkColor));
});
testWidgets('mouse cursor can be set and is inherited', (WidgetTester tester) async {
await tester.pumpWidget(
CupertinoApp(
home: Align(
alignment: Alignment.topLeft,
child: MouseRegion(
cursor: SystemMouseCursors.forbidden,
child: CupertinoMenuItem(
onPressed: () {},
mouseCursor: const WidgetStatePropertyAll<MouseCursor>(SystemMouseCursors.text),
child: Text(Tag.a.text),
),
),
),
),
);
final TestGesture gesture = await tester.createGesture(kind: ui.PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await gesture.addPointer(location: tester.getCenter(find.text(Tag.a.text)));
await tester.pump();
expect(
RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1),
SystemMouseCursors.text,
);
// Test default cursor when disabled
await tester.pumpWidget(
CupertinoApp(
home: Align(
alignment: Alignment.topLeft,
child: CupertinoMenuItem(
onPressed: () {},
child: MouseRegion(cursor: SystemMouseCursors.basic, child: Container()),
),
),
),
);
// The cursor should defer to it's child.
expect(
RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1),
SystemMouseCursors.basic,
);
});
});
group('Layout', () {
Alignment offsetAlongSize(ui.Offset offset, ui.Size size) {
final double x = (offset.dx / size.width) * 2 - 1;
final double y = (offset.dy / size.height) * 2 - 1;
return Alignment(x, y);
}
double lineHeight(TextStyle style) {
return style.height! * style.fontSize!;
}
testWidgets('LTR hasLeading shift', (WidgetTester tester) async {
// When no menu item has a leading widget, leadingWidth defaults to 16.
// If leadingWidth is set, the default is ignored.
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text)),
CupertinoMenuItem(onPressed: () {}, child: Text(Tag.b.text)),
CupertinoMenuItem(onPressed: () {}, leadingWidth: 3, child: Text(Tag.c.text)),
],
),
),
);
controller.open();
await tester.pumpAndSettle();
final Rect a1 = tester.getRect(find.text(Tag.a.text));
final Rect b1 = tester.getRect(find.text(Tag.b.text));
final Rect c1 = tester.getRect(find.text(Tag.c.text));
expect(a1.left, b1.left);
expect(a1.left - c1.left, closeTo(16 - 3, 0.01));
// When any menu item has a leading widget, leadingWidth defaults to 32
// for all menu items on this menu layer. If leadingWidth is set on an
// item, that item ignores the default leading width.
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text)),
CupertinoMenuItem(
onPressed: () {},
leading: const Icon(CupertinoIcons.left_chevron),
child: Text(Tag.b.text),
),
CupertinoMenuItem(onPressed: () {}, leadingWidth: 3, child: Text(Tag.c.text)),
],
),
),
);
final Rect a2 = tester.getRect(find.text(Tag.a.text));
final Rect b2 = tester.getRect(find.text(Tag.b.text));
final Rect c2 = tester.getRect(find.text(Tag.c.text));
expect(a2.left, b2.left);
expect(a2.left - c2.left, closeTo(32 - 3, 0.01));
expect(a2.left - a1.left, closeTo(32 - 16, 0.01));
});
testWidgets('RTL hasLeading shift', (WidgetTester tester) async {
// When no menu item has a leading widget, leadingWidth defaults to 16.
// If leadingWidth is set, the default is ignored.
await tester.pumpWidget(
App(
textDirection: TextDirection.rtl,
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text)),
CupertinoMenuItem(onPressed: () {}, child: Text(Tag.b.text)),
CupertinoMenuItem(onPressed: () {}, leadingWidth: 3, child: Text(Tag.c.text)),
],
),
),
);
controller.open();
await tester.pumpAndSettle();
final Rect a1 = tester.getRect(find.text(Tag.a.text));
final Rect b1 = tester.getRect(find.text(Tag.b.text));
final Rect c1 = tester.getRect(find.text(Tag.c.text));
expect(a1.right, b1.right);
expect(a1.right - c1.right, closeTo(-16 + 3, 0.01));
// When any menu item has a leading widget, leadingWidth defaults to 32
// for all menu items on this menu layer. If leadingWidth is set on an
// item, that item ignores the default leading width.
await tester.pumpWidget(
App(
textDirection: TextDirection.rtl,
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text)),
CupertinoMenuItem(
onPressed: () {},
leading: const Icon(CupertinoIcons.left_chevron),
child: Text(Tag.b.text),
),
CupertinoMenuItem(onPressed: () {}, leadingWidth: 3, child: Text(Tag.c.text)),
],
),
),
);
await tester.pumpAndSettle();
final Rect a2 = tester.getRect(find.text(Tag.a.text));
final Rect b2 = tester.getRect(find.text(Tag.b.text));
final Rect c2 = tester.getRect(find.text(Tag.c.text));
expect(a2.right, b2.right);
expect(a2.right - c2.right, closeTo(-32 + 3, 0.01));
expect(a2.right - a1.right, closeTo(-32 + 16, 0.01));
});
group('Child ', () {
testWidgets('LTR child layout', (WidgetTester tester) async {
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(onPressed: () {}, child: Text(Tag.child.text)),
],
),
),
);
controller.open();
await tester.pumpAndSettle();
final double childLineHeight = lineHeight(DynamicTypeStyle.body.large);
final Rect childRect = tester.getRect(find.text(Tag.child.text));
final Rect menuItemRect = tester.getRect(find.byType(CupertinoMenuItem));
expect(childRect.height, closeTo(childLineHeight, 0.1));
expect(childRect.top, closeTo(menuItemRect.top + 10.83, 0.1));
expect(childRect.left, closeTo(menuItemRect.left + 16, 0.1));
});
testWidgets('RTL child layout', (WidgetTester tester) async {
await tester.pumpWidget(
App(
Directionality(
textDirection: TextDirection.rtl,
child: CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(onPressed: () {}, child: Text(Tag.child.text)),
],
),
),
),
);
controller.open();
await tester.pumpAndSettle();
final double childLineHeight = lineHeight(DynamicTypeStyle.body.large);
final Rect childRect = tester.getRect(find.text(Tag.child.text));
final Rect menuItemRect = tester.getRect(find.byType(CupertinoMenuItem));
expect(childRect.height, closeTo(childLineHeight, 0.1));
expect(childRect.top, closeTo(menuItemRect.top + 10.83, 0.1));
expect(childRect.right, closeTo(menuItemRect.right - 16, 0.1));
});
testWidgets('child text overflow', (WidgetTester tester) async {
final String longText = 'Very long subtitle ' * 100;
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[CupertinoMenuItem(onPressed: () {}, child: Text(longText))],
),
),
);
controller.open();
await tester.pumpAndSettle();
final Size childText = tester.getSize(find.text(longText));
final TextStyle childStyle = DynamicTypeStyle.body.large;
expect(childText.height, closeTo(lineHeight(childStyle) * 2, 1)); // 2 lines of text
});
testWidgets('child text overflow in accessibility mode', (WidgetTester tester) async {
final String longText = 'Very long text ' * 1000;
await tester.pumpWidget(
App(
MediaQuery(
data: const MediaQueryData(textScaler: AccessibilityTextSize.ax1),
child: CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(onPressed: () {}, child: Text(longText)),
],
),
),
),
);
controller.open();
await tester.pumpAndSettle();
final RenderParagraph paragraph = findDescendantParagraph(tester, find.text(longText))!;
final double childLineHeight = lineHeight(DynamicTypeStyle.body.ax1);
expect(paragraph.maxLines, equals(100));
expect(tester.getSize(find.text(longText)).height, closeTo(childLineHeight * 100, 1));
});
testWidgets('LTR child with leading and trailing', (WidgetTester tester) async {
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
onPressed: () {},
leadingWidth: 33,
trailingWidth: 47,
leading: Icon(CupertinoIcons.star, key: Tag.leading.key),
trailing: Icon(CupertinoIcons.heart, key: Tag.trailing.key),
child: Text(Tag.child.text),
),
],
),
),
);
controller.open();
await tester.pumpAndSettle();
final Rect leading = tester.getRect(find.byKey(Tag.leading.key));
final Rect trailing = tester.getRect(find.byKey(Tag.trailing.key));
final Rect child = tester.getRect(find.text(Tag.child.text));
final Rect menuItem = tester.getRect(find.byType(CupertinoMenuItem));
expect(child.left, closeTo(menuItem.left + 33, 0.1));
expect(child.right, lessThanOrEqualTo(menuItem.right - 47));
expect(child.left, greaterThan(leading.right));
expect(child.right, lessThan(trailing.left));
expect(leading.center.dy, closeTo(child.center.dy, 0.1));
expect(trailing.center.dy, closeTo(child.center.dy, 0.1));
});
testWidgets('RTL child with leading and trailing', (WidgetTester tester) async {
await tester.pumpWidget(
App(
Directionality(
textDirection: TextDirection.rtl,
child: CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
onPressed: () {},
leadingWidth: 33,
trailingWidth: 47,
leading: Icon(CupertinoIcons.star, key: Tag.leading.key),
trailing: Icon(CupertinoIcons.heart, key: Tag.trailing.key),
child: Text(Tag.child.text),
),
],
),
),
),
);
controller.open();
await tester.pumpAndSettle();
final Rect leading = tester.getRect(find.byKey(Tag.leading.key));
final Rect trailing = tester.getRect(find.byKey(Tag.trailing.key));
final Rect child = tester.getRect(find.text(Tag.child.text));
final Rect menuItem = tester.getRect(find.byType(CupertinoMenuItem));
expect(child.right, closeTo(menuItem.right - 33, 0.1));
expect(child.right, lessThan(leading.right));
expect(child.left, greaterThan(trailing.left));
expect(leading.center.dy, closeTo(child.center.dy, 0.1));
expect(trailing.center.dy, closeTo(child.center.dy, 0.1));
});
testWidgets('child text overflow with maxLines', (WidgetTester tester) async {
final String longText = 'Very long text ' * 1000;
await tester.pumpWidget(
App(
MediaQuery(
data: const MediaQueryData(textScaler: AccessibilityTextSize.xxxLarge),
child: CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
onPressed: () {},
child: Text(longText, key: Tag.a.key),
),
],
),
),
),
);
controller.open();
await tester.pumpAndSettle();
final RenderParagraph paragraph = findDescendantParagraph(tester, find.byKey(Tag.a.key))!;
expect(paragraph.maxLines, equals(2));
expect(paragraph.size.height, closeTo(58, 1)); // 2 lines of text
expect(tester.getSize(find.byType(CupertinoMenuItem)).height, closeTo(87, 1));
});
testWidgets('child text overflow with accessibility mode', (WidgetTester tester) async {
final String longText = 'Very long text ' * 1000;
await tester.pumpWidget(
App(
MediaQuery(
data: const MediaQueryData(textScaler: AccessibilityTextSize.ax1),
child: CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
onPressed: () {},
child: Text(longText, key: Tag.a.key),
),
],
),
),
),
);
controller.open();
await tester.pumpAndSettle();
final RenderParagraph paragraph = findDescendantParagraph(tester, find.byKey(Tag.a.key))!;
expect(paragraph.maxLines, equals(100));
expect(paragraph.size.height, closeTo(3400, 1)); // 100 lines of text
expect(tester.getSize(find.byType(CupertinoMenuItem)).height, closeTo(3433, 1));
});
testWidgets('child adjusts to dynamic type', (WidgetTester tester) async {
Widget buildApp({TextScaler textScaler = AccessibilityTextSize.large}) {
return App(
Builder(
builder: (BuildContext context) {
return MediaQuery(
data: MediaQuery.of(context).copyWith(textScaler: textScaler),
child: CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
onPressed: () {},
leading: Icon(CupertinoIcons.left_chevron, key: Tag.leading.key),
trailing: Icon(CupertinoIcons.right_chevron, key: Tag.trailing.key),
subtitle: Text(Tag.subtitle.text),
child: Text(Tag.child.text),
),
],
),
);
},
),
);
}
await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.undersized));
controller.open();
await tester.pumpAndSettle();
final double undersizedLineHeight = lineHeight(DynamicTypeStyle.body.xSmall);
Size childSize = tester.getSize(find.text(Tag.child.text));
expect(childSize.height, closeTo(undersizedLineHeight, 0.1));
await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.xSmall));
final double xSmallLineHeight = lineHeight(DynamicTypeStyle.body.xSmall);
childSize = tester.getSize(find.text(Tag.child.text));
expect(childSize.height, closeTo(xSmallLineHeight, 0.1));
await tester.pumpWidget(buildApp());
final double largeLineHeight = lineHeight(DynamicTypeStyle.body.large);
childSize = tester.getSize(find.text(Tag.child.text));
expect(childSize.height, closeTo(largeLineHeight, 0.1));
await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.ax5));
final double ax5LineHeight = lineHeight(DynamicTypeStyle.body.ax5);
childSize = tester.getSize(find.text(Tag.child.text));
expect(childSize.height, closeTo(ax5LineHeight * 2, 0.1));
await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.oversized));
final double oversizedLineHeight = lineHeight(DynamicTypeStyle.body.ax5);
childSize = tester.getSize(find.text(Tag.child.text));
expect(childSize.height, closeTo(oversizedLineHeight * 2, 0.1));
});
});
group('Leading ', () {
testWidgets('LTR leading position', (WidgetTester tester) async {
await tester.pumpWidget(
App(
textDirection: TextDirection.ltr,
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
onPressed: () {},
leading: Icon(CupertinoIcons.left_chevron, key: Tag.leading.key),
trailing: Icon(CupertinoIcons.right_chevron, key: Tag.trailing.key),
subtitle: const Text('Subtitle'),
child: Text(Tag.child.text),
),
],
),
),
);
controller.open();
await tester.pumpAndSettle();
final ui.Rect leadingRect = tester.getRect(find.byKey(Tag.leading.key));
final ui.Rect menuItemRect = tester.getRect(find.byType(CupertinoMenuItem));
final ui.Rect childRect = tester.getRect(find.text(Tag.child.text));
final double leadingWidth = childRect.left - menuItemRect.left;
final leadingSize = ui.Size(leadingWidth, menuItemRect.height);
final Rect leadingWidgetRect = tester
.getRect(find.byKey(Tag.leading.key))
.translate(-menuItemRect.left, -menuItemRect.top);
final Alignment leadingAlignment = offsetAlongSize(leadingWidgetRect.center, leadingSize);
expect(leadingAlignment.x, closeTo(0.1680, 0.01));
expect(leadingAlignment.y, closeTo(0, 0.01));
expect(leadingRect.left, greaterThan(menuItemRect.left));
expect(leadingRect.right, lessThan(childRect.right));
});
testWidgets('RTL leading position', (WidgetTester tester) async {
await tester.pumpWidget(
App(
textDirection: TextDirection.rtl,
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
onPressed: () {},
leading: Icon(CupertinoIcons.left_chevron, key: Tag.leading.key),
trailing: Icon(CupertinoIcons.right_chevron, key: Tag.trailing.key),
subtitle: Text(Tag.subtitle.text),
child: Text(Tag.child.text),
),
],
),
),
);
controller.open();
await tester.pumpAndSettle();
final ui.Rect leadingRect = tester.getRect(find.byKey(Tag.leading.key));
final ui.Rect menuItemRect = tester.getRect(find.byType(CupertinoMenuItem));
final ui.Rect childRect = tester.getRect(find.text(Tag.child.text));
final double leadingWidth = menuItemRect.right - childRect.right;
final leadingSize = ui.Size(leadingWidth, menuItemRect.height);
final Rect leadingWidgetRect = tester
.getRect(find.byKey(Tag.leading.key))
.translate(-childRect.right, -menuItemRect.top);
final Alignment leadingAlignment = offsetAlongSize(leadingWidgetRect.center, leadingSize);
expect(leadingAlignment.x, closeTo(-0.168, 0.01));
expect(leadingAlignment.y, closeTo(0, 0.01));
expect(leadingRect.right, lessThan(menuItemRect.right));
expect(leadingRect.left, greaterThan(childRect.left));
});
testWidgets('leadingMidpointAlignment adjusts to dynamic type', (
WidgetTester tester,
) async {
Widget buildApp({TextScaler textScaler = AccessibilityTextSize.large}) {
return App(
Builder(
builder: (BuildContext context) {
return MediaQuery(
data: MediaQuery.of(context).copyWith(textScaler: textScaler),
child: CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
onPressed: () {},
leading: Icon(CupertinoIcons.left_chevron, key: Tag.leading.key),
trailing: Icon(CupertinoIcons.right_chevron, key: Tag.trailing.key),
subtitle: Text(Tag.subtitle.text),
child: Text(Tag.child.text),
),
],
),
);
},
),
);
}
await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.undersized));
controller.open();
await tester.pumpAndSettle();
Alignment leadingAlignment() {
final ui.Rect menuItemRect = tester.getRect(find.byType(CupertinoMenuItem));
final ui.Rect childRect = tester.getRect(find.text(Tag.child.text));
final double leadingWidth = childRect.left - menuItemRect.left;
final leadingSize = ui.Size(leadingWidth, menuItemRect.height);
final Rect leadingWidgetRect = tester
.getRect(find.byKey(Tag.leading.key))
.translate(-menuItemRect.left, -menuItemRect.top);
return offsetAlongSize(leadingWidgetRect.center, leadingSize);
}
Alignment alignment = leadingAlignment();
expect(alignment.x, closeTo(0.1673, 0.01));
expect(alignment.y, closeTo(0, 0.01));
await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.xSmall));
alignment = leadingAlignment();
expect(alignment.x, closeTo(0.1673, 0.01));
expect(alignment.y, closeTo(0, 0.01));
await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.ax5));
alignment = leadingAlignment();
expect(alignment.x, closeTo(0.1765, 0.01));
expect(alignment.y, closeTo(0, 0.01));
await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.oversized));
alignment = leadingAlignment();
expect(alignment.x, closeTo(0.1765, 0.01));
expect(alignment.y, closeTo(0, 0.01));
});
testWidgets('leadingWidth adjusts to dynamic type', (WidgetTester tester) async {
Widget buildApp({TextScaler textScaler = AccessibilityTextSize.large}) {
return App(
Builder(
builder: (BuildContext context) {
return MediaQuery(
data: MediaQuery.of(context).copyWith(textScaler: textScaler),
child: CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
onPressed: () {},
leading: Icon(CupertinoIcons.left_chevron, key: Tag.leading.key),
trailing: Icon(CupertinoIcons.right_chevron, key: Tag.trailing.key),
subtitle: Text(Tag.subtitle.text),
child: Text(Tag.child.text),
),
],
),
);
},
),
);
}
await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.undersized));
controller.open();
await tester.pumpAndSettle();
Rect childRect() => tester.getRect(find.text(Tag.child.text));
Rect menuItemRect() => tester.getRect(find.byType(CupertinoMenuItem));
double leadingWidth() => childRect().left - menuItemRect().left;
expect(leadingWidth(), closeTo(30.0, 0.1));
await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.xSmall));
expect(leadingWidth(), closeTo(30.0, 0.1));
await tester.pumpWidget(buildApp());
expect(leadingWidth(), closeTo(32.0, 0.1));
await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.ax5));
expect(leadingWidth(), closeTo(61.0, 0.5));
await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.oversized));
expect(leadingWidth(), closeTo(61.0, 0.5));
});
testWidgets('leadingWidth is quantized to pixel ratio', (WidgetTester tester) async {
Rect childRect() => tester.getRect(find.text(Tag.child.text));
Rect menuItemRect() => tester.getRect(find.byType(CupertinoMenuItem));
Widget buildApp({
TextScaler textScaler = AccessibilityTextSize.large,
double devicePixelRatio = 2.0,
}) {
return App(
Builder(
builder: (BuildContext context) {
return MediaQuery(
data: MediaQuery.of(
context,
).copyWith(textScaler: textScaler, devicePixelRatio: devicePixelRatio),
child: CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
onPressed: () {},
leading: Icon(CupertinoIcons.left_chevron, key: Tag.leading.key),
trailing: Icon(CupertinoIcons.right_chevron, key: Tag.trailing.key),
subtitle: Text(Tag.subtitle.text),
child: Text(Tag.child.text),
),
],
),
);
},
),
);
}
await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.medium));
controller.open();
await tester.pumpAndSettle();
final double leadingWidth2x = childRect().left - menuItemRect().left;
expect(leadingWidth2x - leadingWidth2x.floorToDouble(), closeTo(1 / 2, 0.01));
await tester.pumpWidget(
buildApp(devicePixelRatio: 3.0, textScaler: AccessibilityTextSize.medium),
);
final double leadingWidth3x = childRect().left - menuItemRect().left;
expect(leadingWidth3x - leadingWidth3x.floorToDouble(), closeTo(1 / 3, 0.01));
});
testWidgets('custom leadingWidth', (WidgetTester tester) async {
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
onPressed: () {},
leadingWidth: 60,
child: Text(Tag.child.text, key: Tag.child.key),
),
],
),
),
);
controller.open();
await tester.pumpAndSettle();
final Rect child = tester.getRect(find.byKey(Tag.child.key));
final Rect menuItemRect = tester.getRect(find.byType(CupertinoMenuItem));
final double leadingSpace = child.left - menuItemRect.left;
expect(leadingSpace, moreOrLessEquals(60, epsilon: 1));
});
testWidgets('custom leadingMidpointAlignment', (WidgetTester tester) async {
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
padding: EdgeInsets.zero,
leadingWidth: 60,
leadingMidpointAlignment: const Alignment(0.5, 0.5),
leading: Container(
color: CupertinoColors.systemBlue,
width: 5,
height: 5,
key: Tag.leading.key,
),
onPressed: () {},
child: Text('TTT', key: Tag.child.key),
),
],
),
),
);
controller.open();
await tester.pumpAndSettle();
final ui.Rect menuItemRect = tester.getRect(find.byType(CupertinoMenuItem));
final ui.Rect childRect = tester.getRect(find.byKey(Tag.child.key));
final double leadingWidth = childRect.left - menuItemRect.left;
final leadingSize = ui.Size(leadingWidth, menuItemRect.height);
final Rect leadingWidgetRect = tester
.getRect(find.byKey(Tag.leading.key))
.shift(-menuItemRect.topLeft);
final Alignment alignment = offsetAlongSize(leadingWidgetRect.center, leadingSize);
expect(alignment.x, closeTo(0.5, 0.01));
expect(alignment.y, closeTo(0.5, 0.01));
});
});
group('Trailing ', () {
testWidgets('LTR trailing position', (WidgetTester tester) async {
await tester.pumpWidget(
App(
Directionality(
textDirection: TextDirection.ltr,
child: CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
onPressed: () {},
leading: Icon(CupertinoIcons.left_chevron, key: Tag.leading.key),
trailing: Icon(CupertinoIcons.right_chevron, key: Tag.trailing.key),
subtitle: Text(Tag.subtitle.text),
child: Text(Tag.child.text),
),
],
),
),
),
);
controller.open();
await tester.pumpAndSettle();
final ui.Rect trailingRect = tester.getRect(find.byKey(Tag.trailing.key));
final ui.Rect menuItemRect = tester.getRect(find.byType(CupertinoMenuItem));
final ui.Rect childRect = tester.getRect(find.text(Tag.child.text));
final double trailingWidth = menuItemRect.right - childRect.right;
final trailingSize = ui.Size(trailingWidth, menuItemRect.height);
final Rect trailingWidgetRect = tester
.getRect(find.byKey(Tag.trailing.key))
.translate(-childRect.right, -menuItemRect.top);
final Alignment trailingAlignment = offsetAlongSize(
trailingWidgetRect.center,
trailingSize,
);
expect(trailingAlignment.x, closeTo(-0.2727, 0.01));
expect(trailingAlignment.y, closeTo(0, 0.01));
expect(trailingRect.right, lessThan(menuItemRect.right));
expect(trailingRect.left, greaterThan(childRect.left));
});
testWidgets('RTL trailing position', (WidgetTester tester) async {
await tester.pumpWidget(
App(
Directionality(
textDirection: TextDirection.rtl,
child: CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
onPressed: () {},
leading: Icon(CupertinoIcons.left_chevron, key: Tag.leading.key),
trailing: Icon(CupertinoIcons.right_chevron, key: Tag.trailing.key),
subtitle: Text(Tag.subtitle.text),
child: Text(Tag.child.text),
),
],
),
),
),
);
controller.open();
await tester.pumpAndSettle();
final ui.Rect trailingRect = tester.getRect(find.byKey(Tag.trailing.key));
final ui.Rect menuItemRect = tester.getRect(find.byType(CupertinoMenuItem));
final ui.Rect childRect = tester.getRect(find.text(Tag.child.text));
final double trailingWidth = childRect.left - menuItemRect.left;
final trailingSize = ui.Size(trailingWidth, menuItemRect.height);
final Rect trailingWidgetRect = tester
.getRect(find.byKey(Tag.trailing.key))
.translate(-menuItemRect.left, -menuItemRect.top);
final Alignment trailingAlignment = offsetAlongSize(
trailingWidgetRect.center,
trailingSize,
);
expect(trailingAlignment.x, closeTo(0.2727, 0.01));
expect(trailingAlignment.y, closeTo(0, 0.01));
expect(trailingRect.left, greaterThan(menuItemRect.left));
expect(trailingRect.right, lessThan(childRect.right));
});
testWidgets('trailingMidpointAlignment adjusts to dynamic type', (
WidgetTester tester,
) async {
Widget buildApp({TextScaler textScaler = AccessibilityTextSize.large}) {
return App(
Builder(
builder: (BuildContext context) {
return MediaQuery(
data: MediaQuery.of(context).copyWith(textScaler: textScaler),
child: CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
onPressed: () {},
leading: Icon(CupertinoIcons.left_chevron, key: Tag.leading.key),
trailing: Icon(CupertinoIcons.right_chevron, key: Tag.trailing.key),
subtitle: Text(Tag.subtitle.text),
child: Text(Tag.child.text),
),
],
),
);
},
),
);
}
await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.undersized));
controller.open();
await tester.pumpAndSettle();
Alignment getTrailingAlignment() {
final ui.Rect menuItemRect = tester.getRect(find.byType(CupertinoMenuItem));
final ui.Rect childRect = tester.getRect(find.text(Tag.child.text));
final double trailingWidth = menuItemRect.right - childRect.right;
final trailingSize = ui.Size(trailingWidth, menuItemRect.height);
final Rect trailingWidgetRect = tester
.getRect(find.byKey(Tag.trailing.key))
.translate(-childRect.right, -menuItemRect.top);
return offsetAlongSize(trailingWidgetRect.center, trailingSize);
}
Alignment alignment = getTrailingAlignment();
expect(alignment.x, closeTo(-0.2963, 0.01));
expect(alignment.y, closeTo(0, 0.01));
await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.xSmall));
alignment = getTrailingAlignment();
expect(alignment.x, closeTo(-0.2963, 0.01));
expect(alignment.y, closeTo(0, 0.01));
await tester.pumpWidget(buildApp());
alignment = getTrailingAlignment();
expect(alignment.x, closeTo(-0.2727, 0.01));
expect(alignment.y, closeTo(0, 0.01));
await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.ax5));
expect(find.byKey(Tag.trailing.key), findsNothing);
await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.oversized));
expect(find.byKey(Tag.trailing.key), findsNothing);
});
testWidgets('trailingWidth adjusts to dynamic type', (WidgetTester tester) async {
Widget buildApp({TextScaler textScaler = AccessibilityTextSize.large}) {
return App(
Builder(
builder: (BuildContext context) {
return MediaQuery(
data: MediaQuery.of(context).copyWith(textScaler: textScaler),
child: CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
onPressed: () {},
leading: Icon(CupertinoIcons.left_chevron, key: Tag.leading.key),
trailing: Icon(CupertinoIcons.right_chevron, key: Tag.trailing.key),
subtitle: Text(Tag.subtitle.text),
child: Text(Tag.child.text),
),
],
),
);
},
),
);
}
await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.undersized));
controller.open();
await tester.pumpAndSettle();
Rect childRect() => tester.getRect(find.text(Tag.child.text));
Rect menuItemRect() => tester.getRect(find.byType(CupertinoMenuItem));
double trailingWidth() => menuItemRect().right - childRect().right;
expect(trailingWidth(), closeTo(40.5, 0.25));
await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.xSmall));
expect(trailingWidth(), closeTo(40.5, 0.25));
await tester.pumpWidget(buildApp());
expect(trailingWidth(), closeTo(44.0, 0.1));
await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.ax5));
expect(trailingWidth(), closeTo(16.0, 0.5));
await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.oversized));
expect(trailingWidth(), closeTo(16.0, 0.5));
});
testWidgets('trailingWidth is quantized to pixel ratio', (WidgetTester tester) async {
Widget buildApp({
TextScaler textScaler = AccessibilityTextSize.large,
double devicePixelRatio = 2.0,
}) {
return App(
Builder(
builder: (BuildContext context) {
return MediaQuery(
data: MediaQuery.of(
context,
).copyWith(textScaler: textScaler, devicePixelRatio: devicePixelRatio),
child: CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
onPressed: () {},
leading: Icon(CupertinoIcons.left_chevron, key: Tag.leading.key),
trailing: Icon(CupertinoIcons.right_chevron, key: Tag.trailing.key),
subtitle: Text(Tag.subtitle.text),
child: Text(Tag.child.text),
),
],
),
);
},
),
);
}
await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.xSmall));
controller.open();
await tester.pumpAndSettle();
Rect childRect() => tester.getRect(find.text(Tag.child.text));
Rect menuItemRect() => tester.getRect(find.byType(CupertinoMenuItem));
final double trailingWidth2x = menuItemRect().right - childRect().right;
expect(trailingWidth2x - trailingWidth2x.floorToDouble(), closeTo(1 / 2, 0.01));
await tester.pumpWidget(
buildApp(devicePixelRatio: 3.0, textScaler: AccessibilityTextSize.xSmall),
);
final double trailingWidth3x = menuItemRect().right - childRect().right;
expect(trailingWidth3x - trailingWidth3x.floorToDouble(), closeTo(2 / 3, 0.01));
});
testWidgets('custom trailingWidth', (WidgetTester tester) async {
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
onPressed: () {},
trailingWidth: 60,
trailing: Icon(CupertinoIcons.right_chevron, key: Tag.trailing.key),
child: Center(key: Tag.child.key, child: Text(Tag.child.text)),
),
],
),
),
);
controller.open();
await tester.pumpAndSettle();
final Rect child = tester.getRect(find.byKey(Tag.child.key));
final Rect menuItemRect = tester.getRect(find.byType(CupertinoMenuItem));
final double trailingSpace = menuItemRect.right - child.right;
expect(trailingSpace, moreOrLessEquals(60, epsilon: 1));
});
testWidgets('custom trailingMidpointAlignment', (WidgetTester tester) async {
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
padding: EdgeInsets.zero,
trailingWidth: 60,
trailingMidpointAlignment: const Alignment(0.5, 0.5),
trailing: Container(
color: CupertinoColors.systemRed,
width: 5,
height: 5,
key: Tag.trailing.key,
),
onPressed: () {},
child: Center(key: Tag.child.key, child: Text(Tag.child.text)),
),
],
),
),
);
controller.open();
await tester.pumpAndSettle();
final ui.Rect menuItemRect = tester.getRect(find.byType(CupertinoMenuItem));
final ui.Rect childRect = tester.getRect(find.byKey(Tag.child.key));
final double trailingWidth = menuItemRect.right - childRect.right;
final trailingSize = ui.Size(trailingWidth, menuItemRect.height);
final Rect trailingWidgetRect = tester
.getRect(find.byKey(Tag.trailing.key))
.translate(-childRect.right, -menuItemRect.top);
final Alignment trailingAlignment = offsetAlongSize(
trailingWidgetRect.center,
trailingSize,
);
expect(trailingAlignment.x, closeTo(0.5, 0.01));
expect(trailingAlignment.y, closeTo(0.5, 0.01));
});
});
group('Subtitle ', () {
testWidgets('default layout', (WidgetTester tester) async {
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
onPressed: () {},
leading: Icon(CupertinoIcons.left_chevron, key: Tag.leading.key),
trailing: Icon(CupertinoIcons.right_chevron, key: Tag.trailing.key),
subtitle: Text(Tag.subtitle.text),
child: Text(Tag.child.text),
),
],
),
),
);
controller.open();
await tester.pumpAndSettle();
final double largeSubtitleLineHeight = lineHeight(DynamicTypeStyle.subhead.large);
final Rect subtitleRect = tester.getRect(find.text(Tag.subtitle.text));
final Rect childRect = tester.getRect(find.text(Tag.child.text));
expect(subtitleRect.height, closeTo(largeSubtitleLineHeight, 0.1));
expect(subtitleRect.width, equals(childRect.width));
expect(subtitleRect.top, closeTo(childRect.bottom + 1, 0.1));
expect(subtitleRect.left, equals(childRect.left));
expect(subtitleRect.right, equals(childRect.right));
});
testWidgets('subtitle text overflow', (WidgetTester tester) async {
final String longText = 'Very long subtitle ' * 100;
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
onPressed: () {},
subtitle: Text(longText),
child: Text(Tag.child.text),
),
],
),
),
);
controller.open();
await tester.pumpAndSettle();
final Size subtitleText = tester.getSize(find.text(longText));
final TextStyle subtitleStyle = DynamicTypeStyle.subhead.large;
expect(subtitleText.height, closeTo(lineHeight(subtitleStyle) * 2, 1)); // 2 lines of text
});
testWidgets('subtitle text overflow in accessibility mode', (WidgetTester tester) async {
final String longText = 'Very long text ' * 100;
await tester.pumpWidget(
App(
MediaQuery(
data: const MediaQueryData(textScaler: AccessibilityTextSize.ax1),
child: CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
onPressed: () {},
subtitle: Text(longText),
child: Text(Tag.child.text),
),
],
),
),
),
);
controller.open();
await tester.pumpAndSettle();
final RenderParagraph paragraph = findDescendantParagraph(tester, find.text(longText))!;
expect(paragraph.maxLines, equals(100));
expect(tester.getSize(find.text(longText)).height, closeTo(3100, 1));
});
testWidgets('subtitle adjusts to dynamic type', (WidgetTester tester) async {
Widget buildApp({TextScaler textScaler = AccessibilityTextSize.large}) {
return App(
Builder(
builder: (BuildContext context) {
return MediaQuery(
data: MediaQuery.of(context).copyWith(textScaler: textScaler),
child: CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
onPressed: () {},
leading: Icon(CupertinoIcons.left_chevron, key: Tag.leading.key),
trailing: Icon(CupertinoIcons.right_chevron, key: Tag.trailing.key),
subtitle: Text(Tag.subtitle.text),
child: Text(Tag.child.text),
),
],
),
);
},
),
);
}
await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.undersized));
controller.open();
await tester.pumpAndSettle();
Rect subtitleRect = tester.getRect(find.text(Tag.subtitle.text));
Rect childRect = tester.getRect(find.text(Tag.child.text));
final double undersizedSubtitleLineHeight = lineHeight(DynamicTypeStyle.subhead.xSmall);
expect(subtitleRect.height, closeTo(undersizedSubtitleLineHeight, 0.1));
expect(subtitleRect.top, closeTo(childRect.bottom + 1, 0.1));
expect(subtitleRect.left, equals(childRect.left));
expect(subtitleRect.right, equals(childRect.right));
await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.xSmall));
childRect = tester.getRect(find.text(Tag.child.text));
subtitleRect = tester.getRect(find.text(Tag.subtitle.text));
final double xSmallSubtitleLineHeight = lineHeight(DynamicTypeStyle.subhead.xSmall);
expect(subtitleRect.height, closeTo(xSmallSubtitleLineHeight, 0.1));
expect(subtitleRect.top, closeTo(childRect.bottom + 1, 0.1));
expect(subtitleRect.left, equals(childRect.left));
expect(subtitleRect.right, equals(childRect.right));
await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.ax5));
await tester.pumpAndSettle();
childRect = tester.getRect(find.text(Tag.child.text));
subtitleRect = tester.getRect(find.text(Tag.subtitle.text));
expect(subtitleRect.height, closeTo(110, 3));
expect(subtitleRect.top, closeTo(childRect.bottom + 1, 0.1));
expect(subtitleRect.left, equals(childRect.left));
expect(subtitleRect.right, equals(childRect.right));
await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.oversized));
childRect = tester.getRect(find.text(Tag.child.text));
subtitleRect = tester.getRect(find.text(Tag.subtitle.text));
expect(subtitleRect.height, closeTo(110, 3));
expect(subtitleRect.top, closeTo(childRect.bottom + 1, 0.1));
expect(subtitleRect.left, equals(childRect.left));
expect(subtitleRect.right, equals(childRect.right));
});
});
group('Padding', () {
testWidgets('default padding', (WidgetTester tester) async {
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
onPressed: () {},
leadingWidth: 0,
trailingWidth: 0,
subtitle: Text(Tag.subtitle.text),
child: Text(Tag.child.text),
),
],
),
),
);
controller.open();
await tester.pumpAndSettle();
final Rect menuItemRect = tester.getRect(find.byType(CupertinoMenuItem));
final Rect childRect = tester.getRect(find.text(Tag.child.text));
final Rect subtitleRect = tester.getRect(find.text(Tag.subtitle.text));
expect(childRect.top - menuItemRect.top, closeTo(10.8, 0.1));
expect(menuItemRect.bottom - subtitleRect.bottom, closeTo(10.8, 0.1));
expect(childRect.left - menuItemRect.left, closeTo(0.0, 0.1));
expect(menuItemRect.right - childRect.right, closeTo(0.0, 0.1));
});
testWidgets('padding adjusts to dynamic type', (WidgetTester tester) async {
Widget buildApp({TextScaler textScaler = AccessibilityTextSize.large}) {
return App(
Builder(
builder: (BuildContext context) {
return MediaQuery(
data: MediaQuery.of(context).copyWith(textScaler: textScaler),
child: CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
onPressed: () {},
subtitle: Text(Tag.subtitle.text),
child: Text(Tag.child.text),
),
],
),
);
},
),
);
}
await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.undersized));
controller.open();
await tester.pumpAndSettle();
Rect getMenuItemRect() => tester.getRect(find.byType(CupertinoMenuItem));
Rect getChildRect() => tester.getRect(find.text(Tag.child.text));
Rect getSubtitleRect() => tester.getRect(find.text(Tag.subtitle.text));
Rect childRect = getChildRect();
Rect menuItemRect = getMenuItemRect();
Rect subtitleRect = getSubtitleRect();
expect(childRect.top - menuItemRect.top, closeTo(9.3, 0.1));
expect(menuItemRect.bottom - subtitleRect.bottom, closeTo(9.3, 0.1));
expect(childRect.left - menuItemRect.left, closeTo(16.0, 0.1));
expect(menuItemRect.right - childRect.right, closeTo(16.0, 0.1));
await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.xSmall));
childRect = getChildRect();
menuItemRect = getMenuItemRect();
subtitleRect = getSubtitleRect();
expect(childRect.top - menuItemRect.top, closeTo(9.3, 0.1));
expect(menuItemRect.bottom - subtitleRect.bottom, closeTo(9.3, 0.1));
expect(childRect.left - menuItemRect.left, closeTo(16.0, 0.1));
expect(menuItemRect.right - childRect.right, closeTo(16.0, 0.1));
await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.ax5));
childRect = getChildRect();
menuItemRect = getMenuItemRect();
subtitleRect = getSubtitleRect();
expect(childRect.top - menuItemRect.top, closeTo(30.5, 0.1));
expect(menuItemRect.bottom - subtitleRect.bottom, closeTo(30.5, 0.1));
expect(childRect.left - menuItemRect.left, closeTo(16.0, 0.1));
expect(menuItemRect.right - childRect.right, closeTo(16.0, 0.1));
await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.oversized));
childRect = getChildRect();
menuItemRect = getMenuItemRect();
subtitleRect = getSubtitleRect();
expect(childRect.top - menuItemRect.top, closeTo(30.5, 0.1));
expect(menuItemRect.bottom - subtitleRect.bottom, closeTo(30.5, 0.1));
expect(childRect.left - menuItemRect.left, closeTo(16.0, 0.1));
expect(menuItemRect.right - childRect.right, closeTo(16.0, 0.1));
});
testWidgets('LTR custom padding', (WidgetTester tester) async {
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
padding: const EdgeInsets.fromLTRB(7, 17, 13, 11),
onPressed: () {},
child: Center(key: Tag.child.key, child: Text(Tag.child.text)),
),
],
),
),
);
controller.open();
await tester.pumpAndSettle();
final Rect child = tester.getRect(find.byKey(Tag.child.key));
final Rect item = tester.getRect(find.byType(CupertinoMenuItem));
expect(child.top - item.top, closeTo(17.0, 0.1));
expect(item.bottom - child.bottom, closeTo(11.0, 0.1));
// Padding is applied in addition to the leading and trailing width.
expect(child.left - item.left, closeTo(7.0 + 16, 0.1));
expect(item.right - child.right, closeTo(13.0 + 16, 0.1));
});
testWidgets('RTL custom padding', (WidgetTester tester) async {
await tester.pumpWidget(
App(
Directionality(
textDirection: TextDirection.rtl,
child: CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
padding: const EdgeInsetsDirectional.fromSTEB(7, 17, 13, 11),
onPressed: () {},
child: Center(key: Tag.child.key, child: Text(Tag.child.text)),
),
],
),
),
),
);
controller.open();
await tester.pumpAndSettle();
final Rect child = tester.getRect(find.byKey(Tag.child.key));
final Rect item = tester.getRect(find.byType(CupertinoMenuItem));
expect(child.top - item.top, closeTo(17.0, 0.1));
expect(item.bottom - child.bottom, closeTo(11.0, 0.1));
expect(item.right - child.right, closeTo(7.0 + 16, 0.1));
expect(child.left - item.left, closeTo(13.0 + 16, 0.1));
});
testWidgets('LTR custom padding is added to leading and trailing width', (
WidgetTester tester,
) async {
await tester.pumpWidget(
App(
Directionality(
textDirection: TextDirection.ltr,
child: CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
padding: const EdgeInsetsDirectional.only(start: 7, end: 13),
leadingWidth: 19,
trailingWidth: 23,
onPressed: () {},
child: Center(key: Tag.child.key, child: Text(Tag.child.text)),
),
],
),
),
),
);
controller.open();
await tester.pumpAndSettle();
final Rect child = tester.getRect(find.byKey(Tag.child.key));
final Rect item = tester.getRect(find.byType(CupertinoMenuItem));
expect(item.right - child.right, closeTo(13.0 + 23, 0.1));
expect(child.left - item.left, closeTo(7.0 + 19, 0.1));
});
testWidgets('RTL custom padding is added to leading and trailing width', (
WidgetTester tester,
) async {
await tester.pumpWidget(
App(
Directionality(
textDirection: TextDirection.rtl,
child: CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
padding: const EdgeInsetsDirectional.only(start: 7, end: 13),
leadingWidth: 19,
trailingWidth: 23,
onPressed: () {},
child: Center(key: Tag.child.key, child: Text(Tag.child.text)),
),
],
),
),
),
);
controller.open();
await tester.pumpAndSettle();
final Rect child = tester.getRect(find.byKey(Tag.child.key));
final Rect item = tester.getRect(find.byType(CupertinoMenuItem));
expect(item.right - child.right, closeTo(7.0 + 19, 0.1));
expect(child.left - item.left, closeTo(13.0 + 23, 0.1));
});
testWidgets('padding is applied before constraints', (WidgetTester tester) async {
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
padding: const EdgeInsets.fromLTRB(30, 7, 30, 11),
constraints: const BoxConstraints(minHeight: 100, maxWidth: 50),
onPressed: () {},
child: Text(Tag.child.text),
),
],
),
),
);
controller.open();
await tester.pumpAndSettle();
final Size menuItemRect = tester.getSize(find.byType(CupertinoMenuItem));
expect(menuItemRect.height, equals(100));
expect(menuItemRect.width, equals(50));
});
});
group('Constraints', () {
testWidgets('custom constraints applied', (WidgetTester tester) async {
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
constraints: const BoxConstraints(minHeight: 80, maxWidth: 100),
onPressed: () {},
child: const Text('Child'),
),
],
),
),
);
controller.open();
await tester.pumpAndSettle();
final Size item = tester.getSize(find.byType(CupertinoMenuItem));
expect(item.height, greaterThanOrEqualTo(80));
expect(item.width, greaterThanOrEqualTo(100));
});
testWidgets('custom constraints are constrained by menu constraints', (
WidgetTester tester,
) async {
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
constraints: const BoxConstraints(minWidth: 500),
onPressed: () {},
child: const Text('Child'),
),
],
),
),
);
controller.open();
await tester.pumpAndSettle();
final Size item = tester.getSize(find.byType(CupertinoMenuItem));
expect(item.width, equals(262));
});
testWidgets('minimum height constraint adjusts to dynamic type', (
WidgetTester tester,
) async {
Widget buildApp({TextScaler textScaler = AccessibilityTextSize.large}) {
return App(
Builder(
builder: (BuildContext context) {
return MediaQuery(
data: MediaQuery.of(
context,
).copyWith(textScaler: textScaler, devicePixelRatio: 2.0),
child: CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(onPressed: () {}, child: Text(Tag.child.text)),
],
),
);
},
),
);
}
await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.undersized));
controller.open();
await tester.pumpAndSettle();
Rect menuItemRect = tester.getRect(find.byType(CupertinoMenuItem));
expect(menuItemRect.height, closeTo(37.5, 0.1));
await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.xSmall));
menuItemRect = tester.getRect(find.byType(CupertinoMenuItem));
expect(menuItemRect.height, closeTo(37.5, 0.1));
await tester.pumpWidget(buildApp());
menuItemRect = tester.getRect(find.byType(CupertinoMenuItem));
expect(menuItemRect.height, closeTo(43.5, 0.1));
await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.ax5));
menuItemRect = tester.getRect(find.byType(CupertinoMenuItem));
expect(menuItemRect.height, closeTo(123.0, 0.1));
await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.oversized));
menuItemRect = tester.getRect(find.byType(CupertinoMenuItem));
expect(menuItemRect.height, closeTo(123.0, 0.1));
});
testWidgets('minimum height constraint is quantized to pixel ratio', (
WidgetTester tester,
) async {
Widget buildApp({
TextScaler textScaler = AccessibilityTextSize.large,
required double devicePixelRatio,
}) {
return App(
Builder(
builder: (BuildContext context) {
return MediaQuery(
data: MediaQuery.of(
context,
).copyWith(textScaler: textScaler, devicePixelRatio: devicePixelRatio),
child: CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(onPressed: () {}, child: Text(Tag.child.text)),
],
),
);
},
),
);
}
await tester.pumpWidget(buildApp(devicePixelRatio: 2.0));
controller.open();
await tester.pumpAndSettle();
final double minimumHeight2x = tester.getSize(find.byType(CupertinoMenuItem)).height;
expect(minimumHeight2x - minimumHeight2x.floorToDouble(), closeTo(1 / 2, 0.01));
await tester.pumpWidget(buildApp(devicePixelRatio: 3.0));
final double minimumHeight3x = tester.getSize(find.byType(CupertinoMenuItem)).height;
expect(minimumHeight3x - minimumHeight3x.floorToDouble(), closeTo(2 / 3, 0.01));
});
testWidgets('unconstrained width outside of menu', (WidgetTester tester) async {
await changeSurfaceSize(tester, const Size(800, 800));
await tester.pumpWidget(
App(
Column(
children: <Widget>[
Center(
child: CupertinoMenuItem(onPressed: () {}, child: Text(Tag.child.text)),
),
],
),
),
);
final ui.Size size = tester.getSize(find.byType(CupertinoMenuItem));
expect(size.width, equals(800));
expect(size.height, closeTo(43.5, 0.25));
});
});
});
testWidgets('onFocusChange is called on enabled items', (WidgetTester tester) async {
final focusChanges = <bool>[];
final disabledFocusChanges = <bool>[];
final focusNode = FocusNode();
addTearDown(focusNode.dispose);
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
focusNode: focusNode,
onFocusChange: focusChanges.add,
onPressed: () {},
child: Text(Tag.a.text),
),
CupertinoMenuItem(onFocusChange: disabledFocusChanges.add, child: Text(Tag.b.text)),
CupertinoMenuItem(child: Text(Tag.c.text), onPressed: () {}),
],
child: const AnchorButton(Tag.anchor),
),
),
);
controller.open();
await tester.pumpAndSettle();
// Move focus to first item
focusNode.requestFocus();
await tester.pump();
// Move focus away
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
// Move focus back
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
await tester.pump();
// Close menu, should lose focus
controller.close();
await tester.pumpAndSettle();
expect(focusChanges, <bool>[true, false, true, false]);
expect(disabledFocusChanges, isEmpty);
});
testWidgets('onHover is called on enabled items', (WidgetTester tester) async {
final hovered = <(Tag, bool)>[];
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
onPressed: () {},
onHover: (bool value) {
hovered.add((Tag.a, value));
},
child: Text(Tag.a.text),
),
// Disabled item -- should not request focus
CupertinoMenuItem(
onHover: (bool value) {
hovered.add((Tag.b, value));
},
child: Text(Tag.b.text, key: Tag.b.key),
),
CupertinoMenuItem(
onPressed: () {},
onHover: (bool value) {
hovered.add((Tag.c, value));
},
child: Text(Tag.c.text),
),
],
),
),
);
controller.open();
await tester.pumpAndSettle();
final TestGesture gesture = await tester.createGesture(kind: ui.PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
// (Tag.a, true)
await gesture.moveTo(tester.getCenter(find.text(Tag.a.text)));
await tester.pump();
await gesture.moveTo(tester.getCenter(find.text(Tag.b.text)));
await tester.pump();
// (Tag.a, false)
// (Tag.c, true)
await gesture.moveTo(tester.getCenter(find.text(Tag.c.text)));
await tester.pump();
// (Tag.c, false)
// (Tag.a, true)
await gesture.moveTo(tester.getCenter(find.text(Tag.a.text)));
await tester.pump();
expect(hovered, <(Tag, bool)>[
(Tag.a, true),
(Tag.a, false),
(Tag.c, true),
(Tag.c, false),
(Tag.a, true),
]);
});
testWidgets('onPressed is called when set', (WidgetTester tester) async {
var pressed = 0;
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
onPressed: () {
pressed += 1;
},
child: Text(Tag.a.text),
),
],
),
),
);
controller.open();
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
// Tap when partially open
await tester.tap(find.text(Tag.a.text));
await tester.pump();
expect(pressed, 1);
controller.open();
await tester.pumpAndSettle();
// Tap when fully open
await tester.tap(find.text(Tag.a.text));
await tester.pumpAndSettle();
expect(pressed, 2);
controller.open();
await tester.pumpAndSettle();
controller.close();
await tester.pump();
// Do not tap if closing.
await tester.tap(find.text(Tag.a.text), warnIfMissed: false);
await tester.pumpAndSettle();
expect(pressed, 2);
});
testWidgets('HitTestBehavior can be set', (WidgetTester tester) async {
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text)),
CupertinoMenuItem(
onPressed: () {},
behavior: HitTestBehavior.translucent,
child: Text(Tag.b.text),
),
],
),
),
);
controller.open();
await tester.pumpAndSettle();
final RawGestureDetector first = tester.firstWidget(
find.widgetWithText(RawGestureDetector, Tag.a.text),
);
// Test default
expect(first.behavior, HitTestBehavior.opaque);
final RawGestureDetector second = tester.firstWidget(
find.widgetWithText(RawGestureDetector, Tag.b.text),
);
// Test custom
expect(second.behavior, HitTestBehavior.translucent);
});
testWidgets('respects requestFocusOnHover property', (WidgetTester tester) async {
final focusChanges = <(Tag, bool)>[];
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
onFocusChange: (bool value) {
focusChanges.add((Tag.a, value));
},
onPressed: () {},
child: Text(Tag.a.text),
),
// Disabled item -- should not request focus
CupertinoMenuItem(
onFocusChange: (bool value) {
focusChanges.add((Tag.b, value));
},
child: Text(Tag.b.text, key: Tag.b.key),
),
CupertinoMenuItem(
onFocusChange: (bool value) {
focusChanges.add((Tag.c, value));
},
onPressed: () {},
child: Text(Tag.c.text),
),
// requestFocusOnHover is false -- should not request focus
CupertinoMenuItem(
requestFocusOnHover: false,
onFocusChange: (bool value) {
focusChanges.add((Tag.d, value));
},
onPressed: () {},
child: Text(Tag.d.text),
),
],
),
),
);
controller.open();
await tester.pumpAndSettle();
final TestGesture gesture = await tester.createGesture(kind: ui.PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
// (Tag.a, true)
await gesture.moveTo(tester.getCenter(find.text(Tag.a.text)));
await tester.pump();
await gesture.moveTo(tester.getCenter(find.text(Tag.b.text)));
await tester.pump();
// (Tag.a, false)
// (Tag.c, true)
await gesture.moveTo(tester.getCenter(find.text(Tag.c.text)));
await tester.pump();
await gesture.moveTo(tester.getCenter(find.text(Tag.d.text)));
await tester.pump();
// (Tag.c, false)
// (Tag.a, true)
await gesture.moveTo(tester.getCenter(find.text(Tag.a.text)));
await tester.pump();
expect(focusChanges, <(Tag, bool)>[
(Tag.a, true),
(Tag.a, false),
(Tag.c, true),
(Tag.c, false),
(Tag.a, true),
]);
});
testWidgets('respects closeOnActivate property', (WidgetTester tester) async {
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
requestCloseOnActivate: false,
onPressed: () {},
child: Text(Tag.a.text),
),
],
),
),
);
controller.open();
await tester.pumpAndSettle();
// Taps the CupertinoMenuItem which should close the menu
await tester.tap(find.text(Tag.a.text));
await tester.pumpAndSettle();
expect(controller.isOpen, isTrue);
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(key: UniqueKey(), onPressed: () {}, child: Text(Tag.a.text)),
],
),
),
);
// Taps the CupertinoMenuItem which should close the menu
await tester.tap(find.byType(CupertinoMenuItem));
await tester.pumpAndSettle();
expect(controller.isOpen, isFalse);
});
testWidgets('Focus node can be changed', (WidgetTester tester) async {
final focusNode1 = FocusNode(debugLabel: 'Node 1');
final focusNode2 = FocusNode(debugLabel: 'Node 2');
addTearDown(focusNode1.dispose);
addTearDown(focusNode2.dispose);
Widget buildApp(FocusNode? focusNode) {
return App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(onPressed: () {}, focusNode: focusNode, child: Text(Tag.a.text)),
],
child: const AnchorButton(Tag.anchor),
),
);
}
await tester.pumpWidget(buildApp(focusNode1));
controller.open();
await tester.pumpAndSettle();
focusNode1.requestFocus();
await tester.pump();
expect(focusNode1.hasPrimaryFocus, isTrue);
expect(focusNode2.hasPrimaryFocus, isFalse);
await tester.pumpWidget(buildApp(focusNode2));
await tester.pump();
focusNode2.requestFocus();
await tester.pump();
expect(focusNode1.hasPrimaryFocus, isFalse);
expect(focusNode2.hasPrimaryFocus, isTrue);
await tester.pumpWidget(buildApp(null));
await tester.pump();
expect(focusNode1.hasPrimaryFocus, isFalse);
expect(focusNode2.hasPrimaryFocus, isFalse);
});
testWidgets('Autofocus works', (WidgetTester tester) async {
final focusNode = FocusNode();
addTearDown(focusNode.dispose);
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[
CupertinoMenuItem(
onPressed: () {},
autofocus: true,
focusNode: focusNode,
child: Text(Tag.a.text),
),
],
child: const AnchorButton(Tag.anchor),
),
),
);
controller.open();
// Wait for focus effect to resolve (microtasks)
await tester.pump();
expect(focusNode.hasPrimaryFocus, isTrue);
});
testWidgets('Changing DeviceGestureSettings does not throw', (WidgetTester tester) async {
// There is no simple way to verify that the touch slop is being respected other than
// attempting a gesture that would be affected by it. This test ensures that no exceptions
// are thrown when the touch slop is changed during a gesture.
await tester.pumpWidget(
App(
MediaQuery(
data: const MediaQueryData(gestureSettings: DeviceGestureSettings(touchSlop: 30.0)),
child: CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text))],
child: const AnchorButton(Tag.anchor),
),
),
),
);
controller.open();
await tester.pumpAndSettle();
// Simulate a tap down at the center of the widget.
final Offset center = tester.getCenter(find.byType(CupertinoMenuItem));
final TestGesture gesture = await tester.startGesture(center);
addTearDown(gesture.removePointer);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
await tester.pumpWidget(
App(
MediaQuery(
data: const MediaQueryData(gestureSettings: DeviceGestureSettings(touchSlop: 100.0)),
child: CupertinoMenuAnchor(
controller: controller,
menuChildren: <Widget>[CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text))],
child: const AnchorButton(Tag.anchor),
),
),
),
);
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
});
});
group('Semantics', () {
testWidgets('CupertinoMenuItem default semantics', (WidgetTester tester) async {
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.rtl,
child: Center(
child: CupertinoMenuItem(
onPressed: () {},
constraints: BoxConstraints.tight(const Size(250, 48.0)),
child: Text(Tag.a.text),
),
),
),
);
final SemanticsHandle handle = tester.ensureSemantics();
// The flags should not have SemanticsFlag.isButton
expect(
tester.getSemantics(find.widgetWithText(CupertinoMenuItem, Tag.a.text)),
matchesSemantics(
hasTapAction: true,
hasDismissAction: true,
hasFocusAction: true,
isEnabled: true,
isFocusable: true,
hasEnabledState: true,
textDirection: TextDirection.rtl,
rect: const Rect.fromLTRB(0.0, 0.0, 250, 48),
),
);
handle.dispose();
});
testWidgets('CupertinoMenuItem disabled semantics', (WidgetTester tester) async {
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: CupertinoMenuItem(
constraints: BoxConstraints.tight(const Size(250, 48.0)),
child: Text(Tag.a.text),
),
),
),
);
final SemanticsHandle handle = tester.ensureSemantics();
// The flags should not have SemanticsFlag.isButton
expect(
tester.getSemantics(find.widgetWithText(CupertinoMenuItem, Tag.a.text)),
matchesSemantics(
hasEnabledState: true,
textDirection: TextDirection.ltr,
rect: const Rect.fromLTRB(0.0, 0.0, 250, 48),
),
);
handle.dispose();
});
testWidgets(
'CupertinoMenuAnchor semantics',
// [intended] Web inserts overlay contents as a sibling to the anchor rather than a child.
skip: kIsWeb,
(WidgetTester tester) async {
final semantics = SemanticsTester(tester);
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
menuChildren: <Widget>[
CupertinoMenuItem(
onPressed: () {},
constraints: BoxConstraints.tight(const Size(250, 48.0)),
child: Text(Tag.a.text),
),
],
child: const AnchorButton(Tag.anchor),
),
),
);
await tester.tap(find.byType(AnchorButton));
await tester.pumpAndSettle();
expect(
semantics,
hasSemantics(
ignoreId: true,
ignoreTransform: true,
ignoreRect: true,
TestSemantics.root(
children: <TestSemantics>[
TestSemantics(
id: 1,
textDirection: TextDirection.ltr,
children: <TestSemantics>[
TestSemantics(
id: 2,
children: <TestSemantics>[
TestSemantics(
id: 3,
flags: <SemanticsFlag>[SemanticsFlag.scopesRoute],
children: <TestSemantics>[
TestSemantics(
id: 4,
flags: <SemanticsFlag>[
SemanticsFlag.isButton,
SemanticsFlag.isFocusable,
],
actions: <SemanticsAction>[
SemanticsAction.tap,
SemanticsAction.focus,
],
label: 'anchor',
textDirection: TextDirection.ltr,
children: <TestSemantics>[
TestSemantics(
id: 5,
children: <TestSemantics>[
TestSemantics(
id: 6,
children: <TestSemantics>[
TestSemantics(
id: 7,
flags: <SemanticsFlag>[SemanticsFlag.scopesRoute],
children: <TestSemantics>[
TestSemantics(
id: 8,
flags: <SemanticsFlag>[
SemanticsFlag.hasImplicitScrolling,
],
children: <TestSemantics>[
TestSemantics(
id: 9,
flags: <SemanticsFlag>[
SemanticsFlag.hasEnabledState,
SemanticsFlag.isEnabled,
SemanticsFlag.isFocusable,
],
actions: <SemanticsAction>[
SemanticsAction.tap,
SemanticsAction.dismiss,
SemanticsAction.focus,
],
label: 'a',
textDirection: TextDirection.ltr,
),
],
),
],
),
],
),
],
),
],
),
],
),
],
),
],
),
],
),
),
);
semantics.dispose();
},
);
testWidgets(
'CupertinoMenuAnchor semantics (web)',
// [intended] Web inserts overlay contents as a sibling to the anchor rather than a child.
skip: !kIsWeb,
(WidgetTester tester) async {
final semantics = SemanticsTester(tester);
await tester.pumpWidget(
App(
CupertinoMenuAnchor(
menuChildren: <Widget>[
CupertinoMenuItem(
onPressed: () {},
constraints: BoxConstraints.tight(const Size(250, 48.0)),
child: Text(Tag.a.text),
),
],
child: const AnchorButton(Tag.anchor),
),
),
);
await tester.tap(find.byType(AnchorButton));
await tester.pumpAndSettle();
expect(
semantics,
hasSemantics(
ignoreId: true,
ignoreTransform: true,
ignoreRect: true,
TestSemantics.root(
children: <TestSemantics>[
TestSemantics(
id: 1,
textDirection: TextDirection.ltr,
children: <TestSemantics>[
TestSemantics(
id: 2,
children: <TestSemantics>[
TestSemantics(
id: 3,
flags: <SemanticsFlag>[SemanticsFlag.scopesRoute],
children: <TestSemantics>[
TestSemantics(
id: 4,
flags: <SemanticsFlag>[
SemanticsFlag.isButton,
SemanticsFlag.isFocusable,
],
actions: <SemanticsAction>[
SemanticsAction.tap,
SemanticsAction.focus,
],
label: 'anchor',
textDirection: TextDirection.ltr,
),
],
),
],
),
TestSemantics(
id: 5,
children: <TestSemantics>[
TestSemantics(
id: 6,
children: <TestSemantics>[
TestSemantics(
id: 7,
flags: <SemanticsFlag>[SemanticsFlag.scopesRoute],
children: <TestSemantics>[
TestSemantics(
id: 8,
flags: <SemanticsFlag>[SemanticsFlag.hasImplicitScrolling],
children: <TestSemantics>[
TestSemantics(
id: 9,
flags: <SemanticsFlag>[
SemanticsFlag.hasEnabledState,
SemanticsFlag.isEnabled,
SemanticsFlag.isFocusable,
],
actions: <SemanticsAction>[
SemanticsAction.tap,
SemanticsAction.dismiss,
SemanticsAction.focus,
],
label: 'a',
textDirection: TextDirection.ltr,
),
],
),
],
),
],
),
],
),
],
),
],
),
),
);
semantics.dispose();
},
);
});
}
// ********* UTILITIES ********* //
/// Allows the creation of arbitrarily-nested tags in tests.
abstract class Tag {
const Tag();
static const NestedTag anchor = NestedTag('anchor');
static const NestedTag outside = NestedTag('outside');
static const NestedTag a = NestedTag('a');
static const NestedTag b = NestedTag('b');
static const NestedTag c = NestedTag('c');
static const NestedTag d = NestedTag('d');
static const NestedTag child = NestedTag('child');
static const NestedTag subtitle = NestedTag('subtitle');
static const NestedTag leading = NestedTag('leading');
static const NestedTag trailing = NestedTag('trailing');
String get text;
int get level;
@override
String toString() {
return 'Tag($text, level: $level)';
}
}
class NestedTag extends Tag {
const NestedTag(String name, {Tag? prefix, this.level = 0})
: assert(
// Limit the nesting level to prevent stack overflow.
level < 9,
'NestedTag.level must be less than 9 (was $level).',
),
_name = name,
_prefix = prefix;
final String _name;
final Tag? _prefix;
@override
final int level;
NestedTag get a => NestedTag('a', prefix: this, level: level + 1);
NestedTag get b => NestedTag('b', prefix: this, level: level + 1);
NestedTag get c => NestedTag('c', prefix: this, level: level + 1);
@override
String get text {
if (level == 0 || _prefix == null) {
return _name;
}
return '${_prefix.text}.$_name';
}
Key get key => ValueKey<String>('${text}_Key');
}
class AnchorButton extends StatelessWidget {
const AnchorButton(
this.tag, {
super.key,
this.onPressed,
this.constraints,
this.autofocus = false,
this.focusNode,
});
final Tag tag;
final void Function(Tag)? onPressed;
final bool autofocus;
final BoxConstraints? constraints;
final FocusNode? focusNode;
@override
Widget build(BuildContext context) {
final MenuController? controller = MenuController.maybeOf(context);
return CupertinoButton.filled(
minimumSize: constraints?.biggest,
onPressed: () {
onPressed?.call(tag);
if (controller != null) {
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
}
},
focusNode: focusNode,
autofocus: autofocus,
child: Text(tag.text),
);
}
}
class App extends StatelessWidget {
const App(this.child, {super.key, this.textDirection, this.alignment = Alignment.center});
final Widget child;
final TextDirection? textDirection;
final AlignmentGeometry alignment;
@override
Widget build(BuildContext context) {
return CupertinoApp(
home: ColoredBox(
color: const Color(0xff000000),
child: Directionality(
textDirection: textDirection ?? Directionality.maybeOf(context) ?? TextDirection.ltr,
child: Align(alignment: alignment, child: child),
),
),
);
}
}
abstract class AccessibilityTextSize {
static const TextScaler xSmall = TextScaler.linear(1 - 3 / 17);
static const TextScaler small = TextScaler.linear(1 - 2 / 17);
static const TextScaler medium = TextScaler.linear(1 - 1 / 17);
static const TextScaler large = TextScaler.noScaling;
static const TextScaler xLarge = TextScaler.linear(1 + 2 / 17);
static const TextScaler xxLarge = TextScaler.linear(1 + 4 / 17);
static const TextScaler xxxLarge = TextScaler.linear(1 + 6 / 17);
static const TextScaler ax1 = TextScaler.linear(1 + 11 / 17);
static const TextScaler ax2 = TextScaler.linear(1 + 16 / 17);
static const TextScaler ax3 = TextScaler.linear(1 + 23 / 17);
static const TextScaler ax4 = TextScaler.linear(1 + 30 / 17);
static const TextScaler ax5 = TextScaler.linear(1 + 36 / 17);
// For testing
static const TextScaler oversized = TextScaler.linear(1 + 46 / 17);
static const TextScaler undersized = TextScaler.linear(1 - 10 / 17);
static List<TextScaler> get values => <TextScaler>[
xSmall,
small,
medium,
large,
xLarge,
xxLarge,
xxxLarge,
ax1,
ax2,
ax3,
ax4,
ax5,
];
}
/// 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';
enum DynamicTypeStyle {
body(
xSmall: TextStyle(fontSize: 14, height: 19 / 14, letterSpacing: -0.15, fontFamily: _kBodyFont),
small: TextStyle(fontSize: 15, height: 20 / 15, letterSpacing: -0.23, fontFamily: _kBodyFont),
medium: TextStyle(fontSize: 16, height: 21 / 16, letterSpacing: -0.31, fontFamily: _kBodyFont),
large: TextStyle(fontSize: 17, height: 22 / 17, letterSpacing: -0.43, fontFamily: _kBodyFont),
xLarge: TextStyle(fontSize: 19, height: 24 / 19, letterSpacing: -0.44, fontFamily: _kBodyFont),
xxLarge: TextStyle(fontSize: 21, height: 26 / 21, letterSpacing: -0.36, fontFamily: _kBodyFont),
xxxLarge: TextStyle(
fontSize: 23,
height: 29 / 23,
letterSpacing: -0.10,
fontFamily: _kDisplayFont,
),
ax1: TextStyle(fontSize: 28, height: 34 / 28, letterSpacing: 0.38, fontFamily: _kDisplayFont),
ax2: TextStyle(fontSize: 33, height: 40 / 33, letterSpacing: 0.40, fontFamily: _kDisplayFont),
ax3: TextStyle(fontSize: 40, height: 48 / 40, letterSpacing: 0.37, fontFamily: _kDisplayFont),
ax4: TextStyle(fontSize: 47, height: 56 / 47, letterSpacing: 0.37, fontFamily: _kDisplayFont),
ax5: TextStyle(fontSize: 53, height: 62 / 53, letterSpacing: 0.31, fontFamily: _kDisplayFont),
),
subhead(
xSmall: TextStyle(fontSize: 12, height: 16 / 12, letterSpacing: 0, fontFamily: _kBodyFont),
small: TextStyle(fontSize: 13, height: 18 / 13, letterSpacing: -0.08, fontFamily: _kBodyFont),
medium: TextStyle(fontSize: 14, height: 19 / 14, letterSpacing: -0.15, fontFamily: _kBodyFont),
large: TextStyle(fontSize: 15, height: 20 / 15, letterSpacing: -0.23, fontFamily: _kBodyFont),
xLarge: TextStyle(fontSize: 17, height: 22 / 17, letterSpacing: -0.43, fontFamily: _kBodyFont),
xxLarge: TextStyle(fontSize: 19, height: 24 / 19, letterSpacing: -0.45, fontFamily: _kBodyFont),
xxxLarge: TextStyle(
fontSize: 21,
height: 28 / 21,
letterSpacing: -0.36,
fontFamily: _kBodyFont,
),
ax1: TextStyle(fontSize: 25, height: 31 / 25, letterSpacing: 0.15, fontFamily: _kDisplayFont),
ax2: TextStyle(fontSize: 30, height: 37 / 30, letterSpacing: 0.40, fontFamily: _kDisplayFont),
ax3: TextStyle(fontSize: 36, height: 43 / 36, letterSpacing: 0.37, fontFamily: _kDisplayFont),
ax4: TextStyle(fontSize: 42, height: 50 / 42, letterSpacing: 0.37, fontFamily: _kDisplayFont),
ax5: TextStyle(fontSize: 49, height: 58 / 49, letterSpacing: 0.33, 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;
TextStyle resolveTextStyle(TextScaler textScaler) {
final double units = (textScaler.scale(17) - 17).roundToDouble();
return switch (units) {
<= -3 => xSmall,
== -2 => small,
== -1 => medium,
== 0 => large,
== 2 => xLarge,
== 4 => xxLarge,
== 6 => xxxLarge,
== 11 => ax1,
== 16 => ax2,
== 23 => ax3,
== 30 => ax4,
== 36 => ax5,
_ => ax5,
};
}
}
class DebugCupertinoMenuEntry extends StatelessWidget implements CupertinoMenuEntry {
const DebugCupertinoMenuEntry({
super.key,
bool hasLeading = false,
this.isDivider = false,
this.child,
}) : _hasLeading = hasLeading;
final bool _hasLeading;
@override
bool hasLeading(BuildContext context) {
return _hasLeading;
}
@override
final bool isDivider;
final Widget? child;
@override
Widget build(BuildContext context) {
return child ??
Container(
height: 30,
width: 100,
color: isDivider ? CupertinoColors.systemMint : CupertinoColors.systemOrange,
);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment