Instantly share code, notes, and snippets.
Last active
November 6, 2024 11:53
-
Star
(0)
0
You must be signed in to star a gist -
Fork
(0)
0
You must be signed in to fork a gist
-
Save davidhicks980/5632b826ee281f9f420268a4da330bfb to your computer and use it in GitHub Desktop.
Alignment demo using the RawMenuAnchor draft component. WARNING: This demo is meant to be viewed in DartPad, and may be exceptionally sloppy.
This file contains 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:async'; | |
import 'dart:ui' as ui; | |
import 'package:flutter/foundation.dart'; | |
import 'package:flutter/rendering.dart'; | |
import 'package:flutter/scheduler.dart'; | |
import 'package:flutter/services.dart'; | |
import 'package:flutter/widgets.dart'; | |
void main() { | |
runApp(const AlignmentExampleApp()); | |
} | |
const bool _kDebugAnchorAlignment = true; | |
List<Widget> buildChildren( | |
AlignmentGeometry anchorAlignment, | |
AlignmentGeometry menuAlignment, | |
Offset alignmentOffset, | |
) { | |
List<Widget> children = <Widget>[ | |
Container( | |
height: 50, | |
width: 50, | |
color: const ui.Color.fromARGB(255, 255, 0, 153), | |
) | |
]; | |
int layers = 5; | |
while (layers-- > 0) { | |
final int depth = layers; | |
children = <Widget>[ | |
for (int index = 0; index < 4; index++) | |
Button.text( | |
"Sub" * depth + 'menu Item $depth.$index', | |
constraints: const BoxConstraints(maxHeight: 30), | |
), | |
RawMenuAnchor( | |
constraints: BoxConstraints(minWidth: 125), | |
padding: const EdgeInsetsDirectional.fromSTEB(0.5, 4, 1, 6), | |
alignmentOffset: alignmentOffset, | |
alignment: anchorAlignment, | |
menuAlignment: menuAlignment, | |
menuChildren: children, | |
builder: (BuildContext context, MenuController controller, Widget? child) { | |
return ColoredBox( | |
color: controller.isOpen | |
? const ui.Color.fromARGB(30, 255, 255, 255) | |
: const Color(0x00000000), | |
child: Button( | |
Row( | |
mainAxisAlignment: MainAxisAlignment.spaceBetween, | |
children: <Widget>[ | |
Flexible(child: Text('Menu $depth')), | |
const Text('▶', style: TextStyle(fontSize: 10)), | |
], | |
), | |
onPressed: () { | |
if (controller.isOpen) { | |
controller.close(); | |
} else { | |
controller.open(); | |
} | |
}, | |
), | |
); | |
}, | |
), | |
]; | |
} | |
return children; | |
} | |
// ANCHOR CODE | |
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), | |
SingleActivator(LogicalKeyboardKey.home): _FocusFirstMenuItemIntent(), | |
SingleActivator(LogicalKeyboardKey.end): _FocusLastMenuItemIntent(), | |
}; | |
/// Describes the position of the menu overlay for the | |
/// [RawMenuAnchor.overlayBuilder] constructor. | |
class RawMenuAnchorOverlayPosition { | |
/// Creates a [RawMenuAnchorOverlayPosition]. | |
const RawMenuAnchorOverlayPosition({ | |
required this.anchorRect, | |
required this.overlaySize, | |
required this.tapRegionGroupId, | |
this.position, | |
}); | |
/// The global position of the anchor widget that the menu is attached to, | |
/// relative to the [overlaySize]. | |
final ui.Rect anchorRect; | |
/// The size of the overlay that the menu is being rendered in. | |
final ui.Size overlaySize; | |
/// The `position` argument passed to [MenuController.open]. | |
/// | |
/// When defined, the `position` will override [RawMenuAnchor.alignmentOffset] | |
/// and [RawMenuAnchor.alignment]. | |
final Offset? position; | |
/// The group ID of the tap region that should be used to consume taps outside | |
/// of the menu. | |
// This used to be a separate parameter, but was moved into the position class | |
// to keep the constructor API cleaner. | |
final Object tapRegionGroupId; | |
} | |
/// The type of builder function used by [RawMenuAnchor.overlayBuilder] to build | |
/// the overlay attached to a [RawMenuAnchor]. | |
/// | |
/// The `context` is the context that the overlay is being built in. | |
/// | |
/// The `menuChildren` is the list of children containing the menu items that | |
/// was passed to the [RawMenuAnchor]. | |
/// | |
/// The `position` describes the position of the menu overlay for the | |
/// [RawMenuAnchor.overlayBuilder] constructor. | |
typedef RawMenuAnchorOverlayBuilder = Widget Function( | |
BuildContext context, | |
List<Widget> menuChildren, | |
RawMenuAnchorOverlayPosition position, | |
); | |
/// The type of builder function used by [RawMenuAnchor.panelBuilder] to | |
/// build the panel displayed by a [RawMenuAnchor]. | |
typedef RawMenuAnchorPanelBuilder = Widget Function( | |
BuildContext context, | |
List<Widget> menuChildren, | |
); | |
/// The type of 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, | |
); | |
class _RawMenuAnchorScope extends InheritedWidget { | |
const _RawMenuAnchorScope( { | |
required super.child, | |
required this.anchor, | |
required this.isOpen, | |
required this.controller, | |
}); | |
final _RawMenuAnchorState anchor; | |
final bool isOpen; | |
final MenuController controller; | |
@override | |
bool updateShouldNotify(_RawMenuAnchorScope oldWidget) { | |
return anchor != oldWidget.anchor || | |
isOpen != oldWidget.isOpen || | |
controller != oldWidget.controller; | |
} | |
} | |
/// A widget used to mark the "anchor" for a set of submenus, defining the | |
/// rectangle used to position the menu, which can be done either with an | |
/// explicit location, or with an alignment. | |
/// | |
/// The [RawMenuAnchor] is meant to be used when creating a custom menu with | |
/// unique styling, layout, or behavior. | |
/// | |
/// The default [RawMenuAnchor] constructor creates a simple menu overlay that | |
/// has minimal styling, layout, and behavior. | |
/// | |
/// To completely customize the overlay, [RawMenuAnchor.overlayBuilder] can be | |
/// used to completely manage the positioning, appearance, semantics, and | |
/// interaction of the menu overlay. No default overlay is provided when using | |
/// this constructor. | |
/// | |
/// The [RawMenuAnchor.panelBuilder] constructor can be used to create menus | |
/// that are always visible and are not displayed in an [OverlayPortal]. This is | |
/// useful for creating a menu bars or other custom menu layouts. | |
/// | |
/// {@tool snippet} | |
/// | |
/// This example uses a [RawMenuAnchor] to create a simple edit menu. | |
/// | |
/// ```dart | |
/// RawMenuAnchor( | |
/// constraints: const BoxConstraints(minWidth: 200), | |
/// padding: const EdgeInsets.symmetric(vertical: 4), | |
/// alignmentOffset: const Offset(0, 6), | |
/// menuChildren: <Widget>[ | |
/// TextButton(onPressed: () {}, child: const Text('Undo')), | |
/// TextButton(onPressed: () {}, child: const Text('Redo')), | |
/// const Divider(), | |
/// TextButton(onPressed: () {}, child: const Text('Cut')), | |
/// TextButton(onPressed: () {}, child: const Text('Copy')), | |
/// TextButton(onPressed: () {}, child: const Text('Paste')), | |
/// TextButton(onPressed: () {}, child: const Text('Delete')), | |
/// TextButton(onPressed: () {}, child: const Text('Select All')), | |
/// ], | |
/// builder: ( | |
/// BuildContext context, | |
/// MenuController controller, | |
/// Widget? child, | |
/// ) { | |
/// return TextButton( | |
/// onPressed: () { | |
/// if (controller.isOpen) { | |
/// controller.close(); | |
/// } else { | |
/// controller.open(); | |
/// } | |
/// }, | |
/// child: const Text('Edit'), | |
/// ); | |
/// }, | |
/// ), | |
/// ``` | |
/// {@end-tool} | |
/// | |
/// | |
class RawMenuAnchor extends StatelessWidget { | |
/// Creates a [RawMenuAnchor]. | |
/// | |
/// The [menuChildren] argument is required. | |
const RawMenuAnchor({ | |
super.key, | |
this.controller, | |
this.childFocusNode, | |
this.alignment, | |
this.menuAlignment, | |
this.alignmentOffset = Offset.zero, | |
this.clipBehavior = Clip.antiAlias, | |
this.constraints, | |
this.consumeOutsideTaps = false, | |
this.onOpen, | |
this.onClose, | |
required this.menuChildren, | |
this.builder, | |
this.child, | |
this.panelDecoration, | |
this.padding = EdgeInsets.zero, | |
this.constrainCrossAxis = false, | |
String? semanticLabel, | |
}) : _semanticLabel = semanticLabel, | |
_overlayBuilder = null, | |
_panelBuilder = null; | |
/// Creates a [RawMenuAnchor] that lays out it's [menuChildren] in a custom | |
/// overlay built by `overlayBuilder`. | |
/// | |
/// Because providing an `overlayBuilder` entails managing the positioning, | |
/// appearance, semantics, and interaction of the menu overlay, in most cases | |
/// the default overlay provided by [RawMenuAnchor] is sufficient. However, in | |
/// cases where a custom overlay is needed (e.g. an animated menu), this | |
/// constructor can be used to provide one. | |
const RawMenuAnchor.overlayBuilder({ | |
super.key, | |
this.controller, | |
this.childFocusNode, | |
this.consumeOutsideTaps = false, | |
this.onOpen, | |
this.onClose, | |
required this.menuChildren, | |
required RawMenuAnchorOverlayBuilder overlayBuilder, | |
this.builder, | |
this.child, | |
}) : alignment = null, | |
menuAlignment = null, | |
panelDecoration = null, | |
alignmentOffset = Offset.zero, | |
clipBehavior = Clip.hardEdge, | |
constraints = null, | |
_overlayBuilder = overlayBuilder, | |
_panelBuilder = null, | |
padding = EdgeInsets.zero, | |
constrainCrossAxis = false, | |
_semanticLabel = null; | |
/// Creates a [RawMenuAnchor] whose [builder] creates a menu panel instead of | |
/// an overlay anchor. | |
/// | |
/// Unlike an overlay menu, a menu panel's [menuChildren] are always visible | |
/// and are not displayed in an [OverlayPortal]. As a result, calling | |
/// [MenuController.open] is a no-op, and calling [MenuController.close] will | |
/// close all children of this anchor. [MenuController.isOpen] will only | |
/// return true when a child of this anchor is open. | |
/// | |
/// Because building a custom menu panel entails managing layout, appearance, | |
/// semantics, and interaction, the [MenuBar] widget is the recommended way of | |
/// a creating a horizontal menu panel. However, in cases where finer control | |
/// over focus behavior is needed, or where a custom layout (such as a | |
/// vertical menu bar) is desired, this constructor can be used. | |
/// | |
/// The [menuChildren] and [builder] arguments are required. | |
/// | |
/// {@tool snippet} | |
/// | |
/// This snippet renders a vertical [RawMenuAnchor.node] with 5 fly-out | |
/// submenus. | |
/// | |
/// ```dart | |
/// RawMenuAnchor.menuPanel( | |
/// builder: (BuildContext context, List<Widget> menuChildren) { | |
/// return Row( | |
/// mainAxisSize: MainAxisSize.min, | |
/// children: menuChildren, | |
/// ); | |
/// }, | |
/// menuChildren: <Widget>[ | |
/// for (int i = 0; i < 5; i++) | |
/// RawMenuAnchor( | |
/// builder: (BuildContext context, MenuController controller, Widget? child) { | |
/// return TextButton( | |
/// onPressed: () { | |
/// if (controller.isOpen) { | |
/// controller.close(); | |
/// } else { | |
/// controller.open(); | |
/// } | |
/// }, | |
/// child: Text('Submenu $i ${controller.isOpen ? '▲' : '▼'}'), | |
/// ); | |
/// }, | |
/// menuChildren: <Widget>[ | |
/// for (int j = 0; j < 5; j++) | |
/// Builder(builder: (BuildContext context) { | |
/// return TextButton( | |
/// onPressed: () {}, | |
/// child: Align( | |
/// alignment: Alignment.centerLeft, | |
/// child: Text('Menu Item $i.$j'), | |
/// ), | |
/// ); | |
/// }), | |
/// ], | |
/// ) | |
/// ], | |
/// ); | |
/// ``` | |
/// {@end-tool} | |
const RawMenuAnchor.node({ | |
super.key, | |
this.controller, | |
required RawMenuAnchorPanelBuilder builder, | |
required this.menuChildren, | |
}) : _overlayBuilder = null, | |
_panelBuilder = builder, | |
alignment = null, | |
menuAlignment = null, | |
panelDecoration = null, | |
alignmentOffset = Offset.zero, | |
clipBehavior = Clip.hardEdge, | |
onOpen = null, | |
onClose = null, | |
childFocusNode = null, | |
consumeOutsideTaps = false, | |
builder = null, | |
constraints = null, | |
child = null, | |
padding = EdgeInsets.zero, | |
constrainCrossAxis = false, | |
_semanticLabel = null; | |
/// An optional [MenuController] that allows opening and closing of the menu | |
/// from other widgets. | |
/// | |
/// If not supplied, a new [MenuController] will be created and managed by the | |
/// [RawMenuAnchor]. | |
final MenuController? controller; | |
/// 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 take focus | |
/// when the menu is opened or closed. On the default [RawMenuAnchor] constructor, | |
/// invoking NextFocus or PreviousFocus will close the menu and move focus to | |
/// the next and previous siblings of [childFocusNode]. | |
/// | |
/// If not supplied, | |
final FocusNode? childFocusNode; | |
/// The [Decoration] that defines the visual attributes of the menu surface. | |
/// | |
/// Defaults to [defaultLightOverlayDecoration] when | |
/// [MediaQuery.platformBrightness] returns [Brightness.light] or null and | |
/// [defaultDarkOverlayDecoration] when [MediaQuery.platformBrightness] | |
/// returns [Brightness.dark]. | |
final Decoration? panelDecoration; | |
/// {@macro flutter.material.Material.clipBehavior} | |
/// | |
/// Defaults to [Clip.hardEdge]. | |
final Clip clipBehavior; | |
/// 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 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; | |
/// The menu items displayed by this [RawMenuAnchor]. | |
/// | |
/// {@macro flutter.material.MenuBar.shortcuts_note} | |
final List<Widget> menuChildren; | |
/// 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 point on the anchor surface that attaches to the menu. | |
/// | |
/// The [alignment] is ignored if a `position` argument is provided to | |
/// [MenuController.open]. | |
/// | |
/// If the menu overflows the edge of the screen, the menu will be flipped | |
/// across the anchor's midpoint on the axis of overflow, effectively negating | |
/// the alignment on that axis. For example, if the menu on the right side of | |
/// the anchor overflows the right edge of the screen, the menu will be | |
/// flipped to the left side of the anchor. | |
/// | |
/// Defaults to [AlignmentDirectional.bottomStart]. | |
final AlignmentGeometry? alignment; | |
/// The amount to offset the menu relative to the anchor attachment point. | |
/// | |
/// By default, increasing the [Offset.dx] and [Offset.dy] value of | |
/// [alignmentOffset] will shift the menu position rightward and downward, | |
/// respectively. | |
/// | |
/// However, when the [alignment] is an [AlignmentDirectional], increasing the | |
/// [Offset.dx] value of [alignmentOffset] will shift the menu in the reading | |
/// direction of the ambient [Directionality] -- leftward in | |
/// [TextDirection.ltr] and rightward in [TextDirection.rtl]. | |
/// | |
/// The [alignment] and [alignmentOffset] are ignored if a `position` argument | |
/// is provided to [MenuController.open]. | |
/// | |
/// Defaults to [Offset.zero]. | |
final Offset alignmentOffset; | |
/// The point on the menu surface that attaches to the anchor. | |
/// | |
/// Unlike [alignment] and [alignmentOffset], the [menuAlignment] will be | |
/// applied when the menu is opened with a `position` argument. | |
/// | |
/// Defaults to [AlignmentDirectional.topStart]. | |
final AlignmentGeometry? menuAlignment; | |
/// Whether the menu's cross axis should be laid out with regard to the bounds | |
/// of the overlay. | |
/// | |
/// When true, the width of the menu will be constrained by the width of the | |
/// overlay. This can cause the menu contents to wrap. | |
/// | |
/// When false, the menu will be allowed to expand to the intrinsic size of | |
/// its children, and menu items that overflow will be visually clipped. | |
/// | |
/// Defaults to false. | |
final bool constrainCrossAxis; | |
/// The [EdgeInsetsGeometry] applied to the menu surface but ignored during | |
/// menu positioning. | |
/// | |
/// Menus commonly apply padding to the top and bottom of the menu surface, | |
/// which can cause a submenu's items to be vertically misaligned with their | |
/// parent menu items. To ensure a submenu's items align with their parent's | |
/// items, the [padding] applied to the menu surface is ignored when | |
/// calculating the position of the menu. | |
/// | |
/// Defaults to [EdgeInsets.zero]. | |
final EdgeInsetsGeometry padding; | |
// The semanticLabel argument is used by accessibility frameworks to announce | |
// the name of the menu. | |
final String? _semanticLabel; | |
/// The constraints to apply to the menu surface. | |
/// | |
/// If null, the menu will be allowed to expand to the intrinsic size of its | |
/// children. | |
final BoxConstraints? constraints; | |
final RawMenuAnchorPanelBuilder? _panelBuilder; | |
final RawMenuAnchorOverlayBuilder? _overlayBuilder; | |
static const Decoration defaultLightOverlayDecoration = BoxDecoration( | |
borderRadius: BorderRadius.all(Radius.circular(6.0)), | |
color: ui.Color.fromARGB(255, 232, 234, 237), | |
border: Border.fromBorderSide( | |
BorderSide( | |
color: ui.Color.fromARGB(255, 175, 175, 175), | |
width: 0.5, | |
), | |
), | |
boxShadow: <BoxShadow>[ | |
BoxShadow( | |
color: ui.Color.fromARGB(30, 0, 0, 0), | |
offset: Offset(0, 2), | |
blurRadius: 6.0, | |
), | |
BoxShadow( | |
color: ui.Color.fromARGB(12, 0, 0, 0), | |
offset: Offset(0, 6), | |
spreadRadius: 8, | |
blurRadius: 12.0, | |
), | |
] | |
); | |
static const Decoration defaultDarkOverlayDecoration = BoxDecoration( | |
borderRadius: BorderRadius.all(Radius.circular(6.0)), | |
color: ui.Color.fromARGB(255, 32, 33, 36), | |
border: Border.fromBorderSide( | |
BorderSide( | |
color: ui.Color.fromARGB(200, 0, 0, 0), | |
width: 0.5 | |
), | |
), | |
boxShadow: <BoxShadow>[ | |
BoxShadow( | |
color: ui.Color.fromARGB(45, 0, 0, 0), | |
offset: Offset(0, 1), | |
blurRadius: 4.0, | |
), | |
BoxShadow( | |
color: ui.Color.fromARGB(65, 0, 0, 0), | |
offset: Offset(0, 4), | |
blurRadius: 12.0, | |
), | |
] | |
); | |
Widget defaultOverlayBuilder( | |
BuildContext context, | |
List<Widget> menuChildren, | |
RawMenuAnchorOverlayPosition position, | |
) { | |
return _MenuOverlay( | |
position: position, | |
constrainCrossAxis: constrainCrossAxis, | |
alignmentOffset: alignmentOffset, | |
clipBehavior: clipBehavior, | |
menuChildren: menuChildren, | |
alignment: alignment, | |
menuAlignment: menuAlignment, | |
consumeOutsideTaps: consumeOutsideTaps, | |
constraints: constraints, | |
padding: padding, | |
semanticLabel: _semanticLabel, | |
decoration: panelDecoration | |
?? switch (MediaQuery.maybePlatformBrightnessOf(context)) { | |
ui.Brightness.dark => defaultDarkOverlayDecoration, | |
ui.Brightness.light || null => defaultLightOverlayDecoration, | |
}, | |
); | |
} | |
@override | |
Widget build(BuildContext context) { | |
if (_panelBuilder != null) { | |
return _RawMenuAnchorPanel( | |
key: key, | |
controller: controller, | |
consumeOutsideTaps: consumeOutsideTaps, | |
menuChildren: menuChildren, | |
panelBuilder: _panelBuilder!, | |
); | |
} | |
return _RawMenuAnchorOverlay( | |
key: key, | |
controller: controller, | |
childFocusNode: childFocusNode, | |
consumeOutsideTaps: consumeOutsideTaps, | |
onOpen: onOpen, | |
onClose: onClose, | |
menuChildren: menuChildren, | |
overlayBuilder: _overlayBuilder ?? defaultOverlayBuilder, | |
// If there's a custom overlay, then that overlay will manage its own | |
// focus scope. | |
hasExternalFocusScope: _overlayBuilder != null, | |
builder: builder, | |
child: child, | |
); | |
} | |
@visibleForTesting | |
static Type get debugMenuOverlayPanelType => _MenuOverlayPanel; | |
@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?>('focusNode', childFocusNode)); | |
properties.add(EnumProperty<Clip>('clipBehavior', clipBehavior)); | |
properties.add(DiagnosticsProperty<Offset?>('alignmentOffset', alignmentOffset)); | |
properties.add(DiagnosticsProperty<bool>('consumeOutsideTap', consumeOutsideTaps)); | |
if (alignment != null) { | |
properties.add(DiagnosticsProperty<AlignmentGeometry>('alignment', alignment)); | |
} | |
if (menuAlignment != null) { | |
properties.add(DiagnosticsProperty<AlignmentGeometry>('menuAlignment', menuAlignment)); | |
} | |
if (panelDecoration != null) { | |
properties.add(DiagnosticsProperty<Decoration>('panelDecoration', panelDecoration)); | |
} | |
} | |
} | |
// Base class that provides the common interface and state for the different | |
// types of RawMenuAnchors, [_RawMenuAnchorOverlay] and [_RawMenuAnchorPanel]. | |
// | |
// Unlike MenuAnchor, this class does not manage the overlay controller nor | |
// the focus scope node -- it is only responsible for | |
abstract class _RawMenuAnchor extends StatefulWidget { | |
const _RawMenuAnchor({super.key}); | |
MenuController? get controller; | |
bool get consumeOutsideTaps; | |
FocusNode? get childFocusNode => null; | |
@override | |
State<_RawMenuAnchor> createState(); | |
} | |
@optionalTypeArgs | |
abstract class _RawMenuAnchorState<T extends _RawMenuAnchor> extends State<T> { | |
final List<_RawMenuAnchorState> _anchorChildren = <_RawMenuAnchorState>[]; | |
_RawMenuAnchorState? _parent; | |
ScrollPosition? _scrollPosition; | |
Size? _viewSize; | |
MenuController get _menuController => widget.controller ?? _internalMenuController!; | |
MenuController? _internalMenuController; | |
bool get _isRoot => _parent == null; | |
bool get _isRootOverlay => _parent?._hasOverlay != true; | |
bool get _hasOverlay; | |
bool get _isOpen; | |
FocusScopeNode? get _menuScopeNode; | |
FocusNode? get _firstFocus => null; | |
FocusNode? get _lastFocus => null; | |
_RawMenuAnchorState get _root { | |
_RawMenuAnchorState anchor = this; | |
while (anchor._parent != null) { | |
anchor = anchor._parent!; | |
} | |
return anchor; | |
} | |
@override | |
void initState() { | |
super.initState(); | |
if (widget.controller == null) { | |
_internalMenuController = MenuController(); | |
} | |
_menuController._attach(this); | |
} | |
@override | |
void didChangeDependencies() { | |
super.didChangeDependencies(); | |
final _RawMenuAnchorState? newParent = _RawMenuAnchorState._maybeOf(context); | |
if (newParent != _parent) { | |
_parent?._removeChild(this); | |
_parent = newParent; | |
_parent?._addChild(this); | |
} | |
_scrollPosition?.isScrollingNotifier.removeListener(_handleScroll); | |
_scrollPosition = Scrollable.maybeOf(context)?.position; | |
_scrollPosition?.isScrollingNotifier.addListener(_handleScroll); | |
if (!_kDebugAnchorAlignment) { | |
final Size newSize = MediaQuery.sizeOf(context); | |
if (_viewSize != null && newSize != _viewSize) { | |
// Close the menus if the view changes size. | |
_root._close(); | |
} | |
_viewSize = newSize; | |
} | |
} | |
@override | |
void didUpdateWidget(T oldWidget) { | |
super.didUpdateWidget(oldWidget); | |
if (oldWidget.controller != widget.controller) { | |
oldWidget.controller?._detach(this); | |
if (widget.controller != null) { | |
_internalMenuController?._detach(this); | |
_internalMenuController = null; | |
widget.controller?._attach(this); | |
} else { | |
assert(_internalMenuController == null); | |
_internalMenuController = MenuController().._attach(this); | |
} | |
} | |
assert(_menuController._anchor == this); | |
} | |
@override | |
void dispose() { | |
if (_isOpen) { | |
_close(inDispose: true); | |
} | |
_parent?._removeChild(this); | |
_parent = null; | |
_anchorChildren.clear(); | |
_menuController._detach(this); | |
_internalMenuController = null; | |
super.dispose(); | |
} | |
void _addChild(_RawMenuAnchorState child) { | |
_anchorChildren.add(child); | |
} | |
void _removeChild(_RawMenuAnchorState child) { | |
_anchorChildren.remove(child); | |
} | |
void _handleScroll() { | |
if (_kDebugAnchorAlignment) { | |
return; | |
} | |
// 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) { | |
_close(); | |
} | |
} | |
void _childChangedOpenState() { | |
_parent?._childChangedOpenState(); | |
assert(mounted); | |
if (SchedulerBinding.instance.schedulerPhase != | |
SchedulerPhase.persistentCallbacks) { | |
setState(() { | |
// Mark dirty now, but only if not in a build. | |
}); | |
} else { | |
SchedulerBinding.instance.addPostFrameCallback((Duration _) { | |
setState(() { | |
// Mark dirty after this frame, but only if in a build. | |
}); | |
}); | |
} | |
} | |
FocusTraversalPolicy? get _overlayTraversalPolicy { | |
if (_menuScopeNode?.context?.mounted != true) { | |
return null; | |
} | |
return FocusTraversalGroup.maybeOf(_menuScopeNode!.context!) | |
?? ReadingOrderTraversalPolicy(); | |
} | |
/// 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 will specify the location of the menu in | |
/// the local coordinates of the [RawMenuAnchor], ignoring any | |
/// [MenuStyle.alignment] and/or [RawMenuAnchor.alignmentOffset] that were | |
/// specified. | |
void _open({Offset? position}); | |
void _close({bool inDispose = false}); | |
void _closeChildren({bool inDispose = false}) { | |
for ( | |
final _RawMenuAnchorState child | |
in List<_RawMenuAnchorState>.from(_anchorChildren) | |
) { | |
child._close(inDispose: inDispose); | |
} | |
} | |
Widget _buildAnchor(BuildContext context); | |
void _handleOutsideTap(PointerDownEvent pointerDownEvent) { | |
if (_kDebugAnchorAlignment) { | |
return; | |
} | |
_closeChildren(); | |
} | |
@override | |
@nonVirtual | |
Widget build(BuildContext context) { | |
return _RawMenuAnchorScope( | |
anchor: this, | |
isOpen: _isOpen, | |
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), | |
), | |
); | |
} | |
// Returns the active anchor in the given context, if any, and creates a | |
// dependency relationship that will rebuild the context when the node | |
// changes. | |
static _RawMenuAnchorState? _maybeOf(BuildContext context) { | |
return context | |
.dependOnInheritedWidgetOfExactType<_RawMenuAnchorScope>() | |
?.anchor; | |
} | |
@override | |
String toString({DiagnosticLevel minLevel = DiagnosticLevel.debug}) { | |
return describeIdentity(this); | |
} | |
} | |
class _RawMenuAnchorOverlay extends _RawMenuAnchor { | |
const _RawMenuAnchorOverlay({ | |
super.key, | |
this.controller, | |
this.childFocusNode, | |
this.consumeOutsideTaps = false, | |
this.onOpen, | |
this.onClose, | |
this.hasExternalFocusScope = false, | |
required this.menuChildren, | |
required this.overlayBuilder, | |
this.builder, | |
this.child, | |
}); | |
final VoidCallback? onOpen; | |
final VoidCallback? onClose; | |
final List<Widget> menuChildren; | |
final RawMenuAnchorChildBuilder? builder; | |
final Widget? child; | |
final RawMenuAnchorOverlayBuilder overlayBuilder; | |
// Whether focus should be handled by this class or externally. | |
final bool hasExternalFocusScope; | |
@override | |
final FocusNode? childFocusNode; | |
@override | |
final bool consumeOutsideTaps; | |
@override | |
final MenuController? controller; | |
@override | |
State<_RawMenuAnchorOverlay> createState() => _RawMenuAnchorOverlayState(); | |
} | |
class _RawMenuAnchorOverlayState extends _RawMenuAnchorState<_RawMenuAnchorOverlay> { | |
static final Map<Type, Action<Intent>> _rootOverlayAnchorActions = | |
<Type, Action<Intent>>{ | |
DirectionalFocusIntent: _AnchorDirectionalFocusAction(), | |
}; | |
// 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<_RawMenuAnchorOverlayState>( | |
debugLabel: kReleaseMode ? null : 'MenuAnchor', | |
); | |
final OverlayPortalController _overlayController = OverlayPortalController( | |
debugLabel: kReleaseMode ? null : 'MenuAnchor controller', | |
); | |
Offset? _menuPosition; | |
FocusNode? _menuFocusNode; | |
@override | |
FocusScopeNode? _menuScopeNode; | |
@override | |
bool get _hasOverlay => true; | |
@override | |
bool get _isOpen => _overlayController.isShowing; | |
@override | |
FocusNode? get _firstFocus { | |
assert(_menuScopeNode != null, '_firstFocus requires a menu scope node.'); | |
return _overlayTraversalPolicy?.findFirstFocus( | |
_menuScopeNode!, | |
ignoreCurrentFocus: true | |
); | |
} | |
@override | |
FocusNode? get _lastFocus { | |
assert(_menuScopeNode != null, '_lastFocus requires a menu scope node.'); | |
return _overlayTraversalPolicy?.findLastFocus( | |
_menuScopeNode!, | |
ignoreCurrentFocus: true | |
); | |
} | |
@override | |
void initState() { | |
super.initState(); | |
// If the overlay is custom, then focus is handled externally. | |
if (!widget.hasExternalFocusScope) { | |
_menuScopeNode = FocusScopeNode( | |
debugLabel: kReleaseMode ? null : '${describeIdentity(this)} Sub Menu' | |
); | |
_menuFocusNode = FocusNode( | |
debugLabel: kReleaseMode ? null : '${describeIdentity(this)} Focus Node' | |
); | |
} | |
} | |
@override | |
void dispose() { | |
_menuScopeNode?.dispose(); | |
_menuFocusNode?.dispose(); | |
super.dispose(); | |
} | |
@override | |
Widget _buildAnchor(BuildContext context) { | |
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 (widget.hasExternalFocusScope) { | |
return OverlayPortal.targetsRootOverlay( | |
controller: _overlayController, | |
overlayChildBuilder: _buildOverlay, | |
child: child, | |
); | |
} | |
if (_isRootOverlay) { | |
child = Actions( | |
actions: _isOpen ? _rootOverlayAnchorActions : <Type, Action<Intent>>{}, | |
child: child, | |
); | |
} | |
// Focus is only used to monitor focus changes, so it's not necessary to | |
// include semantics or allow focus to be requested. | |
return Focus( | |
focusNode: _menuFocusNode, | |
includeSemantics: false, | |
canRequestFocus: false, | |
onFocusChange: _handleFocusChange, | |
child: OverlayPortal.targetsRootOverlay( | |
controller: _overlayController, | |
overlayChildBuilder: _buildOverlay, | |
child: child, | |
), | |
); | |
} | |
Widget _buildOverlay(BuildContext context) { | |
final BuildContext anchorContext = _anchorKey.currentContext!; | |
final RenderBox overlay = Overlay.of(anchorContext).context.findRenderObject()! as RenderBox; | |
final RenderBox anchor = anchorContext.findRenderObject()! as RenderBox; | |
final Rect anchorRect = anchor.localToGlobal(Offset.zero, ancestor: overlay) | |
& anchor.size; | |
return widget.overlayBuilder( | |
context, | |
widget.menuChildren, | |
RawMenuAnchorOverlayPosition( | |
anchorRect: anchorRect, | |
overlaySize: overlay.size, | |
position: _menuPosition, | |
tapRegionGroupId: _root._menuController, | |
), | |
); | |
} | |
void _focusButton() { | |
widget.childFocusNode?.requestFocus(); | |
} | |
/// 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 will specify the location of the menu in | |
/// the local coordinates of the [RawMenuAnchor], ignoring any | |
/// [MenuStyle.alignment] and/or [RawMenuAnchor.alignmentOffset] that were | |
/// specified. | |
@override | |
void _open({Offset? position}) { | |
assert(_menuController._anchor == this); | |
if (_isOpen) { | |
if (position == _menuPosition) { | |
// 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(); | |
} | |
// Close all siblings. | |
_parent?._closeChildren(); | |
assert(!_overlayController.isShowing); | |
_parent?._childChangedOpenState(); | |
_menuPosition = position; | |
_overlayController.show(); | |
if (_isRootOverlay) { | |
_focusButton(); | |
} | |
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}) { | |
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. | |
}); | |
} | |
} | |
} | |
// Closes the menu if the focus changes to something outside of the menu. | |
// | |
// Only used by the default menu overlay. | |
void _handleFocusChange(bool value) { | |
if (_kDebugAnchorAlignment) { | |
return; | |
} | |
if (!_menuFocusNode!.hasFocus && !_menuScopeNode!.hasFocus) { | |
_close(); | |
} | |
} | |
@override | |
String toString({DiagnosticLevel minLevel = DiagnosticLevel.debug}) { | |
return describeIdentity(this); | |
} | |
} | |
class _RawMenuAnchorPanel extends _RawMenuAnchor { | |
const _RawMenuAnchorPanel({ | |
super.key, | |
this.consumeOutsideTaps = false, | |
this.controller, | |
required this.menuChildren, | |
required this.panelBuilder, | |
}); | |
final List<Widget> menuChildren; | |
final RawMenuAnchorPanelBuilder panelBuilder; | |
@override | |
final bool consumeOutsideTaps; | |
@override | |
final MenuController? controller; | |
@override | |
State<_RawMenuAnchorPanel> createState() => _RawMenuAnchorPanelState(); | |
} | |
class _RawMenuAnchorPanelState extends _RawMenuAnchorState<_RawMenuAnchorPanel> { | |
@override | |
bool get _hasOverlay => false; | |
@override | |
late final FocusScopeNode? _menuScopeNode = null; | |
@override | |
bool get _isOpen => _anchorChildren.any((_RawMenuAnchorState child) => child._isOpen); | |
@override | |
void _close({bool inDispose = false}) { | |
_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 bars are always open, so this is a no-op. | |
return; | |
} | |
@override | |
Widget _buildAnchor(BuildContext context) { | |
return TapRegion( | |
groupId: _root._menuController, | |
consumeOutsideTaps: _root._isOpen && widget.consumeOutsideTaps, | |
onTapOutside: _handleOutsideTap, | |
child: widget.panelBuilder(context, widget.menuChildren), | |
); | |
} | |
} | |
/// A controller used to manage a menu created by a [MenuBar], [MenuAnchor], or | |
/// a [RawMenuAnchor]. | |
/// | |
/// 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]. | |
/// | |
/// [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]. By doing so, the widget will | |
/// establish a dependency relationship that will rebuild the widget when the | |
/// parent menu opens and closes. | |
/// | |
/// {@tool snippet} | |
/// | |
/// This example demonstrates how to use a [MenuController.maybeOf] to open and | |
/// close a menu from a descendent [BuildContext] of a [RawMenuAnchor]. | |
/// | |
/// ```dart | |
/// RawMenuAnchor( | |
/// menuChildren: <Widget>[ | |
/// Builder(builder: (BuildContext context) { | |
/// final MenuController controller = MenuController.maybeOf(context)!; | |
/// return TextButton( | |
/// onPressed: () { | |
/// controller.close(); | |
/// }, | |
/// child: const Text('Close'), | |
/// ); | |
/// }) | |
/// ], | |
/// child: Builder(builder: (BuildContext context) { | |
/// final MenuController controller = MenuController.maybeOf(context)!; | |
/// return TextButton( | |
/// onPressed: () { | |
/// if (controller.isOpen) { | |
/// controller.close(); | |
/// } else { | |
/// controller.open(); | |
/// } | |
/// }, | |
/// child: const Text('Menu'), | |
/// ); | |
/// }), | |
/// ) | |
/// ``` | |
/// {@end-tool} | |
/// | |
/// See also: | |
/// | |
/// * [RawMenuAnchor], a widget that defines a region that has submenu. | |
/// * [MenuAnchor], a RawMenuAnchor that follows the Material Design guidelines. | |
/// * [MenuBar], a widget that creates a menu bar, that can take an optional | |
/// [MenuController]. | |
/// * [SubmenuButton], a widget that has a button that manages a submenu. | |
class MenuController { | |
/// The anchor that this controller controls. | |
/// | |
/// This is set automatically when a [MenuController] is given to the anchor | |
/// it controls. | |
_RawMenuAnchorState? _anchor; | |
/// Whether or not the menu associated with this [MenuController] is open. | |
bool get isOpen { | |
return _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 [RawMenuAnchor] this controller is attached to. | |
/// | |
/// If given, the `position` will override the [RawMenuAnchor.alignmentOffset] | |
/// given to the [RawMenuAnchor]. | |
/// | |
/// If the menu's anchor point (either a [MenuBar] or a [RawMenuAnchor]) is | |
/// scrolled by an ancestor, or the view changes size, then any open menu will | |
/// automatically close. | |
void open({Offset? position}) { | |
assert(_anchor != null); | |
_anchor!._open(position: position); | |
} | |
/// Close the menu that this [MenuController] is associated with. | |
/// | |
/// Associating with a menu is done by passing a [MenuController] to a | |
/// [RawMenuAnchor]. A [MenuController] is also be received by the | |
/// [RawMenuAnchor.builder] when invoked. | |
/// | |
/// If the menu's anchor point (either a [MenuBar] or a [RawMenuAnchor]) is | |
/// scrolled by an ancestor, or the view changes size, then any open menu will | |
/// automatically close. | |
void close() { | |
_anchor?._close(); | |
} | |
/// Close the children of the menu associated with this [MenuController], | |
/// without closing the menu itself. | |
void closeChildren() { | |
assert(_anchor != null); | |
_anchor!._closeChildren(); | |
} | |
// ignore: use_setters_to_change_properties | |
void _attach(_RawMenuAnchorState anchor) { | |
_anchor = anchor; | |
} | |
void _detach(_RawMenuAnchorState anchor) { | |
if (_anchor == anchor) { | |
_anchor = null; | |
} | |
} | |
/// Returns the [MenuController] of the ancestor [RawMenuAnchor] nearest to | |
/// the given `context`, if one exists. | |
/// | |
/// Otherwise, returns null. | |
static MenuController? maybeOf(BuildContext context) { | |
return context | |
.dependOnInheritedWidgetOfExactType<_RawMenuAnchorScope>() | |
?.anchor | |
._menuController; | |
} | |
@override | |
String toString() => describeIdentity(this); | |
} | |
// A widget that defines the menu drawn in the overlay. | |
class _MenuOverlay extends StatelessWidget { | |
const _MenuOverlay({ | |
required this.alignmentOffset, | |
required this.clipBehavior, | |
required this.menuChildren, | |
required this.alignment, | |
required this.menuAlignment, | |
required this.decoration, | |
required this.position, | |
required this.constraints, | |
required this.padding, | |
required this.constrainCrossAxis, | |
this.semanticLabel, | |
this.consumeOutsideTaps = true, | |
}); | |
final Offset alignmentOffset; | |
final RawMenuAnchorOverlayPosition position; | |
final Clip clipBehavior; | |
final List<Widget> menuChildren; | |
final bool consumeOutsideTaps; | |
final AlignmentGeometry? alignment; | |
final AlignmentGeometry? menuAlignment; | |
final BoxConstraints? constraints; | |
final Decoration decoration; | |
final EdgeInsetsGeometry? padding; | |
final bool constrainCrossAxis; | |
final String? semanticLabel; | |
static final Map<Type, Action<Intent>> _defaultOverlayActions = | |
<Type, Action<Intent>>{ | |
DirectionalFocusIntent: _OverlayDirectionalFocusAction(), | |
_FocusFirstMenuItemIntent: _FocusFirstMenuItemAction(), | |
_FocusLastMenuItemIntent: _FocusLastMenuItemAction(), | |
}; | |
@override | |
Widget build(BuildContext context) { | |
final MenuController menuController = MenuController.maybeOf(context)!; | |
final Widget child = Semantics.fromProperties( | |
explicitChildNodes: true, | |
properties: SemanticsProperties( | |
namesRoute: true, | |
scopesRoute: true, | |
label: semanticLabel, | |
), | |
child: TapRegion( | |
groupId: position.tapRegionGroupId, | |
consumeOutsideTaps: consumeOutsideTaps, | |
onTapOutside: (PointerDownEvent event) { | |
if (_kDebugAnchorAlignment) { | |
return; | |
} | |
menuController.close(); | |
}, | |
child: FocusScope( | |
node: menuController._anchor!._menuScopeNode, | |
skipTraversal: true, | |
descendantsAreFocusable: true, | |
child: Actions( | |
actions: _defaultOverlayActions, | |
child: Shortcuts( | |
shortcuts: _kMenuTraversalShortcuts, | |
child: _MenuOverlayPanel( | |
constrainCrossAxis: constrainCrossAxis, | |
decoration: decoration, | |
clipBehavior: clipBehavior, | |
constraints: constraints, | |
menuChildren: menuChildren, | |
padding: padding, | |
), | |
), | |
), | |
), | |
), | |
); | |
return ConstrainedBox( | |
constraints: BoxConstraints.loose(position.overlaySize), | |
child: Builder(builder: (BuildContext context) { | |
final MediaQueryData mediaQuery = MediaQuery.of(context); | |
final TextDirection textDirection = Directionality.of(context); | |
// Resolve fallback alignment here so that alignmentOffset defaults to | |
// being directionally-agnostic. | |
final AlignmentGeometry anchorAttachment = alignment ?? | |
(menuController._anchor!._isRootOverlay | |
? AlignmentDirectional.bottomStart | |
: AlignmentDirectional.topEnd).resolve(textDirection); | |
return CustomSingleChildLayout( | |
delegate: _MenuLayout( | |
screenPadding: mediaQuery.padding, | |
padding: padding, | |
avoidBounds: DisplayFeatureSubScreen.avoidBounds(mediaQuery).toSet(), | |
textDirection: textDirection, | |
anchorRect: position.anchorRect, | |
alignmentOffset: alignmentOffset, | |
menuPosition: position.position, | |
menuAlignment: menuAlignment ?? AlignmentDirectional.topStart, | |
alignment: anchorAttachment, | |
), | |
child: child, | |
); | |
}), | |
); | |
} | |
} | |
// A basic panel that displays a list of menu items. | |
class _MenuOverlayPanel extends StatelessWidget { | |
const _MenuOverlayPanel({ | |
required this.decoration, | |
required this.clipBehavior, | |
required this.constraints, | |
required this.menuChildren, | |
required this.constrainCrossAxis, | |
this.padding, | |
}); | |
final Decoration decoration; | |
final Clip clipBehavior; | |
final BoxConstraints? constraints; | |
final List<Widget> menuChildren; | |
final bool constrainCrossAxis; | |
final EdgeInsetsGeometry? padding; | |
@override | |
Widget build(BuildContext context) { | |
Widget child = IntrinsicWidth( | |
child: Container( | |
padding: padding, | |
decoration: decoration, | |
clipBehavior: clipBehavior, | |
child: SingleChildScrollView( | |
child: ListBody(children: menuChildren), | |
), | |
), | |
); | |
if (constraints != null) { | |
child = ConstrainedBox( | |
constraints: constraints!, | |
child: child, | |
); | |
} | |
if (constrainCrossAxis) { | |
return child; | |
} | |
return UnconstrainedBox( | |
clipBehavior: Clip.hardEdge, | |
alignment: AlignmentDirectional.centerStart, | |
constrainedAxis: Axis.vertical, | |
child: child, | |
); | |
} | |
} | |
/// An action that closes all the menus associated with the given | |
/// [MenuController]. | |
/// | |
/// See also: | |
/// | |
/// * [RawMenuAnchor], a widget that hosts a cascading submenu. | |
/// * [MenuController], a controller used to manage a menu created by a | |
/// [RawMenuAnchor]. | |
/// * [MenuBar], a widget that defines a menu bar with cascading submenus. | |
class DismissMenuAction extends DismissAction { | |
/// Creates a [DismissMenuAction]. | |
DismissMenuAction({required this.controller}); | |
final MenuController controller; | |
@override | |
void invoke(DismissIntent intent) { | |
controller._anchor!._root._close(); | |
} | |
@override | |
bool isEnabled(DismissIntent intent) { | |
return controller._anchor != null; | |
} | |
} | |
class _AnchorDirectionalFocusAction extends ContextAction<DirectionalFocusIntent> { | |
_AnchorDirectionalFocusAction(); | |
@override | |
void invoke(DirectionalFocusIntent intent, [BuildContext? context]) { | |
final _RawMenuAnchorOverlayState? state = | |
_RawMenuAnchorState._maybeOf(context!) as _RawMenuAnchorOverlayState?; | |
if (state == null) { | |
primaryFocus?.focusInDirection(intent.direction); | |
return; | |
} | |
final FocusNode? firstFocus = state._firstFocus; | |
final FocusNode? lastFocus = state._lastFocus; | |
switch (intent.direction) { | |
case TraversalDirection.left: | |
case TraversalDirection.right: | |
break; | |
case TraversalDirection.up: | |
if (lastFocus != null) { | |
return state._overlayTraversalPolicy?.requestFocusCallback(lastFocus); | |
} | |
case TraversalDirection.down: | |
if (firstFocus != null) { | |
return state._overlayTraversalPolicy?.requestFocusCallback(firstFocus); | |
} | |
} | |
primaryFocus?.focusInDirection(intent.direction); | |
} | |
} | |
class _OverlayDirectionalFocusAction extends ContextAction<DirectionalFocusIntent> { | |
_OverlayDirectionalFocusAction(); | |
@override | |
void invoke(DirectionalFocusIntent intent, [BuildContext? context]) { | |
assert(context != null); | |
final _RawMenuAnchorOverlayState? anchor = | |
_RawMenuAnchorState._maybeOf(context!) as _RawMenuAnchorOverlayState?; | |
if (anchor == null) { | |
return; | |
} | |
final bool isAnchorFocused = !(anchor._menuScopeNode?.hasFocus ?? false); | |
_RawMenuAnchorState overlay = anchor; | |
bool isSubmenuAnchor = false; | |
// If we are an anchor in an overlay, switch to our parent anchor to move | |
// between our siblings rather than the children in our overlay. | |
if (isAnchorFocused && !anchor._isRootOverlay) { | |
overlay = anchor._parent!; | |
isSubmenuAnchor = true; | |
} | |
final FocusNode? firstFocus = overlay._firstFocus; | |
final FocusNode? lastFocus = overlay._lastFocus; | |
final TextDirection textDirection = Directionality.of(context); | |
switch ((intent.direction, textDirection)) { | |
case (TraversalDirection.up, _): | |
if (lastFocus?.context == null) { | |
break; | |
} | |
if ( | |
primaryFocus == lastFocus!.enclosingScope || | |
primaryFocus == firstFocus | |
) { | |
overlay._overlayTraversalPolicy?.requestFocusCallback(lastFocus); | |
return; | |
} | |
case (TraversalDirection.down, _): | |
if (firstFocus?.context == null) { | |
break; | |
} | |
if ( | |
primaryFocus == firstFocus!.enclosingScope || | |
primaryFocus == lastFocus | |
) { | |
overlay._overlayTraversalPolicy?.requestFocusCallback(firstFocus); | |
return; | |
} | |
case (TraversalDirection.left, TextDirection.ltr): | |
case (TraversalDirection.right, TextDirection.rtl): | |
if (isSubmenuAnchor) { | |
if (anchor._isOpen) { | |
anchor._close(); | |
} else if (anchor._parent?._parent != null) { | |
anchor._parent?._close(); | |
} | |
return; | |
} else if (!anchor._isRootOverlay) { | |
anchor._close(); | |
return; | |
} | |
case (TraversalDirection.left, TextDirection.rtl): | |
case (TraversalDirection.right, TextDirection.ltr): | |
if (isSubmenuAnchor) { | |
if (anchor._isOpen) { | |
// Use requestFocusCallback to trigger scroll-to-focus behavior. | |
anchor._overlayTraversalPolicy?.requestFocusCallback(anchor._firstFocus!); | |
} else { | |
anchor._open(); | |
SchedulerBinding.instance.addPostFrameCallback((Duration timestamp) { | |
if (anchor._isOpen) { | |
anchor._overlayTraversalPolicy?.requestFocusCallback(anchor._firstFocus!); | |
} | |
}); | |
} | |
return; | |
} | |
} | |
primaryFocus?.focusInDirection(intent.direction); | |
} | |
} | |
class _FocusFirstMenuItemIntent extends Intent { | |
const _FocusFirstMenuItemIntent(); | |
} | |
class _FocusFirstMenuItemAction extends ContextAction<_FocusFirstMenuItemIntent> { | |
_FocusFirstMenuItemAction(); | |
@override | |
void invoke(_FocusFirstMenuItemIntent intent, [BuildContext? context]) { | |
_RawMenuAnchorState? anchor = _RawMenuAnchorState._maybeOf(context!); | |
if (anchor == null) { | |
return; | |
} | |
final bool isAnchorFocused = !(anchor._menuScopeNode?.hasFocus ?? false); | |
// If we are an anchor in an overlay, switch to our parent anchor to move | |
// between our siblings rather than the children in our overlay. | |
if (isAnchorFocused && !anchor._isRootOverlay) { | |
anchor = anchor._parent; | |
} | |
final FocusNode? firstFocus = anchor!._firstFocus; | |
if (firstFocus == null) { | |
return; | |
} | |
anchor._overlayTraversalPolicy?.requestFocusCallback(firstFocus); | |
} | |
} | |
class _FocusLastMenuItemIntent extends Intent { | |
const _FocusLastMenuItemIntent(); | |
} | |
class _FocusLastMenuItemAction extends ContextAction<_FocusLastMenuItemIntent> { | |
_FocusLastMenuItemAction(); | |
@override | |
void invoke(_FocusLastMenuItemIntent intent, [BuildContext? context]) { | |
_RawMenuAnchorState? anchor = _RawMenuAnchorState._maybeOf(context!); | |
if (anchor == null) { | |
return; | |
} | |
final bool isAnchorFocused = !(anchor._menuScopeNode?.hasFocus ?? false); | |
final bool inOverlay = anchor._parent?._hasOverlay ?? false; | |
// If we are an anchor in an overlay, switch to our parent anchor to move | |
// between our siblings rather than the children in our overlay. | |
if (isAnchorFocused && inOverlay) { | |
anchor = anchor._parent; | |
} | |
final FocusNode? lastFocus = anchor!._lastFocus; | |
if (lastFocus == null) { | |
return; | |
} | |
final FocusTraversalPolicy traversalPolicy = | |
FocusTraversalGroup.maybeOfNode(lastFocus) | |
?? ReadingOrderTraversalPolicy(); | |
traversalPolicy.requestFocusCallback(lastFocus); | |
} | |
} | |
// A layout delegate that positions the menu relative to its anchor. | |
class _MenuLayout extends SingleChildLayoutDelegate { | |
const _MenuLayout( { | |
required this.alignmentOffset, | |
required this.anchorRect, | |
required this.screenPadding, | |
required this.avoidBounds, | |
required this.alignment, | |
required this.menuAlignment, | |
required this.textDirection, | |
required EdgeInsetsGeometry? padding, | |
this.menuPosition, | |
}) : menuPadding = padding; | |
// Rectangle of the button anchoring the menu overlay. | |
final ui.Rect anchorRect; | |
// The offset from the alignment position to find the ideal location for the | |
// menu. | |
final ui.Offset alignmentOffset; | |
// The offset of the menu relative to the top-left corner of the anchor. | |
final ui.Offset? menuPosition; | |
// The padding obtained from calling [MediaQuery.paddingOf]. | |
// | |
// Used to prevent the menu from being obstructed by system UI. | |
final EdgeInsets screenPadding; | |
// Padding applied to the menu surface. | |
final EdgeInsetsGeometry? menuPadding; | |
// List of rectangles that the menu should not overlap. Unusable screen area. | |
final Set<Rect> avoidBounds; | |
// The alignment of the menu attachment point relative to the anchor button. | |
final AlignmentGeometry alignment; | |
// The alignment of the menu attachment point relative to the menu surface. | |
final AlignmentGeometry menuAlignment; | |
// The direction in which the text flows within the menu. | |
final ui.TextDirection textDirection; | |
// Finds the closest screen to the anchor position. | |
// | |
// The closest screen is defined as the screen whose center is closest to the | |
// anchor position. | |
Rect _findClosestScreen(Size parentSize, Offset point, Set<Rect> avoidBounds) { | |
final Iterable<ui.Rect> screens = | |
DisplayFeatureSubScreen.subScreensInBounds( | |
Offset.zero & parentSize, | |
avoidBounds | |
); | |
Rect closest = screens.first; | |
for (final ui.Rect screen in screens) { | |
if ((screen.center - point).distance < | |
(closest.center - point).distance) { | |
closest = screen; | |
} | |
} | |
return closest; | |
} | |
Offset _fitInsideScreen( | |
Rect screen, | |
Size childSize, | |
Offset position, | |
Offset anchorPosition, | |
) { | |
final EdgeInsets? padding = menuPadding?.resolve(textDirection); | |
final Rect anchor = menuPosition == null | |
? anchorRect | |
: anchorPosition & Size.zero; | |
double x = position.dx; | |
double y = position.dy; | |
bool overLeftEdge(double x) => x < screen.left; | |
bool overRightEdge(double x) => x > screen.right - childSize.width; | |
bool overTopEdge(double y) => y < screen.top; | |
bool overBottomEdge(double y) => y > screen.bottom - childSize.height; | |
// Layout horizontally first to determine if the menu can be placed on | |
// either side of the anchor without overlapping. | |
bool hasHorizontalAnchorOverlap = childSize.width >= screen.width; | |
if (hasHorizontalAnchorOverlap) { | |
x = screen.left; | |
} else { | |
// Shift the menu left or right to adjust for padding. | |
double? shiftX; | |
if (padding != null && padding.horizontal > 0) { | |
double ratio = (x - anchorPosition.dx) / childSize.width; | |
ratio = ui.clampDouble(ratio, -1, 0); | |
shiftX = padding.right * ratio | |
+ padding.left * (ratio + 1); | |
x -= shiftX; | |
} | |
if (overLeftEdge(x)) { | |
// Flip the X position so that the menu is to the right of the anchor. | |
double flipX = anchor.center.dx * 2 - position.dx - childSize.width; | |
if (shiftX != null) { | |
flipX -= padding!.horizontal + shiftX; | |
} | |
hasHorizontalAnchorOverlap = overRightEdge(flipX); | |
if (hasHorizontalAnchorOverlap || overLeftEdge(flipX)) { | |
x = screen.left; | |
} else { | |
x = flipX; | |
} | |
} else if (overRightEdge(x)) { | |
// Flip the X position so that the menu is to the left of the anchor. | |
double flipX = anchor.center.dx * 2 - position.dx - childSize.width; | |
if (shiftX != null) { | |
flipX += padding!.horizontal - shiftX; | |
} | |
hasHorizontalAnchorOverlap = overLeftEdge(flipX); | |
if (hasHorizontalAnchorOverlap || overRightEdge(flipX)) { | |
x = screen.right - childSize.width; | |
} else { | |
x = flipX; | |
} | |
} | |
} | |
if (childSize.height >= screen.height) { | |
// Menu is too big to fit on screen. Fit as much as possible. | |
return Offset(x, screen.top); | |
} | |
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 is very small. | |
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; | |
} | |
} | |
} | |
// Remove vertical padding from the y component. | |
double? shiftY; | |
if (padding != null && padding.vertical > 0) { | |
double ratio = (y - anchorPosition.dy) / childSize.height; | |
ratio = ui.clampDouble(ratio, -1, 0); | |
shiftY = padding.bottom * ratio | |
+ padding.top * (ratio + 1); | |
y -= shiftY; | |
} | |
if (overTopEdge(y)) { | |
// Flip the Y position so that the menu is below the anchor. | |
double flipY = anchor.center.dy * 2 - position.dy - childSize.height; | |
if (shiftY != null) { | |
flipY -= padding!.vertical + shiftY; | |
} | |
if (overTopEdge(flipY) || overBottomEdge(flipY)) { | |
y = screen.top; | |
} else { | |
y = flipY; | |
} | |
} else if (overBottomEdge(y)) { | |
// Flip the Y position so that the menu is above the anchor. | |
double flipY = anchor.center.dy * 2 - position.dy - childSize.height; | |
if (shiftY != null) { | |
flipY += padding!.vertical - shiftY; | |
} | |
if (overTopEdge(flipY) || overBottomEdge(flipY)) { | |
y = screen.bottom - childSize.height; | |
} else { | |
y = flipY; | |
} | |
} | |
return Offset(x, y); | |
} | |
@override | |
BoxConstraints getConstraintsForChild(BoxConstraints constraints) { | |
// The menu can be at most the size of the overlay minus totalPadding. | |
return BoxConstraints.loose(constraints.biggest).deflate(screenPadding); | |
} | |
@override | |
Offset getPositionForChild(Size size, Size childSize) { | |
// Point on the anchor where the menu is attached. | |
Offset anchorOffset; | |
if (menuPosition == null) { | |
anchorOffset = alignment.resolve(textDirection).withinRect(anchorRect); | |
anchorOffset += switch (textDirection) { | |
ui.TextDirection.ltr => alignmentOffset, | |
ui.TextDirection.rtl => alignment is AlignmentDirectional | |
? Offset(-alignmentOffset.dx, alignmentOffset.dy) | |
: alignmentOffset, | |
}; | |
} else { | |
anchorOffset = anchorRect.topLeft + menuPosition!; | |
} | |
final ui.Offset position = | |
anchorOffset - menuAlignment.resolve(textDirection).alongSize(childSize); | |
final Rect screen = _findClosestScreen( | |
size, | |
anchorRect.center, | |
avoidBounds, | |
); | |
return _fitInsideScreen( | |
screenPadding.deflateRect(screen), | |
childSize, | |
position, | |
anchorOffset, | |
); | |
} | |
@override | |
bool shouldRelayout(_MenuLayout oldDelegate) { | |
return anchorRect != oldDelegate.anchorRect || | |
alignment != oldDelegate.alignment || | |
alignmentOffset != oldDelegate.alignmentOffset || | |
menuAlignment != oldDelegate.menuAlignment || | |
menuPosition != oldDelegate.menuPosition || | |
menuPadding != oldDelegate.menuPadding || | |
screenPadding != oldDelegate.screenPadding || | |
textDirection != oldDelegate.textDirection || | |
!setEquals(avoidBounds, oldDelegate.avoidBounds); | |
} | |
} | |
/** UTILITIES **/ | |
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 e = NestedTag('e'); | |
static const List<NestedTag> values = <NestedTag>[a, b, c, d, e]; | |
String get text; | |
String get focusNode; | |
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'; | |
} | |
@override | |
String get focusNode { | |
return 'Focus[$text]'; | |
} | |
Key get key => ValueKey<String>('${text}_Key'); | |
} | |
// A simple, focusable button that calls onPressed when tapped. | |
class Button extends StatefulWidget { | |
const Button( | |
this.child, { | |
super.key, | |
this.onPressed, | |
this.focusNode, | |
this.autofocus = false, | |
this.onFocusChange, | |
String? focusNodeLabel, | |
BoxConstraints? constraints, | |
}) : _focusNodeLabel = focusNodeLabel, | |
constraints = constraints ?? | |
const BoxConstraints.tightFor(width: 225, height: 32); | |
factory Button.text( | |
String text, { | |
Key? key, | |
VoidCallback? onPressed, | |
FocusNode? focusNode, | |
bool autofocus = false, | |
BoxConstraints? constraints, | |
void Function(bool)? onFocusChange, | |
}) { | |
return Button( | |
Text(text), | |
key: key, | |
onPressed: onPressed, | |
focusNode: focusNode, | |
autofocus: autofocus, | |
constraints: constraints, | |
onFocusChange: onFocusChange, | |
); | |
} | |
factory Button.tag( | |
Tag tag, { | |
Key? key, | |
VoidCallback? onPressed, | |
FocusNode? focusNode, | |
bool autofocus = false, | |
BoxConstraints? constraints, | |
void Function(bool)? onFocusChange, | |
}) { | |
return Button( | |
Text(tag.text), | |
key: key, | |
onPressed: onPressed, | |
focusNode: focusNode, | |
autofocus: autofocus, | |
constraints: constraints, | |
onFocusChange: onFocusChange, | |
focusNodeLabel: tag.focusNode, | |
); | |
} | |
final Widget child; | |
final VoidCallback? onPressed; | |
final void Function(bool)? onFocusChange; | |
final FocusNode? focusNode; | |
final bool autofocus; | |
final BoxConstraints? constraints; | |
final String? _focusNodeLabel; | |
@override | |
State<Button> createState() => _ButtonState(); | |
} | |
class _ButtonState extends State<Button> { | |
FocusNode get _focusNode => widget.focusNode ?? _internalFocusNode!; | |
FocusNode? _internalFocusNode; | |
final WidgetStatesController _states = WidgetStatesController(); | |
ui.Brightness _brightness = ui.Brightness.light; | |
@override | |
void initState() { | |
super.initState(); | |
if (widget.focusNode == null) { | |
_internalFocusNode = FocusNode(debugLabel: widget._focusNodeLabel); | |
} | |
_states.addListener(() { | |
setState(() { /* Rebuild on state changes. */ }); | |
}); | |
} | |
@override | |
void didUpdateWidget(Button oldWidget) { | |
super.didUpdateWidget(oldWidget); | |
if (oldWidget.focusNode != widget.focusNode) { | |
if (widget.focusNode == null) { | |
_internalFocusNode = FocusNode(debugLabel: widget._focusNodeLabel); | |
} else { | |
_internalFocusNode?.dispose(); | |
_internalFocusNode = null; | |
} | |
} | |
} | |
@override | |
void didChangeDependencies() { | |
super.didChangeDependencies(); | |
_brightness = MediaQuery.maybePlatformBrightnessOf(context) ?? _brightness; | |
} | |
@override | |
void dispose() { | |
_internalFocusNode?.dispose(); | |
super.dispose(); | |
} | |
void _activateOnIntent(Intent intent) { | |
_handlePressed(); | |
} | |
void _handlePressed() { | |
widget.onPressed?.call(); | |
_states.update(WidgetState.pressed, true); | |
} | |
void _handleTapDown(TapDownDetails details) { | |
_states.update(WidgetState.pressed, true); | |
} | |
void _handleFocusChange(bool value) { | |
_states.update(WidgetState.focused, value); | |
widget.onFocusChange?.call(value); | |
} | |
void _handleExit(PointerExitEvent event) { | |
_states.update(WidgetState.hovered, false); | |
} | |
void _handleHover(PointerHoverEvent event) { | |
_states.update(WidgetState.hovered, true); | |
} | |
void _handleTap() { | |
_states.update(WidgetState.pressed, false); | |
} | |
void _handleTapUp(TapUpDetails details) { | |
_states.update(WidgetState.pressed, false); | |
_handlePressed.call(); | |
} | |
void _handleTapCancel() { | |
_states.update(WidgetState.pressed, false); | |
} | |
@override | |
Widget build(BuildContext context) { | |
return DefaultTextStyle.merge( | |
style: _textStyle, | |
child: MergeSemantics( | |
child: Semantics( | |
button: true, | |
child: Actions( | |
actions: _actions, | |
child: Focus( | |
debugLabel: widget._focusNodeLabel, | |
onFocusChange: _handleFocusChange, | |
autofocus: widget.autofocus, | |
focusNode: _focusNode, | |
child: MouseRegion( | |
onHover: _handleHover, | |
onExit: _handleExit, | |
child: GestureDetector( | |
onTapDown: _handleTapDown, | |
onTapCancel: _handleTapCancel, | |
onTapUp: _handleTapUp, | |
onTap: _handleTap, | |
child: Container( | |
constraints: widget.constraints, | |
decoration: _decoration, | |
padding: const EdgeInsets.symmetric(horizontal: 12), | |
child: Align( | |
alignment: AlignmentDirectional.centerStart, | |
child: widget.child, | |
), | |
), | |
), | |
), | |
), | |
), | |
), | |
), | |
); | |
} | |
late final Map<Type, Action<Intent>> _actions = { | |
ActivateIntent: CallbackAction<ActivateIntent>(onInvoke: _activateOnIntent), | |
ButtonActivateIntent: CallbackAction<ButtonActivateIntent>(onInvoke: _activateOnIntent), | |
}; | |
BoxDecoration? get _decoration { | |
if (_states.value.contains(WidgetState.pressed)) { | |
return const BoxDecoration(color: Color(0xFF007BFF)); | |
} | |
if (_states.value.contains(WidgetState.focused)) { | |
return switch (_brightness) { | |
Brightness.dark => const BoxDecoration(color: Color(0x95007BFF)), | |
Brightness.light => const BoxDecoration(color: Color(0x95007BFF)) | |
}; | |
} | |
if (_states.value.contains(WidgetState.hovered)) { | |
return const BoxDecoration(color: Color(0x22BBBBBB)); | |
} | |
return null; | |
} | |
TextStyle get _textStyle { | |
if (_states.value.contains(WidgetState.pressed)) { | |
return const TextStyle(color: Color.fromARGB(255, 255, 255, 255)); | |
} | |
return switch (_brightness) { | |
Brightness.dark => const TextStyle(color: Color(0xFFFFFFFF)), | |
Brightness.light => const TextStyle(color: Color(0xFF000000)) | |
}; | |
} | |
} | |
class AnchorButton extends StatelessWidget { | |
const AnchorButton( | |
this.tag, { | |
super.key, | |
this.onPressed, | |
this.constraints, | |
this.autofocus = false, | |
this.focusNode, | |
}); | |
factory AnchorButton.small(Tag tag, {bool autofocus = false}) { | |
return AnchorButton( | |
tag, | |
constraints: BoxConstraints.tight(const Size(100, 30)), | |
autofocus: autofocus, | |
); | |
} | |
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 Button.tag( | |
tag, | |
onPressed: () { | |
onPressed?.call(tag); | |
if (controller != null) { | |
if (controller.isOpen) { | |
controller.close(); | |
} else { | |
controller.open(); | |
} | |
} | |
}, | |
focusNode: focusNode, | |
constraints: constraints, | |
autofocus: autofocus, | |
); | |
} | |
} | |
class App extends StatefulWidget { | |
const App( | |
this.child, { | |
super.key, | |
this.textDirection, | |
this.alignment = Alignment.center, | |
}); | |
final Widget child; | |
final TextDirection? textDirection; | |
final AlignmentGeometry alignment; | |
@override | |
State<App> createState() => _AppState(); | |
} | |
class _AppState extends State<App> { | |
TextDirection? _directionality; | |
@override | |
void didChangeDependencies() { | |
super.didChangeDependencies(); | |
_directionality = Directionality.maybeOf(context); | |
} | |
@override | |
Widget build(BuildContext context) { | |
return ColoredBox( | |
color: const Color(0xff000000), | |
child: WidgetsApp( | |
color: const Color(0xff000000), | |
onGenerateRoute: (RouteSettings settings) { | |
return PageRouteBuilder<void>( | |
settings: settings, | |
pageBuilder: _buildPage, | |
); | |
}, | |
), | |
); | |
} | |
Widget _buildPage( | |
BuildContext context, | |
Animation<double> animation, | |
Animation<double> secondaryAnimation, | |
) { | |
return Directionality( | |
textDirection: widget.textDirection ?? _directionality ?? TextDirection.ltr, | |
child: Align( | |
alignment: widget.alignment, | |
child: widget.child, | |
), | |
); | |
} | |
} | |
// SETUP // | |
class AlignmentExampleApp extends StatefulWidget { | |
const AlignmentExampleApp({super.key}); | |
@override | |
State<AlignmentExampleApp> createState() => _AlignmentExampleAppState(); | |
} | |
class _AlignmentExampleAppState extends State<AlignmentExampleApp> { | |
@override | |
Widget build(BuildContext context) { | |
return App(AlignmentExample()); | |
} | |
} | |
class AlignmentExample extends StatefulWidget { | |
const AlignmentExample({super.key}); | |
@override | |
State<AlignmentExample> createState() => _AlignmentExampleState(); | |
} | |
class _AlignmentExampleState extends State<AlignmentExample> { | |
final FocusNode anchorFocusNode = FocusNode(); | |
final FocusNode anchorFocusNode2 = FocusNode(); | |
final FocusNode anchorFocusNode3 = FocusNode(); | |
ScrollController scrollController = ScrollController(); | |
MenuController controller = MenuController(); | |
Brightness brightness = Brightness.dark; | |
(double, double) _menuPosition = (0, 0); | |
(double, double) _menuAttachment = (-1, 1); | |
(double, double) _anchorAttachment = (1, -1); | |
(double, double) _anchorPosition = (0, 0); | |
(double, double) _alignmentOffset = (0, 0); | |
bool _ltr = true; | |
@override | |
Widget build(BuildContext context) { | |
final AlignmentDirectional anchorAlignment = AlignmentDirectional( | |
_anchorAttachment.$1, | |
_anchorAttachment.$2, | |
); | |
final AlignmentDirectional menuAlignment = AlignmentDirectional( | |
_menuAttachment.$1, | |
_menuAttachment.$2, | |
); | |
final Offset offset = Offset( | |
_alignmentOffset.$1 * 200, | |
_alignmentOffset.$2 * 200, | |
); | |
return Directionality( | |
textDirection: _ltr ? TextDirection.ltr : TextDirection.rtl, | |
child: Container( | |
padding: const EdgeInsets.all(20.0), | |
color: const Color(0xff000000), | |
child: MediaQuery( | |
data: MediaQuery.of(context).copyWith( | |
platformBrightness: brightness, | |
), | |
child: Column( | |
children: <Widget>[ | |
Button.text( | |
"Switch to ${_ltr ? 'RTL' : 'LTR'}", | |
onPressed: () { | |
setState(() { | |
_ltr = !_ltr; | |
}); | |
}, | |
), | |
DefaultTextStyle( | |
style: const TextStyle(color: Color(0xffffffff)), | |
child: FocusTraversalGroup( | |
policy: WidgetOrderTraversalPolicy(), | |
child: Wrap( | |
spacing: 20.0, | |
runSpacing: 20.0, | |
children: <Widget>[ | |
GridSlider( | |
x: _anchorPosition.$1, | |
y: _anchorPosition.$2, | |
title: const Text('Anchor Position'), | |
onChange: (double x, double y) { | |
setState(() { | |
_anchorPosition = (x, y); | |
_open(); | |
}); | |
}, | |
), | |
GridSlider( | |
x: _menuPosition.$1, | |
y: _menuPosition.$2, | |
title: const Text('Controller Position'), | |
onChange: (double x, double y) { | |
setState(() { | |
_menuPosition = (x, y); | |
_open( | |
Offset(x * 200, y * 200), | |
); | |
}); | |
}, | |
), | |
GridSlider( | |
x: _anchorAttachment.$1, | |
y: _anchorAttachment.$2, | |
title: const Text('Alignment'), | |
onChange: (double x, double y) { | |
setState(() { | |
_anchorAttachment = (x, y); | |
_open(); | |
}); | |
}, | |
), | |
GridSlider( | |
x: _alignmentOffset.$1, | |
y: _alignmentOffset.$2, | |
title: const Text('Alignment Offset'), | |
onChange: (double x, double y) { | |
setState(() { | |
_alignmentOffset = (x, y); | |
_open(); | |
}); | |
}, | |
), | |
GridSlider( | |
x: _menuAttachment.$1, | |
y: _menuAttachment.$2, | |
title: const Text('Menu Alignment'), | |
onChange: (double x, double y) { | |
setState(() { | |
_menuAttachment = (x, y); | |
_open(); | |
}); | |
}, | |
), | |
], | |
), | |
), | |
), | |
Expanded( | |
child: Align( | |
alignment: AlignmentDirectional( | |
_anchorPosition.$1, | |
_anchorPosition.$2, | |
), | |
child: RawMenuAnchor( | |
controller: controller, | |
alignment: anchorAlignment, | |
menuAlignment: menuAlignment, | |
alignmentOffset: offset, | |
menuChildren: buildChildren( | |
anchorAlignment, | |
menuAlignment, | |
offset, | |
), | |
child: AnchorButton.small(Tag.anchor, autofocus: true), | |
), | |
), | |
), | |
], | |
), | |
), | |
), | |
); | |
} | |
void _open([Offset? position]) { | |
controller.open( | |
position: position, | |
); | |
} | |
} | |
extension AlongSize on Offset { | |
Alignment relativeTo(Size size) { | |
return Alignment((dx / size.width) * 2 - 1, (dy / size.height) * 2 - 1); | |
} | |
} | |
extension Clamp on Alignment { | |
Alignment clamp(double min, double max, [double? minY, double? maxY]) { | |
return Alignment( | |
ui.clampDouble(x, min, max), | |
ui.clampDouble(y, minY ?? min, maxY ?? max), | |
); | |
} | |
} | |
class GridSlider extends StatefulWidget { | |
const GridSlider({ | |
super.key, | |
this.onChange, | |
required this.title, | |
this.x = 0, | |
this.y = 0, | |
this.size = const Size(150, 150), | |
}); | |
final double x; | |
final double y; | |
final void Function(double x, double y)? onChange; | |
final Size size; | |
final Widget title; | |
@override | |
State<GridSlider> createState() => _GridSliderState(); | |
} | |
class _GridSliderState extends State<GridSlider> { | |
Alignment _position = Alignment.center; | |
final FocusNode _focusNode = FocusNode(); | |
Timer? _debounce; | |
static const Color dotColor = ui.Color.fromARGB(255, 28, 100, 255); | |
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { | |
Alignment? amount = switch (event.logicalKey) { | |
LogicalKeyboardKey.arrowUp => const Alignment(0.00, -0.05), | |
LogicalKeyboardKey.arrowDown => const Alignment(0.00, 0.05), | |
LogicalKeyboardKey.arrowLeft => const Alignment(-0.05, 0.00), | |
LogicalKeyboardKey.arrowRight => const Alignment(0.05, 0.00), | |
_ => null, | |
}; | |
if (amount == null) { | |
return KeyEventResult.ignored; | |
} | |
if (_debounce != null) { | |
return KeyEventResult.handled; | |
} | |
_debounce = Timer(const Duration(milliseconds: 80), () { | |
_debounce = null; | |
}); | |
if (HardwareKeyboard.instance.isShiftPressed) { | |
amount *= 4; | |
} else if (HardwareKeyboard.instance.isMetaPressed) { | |
amount *= 0.25; | |
} | |
setState(() { | |
_position = (_position + amount!).clamp(-1, 1); | |
widget.onChange?.call(_position.x, _position.y); | |
}); | |
return KeyEventResult.handled; | |
} | |
@override | |
void initState() { | |
super.initState(); | |
_position = Alignment(widget.x, widget.y); | |
} | |
@override | |
void didUpdateWidget(covariant GridSlider oldWidget) { | |
super.didUpdateWidget(oldWidget); | |
if (widget.x != oldWidget.x || widget.y != oldWidget.y) { | |
_position = Alignment(widget.x, widget.y); | |
} | |
} | |
@override | |
void dispose() { | |
_focusNode.dispose(); | |
super.dispose(); | |
} | |
void _moveTo(Alignment position) { | |
if (_position != position) { | |
setState(() { | |
_position = position; | |
widget.onChange?.call(position.x, position.y); | |
}); | |
} | |
if (!_focusNode.hasFocus) { | |
_focusNode.requestFocus(); | |
} | |
} | |
@override | |
Widget build(BuildContext context) { | |
final Brightness brightness = | |
MediaQuery.maybePlatformBrightnessOf(context) ?? ui.Brightness.dark; | |
return GestureDetector( | |
onPanUpdate: (DragUpdateDetails details) { | |
_moveTo(details.localPosition.relativeTo(widget.size).clamp(-1, 1)); | |
}, | |
onTapDown: (TapDownDetails details) { | |
_moveTo(details.localPosition.relativeTo(widget.size).clamp(-1, 1)); | |
}, | |
onTapUp: (TapUpDetails details) { | |
_moveTo(details.localPosition.relativeTo(widget.size).clamp(-1, 1)); | |
}, | |
child: SizedBox.fromSize( | |
size: widget.size, | |
child: Stack( | |
alignment: Alignment.center, | |
children: <Widget>[ | |
CustomPaint( | |
painter: GridPainter(_position, brightness), | |
size: Size( | |
widget.size.width - 16, | |
widget.size.height - 16, | |
)), | |
Align( | |
alignment: _position, | |
child: Focus( | |
focusNode: _focusNode, | |
onKeyEvent: _handleKeyEvent, | |
child: ListenableBuilder( | |
listenable: _focusNode, | |
builder: _buildFocusOutline, | |
child: Container( | |
width: 8, | |
height: 8, | |
decoration: const BoxDecoration( | |
color: dotColor, | |
shape: BoxShape.circle, | |
), | |
), | |
), | |
), | |
), | |
Positioned( | |
top: 2, | |
left: 2, | |
child: DefaultTextStyle( | |
style: TextStyle( | |
fontSize: 12, | |
fontWeight: FontWeight.w500, | |
color: brightness == Brightness.dark | |
? Color(0xFFFFFFFF) | |
: Color(0xFF000000)), | |
child: ColoredBox( | |
color: brightness == Brightness.dark | |
? const ui.Color.fromARGB(100, 0, 0, 0) | |
: const ui.Color.fromARGB(100, 255, 255, 255), | |
child: widget.title, | |
), | |
), | |
), | |
], | |
), | |
), | |
); | |
} | |
Widget _buildFocusOutline(BuildContext context, Widget? child) { | |
BoxDecoration outline = BoxDecoration( | |
border: Border.fromBorderSide( | |
BorderSide( | |
color: _focusNode.hasFocus ? dotColor : Color(0x000000000), | |
width: _focusNode.hasFocus ? 1.5 : 0, | |
strokeAlign: BorderSide.strokeAlignOutside, | |
), | |
), | |
shape: BoxShape.circle, | |
); | |
return AnimatedContainer( | |
width: 16, | |
height: 16, | |
decoration: outline, | |
alignment: Alignment.center, | |
duration: const Duration(milliseconds: 150), | |
child: child, | |
); | |
} | |
} | |
class GridPainter extends CustomPainter { | |
const GridPainter( | |
this.dotAlignment, | |
this.brightness, | |
); | |
final Alignment dotAlignment; | |
final Brightness brightness; | |
@override | |
void paint(Canvas canvas, Size size) { | |
final double tenthWidth = size.width / 10; | |
final double tenthHeight = size.height / 10; | |
final Paint paint = Paint() | |
..color = brightness == Brightness.dark | |
? const ui.Color.fromARGB(131, 255, 255, 255) | |
: const Color.fromARGB(53, 0, 0, 0) | |
..strokeWidth = 0.0 | |
..isAntiAlias = false; | |
double x = 0, y = 0; | |
for (int i = 0; i < 11; i++) { | |
if (i % 5 == 0) { | |
paint.strokeWidth = 1.0; | |
} else { | |
paint.strokeWidth = 0.0; | |
} | |
canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint); | |
canvas.drawLine(Offset(0, y), Offset(size.width, y), paint); | |
x += tenthWidth; | |
y += tenthHeight; | |
} | |
} | |
@override | |
bool shouldRepaint(GridPainter oldDelegate) { | |
return oldDelegate.dotAlignment != dotAlignment || | |
oldDelegate.brightness != brightness; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment