Last active
March 28, 2026 13:50
-
-
Save davidhicks980/ddf691e35a65930e6752d46731494f82 to your computer and use it in GitHub Desktop.
Stack Portal demo
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
| import 'package:flutter/material.dart' hide MenuController, RawMenuAnchor; | |
| import 'dart:ui' as ui; | |
| import 'package:flutter/foundation.dart'; | |
| import 'package:flutter/scheduler.dart'; | |
| import 'package:flutter/services.dart'; | |
| import 'package:flutter/rendering.dart'; | |
| void main() { | |
| runApp(const MaterialApp(home: CascadingMenuExample())); | |
| } | |
| class CascadingMenuExample extends StatelessWidget { | |
| const CascadingMenuExample({super.key}); | |
| @override | |
| Widget build(BuildContext context) { | |
| return Scaffold( | |
| body: Center( | |
| child: Column( | |
| mainAxisAlignment: MainAxisAlignment.center, | |
| children: [ | |
| const Text('Cascading StackPortal Menus'), | |
| const SizedBox(height: 20), | |
| Row( | |
| mainAxisAlignment: MainAxisAlignment.center, | |
| children: [ | |
| Material( | |
| child: Anchor( | |
| label: 'File', | |
| menuChildren: [ | |
| MenuItemButton( | |
| child: const Text('New'), | |
| onPressed: () => print('New tapped'), | |
| ), | |
| MenuItemButton( | |
| child: const Text('Open'), | |
| onPressed: () => print('Open tapped'), | |
| ), | |
| // Nested Menu | |
| Submenu( | |
| label: 'Recent Projects', | |
| children: [ | |
| MenuItemButton( | |
| child: const Text('Project A'), | |
| onPressed: () => print('Project A tapped'), | |
| ), | |
| MenuItemButton( | |
| child: const Text('Project B'), | |
| onPressed: () => print('Project B tapped'), | |
| ), | |
| Submenu( | |
| label: 'Archived', | |
| children: [ | |
| MenuItemButton( | |
| child: const Text('2024'), | |
| onPressed: () => print('2024 tapped'), | |
| ), | |
| MenuItemButton( | |
| child: const Text('2023'), | |
| onPressed: () => print('2023 tapped'), | |
| ), | |
| ], | |
| ), | |
| ], | |
| ), | |
| MenuItemButton( | |
| child: const Text('Save'), | |
| onPressed: () => print('Save tapped'), | |
| ), | |
| ], | |
| ), | |
| ), | |
| ], | |
| ), | |
| ], | |
| ), | |
| ), | |
| ); | |
| } | |
| } | |
| /// The root anchor that opens the main overlay. | |
| /// | |
| /// It uses [RawMenuAnchor] to obtain an [Overlay] entry, but fills that overlay | |
| /// with a [StackOutlet] to manage all subsequent nested menus in a single layer. | |
| class Anchor extends StatefulWidget { | |
| const Anchor({super.key, required this.label, required this.menuChildren}); | |
| final String label; | |
| final List<Widget> menuChildren; | |
| @override | |
| State<Anchor> createState() => _AnchorState(); | |
| } | |
| class _AnchorState extends State<Anchor> { | |
| final MenuController _controller = MenuController(); | |
| @override | |
| Widget build(BuildContext context) { | |
| return RawMenuAnchor( | |
| controller: _controller, | |
| overlayBuilder: (context, info) { | |
| return TapRegion( | |
| groupId: info.tapRegionGroupId, | |
| onTapOutside: (event) { | |
| _controller.close(); | |
| }, | |
| child: Align( | |
| alignment: Alignment.topLeft, | |
| child: StackOutlet( | |
| positioningDelegate: _CascadingMenuLayoutDelegate(rootAnchorRect: info.anchorRect), | |
| child: MenuPanel(children: widget.menuChildren), | |
| ), | |
| ), | |
| ); | |
| }, | |
| builder: (context, controller, child) { | |
| return TextButton( | |
| onPressed: () { | |
| if (controller.isOpen) { | |
| controller.close(); | |
| } else { | |
| controller.open(); | |
| } | |
| }, | |
| child: Text(widget.label), | |
| ); | |
| }, | |
| ); | |
| } | |
| } | |
| class Submenu extends StatefulWidget { | |
| const Submenu({super.key, required this.label, required this.children}); | |
| final String label; | |
| final List<Widget> children; | |
| @override | |
| State<Submenu> createState() => _SubmenuState(); | |
| } | |
| class _SubmenuState extends State<Submenu> implements RawMenuNodeDelegate { | |
| final MenuController _controller = MenuController(); | |
| @override | |
| bool get isOpen => _isOpen; | |
| bool _isOpen = false; | |
| @override | |
| void handleOpenRequest(Offset? position, VoidCallback showOverlay) { | |
| showOverlay(); | |
| } | |
| @override | |
| void handleCloseRequest(VoidCallback hideOverlay) { | |
| hideOverlay(); | |
| } | |
| @override | |
| void handleOpen({Offset? position}) { | |
| setState(() { | |
| _isOpen = true; | |
| }); | |
| } | |
| @override | |
| void handleClose() { | |
| setState(() { | |
| _isOpen = false; | |
| }); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| return RawMenuNode( | |
| controller: _controller, | |
| delegate: this, | |
| child: StackPortal( | |
| isShowing: isOpen, | |
| portalBuilder: (context) { | |
| return TapRegion( | |
| groupId: MenuController.maybeTapRegionGroupIdOf(context), | |
| onTapOutside: (event) { | |
| _controller.close(); | |
| }, | |
| child: MenuPanel(children: widget.children), | |
| ); | |
| }, | |
| child: MenuItemButton( | |
| // Open on hover for submenus | |
| onPressed: () { | |
| if (_isOpen) { | |
| _controller.close(); | |
| } else { | |
| _controller.open(); | |
| } | |
| }, | |
| child: SizedBox( | |
| height: 40, | |
| child: Row( | |
| mainAxisAlignment: MainAxisAlignment.spaceBetween, | |
| children: [Text(widget.label), const Icon(Icons.arrow_right, size: 16)], | |
| ), | |
| ), | |
| ), | |
| ), | |
| ); | |
| } | |
| } | |
| class MenuPanel extends StatelessWidget { | |
| const MenuPanel({super.key, required this.children}); | |
| final List<Widget> children; | |
| @override | |
| Widget build(BuildContext context) { | |
| return Container( | |
| width: 200, | |
| decoration: BoxDecoration( | |
| color: Colors.white, | |
| borderRadius: BorderRadius.circular(4), | |
| boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 10, offset: Offset(0, 4))], | |
| ), | |
| child: Column( | |
| mainAxisSize: MainAxisSize.min, | |
| crossAxisAlignment: CrossAxisAlignment.stretch, | |
| children: children, | |
| ), | |
| ); | |
| } | |
| } | |
| /// Positions the root menu relative to the button, and nested menus relative | |
| /// to their triggering items. | |
| class _CascadingMenuLayoutDelegate extends OutletPositioningDelegate { | |
| _CascadingMenuLayoutDelegate({required this.rootAnchorRect}); | |
| final Rect rootAnchorRect; | |
| @override | |
| void positionChildren(Size size, List<OutletPositioner> children) { | |
| if (children.isEmpty) { | |
| return; | |
| } | |
| final OutletPositioner rootMenu = children.first; | |
| final Offset rootMenuPosition = _fitInsideScreen( | |
| Offset.zero & size, | |
| rootMenu.size, | |
| rootAnchorRect.bottomLeft, | |
| rootAnchorRect.bottomLeft, | |
| rootAnchorRect, | |
| ); | |
| rootMenu.setPosition(rootMenuPosition); | |
| Rect panel = rootMenuPosition & rootMenu.size; | |
| for (var i = 1; i < children.length; i++) { | |
| final OutletPositioner submenu = children[i]; | |
| // The transform from the submenu's anchor (the menu item) to the top of | |
| // its panel. This is a stable reference point that can be used to position the | |
| // submenu relative to its anchor, even as the menu item moves due to | |
| // layout changes in the root menu. | |
| final Matrix4 transform = submenu.ancestorAnchorTransform; | |
| final Rect anchor = MatrixUtils.transformRect(transform, panel); | |
| panel = anchor.translate(panel.width, 0); | |
| final Offset position = _fitInsideScreen( | |
| Offset.zero & size, | |
| submenu.size, | |
| panel.topLeft, | |
| anchor.topLeft, | |
| anchor, | |
| ); | |
| panel = position & submenu.size; | |
| submenu.setPosition(position); | |
| } | |
| } | |
| /// Adjusts [position] so the child of [childSize] remains within [screen]. | |
| /// | |
| /// If the child overflows horizontally, it attempts to "flip" to the | |
| /// opposite side of the [anchorRect]. If it still overflows, it is clamped. | |
| Offset _fitInsideScreen( | |
| Rect screen, | |
| Size childSize, | |
| Offset position, | |
| Offset anchorPosition, | |
| Rect anchorRect, | |
| ) { | |
| const Offset? menuPosition = null; | |
| 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 { | |
| if (overLeftEdge(x)) { | |
| // Flip the X position across the horizontal midpoint of the anchor so that the menu is to the right of the anchor. | |
| final double flipX = anchor.center.dx * 2 - position.dx - childSize.width; | |
| hasHorizontalAnchorOverlap = overRightEdge(flipX); | |
| if (hasHorizontalAnchorOverlap || overLeftEdge(flipX)) { | |
| x = screen.left; | |
| } else { | |
| x = flipX; | |
| } | |
| } else if (overRightEdge(x)) { | |
| // Flip the X position across the horizontal midpoint of the anchor so that the menu is to the left of the anchor. | |
| final double flipX = anchor.center.dx * 2 - position.dx - childSize.width; | |
| hasHorizontalAnchorOverlap = overLeftEdge(flipX); | |
| if (hasHorizontalAnchorOverlap || overRightEdge(flipX)) { | |
| x = screen.right - childSize.width; | |
| } else { | |
| x = flipX; | |
| } | |
| } | |
| } | |
| if (childSize.height >= screen.height) { | |
| // Menu is too big to fit on screen. Fit as much as possible. | |
| return Offset(x, screen.top); | |
| } | |
| 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; | |
| } | |
| } | |
| } | |
| if (overTopEdge(y)) { | |
| // Flip the Y position across the vertical midpoint of the anchor so that the menu is below the anchor. | |
| final double flipY = anchor.center.dy * 2 - position.dy - childSize.height; | |
| if (overTopEdge(flipY) || overBottomEdge(flipY)) { | |
| y = screen.top; | |
| } else { | |
| y = flipY; | |
| } | |
| } else if (overBottomEdge(y)) { | |
| // Flip the Y position across the vertical midpoint of the anchor so that | |
| // the menu is above the anchor. | |
| final double flipY = anchor.center.dy * 2 - position.dy - childSize.height; | |
| if (overTopEdge(flipY) || overBottomEdge(flipY)) { | |
| y = screen.bottom - childSize.height; | |
| } else { | |
| y = flipY; | |
| } | |
| } | |
| return Offset(x, y); | |
| } | |
| @override | |
| bool shouldRelayout(_CascadingMenuLayoutDelegate oldDelegate) { | |
| return oldDelegate.rootAnchorRect != rootAnchorRect; | |
| } | |
| } | |
| /** STACK OUTLET / STACK PORTAL **/ | |
| extension type OutletPositioner._(RenderBox renderBox) { | |
| StackOutletParentData get _parentData => renderBox.parentData! as StackOutletParentData; | |
| Size get size => renderBox.size; | |
| Size get anchorSize => _parentData.anchorSize ?? Size.zero; | |
| Matrix4 get ancestorAnchorTransform { | |
| return _parentData.ancestorAnchorTransform ?? Matrix4.identity(); | |
| } | |
| // ignore: use_setters_to_change_properties | |
| void setPosition(ui.Offset position) { | |
| _parentData.offset = position; | |
| } | |
| } | |
| abstract class OutletPositioningDelegate { | |
| const OutletPositioningDelegate(); | |
| /// Override to control the layout of the portal children of the outlet. | |
| void positionChildren(Size size, List<OutletPositioner> children); | |
| /// Override to return true when the layout needs to be updated. | |
| bool shouldRelayout(covariant OutletPositioningDelegate oldDelegate) => true; | |
| @override | |
| String toString() => objectRuntimeType(this, 'OutletPositioningDelegate'); | |
| } | |
| class StackOutletParentData extends ContainerBoxParentData<RenderBox> { | |
| Matrix4? ancestorAnchorTransform; | |
| Size? anchorSize; | |
| } | |
| /// A widget that acts as a container for a main child and allows [StackPortal]s | |
| /// deeper in the tree to project content on top of it. | |
| class StackOutlet extends StatelessWidget { | |
| const StackOutlet({super.key, required this.child, required this.positioningDelegate}); | |
| /// The main content of the application or section. | |
| final Widget child; | |
| final OutletPositioningDelegate positioningDelegate; | |
| @override | |
| Widget build(BuildContext context) { | |
| return _Outlet( | |
| positioningDelegate: positioningDelegate, | |
| child: Builder(builder: _buildScope), | |
| ); | |
| } | |
| Widget _buildScope(BuildContext context) { | |
| final _RenderOutlet stackOutlet = context.findAncestorRenderObjectOfType<_RenderOutlet>()!; | |
| return _StackOutletScope(renderOutlet: stackOutlet, child: child); | |
| } | |
| } | |
| class _StackOutletScope extends InheritedWidget { | |
| const _StackOutletScope({required this.renderOutlet, required super.child}); | |
| final _RenderOutlet renderOutlet; | |
| static _RenderOutlet? of(BuildContext context) { | |
| return context.dependOnInheritedWidgetOfExactType<_StackOutletScope>()?.renderOutlet; | |
| } | |
| @override | |
| bool updateShouldNotify(_StackOutletScope oldWidget) => renderOutlet != oldWidget.renderOutlet; | |
| } | |
| class _Outlet extends SingleChildRenderObjectWidget { | |
| const _Outlet({required super.child, required this.positioningDelegate}); | |
| final OutletPositioningDelegate positioningDelegate; | |
| @override | |
| _RenderOutlet createRenderObject(BuildContext context) { | |
| return _RenderOutlet(positioningDelegate: positioningDelegate); | |
| } | |
| @override | |
| void updateRenderObject(BuildContext context, _RenderOutlet renderObject) { | |
| renderObject.positioningDelegate = positioningDelegate; | |
| } | |
| } | |
| class _RenderOutlet extends RenderBox with RenderObjectWithChildMixin<RenderBox> { | |
| _RenderOutlet({required OutletPositioningDelegate positioningDelegate}) | |
| : _positioningDelegate = positioningDelegate; | |
| OutletPositioningDelegate get positioningDelegate => _positioningDelegate; | |
| OutletPositioningDelegate _positioningDelegate; | |
| set positioningDelegate(OutletPositioningDelegate newDelegate) { | |
| if (newDelegate == _positioningDelegate) { | |
| return; | |
| } | |
| final OutletPositioningDelegate oldDelegate = _positioningDelegate; | |
| _positioningDelegate = newDelegate; | |
| if (newDelegate.runtimeType != oldDelegate.runtimeType || | |
| newDelegate.shouldRelayout(oldDelegate)) { | |
| markNeedsLayout(); | |
| } | |
| } | |
| final List<_RenderPortalContents> _portalChildren = []; | |
| bool _isMarkNeedsLayoutSkipped = false; | |
| void addPortalChild(_RenderPortalContents portalItem) { | |
| assert(!_isMarkNeedsLayoutSkipped); | |
| _isMarkNeedsLayoutSkipped = true; | |
| _portalChildren.add(portalItem); | |
| adoptChild(portalItem); | |
| _isMarkNeedsLayoutSkipped = false; | |
| markNeedsLayout(); | |
| } | |
| void removePortalChild(_RenderPortalContents portalItem) { | |
| assert(!_isMarkNeedsLayoutSkipped); | |
| assert(portalItem.parent == this); | |
| assert(_portalChildren.contains(portalItem)); | |
| _isMarkNeedsLayoutSkipped = true; | |
| dropChild(portalItem); | |
| // Remove the item from the tracking list. | |
| _portalChildren.remove(portalItem); | |
| _isMarkNeedsLayoutSkipped = false; | |
| markNeedsLayout(); | |
| } | |
| @override | |
| void markNeedsLayout() { | |
| if (!_isMarkNeedsLayoutSkipped) { | |
| super.markNeedsLayout(); | |
| } | |
| } | |
| @override | |
| void setupParentData(RenderBox child) { | |
| if (child.parentData is! StackOutletParentData) { | |
| child.parentData = StackOutletParentData(); | |
| } | |
| } | |
| @override | |
| void attach(PipelineOwner owner) { | |
| super.attach(owner); | |
| for (final _RenderPortalContents child in _portalChildren) { | |
| child.attach(owner); | |
| } | |
| } | |
| @override | |
| void detach() { | |
| super.detach(); | |
| for (final _RenderPortalContents child in _portalChildren) { | |
| child.detach(); | |
| } | |
| } | |
| @override | |
| void redepthChildren() { | |
| super.redepthChildren(); | |
| _portalChildren.forEach(redepthChild); | |
| } | |
| @override | |
| void visitChildren(RenderObjectVisitor visitor) { | |
| super.visitChildren(visitor); | |
| _portalChildren.forEach(visitor); | |
| } | |
| void _updatePositioning() { | |
| final RenderBox? main = child; | |
| if (main == null) { | |
| assert(_portalChildren.isEmpty, 'Portal children should not exist when main child is null.'); | |
| return; | |
| } | |
| for (final _RenderPortalContents child in _portalChildren) { | |
| child.anchor!.updateAnchorParentData(child); | |
| } | |
| final List<OutletPositioner> children = [ | |
| OutletPositioner._(main), | |
| for (final child in _portalChildren) OutletPositioner._(child), | |
| ]; | |
| _positioningDelegate.positionChildren(size, children); | |
| } | |
| @override | |
| void performLayout() { | |
| size = constraints.biggest; | |
| final RenderBox? main = child; | |
| if (main == null) { | |
| assert(_portalChildren.isEmpty, 'Portal children should not exist when main child is null.'); | |
| return; | |
| } | |
| main.layout(constraints, parentUsesSize: true); | |
| // Layout Portals | |
| final portalConstraints = BoxConstraints.loose(size); | |
| for (final _RenderPortalContents child in _portalChildren) { | |
| child.layout(portalConstraints, parentUsesSize: true); | |
| } | |
| } | |
| @override | |
| bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { | |
| assert(_portalChildren.isEmpty || child != null); | |
| for (final _RenderPortalContents child in _portalChildren.reversed) { | |
| final StackOutletParentData childParentData = child.stackParentData; | |
| final bool isHit = result.addWithPaintOffset( | |
| offset: childParentData.offset, | |
| position: position, | |
| hitTest: (BoxHitTestResult result, Offset transformed) { | |
| return child.hitTest(result, position: transformed); | |
| }, | |
| ); | |
| if (isHit) { | |
| return true; | |
| } | |
| } | |
| final RenderBox? main = child; | |
| if (main == null) { | |
| return false; | |
| } | |
| final mainParentData = main.parentData! as StackOutletParentData; | |
| return result.addWithPaintOffset( | |
| offset: mainParentData.offset, | |
| position: position, | |
| hitTest: (BoxHitTestResult result, Offset transformed) { | |
| return main.hitTest(result, position: transformed); | |
| }, | |
| ); | |
| } | |
| @override | |
| void paint(PaintingContext context, Offset offset) { | |
| _updatePositioning(); | |
| final RenderBox? main = child; | |
| if (main != null) { | |
| final mainParentData = main.parentData! as StackOutletParentData; | |
| context.paintChild(main, mainParentData.offset + offset); | |
| } | |
| for (final _RenderPortalContents child in _portalChildren) { | |
| context.paintChild(child, child.stackParentData.offset + offset); | |
| } | |
| } | |
| @override | |
| void applyPaintTransform(RenderBox child, Matrix4 transform) { | |
| final childParentData = child.parentData! as BoxParentData; | |
| final Offset offset = childParentData.offset; | |
| transform.translateByDouble(offset.dx, offset.dy, 0, 1); | |
| } | |
| } | |
| /// A widget that keeps its [child] in the normal tree (the "anchor") but | |
| /// projects [portalBuilder] content into the nearest ancestor [StackOutlet]. | |
| class StackPortal extends StatelessWidget { | |
| const StackPortal({ | |
| super.key, | |
| required this.child, | |
| required this.portalBuilder, | |
| this.isShowing = false, | |
| }); | |
| final Widget child; | |
| final WidgetBuilder portalBuilder; | |
| final bool isShowing; | |
| @override | |
| Widget build(BuildContext context) { | |
| return _StackPortalContentWrapper( | |
| portal: isShowing | |
| ? _PortalContents( | |
| childIdentifier: this, | |
| child: Builder(builder: portalBuilder), | |
| ) | |
| : null, | |
| child: Semantics(traversalParentIdentifier: this, child: child), | |
| ); | |
| } | |
| } | |
| class _StackPortalContentWrapper extends RenderObjectWidget { | |
| const _StackPortalContentWrapper({required this.child, required this.portal}); | |
| final Widget child; | |
| final Widget? portal; | |
| @override | |
| RenderObjectElement createElement() => _StackPortalElement(this); | |
| @override | |
| RenderObject createRenderObject(BuildContext context) => _RenderAnchor(); | |
| } | |
| enum _Slot { anchor, portal } | |
| class _StackPortalElement extends RenderObjectElement { | |
| _StackPortalElement(_StackPortalContentWrapper super.widget); | |
| Element? _anchorElement; | |
| Element? _portalElement; // The content projected to the outlet | |
| @override | |
| _StackPortalContentWrapper get widget => super.widget as _StackPortalContentWrapper; | |
| @override | |
| _RenderAnchor get renderObject => super.renderObject as _RenderAnchor; | |
| _RenderOutlet? _currentOutlet; | |
| _RenderPortalContents? _portalRenderObject; | |
| void _attachToOutlet() { | |
| if (_portalRenderObject != null) { | |
| _currentOutlet = findAncestorRenderObjectOfType<_RenderOutlet>(); | |
| assert(_currentOutlet != null, 'StackPortal must be a descendant of a StackOutlet.'); | |
| _currentOutlet!.addPortalChild(_portalRenderObject!); | |
| } | |
| } | |
| void _detachFromOutlet() { | |
| if (_portalRenderObject != null && _currentOutlet != null) { | |
| _currentOutlet!.removePortalChild(_portalRenderObject!); | |
| _currentOutlet = null; | |
| } | |
| } | |
| @override | |
| void mount(Element? parent, Object? newSlot) { | |
| super.mount(parent, newSlot); | |
| _anchorElement = updateChild(_anchorElement, widget.child, _Slot.anchor); | |
| _portalElement = updateChild(_portalElement, widget.portal, _Slot.portal); | |
| (_portalElement?.renderObject as _RenderPortalContents?)?.anchor = renderObject; | |
| } | |
| @override | |
| void update(_StackPortalContentWrapper newWidget) { | |
| super.update(newWidget); | |
| _anchorElement = updateChild(_anchorElement, widget.child, _Slot.anchor); | |
| _portalElement = updateChild(_portalElement, widget.portal, _Slot.portal); | |
| (_portalElement?.renderObject as _RenderPortalContents?)?.anchor = renderObject; | |
| } | |
| @override | |
| void forgetChild(Element child) { | |
| assert(child == _anchorElement || child == _portalElement); | |
| if (child == _anchorElement) { | |
| _anchorElement = null; | |
| } else if (child == _portalElement) { | |
| _portalElement = null; | |
| } | |
| super.forgetChild(child); | |
| } | |
| @override | |
| void activate() { | |
| super.activate(); | |
| _attachToOutlet(); | |
| } | |
| @override | |
| void deactivate() { | |
| _detachFromOutlet(); | |
| super.deactivate(); | |
| } | |
| @override | |
| void insertRenderObjectChild(RenderObject child, Object? slot) { | |
| assert(child.parent == null, "$child's parent is not null: ${child.parent}"); | |
| switch (slot) { | |
| case _Slot.portal: | |
| assert(child is _RenderPortalContents); | |
| final portalChild = child as _RenderPortalContents; | |
| _portalRenderObject = portalChild; | |
| portalChild.anchor = renderObject; | |
| renderObject._portalContents = portalChild; | |
| _attachToOutlet(); | |
| renderObject.markNeedsSemanticsUpdate(); | |
| case _Slot.anchor: | |
| renderObject.child = child as RenderBox; | |
| } | |
| } | |
| @override | |
| void removeRenderObjectChild(RenderObject child, Object? slot) { | |
| switch (slot) { | |
| case _Slot.portal: | |
| _detachFromOutlet(); | |
| _portalRenderObject?.anchor = null; | |
| renderObject._portalContents = null; | |
| renderObject.markNeedsSemanticsUpdate(); | |
| case _Slot.anchor: | |
| renderObject.child = null; | |
| } | |
| } | |
| @override | |
| void moveRenderObjectChild(RenderObject child, Object? oldSlot, Object? newSlot) { | |
| assert(false, 'Reparenting of StackPortal content is not supported.'); | |
| } | |
| @override | |
| void visitChildren(ElementVisitor visitor) { | |
| if (_anchorElement != null) { | |
| visitor(_anchorElement!); | |
| } | |
| if (_portalElement != null) { | |
| visitor(_portalElement!); | |
| } | |
| } | |
| } | |
| class _PortalContents extends SingleChildRenderObjectWidget { | |
| const _PortalContents({required Widget super.child, this.childIdentifier}); | |
| final Object? childIdentifier; | |
| _RenderAnchor getLayoutParent(BuildContext context) { | |
| return context.findAncestorRenderObjectOfType<_RenderAnchor>()!; | |
| } | |
| @override | |
| _RenderPortalContents createRenderObject(BuildContext context) { | |
| final _RenderAnchor parent = getLayoutParent(context); | |
| final renderObject = _RenderPortalContents(parent, childIdentifier: childIdentifier); | |
| parent._portalContents = renderObject; | |
| return renderObject; | |
| } | |
| @override | |
| void updateRenderObject(BuildContext context, _RenderPortalContents renderObject) { | |
| assert(renderObject.anchor == getLayoutParent(context)); | |
| assert(getLayoutParent(context)._portalContents == renderObject); | |
| renderObject.childIdentifier = childIdentifier; | |
| } | |
| } | |
| /// A RenderProxyBox that wraps the portal content in the outlet's render tree. | |
| /// It holds a reference to the [RenderObject] of its anchor so that it can | |
| /// calculate a stable transform to the previous [_RenderPortalContents] or | |
| /// [_RenderOutlet] ancestor. | |
| /// | |
| /// Unlike the [RenderObject] backing [OverlayPortal], this RenderObject is not | |
| /// a relayout boundary. Since [_RenderOutlet] passes the size of each portal | |
| /// child to the [OutletPositioningDelegate], [_RenderOutlet] needs to rebuild | |
| /// whenever the size of a portal changes. | |
| final class _RenderPortalContents extends RenderProxyBox { | |
| _RenderPortalContents(this.anchor, {required this.childIdentifier}); | |
| StackOutletParentData get stackParentData => parentData! as StackOutletParentData; | |
| _RenderAnchor? anchor; | |
| Object? childIdentifier; | |
| @override | |
| void redepthChildren() { | |
| anchor!.redepthChild(this); | |
| super.redepthChildren(); | |
| } | |
| @override | |
| double? computeDryBaseline(BoxConstraints constraints, TextBaseline baseline) { | |
| return child?.getDryBaseline(constraints.loosen(), baseline); | |
| } | |
| @override | |
| ui.Size computeDryLayout(BoxConstraints constraints) { | |
| return child?.getDryLayout(constraints.loosen()) ?? constraints.smallest; | |
| } | |
| @override | |
| RenderObject? get debugLayoutParent => anchor; | |
| @override | |
| void performLayout() { | |
| assert(parent is _RenderOutlet); | |
| final RenderBox? child = this.child; | |
| if (child == null) { | |
| size = constraints.smallest; | |
| return; | |
| } | |
| child.layout(constraints.loosen(), parentUsesSize: true); | |
| size = child.size; | |
| } | |
| @override | |
| void describeSemanticsConfiguration(SemanticsConfiguration config) { | |
| super.describeSemanticsConfiguration(config); | |
| if (childIdentifier != null) { | |
| config.traversalChildIdentifier = childIdentifier; | |
| } | |
| } | |
| @override | |
| void setupParentData(RenderBox child) { | |
| if (child.parentData is! StackOutletParentData) { | |
| child.parentData = StackOutletParentData(); | |
| } | |
| } | |
| @override | |
| void applyPaintTransform(RenderBox child, Matrix4 transform) { | |
| final childParentData = child.parentData! as StackOutletParentData; | |
| final Offset offset = childParentData.offset; | |
| transform.translateByDouble(offset.dx, offset.dy, 0, 1); | |
| } | |
| } | |
| // A RenderProxyBox that makes sure its `_portalContents` has a greater | |
| // depth than itself, and triggers updates when it moves. | |
| class _RenderAnchor extends RenderProxyBox { | |
| _RenderPortalContents? _portalContents; | |
| _RenderOutlet get outlet { | |
| RenderObject? ancestor = parent; | |
| while (ancestor != null) { | |
| if (ancestor is _RenderOutlet) { | |
| return ancestor; | |
| } | |
| ancestor = ancestor.parent; | |
| } | |
| throw FlutterError('No ancestor _RenderOutlet found for _RenderAnchor.'); | |
| } | |
| @override | |
| void redepthChildren() { | |
| super.redepthChildren(); | |
| final _RenderPortalContents? child = _portalContents; | |
| // If child is not attached yet, this method will be invoked by child's real | |
| // parent (the outlet) when it becomes attached. | |
| if (child != null && child.attached) { | |
| redepthChild(child); | |
| } | |
| } | |
| void updateAnchorParentData(_RenderPortalContents portal) { | |
| RenderObject? ancestor = parent; | |
| while (ancestor != null) { | |
| // Stop when a parent Portal (nested case) or an Outlet (root case) is found. | |
| if (ancestor.parent is _RenderPortalContents || ancestor.parent is _RenderOutlet) { | |
| break; | |
| } | |
| ancestor = ancestor.parent; | |
| } | |
| if (ancestor == null) { | |
| assert(false, 'No ancestor Portal or Outlet found for StackPortal content.'); | |
| return; | |
| } | |
| // Calculate the anchor rect transform relative to the panel. | |
| final Matrix4 transform = getTransformTo(ancestor); | |
| portal.stackParentData | |
| ..ancestorAnchorTransform = transform | |
| ..anchorSize = size; | |
| } | |
| } | |
| /** RawMenuNode **/ | |
| 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); | |
| /// Signature for the callback used by [RawMenuAnchor.onOpenRequested] to | |
| /// intercept requests to open a menu. | |
| /// | |
| /// See [RawMenuAnchor.onOpenRequested] for more information. | |
| typedef RawMenuAnchorOpenRequestedCallback = | |
| void Function(Offset? position, VoidCallback showOverlay); | |
| /// Signature for the callback used by [RawMenuAnchor.onCloseRequested] to | |
| /// intercept requests to close a menu. | |
| /// | |
| /// See [RawMenuAnchor.onCloseRequested] for more information. | |
| typedef RawMenuAnchorCloseRequestedCallback = void Function(VoidCallback hideOverlay); | |
| // 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.controller, | |
| required this.tapRegionGroupId, | |
| required super.child, | |
| }); | |
| final bool isOpen; | |
| final MenuController controller; | |
| final Object tapRegionGroupId; | |
| @override | |
| bool updateShouldNotify(_MenuControllerScope oldWidget) { | |
| return isOpen != oldWidget.isOpen || tapRegionGroupId != oldWidget.tapRegionGroupId; | |
| } | |
| } | |
| /// A mixin used to define the behavior of a [RawMenuNode]. | |
| /// | |
| /// Typically, [RawMenuNodeDelegate] is mixed into the [State] of a widget | |
| /// that builds a [RawMenuNode]. | |
| /// | |
| /// See [RawMenuAnchor] for example usage of this delegate. | |
| abstract interface class RawMenuNodeDelegate { | |
| /// Whether the menu is open. | |
| bool get isOpen; | |
| /// Implementers should define what to do when [MenuController.open] is | |
| /// called. | |
| /// | |
| /// The `position` argument is the `position` argument passed to | |
| /// [MenuController.open]. It should be used to position the menu in the local | |
| /// coordinates of the [RawMenuNode.child]. | |
| /// | |
| /// The `showOverlay` callback should be called when the menu should be shown. | |
| /// This can occur immediately (the default behavior), or after a delay. | |
| /// | |
| /// If an opening animation is required, it should typically be started | |
| /// immediately after calling `showOverlay` within this method. | |
| void handleOpenRequest(Offset? position, VoidCallback showOverlay); | |
| /// Implementers should define what to do when [MenuController.close] is | |
| /// called. | |
| /// | |
| /// The `hideOverlay` callback should be called when the menu should be | |
| /// hidden. This can occur immediately (the default behavior), or after a | |
| /// delay, such as after a closing animation has completed. | |
| /// | |
| /// The [hideOverlay] callback is safe to call after the widget is dismounted, | |
| /// or not at all. | |
| /// | |
| /// Pending timers started in a previous call to [handleCloseRequest] should be | |
| /// canceled when this callback is triggered. | |
| /// | |
| /// This method is not called if the menu is already closed. | |
| void handleCloseRequest(VoidCallback hideOverlay); | |
| /// Implement to define how to open the menu. | |
| /// | |
| /// This is called when the menu overlay should be shown and added to the | |
| /// widget tree. | |
| /// | |
| /// The optional `position` argument should be used to position the menu in | |
| /// the local coordinates of the [RawMenuNode.child]. | |
| void handleOpen({Offset? position}); | |
| /// Implement to define how to close the menu. | |
| /// | |
| /// This is called when the menu overlay should be hidden and removed from the | |
| /// widget tree. | |
| /// | |
| /// Prior to [handleClose] being called, all descendant menus will have been closed. | |
| /// | |
| /// This method is not called if the menu is already closed. | |
| void handleClose(); | |
| } | |
| /// A widget that acts as a logical node in a menu tree and delegates its | |
| /// presentation to a [RawMenuNodeDelegate]. | |
| /// | |
| /// Its primary responsibilities are: | |
| /// | |
| /// 1. **State Coordination**: It orchestrates open/close operations across the | |
| /// menu tree. For example, when this node opens, it first begins closing | |
| /// all siblings. | |
| /// | |
| /// 2. **Inheritance**: It provides the [MenuController] and | |
| /// [RawMenuNodeDelegate.isOpen] to descendants. These can be accessed using | |
| /// [MenuController.maybeOf] and [MenuController.maybeIsOpenOf], | |
| /// respectively. | |
| /// | |
| /// 3. **Behavioral Triggers**: It listens for external events (like scrolling | |
| /// or screen resizing) to automatically close the menu tree when necessary. | |
| /// | |
| /// ### Relationship to [RawMenuAnchor] | |
| /// | |
| /// [RawMenuAnchor] uses [RawMenuNode] internally. Its state mixes in | |
| /// [RawMenuNodeDelegate] and builds a [RawMenuNode] that delegates to itself. | |
| /// For most use cases, [RawMenuAnchor] is the correct widget to use. | |
| /// [RawMenuNode] is intended for cases where the anchor widget needs full | |
| /// control over its own presentation — for example, when the menu content is | |
| /// rendered in a [Stack] rather than in an [Overlay]. | |
| /// | |
| /// See also: | |
| /// | |
| /// * [RawMenuNodeDelegate], the mixin that defines what should happen when a | |
| /// [RawMenuNode] is opened or closed. | |
| /// * [RawMenuAnchor], a widget that wraps a [RawMenuNode] and presents its | |
| /// menu in an [OverlayPortal]. | |
| /// * [RawMenuAnchorGroup], a widget that registers a group of related | |
| /// [RawMenuNode]s in the menu tree without displaying its own overlay. | |
| /// * [MenuController], the controller used to open, close, and interrogate a | |
| /// menu anchor. | |
| class RawMenuNode extends StatefulWidget { | |
| const RawMenuNode({ | |
| super.key, | |
| required this.delegate, | |
| required this.controller, | |
| required this.child, | |
| }); | |
| /// The delegate that defines the presentation of this menu. | |
| /// | |
| /// This is typically the [State] of the widget that wraps this [RawMenuNode]. | |
| final RawMenuNodeDelegate delegate; | |
| /// A [MenuController] that allows opening and closing of the menu from other | |
| /// widgets. | |
| final MenuController controller; | |
| /// The child widget that this [RawMenuNode] surrounds. | |
| /// | |
| /// This is typically a widget that contains both the menu button and the menu | |
| /// panel, such as a [Stack]. | |
| final Widget child; | |
| @override | |
| State<RawMenuNode> createState() => _RawMenuNodeState(); | |
| } | |
| class _RawMenuNodeState extends State<RawMenuNode> { | |
| final List<_RawMenuNodeState> _anchorChildren = <_RawMenuNodeState>[]; | |
| _RawMenuNodeState? _parent; | |
| ScrollPosition? _scrollPosition; | |
| Size? _viewSize; | |
| RawMenuNodeDelegate get delegate => widget.delegate; | |
| bool get isOpen => delegate.isOpen; | |
| /// Whether this [_RawMenuNodeState] is the top node of the menu tree. | |
| bool get isRoot => _parent == null; | |
| /// The root of the menu tree that this [RawMenuAnchor] is in. | |
| _RawMenuNodeState get root { | |
| var anchor = this; | |
| while (anchor._parent != null) { | |
| anchor = anchor._parent!; | |
| } | |
| return anchor; | |
| } | |
| @override | |
| void initState() { | |
| super.initState(); | |
| widget.controller._attach(this); | |
| } | |
| @override | |
| void didChangeDependencies() { | |
| super.didChangeDependencies(); | |
| final _RawMenuNodeState? 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); | |
| } | |
| if (isRoot) { | |
| _scrollPosition?.isScrollingNotifier.removeListener(_handleScroll); | |
| _scrollPosition = Scrollable.maybeOf(context)?.position; | |
| _scrollPosition?.isScrollingNotifier.addListener(_handleScroll); | |
| final Size newSize = MediaQuery.sizeOf(context); | |
| if (_viewSize != null && newSize != _viewSize && isOpen) { | |
| // Close the menus if the view changes size. | |
| // handleCloseRequest(); | |
| } | |
| _viewSize = newSize; | |
| } | |
| } | |
| @override | |
| void didUpdateWidget(RawMenuNode oldWidget) { | |
| super.didUpdateWidget(oldWidget); | |
| if (oldWidget.controller != widget.controller) { | |
| oldWidget.controller._detach(this); | |
| widget.controller._attach(this); | |
| } | |
| } | |
| @override | |
| void dispose() { | |
| assert(_debugMenuInfo('Disposing of $this')); | |
| _parent?._removeChild(this); | |
| _parent = null; | |
| _anchorChildren.clear(); | |
| widget.controller._detach(this); | |
| super.dispose(); | |
| } | |
| void _addChild(_RawMenuNodeState 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(_RawMenuNodeState 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 (isOpen) { | |
| handleCloseRequest(); | |
| } | |
| } | |
| void _childChangedOpenState() { | |
| _parent?._childChangedOpenState(); | |
| _scheduleSafeCallback(() { | |
| if (mounted) { | |
| setState(() { | |
| // Mark dirty to notify MenuController dependents. | |
| }); | |
| } | |
| }, debugLabel: '_RawMenuAnchorBaseState._childChangedOpenState'); | |
| } | |
| /// Open the menu, optionally at a position relative to the [RawMenuAnchor]. | |
| /// | |
| /// Call this when the menu overlay should be shown and added to the widget | |
| /// tree. | |
| /// | |
| /// The optional `position` argument should specify the location of the menu | |
| /// in the local coordinates of the [RawMenuAnchor]. | |
| void open({Offset? position}) { | |
| if (!mounted) { | |
| return; | |
| } | |
| if (isOpen) { | |
| // The menu is already open, but we need to move to another location. If a | |
| // child of this menu is open, calling OverlayPortalController.show() will | |
| // show this menu above the child. To avoid this, we close the menu and | |
| // all of its children first, which will remove the menu overlay from the | |
| // widget tree. | |
| close(); | |
| } | |
| assert(_debugMenuInfo('Opening $this at ${position ?? Offset.zero}')); | |
| // Close all siblings. | |
| _parent?.requestChildrenClose(); | |
| delegate.handleOpen(position: position); | |
| _parent?._childChangedOpenState(); | |
| setState(() { | |
| // Mark dirty to notify MenuController dependents. | |
| }); | |
| } | |
| /// Close the menu and all of its children. | |
| /// | |
| /// Called when the menu overlay should be hidden and removed from the widget | |
| /// tree. | |
| void close() { | |
| assert(_debugMenuInfo('Closing $this')); | |
| if (!isOpen) { | |
| return; | |
| } | |
| assert(mounted, 'A RawMenuAnchorDelegate returned true from isOpen after it was disposed.'); | |
| closeChildren(); | |
| delegate.handleClose(); | |
| _parent?._childChangedOpenState(); | |
| setState(() { | |
| // Mark dirty to notify MenuController dependents. | |
| }); | |
| } | |
| /// Called by [MenuController.open] to trigger the opening sequence of this | |
| /// menu, which eventually leads to [_RawMenuNodeState.open]. | |
| void handleOpenRequest({ui.Offset? position}) { | |
| delegate.handleOpenRequest(position, () { | |
| open(position: position); | |
| }); | |
| } | |
| /// Called by [MenuController.close] to trigger the closing sequence of this | |
| /// menu, which eventually leads to [_RawMenuNodeState.close]. | |
| void handleCloseRequest() { | |
| if (!isOpen) { | |
| return; | |
| } | |
| // Changes in MediaQuery.sizeOf(context) cause RawMenuAnchor to close during | |
| // didChangeDependencies. When this happens, calling setState during the | |
| // closing sequence (handleCloseRequest -> onCloseRequested -> hideOverlay) | |
| // will throw an error, since we'd be scheduling a build during a build. We | |
| // avoid this by checking if we're in a build, and if so, we schedule the | |
| // close for the next frame. | |
| _scheduleSafeCallback(() { | |
| if (mounted) { | |
| delegate.handleCloseRequest(close); | |
| } | |
| }, debugLabel: '_RawMenuAnchorBaseState.handleCloseRequest'); | |
| requestChildrenClose(); | |
| } | |
| /// Close the open submenus of this menu. | |
| /// | |
| /// This method will call [close] on each child of this menu, which will | |
| /// immediately close the child. | |
| void closeChildren() { | |
| assert(_debugMenuInfo('Closing children of $this')); | |
| final children = List<_RawMenuNodeState>.of(_anchorChildren); | |
| for (final child in children) { | |
| child.close(); | |
| } | |
| } | |
| /// Request that the open submenus of this menu be closed. | |
| /// | |
| /// This method will call [handleCloseRequest] on each child of this | |
| /// menu, which will trigger the closing sequence of each child. | |
| void requestChildrenClose() { | |
| assert(_debugMenuInfo('Calling handleCloseRequest for children of $this')); | |
| final children = List<_RawMenuNodeState>.of(_anchorChildren); | |
| for (final child in children) { | |
| child.handleCloseRequest(); | |
| } | |
| } | |
| void handleOutsideTap(PointerDownEvent pointerDownEvent) { | |
| assert(_debugMenuInfo('Tapped Outside ${widget.controller}')); | |
| if (isOpen) { | |
| requestChildrenClose(); | |
| } | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| return _MenuControllerScope( | |
| isOpen: isOpen, | |
| controller: widget.controller, | |
| tapRegionGroupId: root.widget.controller, | |
| child: Actions( | |
| actions: <Type, Action<Intent>>{ | |
| // Check if open to allow DismissIntent to bubble when the menu is | |
| // closed. | |
| if (isOpen) DismissIntent: DismissMenuAction(controller: widget.controller), | |
| }, | |
| child: widget.child, | |
| ), | |
| ); | |
| } | |
| @override | |
| String toString({DiagnosticLevel? minLevel}) { | |
| return describeIdentity(this); | |
| } | |
| } | |
| /// 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. | |
| /// | |
| /// The [RawMenuAnchor] does not manage semantics and focus of the menu. | |
| /// | |
| /// ### Adding animations to menus | |
| /// | |
| /// A [RawMenuAnchor] has no knowledge of animations, as evident from its APIs, | |
| /// which don't involve [AnimationController] at all. It only knows whether the | |
| /// overlay is shown or hidden. | |
| /// | |
| /// If another widget intends to implement a menu with opening and closing | |
| /// transitions, [RawMenuAnchor]'s overlay should remain visible throughout both | |
| /// the opening and closing animation durations. | |
| /// | |
| /// This means that the `showOverlay` callback passed to [onOpenRequested] | |
| /// should be called before the first frame of the opening animation. | |
| /// Conversely, `hideOverlay` within [onCloseRequested] should only be called | |
| /// after the closing animation has completed. | |
| /// | |
| /// This also means that, if [MenuController.open] is called while the overlay | |
| /// is already visible, [RawMenuAnchor] has no way of knowing whether the menu | |
| /// is currently opening, closing, or stably displayed. The parent widget will | |
| /// need to manage additional information (such as the state of an | |
| /// [AnimationController]) to determine how to respond in such scenarios. | |
| /// | |
| /// To programmatically control a [RawMenuAnchor], like opening or closing it, | |
| /// or checking its state, you can get its associated [MenuController]. Use | |
| /// `MenuController.maybeOf(BuildContext context)` to retrieve the controller | |
| /// for the closest [RawMenuAnchor] ancestor of a given `BuildContext`. More | |
| /// detailed usage of [MenuController] is available in its class documentation. | |
| /// | |
| /// {@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} | |
| /// | |
| /// {@tool dartpad} | |
| /// | |
| /// This example uses [RawMenuAnchor.onOpenRequested] and | |
| /// [RawMenuAnchor.onCloseRequested] to build an animated menu. | |
| /// | |
| /// ** See code in examples/api/lib/widgets/raw_menu_anchor/raw_menu_anchor.2.dart ** | |
| /// {@end-tool} | |
| /// | |
| /// {@tool dartpad} | |
| /// | |
| /// This example uses [RawMenuAnchor.onOpenRequested] and | |
| /// [RawMenuAnchor.onCloseRequested] to build an animated nested menu. | |
| /// | |
| /// ** See code in examples/api/lib/widgets/raw_menu_anchor/raw_menu_anchor.3.dart ** | |
| /// {@end-tool} | |
| class RawMenuAnchor extends StatefulWidget { | |
| /// A [RawMenuAnchor] that delegates overlay construction to an [overlayBuilder]. | |
| /// | |
| /// The [overlayBuilder] must not be null. | |
| const RawMenuAnchor({ | |
| super.key, | |
| this.childFocusNode, | |
| this.consumeOutsideTaps = false, | |
| this.onOpen, | |
| this.onClose, | |
| this.onOpenRequested = _defaultOnOpenRequested, | |
| this.onCloseRequested = _defaultOnCloseRequested, | |
| this.useRootOverlay = false, | |
| this.builder, | |
| required this.controller, | |
| required this.overlayBuilder, | |
| this.child, | |
| }); | |
| /// Called when the menu overlay is shown. | |
| /// | |
| /// When [MenuController.open] is called, [onOpenRequested] is invoked with a | |
| /// `showOverlay` callback that, when called, shows the menu overlay and | |
| /// triggers [onOpen]. | |
| /// | |
| /// The default implementation of [onOpenRequested] calls `showOverlay` | |
| /// synchronously, thereby calling [onOpen] synchronously. In this case, | |
| /// [onOpen] is called regardless of whether the menu overlay is already | |
| /// showing. | |
| /// | |
| /// Custom implementations of [onOpenRequested] can delay the call to | |
| /// `showOverlay`, or not call it at all, in which case [onOpen] will not be | |
| /// called. Calling `showOverlay` after disposal is a no-op, and will not | |
| /// trigger [onOpen]. | |
| /// | |
| /// A typical usage is to respond when the menu first becomes interactive, | |
| /// such as by setting focus to a menu item. | |
| final VoidCallback? onOpen; | |
| /// Called when the menu overlay is hidden. | |
| /// | |
| /// When [MenuController.close] is called, [onCloseRequested] is invoked with | |
| /// a `hideOverlay` callback that, when called, hides the menu overlay and | |
| /// triggers [onClose]. | |
| /// | |
| /// The default implementation of [onCloseRequested] calls `hideOverlay` | |
| /// synchronously, thereby calling [onClose] synchronously. In this case, | |
| /// [onClose] is called regardless of whether the menu overlay is already | |
| /// hidden. | |
| /// | |
| /// Custom implementations of [onCloseRequested] can delay the call to | |
| /// `hideOverlay` or not call it at all, in which case [onClose] will not be | |
| /// called. Calling `hideOverlay` after disposal is a no-op, and will not | |
| /// trigger [onClose]. | |
| final VoidCallback? onClose; | |
| /// Called when a request is made to open the menu. | |
| /// | |
| /// This callback is triggered every time [MenuController.open] is called, | |
| /// even when the menu overlay is already showing. As a result, this callback | |
| /// is a good place to begin menu opening animations, or observe when a menu | |
| /// is repositioned. | |
| /// | |
| /// After an open request is intercepted, the `showOverlay` callback should be | |
| /// called when the menu overlay (the widget built by [overlayBuilder]) is | |
| /// ready to be shown. This can occur immediately (the default behavior), or | |
| /// after a delay. Calling `showOverlay` sets [MenuController.isOpen] to true, | |
| /// builds (or rebuilds) the overlay widget, and shows the menu overlay at the | |
| /// front of the overlay stack. | |
| /// | |
| /// If `showOverlay` is not called, the menu will stay hidden. Calling | |
| /// `showOverlay` after disposal is a no-op, meaning it will not trigger | |
| /// [onOpen] or show the menu overlay. | |
| /// | |
| /// If a [RawMenuAnchor] is used in a themed menu that plays an opening | |
| /// animation, the themed menu should show the overlay before starting the | |
| /// opening animation, since the animation plays on the overlay itself. | |
| /// | |
| /// The `position` argument is the `position` that [MenuController.open] was | |
| /// called with. | |
| /// | |
| /// A typical [onOpenRequested] consists of the following steps: | |
| /// | |
| /// 1. Optional delay. | |
| /// 2. Call `showOverlay` (whose call chain eventually invokes [onOpen]). | |
| /// 3. Optionally start the opening animation. | |
| /// | |
| /// Defaults to a callback that immediately shows the menu. | |
| final RawMenuAnchorOpenRequestedCallback onOpenRequested; | |
| /// Called when a request is made to close the menu. | |
| /// | |
| /// This callback is triggered every time [MenuController.close] is called, | |
| /// regardless of whether the overlay is already hidden. As a result, this | |
| /// callback can be used to add a delay or a closing animation before the menu | |
| /// is hidden. | |
| /// | |
| /// This callback is also triggered when a parent [RawMenuAnchor] is opened, | |
| /// since that triggers [MenuController.close] on all descendant menu | |
| /// controllers. As a result, pending timers or animations started in | |
| /// [onCloseRequested] should be canceled when this callback is triggered, to | |
| /// prevent them from closing the menu at an unintended time. | |
| /// | |
| /// If the menu is not closed, this callback will also be called when the root | |
| /// menu anchor is scrolled and when the screen is resized. | |
| /// | |
| /// After a close request is intercepted and closing behaviors have completed, | |
| /// the `hideOverlay` callback should be called. This callback sets | |
| /// [MenuController.isOpen] to false and hides the menu overlay widget. If the | |
| /// [RawMenuAnchor] is used in a themed menu that plays a closing animation, | |
| /// `hideOverlay` should be called after the closing animation has ended, | |
| /// since the animation plays on the overlay itself. This means that | |
| /// [MenuController.isOpen] will stay true while closing animations are | |
| /// running. | |
| /// | |
| /// Calling `hideOverlay` after disposal is a no-op, meaning it will not | |
| /// trigger [onClose] or hide the menu overlay. | |
| /// | |
| /// Typically, [onCloseRequested] consists of the following steps: | |
| /// | |
| /// 1. Optionally start the closing animation and wait for it to complete. | |
| /// 2. Call `hideOverlay` (whose call chain eventually invokes [onClose]). | |
| /// | |
| /// Throughout the closing sequence, menus should typically not be focusable | |
| /// or interactive. | |
| /// | |
| /// Defaults to a callback that immediately hides the menu. | |
| final RawMenuAnchorCloseRequestedCallback onCloseRequested; | |
| /// 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; | |
| /// Called to build and position the menu overlay. | |
| /// | |
| /// 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 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; | |
| static void _defaultOnOpenRequested(Offset? position, VoidCallback showOverlay) { | |
| showOverlay(); | |
| } | |
| static void _defaultOnCloseRequested(VoidCallback hideOverlay) { | |
| hideOverlay(); | |
| } | |
| @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', | |
| ), | |
| ); | |
| } | |
| } | |
| class _RawMenuAnchorState extends State<RawMenuAnchor> implements RawMenuNodeDelegate { | |
| final OverlayPortalController _overlayController = OverlayPortalController( | |
| debugLabel: kReleaseMode ? null : 'MenuAnchor controller', | |
| ); | |
| Offset? _menuPosition; | |
| RawMenuNodeDelegate? get parent => widget.controller._anchor?._parent?.delegate; | |
| bool get _isRootOverlayAnchor { | |
| return 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 | |
| void handleClose() { | |
| _scheduleSafeCallback(() { | |
| if (mounted) { | |
| _overlayController.hide(); | |
| widget.onClose?.call(); | |
| } | |
| }, debugLabel: 'MenuAnchor.onClose'); | |
| } | |
| @override | |
| void handleCloseRequest(ui.VoidCallback hideOverlay) { | |
| widget.onCloseRequested(hideOverlay); | |
| } | |
| @override | |
| void handleOpen({ui.Offset? position}) { | |
| _menuPosition = position; | |
| _overlayController.show(); | |
| if (_isRootOverlayAnchor) { | |
| widget.childFocusNode?.requestFocus(); | |
| } | |
| widget.onOpen?.call(); | |
| } | |
| @override | |
| void handleOpenRequest(ui.Offset? position, ui.VoidCallback showOverlay) { | |
| widget.onOpenRequested(position, showOverlay); | |
| } | |
| Widget _buildOverlay(BuildContext context, OverlayChildLayoutInfo layoutInfo) { | |
| final Matrix4 transform = layoutInfo.childPaintTransform; | |
| final Size anchorSize = layoutInfo.childSize; | |
| // Transform the anchor rectangle using the full transform matrix. | |
| final Rect anchorRect = MatrixUtils.transformRect(transform, Offset.zero & anchorSize); | |
| final info = RawMenuOverlayInfo( | |
| anchorRect: anchorRect, | |
| overlaySize: layoutInfo.overlaySize, | |
| position: _menuPosition, | |
| tapRegionGroupId: MenuController.maybeTapRegionGroupIdOf(context)!, | |
| ); | |
| return widget.overlayBuilder(context, info); | |
| } | |
| /// Handles taps outside of the menu surface. | |
| /// | |
| /// By default, this closes this submenu's children. | |
| @protected | |
| void handleOutsideTap(PointerDownEvent pointerDownEvent) { | |
| assert(_debugMenuInfo('Tapped Outside ${widget.controller}')); | |
| if (isOpen) { | |
| widget.controller.closeChildren(); | |
| } | |
| } | |
| Widget _buildAnchor(BuildContext context) { | |
| // A dependency on MenuController.isOpen is established to ensure the | |
| // anchor child is rebuilt when the menu opens and closes. | |
| MenuController.maybeIsOpenOf(context); | |
| return TapRegion( | |
| groupId: MenuController.maybeTapRegionGroupIdOf(context), | |
| consumeOutsideTaps: isOpen && widget.consumeOutsideTaps, | |
| onTapOutside: handleOutsideTap, | |
| child: | |
| widget.builder?.call(context, widget.controller, widget.child) ?? | |
| widget.child ?? | |
| const SizedBox(), | |
| ); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| final Widget child = Shortcuts( | |
| includeSemantics: false, | |
| shortcuts: _kMenuTraversalShortcuts, | |
| child: Builder(builder: _buildAnchor), | |
| ); | |
| return RawMenuNode( | |
| controller: widget.controller, | |
| delegate: this, | |
| child: Builder( | |
| builder: (BuildContext context) { | |
| return OverlayPortal.overlayChildLayoutBuilder( | |
| controller: _overlayController, | |
| overlayChildBuilder: _buildOverlay, | |
| overlayLocation: useRootOverlay | |
| ? OverlayChildLocation.rootOverlay | |
| : OverlayChildLocation.nearestOverlay, | |
| 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> implements RawMenuNodeDelegate { | |
| @override | |
| bool get isOpen => | |
| widget.controller._anchor?._anchorChildren.any((child) => child.isOpen) ?? false; | |
| @override | |
| void handleClose() {} | |
| @override | |
| void handleCloseRequest(ui.VoidCallback hideOverlay) {} | |
| @override | |
| void handleOpen({ui.Offset? position}) { | |
| assert(false, 'RawMenuAnchorGroup cannot be opened directly.'); | |
| } | |
| @override | |
| void handleOpenRequest(ui.Offset? position, ui.VoidCallback showOverlay) { | |
| // RawMenuAnchorGroup cannot be opened directly, so we don't call showOverlay. | |
| } | |
| void handleOutsideTap(PointerDownEvent pointerDownEvent) { | |
| assert(_debugMenuInfo('Tapped Outside ${widget.controller}')); | |
| if (isOpen) { | |
| widget.controller.closeChildren(); | |
| } | |
| } | |
| Widget _buildChild(BuildContext context) { | |
| return TapRegion( | |
| groupId: MenuController.maybeTapRegionGroupIdOf(context), | |
| onTapOutside: handleOutsideTap, | |
| child: widget.child, | |
| ); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| return RawMenuNode( | |
| controller: widget.controller, | |
| delegate: this, | |
| child: Builder(builder: _buildChild), | |
| ); | |
| } | |
| } | |
| /// A controller used to manage a menu created by a subclass of [RawMenuAnchor], | |
| /// such as [MenuAnchor], [MenuBar], [SubmenuButton]. | |
| /// | |
| /// 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]. Doing so will not establish | |
| /// a dependency relationship. | |
| /// | |
| /// 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 widget that has a button that manages a submenu. | |
| /// * [RawMenuAnchor], a widget that defines a region that has submenu. | |
| class MenuController { | |
| // The anchor that this controller controls. | |
| // | |
| // This is set automatically when this `MenuController` is attached to an | |
| // anchor. | |
| _RawMenuNodeState? _anchor; | |
| /// Whether or not the menu associated with this [MenuController] is open. | |
| 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 [RawMenuAnchor] that this controller is | |
| /// attached to. | |
| /// | |
| /// If given, the `position` will override the [MenuAnchor.alignmentOffset] | |
| /// given to the [MenuAnchor]. | |
| /// | |
| /// If the menu's anchor point 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!.handleOpenRequest(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?.handleCloseRequest(); | |
| } | |
| /// Close the children of the menu associated with this [MenuController], | |
| /// without closing the menu itself. | |
| void closeChildren() { | |
| assert(_anchor != null); | |
| _anchor!.requestChildrenClose(); | |
| } | |
| // ignore: use_setters_to_change_properties | |
| void _attach(_RawMenuNodeState anchor) { | |
| _anchor = anchor; | |
| } | |
| void _detach(_RawMenuNodeState anchor) { | |
| if (_anchor == anchor) { | |
| _anchor = null; | |
| } | |
| } | |
| /// Returns the [MenuController] of the ancestor [RawMenuAnchor] 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 [TapRegion.groupId] used by the ancestor [RawMenuNode] 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's [TapRegion.groupId] changes. | |
| static Object? maybeTapRegionGroupIdOf(BuildContext context) { | |
| return context.dependOnInheritedWidgetOfExactType<_MenuControllerScope>()?.tapRegionGroupId; | |
| } | |
| @override | |
| String toString() => describeIdentity(this); | |
| } | |
| /// 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.handleCloseRequest(); | |
| } | |
| @override | |
| bool isEnabled(DismissIntent intent) { | |
| return controller._anchor != null; | |
| } | |
| } | |
| /// Invokes a `callback` immediately if the scheduler is idle, or schedules | |
| /// it for the next frame if the scheduler is currently processing a build. | |
| void _scheduleSafeCallback(VoidCallback callback, {String? debugLabel}) { | |
| if (SchedulerBinding.instance.schedulerPhase != SchedulerPhase.persistentCallbacks) { | |
| callback(); | |
| } else { | |
| SchedulerBinding.instance.addPostFrameCallback((_) { | |
| callback(); | |
| }, debugLabel: debugLabel ?? '_scheduleSafeCallback'); | |
| } | |
| } | |
| /// 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