Created
March 29, 2025 22:01
-
-
Save davidhicks980/cc66b6679330c90fc3cf18f7c9ca3dc0 to your computer and use it in GitHub Desktop.
Menu builder with animation param
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Copyright 2014 The Flutter Authors. All rights reserved. | |
// Use of this source code is governed by a BSD-style license that can be | |
// found in the LICENSE file. | |
/// @docImport 'package:flutter/material.dart'; | |
library; | |
import 'dart:ui' as ui; | |
import 'package:flutter/foundation.dart'; | |
import 'package:flutter/material.dart'; | |
import 'package:flutter/rendering.dart'; | |
import 'package:flutter/scheduler.dart'; | |
import 'package:flutter/services.dart'; | |
import 'package:flutter/physics.dart'; | |
/// Flutter code sample for a [RawMenuAnchorAnimationDelegate] that animates a | |
/// [RawMenuAnchor] with a [SpringSimulation]. | |
void main() { | |
runApp(const App()); | |
} | |
class App extends StatelessWidget { | |
const App({super.key}); | |
@override | |
Widget build(BuildContext context) { | |
return MaterialApp( | |
theme: ThemeData.from( | |
useMaterial3: true, | |
colorScheme: ColorScheme.fromSeed( | |
seedColor: Colors.blue, | |
dynamicSchemeVariant: DynamicSchemeVariant.vibrant, | |
), | |
), | |
home: const Scaffold( | |
body: Center(child: Example()), | |
), | |
); | |
} | |
} | |
class Example extends StatelessWidget { | |
const Example({super.key}); | |
@override | |
Widget build(BuildContext context) { | |
return MenuAnchor( | |
panelBuilder: (BuildContext context, animation) { | |
final MenuController rootMenuController = | |
MenuController.maybeOf(context)!; | |
return FadeTransition( | |
opacity: animation, | |
child: SizeTransition( | |
sizeFactor: animation, | |
child: Align( | |
alignment: Alignment.topRight, | |
child: Column( | |
children: <Widget>[ | |
for (int i = 0; i < 4; i++) | |
MenuAnchor( | |
panelBuilder: (BuildContext context, animation) { | |
final String animationStatus = | |
MenuController.maybeAnimationStatusOf( | |
context, | |
)!.name; | |
return FadeTransition( | |
opacity: animation, | |
child: SizeTransition( | |
sizeFactor: animation, | |
child: SizedBox( | |
height: 120, | |
width: 120, | |
child: Center( | |
child: Text( | |
'Panel $i:\n$animationStatus', | |
textAlign: TextAlign.center, | |
), | |
), | |
), | |
), | |
); | |
}, | |
builder: (BuildContext context, MenuController controller) { | |
return MenuItemButton( | |
onFocusChange: (bool focused) { | |
if (focused) { | |
rootMenuController.closeChildren(); | |
controller.open(); | |
} | |
}, | |
onPressed: () { | |
if (!controller | |
.animationStatus | |
.isForwardOrCompleted) { | |
rootMenuController.closeChildren(); | |
controller.open(); | |
} else { | |
controller.close(); | |
} | |
}, | |
trailingIcon: const Text('▶'), | |
child: Text('Submenu $i'), | |
); | |
}, | |
), | |
], | |
), | |
), | |
), | |
); | |
}, | |
builder: (BuildContext context, MenuController controller) { | |
return FilledButton( | |
onPressed: () { | |
if (controller.animationStatus.isForwardOrCompleted) { | |
controller.close(); | |
} else { | |
controller.open(); | |
} | |
}, | |
child: const Text('Menu'), | |
); | |
}, | |
); | |
} | |
} | |
class MenuAnchor extends StatefulWidget { | |
const MenuAnchor({super.key, required this.panelBuilder, required this.builder}); | |
final Widget Function(BuildContext, Animation<double>) panelBuilder; | |
final Widget Function(BuildContext, MenuController) builder; | |
@override | |
State<MenuAnchor> createState() => MenuAnchorState(); | |
} | |
class MenuAnchorState extends State<MenuAnchor> with | |
SingleTickerProviderStateMixin, RawMenuAnchorAnimationDelegate { | |
final MenuController menuController = MenuController(); | |
late final AnimationController animationController; | |
late final CurvedAnimation animation; | |
bool get isSubmenu => MenuController.maybeOf(context) != null; | |
@override | |
void initState() { | |
super.initState(); | |
animationController = AnimationController( | |
vsync: this, | |
duration: const Duration(milliseconds: 200), | |
); | |
animation = CurvedAnimation( | |
parent: animationController, | |
curve: Curves.easeOutQuart, | |
); | |
} | |
@override | |
void dispose() { | |
animationController.dispose(); | |
animation.dispose(); | |
super.dispose(); | |
} | |
@override | |
void handleMenuOpenRequest({ui.Offset? position}) { | |
// Call whenComplete() rather than whenCompleteOrCancel() to avoid marking | |
// the menu as opened when the AnimationStatus moves from forward to | |
// reverse. | |
animationController.forward().whenComplete(markMenuOpened); | |
} | |
@override | |
void handleMenuCloseRequest() { | |
// Animate the children of this menu closed. | |
menuController.closeChildren(); | |
animationController.reverse().whenComplete(markMenuClosed); | |
} | |
@override | |
Widget build(BuildContext context) { | |
return RawMenuAnchor( | |
controller: menuController, | |
delegate: this, | |
overlayBuilder: (BuildContext context, RawMenuOverlayInfo info) { | |
final ui.Offset position = | |
isSubmenu ? info.anchorRect.topRight : info.anchorRect.bottomLeft; | |
final ColorScheme colorScheme = ColorScheme.of(context); | |
return Positioned( | |
top: position.dy, | |
left: position.dx, | |
child: Semantics( | |
explicitChildNodes: true, | |
scopesRoute: true, | |
child: ExcludeFocus( | |
// Remove focus while the menu is closing. | |
excluding: animation.status == AnimationStatus.reverse, | |
child: TapRegion( | |
groupId: info.tapRegionGroupId, | |
onTapOutside: (PointerDownEvent event) { | |
menuController.close(); | |
}, | |
child: FadeTransition( | |
opacity: animation, | |
child: Material( | |
elevation: 8, | |
clipBehavior: Clip.antiAlias, | |
borderRadius: BorderRadius.circular(8), | |
shadowColor: colorScheme.shadow, | |
child: widget.panelBuilder(context, animation) | |
), | |
), | |
), | |
), | |
),); | |
}, | |
builder: ( | |
BuildContext context, | |
MenuController controller, | |
Widget? child, | |
) { | |
return widget.builder(context, controller); | |
}, | |
); | |
} | |
} | |
// Examples can assume: | |
// late BuildContext context; | |
// late List<Widget> menuItems; | |
// late RawMenuOverlayInfo info; | |
const bool _kDebugMenus = false; | |
const Map<ShortcutActivator, Intent> _kMenuTraversalShortcuts = | |
<ShortcutActivator, Intent>{ | |
SingleActivator(LogicalKeyboardKey.gameButtonA): ActivateIntent(), | |
SingleActivator(LogicalKeyboardKey.escape): DismissIntent(), | |
SingleActivator(LogicalKeyboardKey.arrowDown): DirectionalFocusIntent( | |
TraversalDirection.down, | |
), | |
SingleActivator(LogicalKeyboardKey.arrowUp): DirectionalFocusIntent( | |
TraversalDirection.up, | |
), | |
SingleActivator(LogicalKeyboardKey.arrowLeft): DirectionalFocusIntent( | |
TraversalDirection.left, | |
), | |
SingleActivator(LogicalKeyboardKey.arrowRight): DirectionalFocusIntent( | |
TraversalDirection.right, | |
), | |
}; | |
/// Anchor and menu information passed to [RawMenuAnchor]. | |
@immutable | |
class RawMenuOverlayInfo { | |
/// Creates a [RawMenuOverlayInfo]. | |
const RawMenuOverlayInfo({ | |
required this.anchorRect, | |
required this.overlaySize, | |
required this.tapRegionGroupId, | |
this.position, | |
}); | |
/// The position of the anchor widget that the menu is attached to, relative to | |
/// the nearest ancestor [Overlay] when [RawMenuAnchor.useRootOverlay] is false, | |
/// or the root [Overlay] when [RawMenuAnchor.useRootOverlay] is true. | |
final ui.Rect anchorRect; | |
/// The [Size] of the overlay that the menu is being shown in. | |
final ui.Size overlaySize; | |
/// The `position` argument passed to [MenuController.open]. | |
/// | |
/// The position should be used to offset the menu relative to the top-left | |
/// corner of the anchor. | |
final Offset? position; | |
/// The [TapRegion.groupId] of the [TapRegion] that wraps widgets in this menu | |
/// system. | |
final Object tapRegionGroupId; | |
@override | |
bool operator ==(Object other) { | |
if (identical(this, other)) { | |
return true; | |
} | |
if (other.runtimeType != runtimeType) { | |
return false; | |
} | |
return other is RawMenuOverlayInfo && | |
other.anchorRect == anchorRect && | |
other.overlaySize == overlaySize && | |
other.position == position && | |
other.tapRegionGroupId == tapRegionGroupId; | |
} | |
@override | |
int get hashCode { | |
return Object.hash(anchorRect, overlaySize, position, tapRegionGroupId); | |
} | |
} | |
/// Signature for the builder function used by [RawMenuAnchor.overlayBuilder] to | |
/// build a menu's overlay. | |
/// | |
/// The `context` is the context that the overlay is being built in. | |
/// | |
/// The `info` describes the anchor's [Rect], the [Size] of the overlay, | |
/// the [TapRegion.groupId] used by members of the menu system, and the | |
/// `position` argument passed to [MenuController.open]. | |
typedef RawMenuAnchorOverlayBuilder = | |
Widget Function(BuildContext context, RawMenuOverlayInfo info); | |
/// Signature for the builder function used by [RawMenuAnchor.builder] to build | |
/// the widget that the [RawMenuAnchor] surrounds. | |
/// | |
/// The `context` is the context in which the anchor is being built. | |
/// | |
/// The `controller` is the [MenuController] that can be used to open and close | |
/// the menu. | |
/// | |
/// The `child` is an optional child supplied as the [RawMenuAnchor.child] | |
/// attribute. The child is intended to be incorporated in the result of the | |
/// function. | |
typedef RawMenuAnchorChildBuilder = | |
Widget Function( | |
BuildContext context, | |
MenuController controller, | |
Widget? child, | |
); | |
// An [InheritedWidget] used to notify anchor descendants when a menu opens | |
// and closes, and to pass the anchor's controller to descendants. | |
class _MenuControllerScope extends InheritedWidget { | |
const _MenuControllerScope({ | |
required this.isOpen, | |
required this.animationStatus, | |
required this.controller, | |
required super.child, | |
}); | |
final bool isOpen; | |
final AnimationStatus animationStatus; | |
final MenuController controller; | |
@override | |
bool updateShouldNotify(_MenuControllerScope oldWidget) { | |
return isOpen != oldWidget.isOpen || | |
animationStatus != oldWidget.animationStatus; | |
} | |
} | |
/// A widget that wraps a child and anchors a floating menu. | |
/// | |
/// The child can be any widget, but is typically a button, a text field, or, in | |
/// the case of context menus, the entire screen. | |
/// | |
/// The menu overlay of a [RawMenuAnchor] is shown by calling | |
/// [MenuController.open] on an attached [MenuController]. | |
/// | |
/// When a [RawMenuAnchor] is opened, [overlayBuilder] is called to construct | |
/// the menu contents within an [Overlay]. The [Overlay] allows the menu to | |
/// "float" on top of other widgets. The `info` argument passed to | |
/// [overlayBuilder] provides the anchor's [Rect], the [Size] of the overlay, | |
/// the [TapRegion.groupId] used by members of the menu system, and the | |
/// `position` argument passed to [MenuController.open]. | |
/// | |
/// If [MenuController.open] is called with a `position` argument, it will be | |
/// passed to the `info` argument of the `overlayBuilder` function. | |
/// | |
/// Users are responsible for managing the positioning, semantics, and focus of | |
/// the menu. | |
/// | |
/// {@tool dartpad} | |
/// | |
/// This example uses a [RawMenuAnchor] to build a basic select menu with | |
/// four items. | |
/// | |
/// ** See code in examples/api/lib/widgets/raw_menu_anchor/raw_menu_anchor.0.dart ** | |
/// {@end-tool} | |
class RawMenuAnchor extends StatefulWidget { | |
/// A [RawMenuAnchor] that delegates overlay construction to an [overlayBuilder]. | |
/// | |
/// The [overlayBuilder] should not be null. | |
const RawMenuAnchor({ | |
super.key, | |
this.childFocusNode, | |
this.consumeOutsideTaps = false, | |
this.onOpen, | |
this.onClose, | |
this.useRootOverlay = false, | |
this.builder, | |
required this.controller, | |
required this.overlayBuilder, | |
this.delegate, | |
this.child, | |
}); | |
/// A callback that is invoked when the menu is opened. | |
final VoidCallback? onOpen; | |
/// A callback that is invoked when the menu is closed. | |
final VoidCallback? onClose; | |
/// A builder that builds the widget that this [RawMenuAnchor] surrounds. | |
/// | |
/// Typically, this is a button used to open the menu by calling | |
/// [MenuController.open] on the `controller` passed to the builder. | |
/// | |
/// If not supplied, then the [RawMenuAnchor] will be the size that its parent | |
/// allocates for it. | |
final RawMenuAnchorChildBuilder? builder; | |
/// The 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 [overlayBuilder] function is passed a [RawMenuOverlayInfo] object that | |
/// defines the anchor's [Rect], the [Size] of the overlay, the | |
/// [TapRegion.groupId] for the menu system, and the position [Offset] passed | |
/// to [MenuController.open]. | |
/// | |
/// To ensure taps are properly consumed, the | |
/// [RawMenuOverlayInfo.tapRegionGroupId] should be passed to a [TapRegion] | |
/// widget that wraps the menu panel. | |
/// | |
/// ```dart | |
/// TapRegion( | |
/// groupId: info.tapRegionGroupId, | |
/// onTapOutside: (PointerDownEvent event) { | |
/// MenuController.maybeOf(context)?.close(); | |
/// }, | |
/// child: Column(children: menuItems), | |
/// ) | |
/// ``` | |
final RawMenuAnchorOverlayBuilder overlayBuilder; | |
/// {@template flutter.widgets.RawMenuAnchor.useRootOverlay} | |
/// Whether the menu panel should be rendered in the root [Overlay]. | |
/// | |
/// When true, the menu is mounted in the root overlay. Rendering the menu in | |
/// the root overlay prevents the menu from being obscured by other widgets. | |
/// | |
/// When false, the menu is rendered in the nearest ancestor [Overlay]. | |
/// | |
/// Submenus will always use the same overlay as their top-level ancestor, so | |
/// setting a [useRootOverlay] value on a submenu will have no effect. | |
/// {@endtemplate} | |
/// | |
/// Defaults to false on overlay menus. | |
final bool useRootOverlay; | |
/// The [FocusNode] attached to the widget that takes focus when the | |
/// menu is opened or closed. | |
/// | |
/// If not supplied, the anchor will not retain focus when the menu is opened. | |
final FocusNode? childFocusNode; | |
/// 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; | |
/// A [MenuController] that allows opening and closing of the menu from other | |
/// widgets. | |
final MenuController controller; | |
/// A delegate that can be used to customize the opening and closing behavior | |
/// of this menu. | |
final RawMenuAnchorAnimationDelegate? delegate; | |
@override | |
State<RawMenuAnchor> createState() => _RawMenuAnchorState(); | |
@override | |
void debugFillProperties(DiagnosticPropertiesBuilder properties) { | |
super.debugFillProperties(properties); | |
properties.add( | |
ObjectFlagProperty<FocusNode>.has('focusNode', childFocusNode), | |
); | |
properties.add( | |
FlagProperty( | |
'useRootOverlay', | |
value: useRootOverlay, | |
ifFalse: 'use nearest overlay', | |
ifTrue: 'use root overlay', | |
), | |
); | |
} | |
} | |
// Base mixin that provides the common interface and state for both types of | |
// [RawMenuAnchor]s, [RawMenuAnchor] and [RawMenuAnchorGroup]. | |
@optionalTypeArgs | |
mixin _RawMenuAnchorBaseMixin<T extends StatefulWidget> on State<T> { | |
final List<_RawMenuAnchorBaseMixin> _anchorChildren = | |
<_RawMenuAnchorBaseMixin>[]; | |
_RawMenuAnchorBaseMixin? _parent; | |
ScrollPosition? _scrollPosition; | |
Size? _viewSize; | |
/// Whether this [_RawMenuAnchorBaseMixin] is the top node of the menu tree. | |
@protected | |
bool get isRoot => _parent == null; | |
/// The [MenuController] that is used by the [_RawMenuAnchorBaseMixin]. | |
MenuController get menuController; | |
/// Whether this submenu's overlay is visible. | |
@protected | |
bool get isOpen; | |
RawMenuAnchorAnimationDelegate? get delegate; | |
ui.Offset? _menuPosition; | |
/// The root of the menu tree that this [RawMenuAnchor] is in. | |
@protected | |
_RawMenuAnchorBaseMixin get root { | |
_RawMenuAnchorBaseMixin anchor = this; | |
while (anchor._parent != null) { | |
anchor = anchor._parent!; | |
} | |
return anchor; | |
} | |
AnimationStatus get animationStatus { | |
return isOpen | |
? _animationStatus ?? AnimationStatus.completed | |
: AnimationStatus.dismissed; | |
} | |
AnimationStatus? _animationStatus; | |
set animationStatus(AnimationStatus? status) { | |
assert(mounted); | |
if (_animationStatus != status) { | |
_animationStatus = status; | |
setState(() { | |
// Mark dirty | |
}); | |
} | |
} | |
@override | |
void initState() { | |
super.initState(); | |
menuController._attach(this); | |
delegate?._attach(this); | |
} | |
@override | |
void didChangeDependencies() { | |
super.didChangeDependencies(); | |
final _RawMenuAnchorBaseMixin? newParent = | |
MenuController.maybeOf(context)?._anchor; | |
if (newParent != _parent) { | |
assert( | |
newParent != this, | |
'A MenuController should only be attached to one anchor at a time.', | |
); | |
_parent?._removeChild(this); | |
_parent = newParent; | |
_parent?._addChild(this); | |
} | |
_scrollPosition?.isScrollingNotifier.removeListener(_handleScroll); | |
_scrollPosition = Scrollable.maybeOf(context)?.position; | |
_scrollPosition?.isScrollingNotifier.addListener(_handleScroll); | |
final Size newSize = MediaQuery.sizeOf(context); | |
if (_viewSize != null && newSize != _viewSize) { | |
// Close the menus if the view changes size. | |
root.requestClose(); | |
} | |
_viewSize = newSize; | |
} | |
@override | |
void dispose() { | |
assert(_debugMenuInfo('Disposing of $this')); | |
if (isOpen) { | |
close(inDispose: true); | |
} | |
_parent?._removeChild(this); | |
_parent = null; | |
_anchorChildren.clear(); | |
delegate?._detach(this); | |
menuController._detach(this); | |
super.dispose(); | |
} | |
void _addChild(_RawMenuAnchorBaseMixin child) { | |
assert(isRoot || _debugMenuInfo('Added root child: $child')); | |
assert(!_anchorChildren.contains(child)); | |
_anchorChildren.add(child); | |
assert(_debugMenuInfo('Added:\n${child.widget.toStringDeep()}')); | |
assert(_debugMenuInfo('Tree:\n${widget.toStringDeep()}')); | |
} | |
void _removeChild(_RawMenuAnchorBaseMixin child) { | |
assert(isRoot || _debugMenuInfo('Removed root child: $child')); | |
assert(_anchorChildren.contains(child)); | |
assert(_debugMenuInfo('Removing:\n${child.widget.toStringDeep()}')); | |
_anchorChildren.remove(child); | |
assert(_debugMenuInfo('Tree:\n${widget.toStringDeep()}')); | |
} | |
void _handleScroll() { | |
// If an ancestor scrolls, and we're a root anchor, then close the menus. | |
// Don't just close it on *any* scroll, since we want to be able to scroll | |
// menus themselves if they're too big for the view. | |
if (isRoot) { | |
requestClose(); | |
} | |
} | |
void _childChangedOpenState() { | |
_parent?._childChangedOpenState(); | |
if (SchedulerBinding.instance.schedulerPhase != | |
SchedulerPhase.persistentCallbacks) { | |
setState(() { | |
// Mark dirty now, but only if not in a build. | |
}); | |
} else { | |
SchedulerBinding.instance.addPostFrameCallback((Duration timestamp) { | |
setState(() { | |
// Mark dirty | |
}); | |
}); | |
} | |
} | |
/// Open the menu, optionally at a position relative to the [RawMenuAnchor]. | |
/// | |
/// Call this when the menu should be shown to the user. | |
/// | |
/// The optional `position` argument should specify the location of the menu in | |
/// the local coordinates of the [RawMenuAnchor]. | |
@protected | |
void open({Offset? position}); | |
/// Close the menu and all of its children. | |
/// | |
/// Call this to hide the menu overlay, after the closing sequence has | |
/// finished. | |
/// | |
/// If `inDispose` is true, the menu will close without any animations. | |
@protected | |
void close({bool inDispose = false}); | |
/// Request that the anchor begins opening. | |
/// | |
/// Unless the menu needs to be opened immediately, this method should be | |
/// called instead of [open]. Doing so enables [RawMenuAnchorAnimationDelegate] to | |
/// respond when the [menuController] calls `open`. | |
/// | |
/// The optional `position` argument should specify the location of the menu | |
/// in the local coordinates of the [RawMenuAnchor]. | |
void requestOpen({Offset? position}) { | |
assert(_debugMenuInfo('Requesting Open $this')); | |
_menuPosition = position; | |
if (delegate != null) { | |
delegate!.handleMenuOpenRequest(position: position); | |
switch (animationStatus) { | |
case AnimationStatus.forward: | |
case AnimationStatus.completed: | |
break; | |
case AnimationStatus.dismissed: | |
case AnimationStatus.reverse: | |
animationStatus = AnimationStatus.forward; | |
} | |
} | |
open(position: position); | |
} | |
/// Request that the anchor begins closing. | |
/// | |
/// Unless the menu needs to be closed immediately, this method should be | |
/// called instead of [close]. Doing so enables [RawMenuAnchorAnimationDelegate] to | |
/// respond when the attached [menuController] calls `close`. | |
void requestClose() { | |
assert(_debugMenuInfo('Requesting Close $this')); | |
if (delegate != null) { | |
switch (animationStatus) { | |
case AnimationStatus.forward: | |
case AnimationStatus.completed: | |
delegate!.handleMenuCloseRequest(); | |
animationStatus = AnimationStatus.reverse; | |
return; | |
case AnimationStatus.dismissed: | |
case AnimationStatus.reverse: | |
return; | |
} | |
} else { | |
close(); | |
} | |
} | |
/// Request that the submenus of this menu be closed. | |
/// | |
/// By default, this method will call [requestClose] on each child of this | |
/// menu, which will trigger the closing sequence of each child. | |
/// | |
/// When `shouldDelegate` is false, [close] will be called on children instead | |
/// of [requestClose]. By doing so, children will close immediately without | |
/// running any animations. | |
/// | |
/// When `inDispose` is true, along with calling [close] instead of | |
/// [requestClose] on children, each child will close without triggering any | |
/// parent updates. | |
@protected | |
void closeChildren({bool inDispose = false, bool shouldDelegate = true}) { | |
assert( | |
_debugMenuInfo( | |
'Closing children of $this${inDispose ? ' (dispose)' : ''}', | |
), | |
); | |
for (final _RawMenuAnchorBaseMixin child | |
in List<_RawMenuAnchorBaseMixin>.from(_anchorChildren)) { | |
if (inDispose || !shouldDelegate) { | |
child.close(inDispose: inDispose); | |
} else { | |
child.requestClose(); | |
} | |
} | |
} | |
/// Handles taps outside of the menu surface. | |
/// | |
/// By default, this closes this submenu's children. | |
@protected | |
void handleOutsideTap(PointerDownEvent pointerDownEvent) { | |
assert(_debugMenuInfo('Tapped Outside $menuController')); | |
closeChildren(); | |
} | |
// Used to build the anchor widget in subclasses. | |
@protected | |
Widget buildAnchor(BuildContext context); | |
@override | |
@nonVirtual | |
Widget build(BuildContext context) { | |
return _MenuControllerScope( | |
isOpen: isOpen, | |
animationStatus: animationStatus, | |
controller: menuController, | |
child: Actions( | |
actions: <Type, Action<Intent>>{ | |
// Check if open to allow DismissIntent to bubble when the menu is | |
// closed. | |
if (isOpen) | |
DismissIntent: DismissMenuAction(controller: menuController), | |
}, | |
child: Builder(builder: buildAnchor), | |
), | |
); | |
} | |
@override | |
String toString({DiagnosticLevel? minLevel}) => describeIdentity(this); | |
} | |
class _RawMenuAnchorState extends State<RawMenuAnchor> | |
with _RawMenuAnchorBaseMixin<RawMenuAnchor> { | |
// This is the global key that is used later to determine the bounding rect | |
// for the anchor's region that the CustomSingleChildLayout's delegate | |
// uses to determine where to place the menu on the screen and to avoid the | |
// view's edges. | |
final GlobalKey _anchorKey = GlobalKey<_RawMenuAnchorState>( | |
debugLabel: kReleaseMode ? null : 'MenuAnchor', | |
); | |
final OverlayPortalController _overlayController = OverlayPortalController( | |
debugLabel: kReleaseMode ? null : 'MenuAnchor controller', | |
); | |
bool get _isRootOverlayAnchor => _parent is! _RawMenuAnchorState; | |
// If we are a nested menu, we still want to use the same overlay as the | |
// root menu. | |
bool get useRootOverlay { | |
if (_parent case _RawMenuAnchorState(useRootOverlay: final bool useRoot)) { | |
return useRoot; | |
} | |
assert(_isRootOverlayAnchor); | |
return widget.useRootOverlay; | |
} | |
@override | |
bool get isOpen => _overlayController.isShowing; | |
@override | |
MenuController get menuController => widget.controller; | |
@override | |
RawMenuAnchorAnimationDelegate? get delegate => widget.delegate; | |
@override | |
void didUpdateWidget(RawMenuAnchor oldWidget) { | |
super.didUpdateWidget(oldWidget); | |
if (oldWidget.controller != widget.controller) { | |
oldWidget.controller._detach(this); | |
widget.controller._attach(this); | |
} | |
if (oldWidget.delegate != widget.delegate) { | |
oldWidget.delegate?._detach(this); | |
widget.delegate?._attach(this); | |
} | |
} | |
@override | |
void open({Offset? position}) { | |
assert(menuController._anchor == this); | |
if (isOpen) { | |
if (position == _menuPosition) { | |
assert(_debugMenuInfo("Not opening $this because it's already open")); | |
// The menu is open and not being moved, so just return. | |
return; | |
} | |
// The menu is already open, but we need to move to another location, so | |
// close it first. | |
close(); | |
} | |
assert(_debugMenuInfo('Opening $this at ${position ?? Offset.zero}')); | |
// Close all siblings. | |
_parent?.closeChildren(); | |
assert(!_overlayController.isShowing); | |
_parent?._childChangedOpenState(); | |
_menuPosition = position; | |
_overlayController.show(); | |
if (_isRootOverlayAnchor) { | |
widget.childFocusNode?.requestFocus(); | |
} | |
widget.onOpen?.call(); | |
if (mounted && | |
SchedulerBinding.instance.schedulerPhase != | |
SchedulerPhase.persistentCallbacks) { | |
setState(() { | |
// Mark dirty to notify MenuController dependents. | |
}); | |
} | |
} | |
// Close the menu. | |
// | |
// Call this when the menu should be closed. Has no effect if the menu is | |
// already closed. | |
@override | |
void close({bool inDispose = false}) { | |
assert(_debugMenuInfo('Closing $this')); | |
if (!isOpen) { | |
return; | |
} | |
closeChildren(inDispose: inDispose); | |
// Don't hide if we're in the middle of a build. | |
if (SchedulerBinding.instance.schedulerPhase != | |
SchedulerPhase.persistentCallbacks) { | |
_overlayController.hide(); | |
} else if (!inDispose) { | |
SchedulerBinding.instance.addPostFrameCallback((_) { | |
_overlayController.hide(); | |
}, debugLabel: 'MenuAnchor.hide'); | |
} | |
if (!inDispose) { | |
// Notify that _childIsOpen changed state, but only if not | |
// currently disposing. | |
_parent?._childChangedOpenState(); | |
widget.onClose?.call(); | |
if (mounted && | |
SchedulerBinding.instance.schedulerPhase != | |
SchedulerPhase.persistentCallbacks) { | |
setState(() { | |
// Mark dirty, but only if mounted and not in a build. | |
}); | |
} | |
} | |
} | |
Widget _buildOverlay(BuildContext context) { | |
final BuildContext anchorContext = _anchorKey.currentContext!; | |
final RenderBox overlay = | |
Overlay.of( | |
anchorContext, | |
rootOverlay: useRootOverlay, | |
).context.findRenderObject()! | |
as RenderBox; | |
final RenderBox anchorBox = anchorContext.findRenderObject()! as RenderBox; | |
final ui.Offset upperLeft = anchorBox.localToGlobal( | |
Offset.zero, | |
ancestor: overlay, | |
); | |
final ui.Offset bottomRight = anchorBox.localToGlobal( | |
anchorBox.size.bottomRight(Offset.zero), | |
ancestor: overlay, | |
); | |
final RawMenuOverlayInfo info = RawMenuOverlayInfo( | |
anchorRect: Rect.fromPoints(upperLeft, bottomRight), | |
overlaySize: overlay.size, | |
position: _menuPosition, | |
tapRegionGroupId: root.menuController, | |
); | |
return widget.overlayBuilder(context, info); | |
} | |
@override | |
Widget buildAnchor(BuildContext context) { | |
final Widget child = Shortcuts( | |
includeSemantics: false, | |
shortcuts: _kMenuTraversalShortcuts, | |
child: TapRegion( | |
groupId: root.menuController, | |
consumeOutsideTaps: root.isOpen && widget.consumeOutsideTaps, | |
onTapOutside: handleOutsideTap, | |
child: Builder( | |
key: _anchorKey, | |
builder: (BuildContext context) { | |
return widget.builder?.call( | |
context, | |
menuController, | |
widget.child, | |
) ?? | |
widget.child ?? | |
const SizedBox(); | |
}, | |
), | |
), | |
); | |
if (useRootOverlay) { | |
return OverlayPortal.targetsRootOverlay( | |
controller: _overlayController, | |
overlayChildBuilder: _buildOverlay, | |
child: child, | |
); | |
} else { | |
return OverlayPortal( | |
controller: _overlayController, | |
overlayChildBuilder: _buildOverlay, | |
child: child, | |
); | |
} | |
} | |
@override | |
String toString({DiagnosticLevel? minLevel}) { | |
return describeIdentity(this); | |
} | |
} | |
/// Creates a menu anchor that is always visible and is not displayed in an | |
/// [OverlayPortal]. | |
/// | |
/// A [RawMenuAnchorGroup] can be used to create a menu bar that handles | |
/// external taps and keyboard shortcuts, but defines no default focus or | |
/// keyboard traversal to enable more flexibility. | |
/// | |
/// When a [MenuController] is given to a [RawMenuAnchorGroup], | |
/// - [MenuController.open] has no effect. | |
/// - [MenuController.close] closes all child [RawMenuAnchor]s that are open | |
/// - [MenuController.isOpen] reflects whether any child [RawMenuAnchor] is | |
/// open. | |
/// | |
/// A [child] must be provided. | |
/// | |
/// {@tool dartpad} | |
/// | |
/// This example uses [RawMenuAnchorGroup] to build a menu bar with four | |
/// submenus. Hovering over a menu item opens its respective submenu. Selecting | |
/// a menu item will close the menu and update the selected item text. | |
/// | |
/// ** See code in examples/api/lib/widgets/raw_menu_anchor/raw_menu_anchor.1.dart ** | |
/// {@end-tool} | |
/// | |
/// See also: | |
/// * [MenuBar], which wraps this widget with standard layout and semantics and | |
/// focus management. | |
/// * [MenuAnchor], a menu anchor that follows the Material Design guidelines. | |
/// * [RawMenuAnchor], a widget that defines a region attached to a floating | |
/// submenu. | |
class RawMenuAnchorGroup extends StatefulWidget { | |
/// Creates a [RawMenuAnchorGroup]. | |
const RawMenuAnchorGroup({ | |
super.key, | |
required this.child, | |
required this.controller, | |
}); | |
/// The child displayed by the [RawMenuAnchorGroup]. | |
/// | |
/// To access the [MenuController] from the [child], place the child in a | |
/// builder and call [MenuController.maybeOf]. | |
final Widget child; | |
/// An [MenuController] that allows the closing of the menu from other | |
/// widgets. | |
final MenuController controller; | |
@override | |
void debugFillProperties(DiagnosticPropertiesBuilder properties) { | |
super.debugFillProperties(properties); | |
properties.add( | |
ObjectFlagProperty<MenuController>.has('controller', controller), | |
); | |
} | |
@override | |
State<RawMenuAnchorGroup> createState() => _RawMenuAnchorGroupState(); | |
} | |
class _RawMenuAnchorGroupState extends State<RawMenuAnchorGroup> | |
with _RawMenuAnchorBaseMixin<RawMenuAnchorGroup> { | |
@override | |
bool get isOpen => | |
_anchorChildren.any((_RawMenuAnchorBaseMixin child) => child.isOpen); | |
@override | |
MenuController get menuController => widget.controller; | |
@override | |
RawMenuAnchorAnimationDelegate? get delegate => null; | |
@override | |
void didUpdateWidget(RawMenuAnchorGroup oldWidget) { | |
super.didUpdateWidget(oldWidget); | |
if (oldWidget.controller != widget.controller) { | |
oldWidget.controller._detach(this); | |
widget.controller._attach(this); | |
} | |
} | |
@override | |
void close({bool inDispose = false}) { | |
if (!isOpen) { | |
return; | |
} | |
closeChildren(inDispose: inDispose); | |
if (!inDispose) { | |
if (SchedulerBinding.instance.schedulerPhase != | |
SchedulerPhase.persistentCallbacks) { | |
setState(() { | |
// Mark dirty, but only if mounted and not in a build. | |
}); | |
} else { | |
SchedulerBinding.instance.addPostFrameCallback((Duration timestamp) { | |
if (mounted) { | |
setState(() { | |
// Mark dirty. | |
}); | |
} | |
}); | |
} | |
} | |
} | |
@override | |
void open({Offset? position}) { | |
assert(menuController._anchor == this); | |
// Menu nodes are always open, so this is a no-op. | |
return; | |
} | |
@override | |
Widget buildAnchor(BuildContext context) { | |
return TapRegion( | |
groupId: root.menuController, | |
onTapOutside: handleOutsideTap, | |
child: widget.child, | |
); | |
} | |
} | |
/// A controller used to manage a menu created by a [RawMenuAnchor], or | |
/// [RawMenuAnchorGroup]. | |
/// | |
/// A [MenuController] is used to control and interrogate a menu after it has | |
/// been created, with methods such as [open] and [close], and state accessors | |
/// like [isOpen] and [animationStatus]. | |
/// | |
/// [MenuController.maybeOf] can be used to retrieve a controller from the | |
/// [BuildContext] of a widget that is a descendant of a [MenuAnchor], | |
/// [MenuBar], [SubmenuButton], or [RawMenuAnchor]. Doing so will not establish | |
/// a dependency relationship. | |
/// | |
/// [MenuController.maybeIsOpenOf] can be used to interrogate the state of a | |
/// menu from the [BuildContext] of a widget that is a descendant of a | |
/// [MenuAnchor]. Unlike [MenuController.maybeOf], this method will establish a | |
/// dependency relationship, so the calling widget will rebuild when the menu | |
/// opens and closes, and when the [MenuController] changes. | |
/// | |
/// See also: | |
/// | |
/// * [MenuAnchor], a menu anchor that follows the Material Design guidelines. | |
/// * [MenuBar], a widget that creates a menu bar that can take an optional | |
/// [MenuController]. | |
/// * [SubmenuButton], a Material widget that has a button that manages a | |
/// submenu. | |
/// * [RawMenuAnchor], a generic widget that manages a submenu. | |
/// * [RawMenuAnchorGroup], a generic widget that wraps a group of submenus. | |
class MenuController { | |
// The anchor that this controller controls. | |
// | |
// This is set automatically when a [MenuController] is given to the anchor | |
// it controls. | |
_RawMenuAnchorBaseMixin? _anchor; | |
/// Whether or not the menu associated with this [MenuController] is open. | |
/// | |
/// If a menu is open, then the menu's overlay is mounted and visible. As a | |
/// result, when a menu is animated, the menu is considered closed when the | |
/// [AnimationStatus] is [AnimationStatus.dismissed], and open when the | |
/// [AnimationStatus] is [AnimationStatus.completed], | |
/// [AnimationStatus.forward], or [AnimationStatus.reverse]. | |
bool get isOpen => _anchor?.isOpen ?? false; | |
/// Opens the menu that this [MenuController] is associated with. | |
/// | |
/// If `position` is given, then the menu will open at the position given, in | |
/// the coordinate space of the root overlay. | |
/// | |
/// If given, the `position` will override the [MenuAnchor.alignmentOffset] | |
/// given to a [MenuAnchor]. | |
/// | |
/// If the menu's anchor point is scrolled by an ancestor, or the view changes | |
/// size, then any open menus will automatically close. | |
void open({Offset? position}) { | |
assert(_anchor != null); | |
_anchor!.requestOpen(position: position); | |
} | |
/// Close the menu that this [MenuController] is associated with. | |
/// | |
/// Associating with a menu is done by passing a [MenuController] to a | |
/// [MenuAnchor], [RawMenuAnchor], or [RawMenuAnchorGroup]. | |
/// | |
/// If the menu's anchor point is scrolled by an ancestor, or the view changes | |
/// size, then any open menu will automatically close. | |
void close() { | |
_anchor?.requestClose(); | |
} | |
/// Close the children of the menu associated with this [MenuController], | |
/// without closing the menu itself. | |
void closeChildren() { | |
assert(_anchor != null); | |
_anchor!.closeChildren(); | |
} | |
/// The [AnimationStatus] of the menu associated with this [MenuController]. | |
/// | |
/// For menus that are not animated, the [AnimationStatus] will be | |
/// [AnimationStatus.completed] when [isOpen] is true, and | |
/// [AnimationStatus.dismissed] when [isOpen] is false. | |
/// | |
/// For animated menus, the [animationStatus] of a decorated [MenuController] | |
/// should match: | |
/// - [AnimationStatus.forward] when a menu is animating open. | |
/// - [AnimationStatus.completed] when a menu has finished opening. | |
/// - [AnimationStatus.reverse] when a menu is animating closed. | |
/// - [AnimationStatus.dismissed] when a menu has finished closing and has | |
/// hidden its overlay. | |
/// | |
/// Because [isOpen] reflects whether the menu's overlay is mounted, [isOpen] | |
/// will only be false when the [animationStatus] is | |
/// [AnimationStatus.dismissed]. | |
AnimationStatus get animationStatus { | |
return _anchor?.animationStatus ?? AnimationStatus.dismissed; | |
} | |
// Attach this controller to an anchor. | |
// ignore: use_setters_to_change_properties | |
void _attach(_RawMenuAnchorBaseMixin anchor) { | |
_anchor = anchor; | |
} | |
// Detach the controller from an anchor. | |
void _detach(_RawMenuAnchorBaseMixin anchor) { | |
if (_anchor == anchor) { | |
_anchor = null; | |
} | |
} | |
/// Returns the [MenuController] of the ancestor [RawMenuAnchor] or | |
/// [RawMenuAnchorGroup] nearest to the given `context`, if one exists. | |
/// Otherwise, returns null. | |
/// | |
/// This method will not establish a dependency relationship, so the calling | |
/// widget will not rebuild when the menu opens and closes, nor when the | |
/// [MenuController] changes. | |
static MenuController? maybeOf(BuildContext context) { | |
return context | |
.getInheritedWidgetOfExactType<_MenuControllerScope>() | |
?.controller; | |
} | |
/// Returns the value of [MenuController.isOpen] of the ancestor | |
/// [RawMenuAnchor] or [RawMenuAnchorGroup] nearest to the given `context`, if | |
/// one exists. Otherwise, returns null. | |
/// | |
/// This method will establish a dependency relationship, so the calling | |
/// widget will rebuild when the menu opens and closes. | |
static bool? maybeIsOpenOf(BuildContext context) { | |
return context | |
.dependOnInheritedWidgetOfExactType<_MenuControllerScope>() | |
?.isOpen; | |
} | |
/// Returns the value of [MenuController.animationStatus] obtained from the | |
/// nearest ancestor [RawMenuAnchor] or [RawMenuAnchorGroup], if one exists. | |
/// Otherwise, returns null. | |
/// | |
/// This method will establish a dependency relationship, so the calling | |
/// widget will rebuild when the nearest ancestor menu begins opening, begins | |
/// closing, finishes opening, or finishes closing. | |
static AnimationStatus? maybeAnimationStatusOf(BuildContext context) { | |
return context | |
.dependOnInheritedWidgetOfExactType<_MenuControllerScope>() | |
?.animationStatus; | |
} | |
@override | |
String toString() => describeIdentity(this); | |
} | |
/// A delegate used to change how a [RawMenuAnchor] opens and closes. | |
/// | |
/// A [MenuControllerDecorator] can be subclassed and used directly, or mixed into a widget's | |
/// state. | |
/// | |
/// Implementations should override [handleMenuOpenRequest] and | |
/// [handleMenuCloseRequest] to provide custom animations for opening and | |
/// closing the menu, respectively. | |
/// | |
/// {@tool dartpad} | |
/// | |
/// This example uses a [MenuControllerDecorator] to animate a [RawMenuAnchor] | |
/// using simulations. | |
/// | |
/// ** See code in examples/api/lib/widgets/raw_menu_anchor/menu_controller_decorator.0.dart ** | |
/// {@end-tool} | |
/// | |
/// {@tool dartpad} | |
/// | |
/// This example uses a [MenuControllerDecorator] to animate a cascading menu button using | |
/// a [CurvedAnimation]. | |
/// | |
/// ** See code in examples/api/lib/widgets/raw_menu_anchor/menu_controller_decorator.1.dart ** | |
/// {@end-tool} | |
/// | |
/// See also: | |
/// * [MenuController], a controller used to open and close a menu anchor. | |
abstract mixin class RawMenuAnchorAnimationDelegate { | |
/// Called when [MenuController.open] is invoked. Implementations should begin | |
/// animating the menu open. | |
/// | |
/// Once the menu has finished animating open, [markMenuOpened] should be | |
/// called to set the [MenuController.animationStatus] to | |
/// [AnimationStatus.completed]. | |
/// | |
/// The `position` argument is the position passed to [MenuController.open], | |
/// and describes the location of the menu relative to the menu anchor. If no | |
/// position was passed, this will be null. | |
/// | |
/// If the `position` value changes, [handleMenuOpenRequest] may be called | |
/// when the menu is already open or opening. In this case, the menu should be | |
/// repositioned to the new location without restarting the opening animation. | |
/// | |
/// If the opening animation is canceled, users should not call | |
/// [markMenuOpened]. | |
@protected | |
void handleMenuOpenRequest({ui.Offset? position}); | |
/// Called when [MenuController.close] is invoked by the attached | |
/// [menuController]. Implementations should begin animating the menu closed. | |
/// | |
/// Once the menu has finished animating open, [markMenuClosed] should be | |
/// called to set the [MenuController.animationStatus] to | |
/// [AnimationStatus.dismissed]. [markMenuClosed] will also remove the menu | |
/// overlay from the widget tree. | |
/// | |
/// If the closing animation is interrupted, users should not call | |
/// [markMenuClosed]. | |
@protected | |
void handleMenuCloseRequest(); | |
/// Mark the menu as opened and set the [animationStatus] to | |
/// [AnimationStatus.completed]. | |
@mustCallSuper | |
void markMenuOpened() { | |
if (!_anchor!.isOpen) { | |
_anchor!.open(position: _anchor!._menuPosition); | |
} | |
_anchor!.animationStatus = AnimationStatus.completed; | |
} | |
/// Remove the menu overlay from the widget tree and set the [animationStatus] | |
/// to [AnimationStatus.dismissed]. | |
@mustCallSuper | |
void markMenuClosed() { | |
_anchor! | |
..animationStatus = AnimationStatus.dismissed | |
..close(); | |
} | |
// The anchor that calls the methods on this RawMenuAnimationDelegate. | |
// | |
// This is set automatically when a delegate is given to a RawMenuAnchor. | |
_RawMenuAnchorBaseMixin? _anchor; | |
// Attach this controller to an anchor. | |
// ignore: use_setters_to_change_properties | |
void _attach(_RawMenuAnchorBaseMixin anchor) { | |
_anchor = anchor; | |
} | |
// Detach the controller from an anchor. | |
void _detach(_RawMenuAnchorBaseMixin anchor) { | |
if (_anchor == anchor) { | |
_anchor = null; | |
} | |
} | |
} | |
/// An action that closes all the menus associated with the given | |
/// [MenuController]. | |
/// | |
/// See also: | |
/// | |
/// * [MenuAnchor], a material-themed widget that hosts a cascading submenu. | |
/// * [MenuBar], a widget that defines a menu bar with cascading submenus. | |
/// * [RawMenuAnchor], a widget that hosts a cascading submenu. | |
/// * [MenuController], a controller used to manage menus created by a | |
/// [RawMenuAnchor]. | |
class DismissMenuAction extends DismissAction { | |
/// Creates a [DismissMenuAction]. | |
DismissMenuAction({required this.controller}); | |
/// The [MenuController] that manages the menu which should be dismissed upon | |
/// invocation. | |
final MenuController controller; | |
@override | |
void invoke(DismissIntent intent) { | |
controller._anchor!.root.requestClose(); | |
} | |
@override | |
bool isEnabled(DismissIntent intent) { | |
return controller._anchor != null; | |
} | |
} | |
/// A debug print function, which should only be called within an assert, like | |
/// so: | |
/// | |
/// assert(_debugMenuInfo('Debug Message')); | |
/// | |
/// so that the call is entirely removed in release builds. | |
/// | |
/// Enable debug printing by setting [_kDebugMenus] to true at the top of the | |
/// file. | |
bool _debugMenuInfo(String message, [Iterable<String>? details]) { | |
assert(() { | |
if (_kDebugMenus) { | |
debugPrint('MENU: $message'); | |
if (details != null && details.isNotEmpty) { | |
for (final String detail in details) { | |
debugPrint(' $detail'); | |
} | |
} | |
} | |
return true; | |
}()); | |
// Return true so that it can be easily used inside of an assert. | |
return true; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment