Implement RawMenuAnchor (#158255)

This PR adds a `RawMenuAnchor()` widget to widgets.dart. The purpose of
this widget is to provide a menu primitive for the Material and
Cupertino libraries (and others) to build upon. Additionally, this PR
makes MenuController an inherited widget to simplify nested access to
the menu (e.g. if you want to launch a context menu from a deeply-nested
widget).

This PR:
* Centralizes core menu logic to a private class,` _RawMenuAnchor()`, 
  * Provides the internals for interacting with menus:
    * TapRegion interop
    * DismissMenuAction handler
    * Close on scroll/resize
    * Focus traversal information, if applicable
* Subclasses override `_open`, `_close`, `_isOpen`, `_buildAnchor`, and
`_menuScopeNode`
* State is accessible by descendents via
`MenuController.maybeOf(context)._anchor`
* Adds 2 public constructors, backed by a `_RawMenuAnchor()` that
contains shared logic.
  * `RawMenuAnchor()`
    * Users build the overlay from scratch.
* Provides anchor/overlay position information and TapRegionGroupId to
builder
    * Does not provide FocusScope management. 
  * `RawMenuAnchorGroup()`
    * A primitive for menus that do not have overlays (menu bars). 
* This was previously called RawMenuAnchor.node(), but @dkwingsmt made a
good case for splitting out the constructor.

<s>Documentation examples have been added, and can be viewed at
https://menu-anchor.web.app/</s>


<s>https://github.com/user-attachments/assets/25d35f23-2aad-4d07-9172-5c3fd65d53cf</s>

@dkwingsmt 

List which issues are fixed by this PR.
https://github.com/flutter/flutter/pull/143712

Some issues that need to be addressed:

Semantics:
<img width="1027" alt="image"
src="https://github.com/user-attachments/assets/d69661c9-8435-4d9c-b200-474968cb57eb">
I'm basing the menu semantics off of the comment
[here](ef3ca70db2/lib/web_ui/lib/src/engine/semantics/semantics.dart (L382)),
but I'm unsure whether the route should be given a name. There is no
menubar/menu/menuitem role in Flutter, so I'm assuming the menu should
be composed of nested dialogs

<s>Unlike the menubar pattern from
[W3C](https://www.w3.org/WAI/ARIA/apg/patterns/menubar/examples/menubar-navigation/),
the RawMenuAnchor

- does not close on tab/shift-tab. I left this behavior out of the menu
so that users could customize tab behavior, but I'm not opinionated
either way
- does not open on ArrowUp/ArrowDown, because this could interfere with
user focus behavior in unconventional menu setups (e.g. a vertical
menu).
- does not automatically focus the first item in a menu overlay when
activated via enter/spacebar, but does focus the first item when
horizontal traversal opens a submenu. Automatically focusing the first
item whenever an overlay opens interferes with hover traversal, and I
couldn't think of a good way to only focus the first item when an
overlay is triggered via enter/spacebar.
- doesn't focus disabled items (I wasn't sure how to address this
without editing MenuItemButton)

While it is possible to nest menus -- for example, a dropdown anchor
within a full-app context menu area -- nested menus behave as a single
group. I was considering adding an additional parameter that separates
nested root menus from their parents, and am interested to hear your
feedback.</s>

*If you had to change anything in the [flutter/tests] repo, include a
link to the migration guide as per the [breaking change policy].*

## Pre-launch Checklist

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel
on [Discord].

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md

---------

Co-authored-by: Bruno Leroux <bruno.leroux@gmail.com>
Co-authored-by: chunhtai <47866232+chunhtai@users.noreply.github.com>
This commit is contained in:
davidhicks980
2025-02-03 18:55:03 -05:00
committed by GitHub
parent 3315ad2e27
commit b29f8f7fb9
9 changed files with 5399 additions and 1349 deletions

View File

@@ -0,0 +1,182 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// Flutter code sample for a [RawMenuAnchor] that demonstrates
/// how to create a simple menu.
void main() {
runApp(const RawMenuAnchorApp());
}
enum Animal {
cat('Cat', leading: Text('🦁')),
kitten('Kitten', leading: Text('🐱')),
felisCatus('Felis catus', leading: Text('🐈')),
dog('Dog', leading: Text('🐕'));
const Animal(this.label, {this.leading});
final String label;
final Widget? leading;
}
class RawMenuAnchorExample extends StatefulWidget {
const RawMenuAnchorExample({super.key});
@override
State<RawMenuAnchorExample> createState() => _RawMenuAnchorExampleState();
}
class _RawMenuAnchorExampleState extends State<RawMenuAnchorExample> {
final FocusNode focusNode = FocusNode();
final MenuController controller = MenuController();
Animal? _selectedAnimal;
@override
void dispose() {
focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
return UnconstrainedBox(
clipBehavior: Clip.hardEdge,
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text('Favorite Animal:', style: theme.textTheme.titleMedium),
const SizedBox(width: 8),
CustomMenu(
controller: controller,
focusNode: focusNode,
anchor: FilledButton(
focusNode: focusNode,
style: FilledButton.styleFrom(fixedSize: const Size(172, 36)),
onPressed: () {
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
},
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Expanded(flex: 3, child: Text(_selectedAnimal?.label ?? 'Select One')),
const Flexible(child: Icon(Icons.arrow_drop_down, size: 16)),
],
),
),
children: <Widget>[
for (final Animal animal in Animal.values)
MenuItemButton(
autofocus: _selectedAnimal == animal,
onPressed: () {
setState(() {
_selectedAnimal = animal;
});
controller.close();
},
leadingIcon: SizedBox(width: 24, child: Center(child: animal.leading)),
trailingIcon:
_selectedAnimal == animal ? const Icon(Icons.check, size: 20) : null,
child: Text(animal.label),
),
],
),
],
),
);
}
}
class CustomMenu extends StatelessWidget {
const CustomMenu({
super.key,
required this.children,
required this.anchor,
required this.controller,
required this.focusNode,
});
final List<Widget> children;
final Widget anchor;
final MenuController controller;
final FocusNode focusNode;
static const Map<ShortcutActivator, Intent> _shortcuts = <ShortcutActivator, Intent>{
SingleActivator(LogicalKeyboardKey.gameButtonA): ActivateIntent(),
SingleActivator(LogicalKeyboardKey.escape): DismissIntent(),
SingleActivator(LogicalKeyboardKey.arrowDown): DirectionalFocusIntent(TraversalDirection.down),
SingleActivator(LogicalKeyboardKey.arrowUp): DirectionalFocusIntent(TraversalDirection.up),
};
@override
Widget build(BuildContext context) {
return RawMenuAnchor(
controller: controller,
childFocusNode: focusNode,
overlayBuilder: (BuildContext context, RawMenuOverlayInfo info) {
return Positioned(
top: info.anchorRect.bottom + 4,
left: info.anchorRect.left,
// The overlay will be treated as a dialog.
child: Semantics(
scopesRoute: true,
explicitChildNodes: true,
child: TapRegion(
groupId: info.tapRegionGroupId,
onTapOutside: (PointerDownEvent event) {
MenuController.maybeOf(context)?.close();
},
child: FocusScope(
child: IntrinsicWidth(
child: Container(
clipBehavior: Clip.antiAlias,
constraints: const BoxConstraints(minWidth: 168),
padding: const EdgeInsets.symmetric(vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(6),
boxShadow: kElevationToShadow[4],
),
child: Shortcuts(shortcuts: _shortcuts, child: Column(children: children)),
),
),
),
),
),
);
},
child: anchor,
);
}
}
class RawMenuAnchorApp extends StatelessWidget {
const RawMenuAnchorApp({super.key});
static const ButtonStyle menuButtonStyle = ButtonStyle(
overlayColor: WidgetStatePropertyAll<Color>(Color.fromARGB(55, 139, 195, 255)),
iconSize: WidgetStatePropertyAll<double>(17),
padding: WidgetStatePropertyAll<EdgeInsets>(EdgeInsets.symmetric(horizontal: 12)),
);
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData.from(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
dynamicSchemeVariant: DynamicSchemeVariant.vibrant,
),
).copyWith(menuButtonTheme: const MenuButtonThemeData(style: menuButtonStyle)),
home: const Scaffold(body: Center(child: RawMenuAnchorExample())),
);
}
}

View File

@@ -0,0 +1,262 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// Flutter code sample for a [RawMenuAnchorGroup] that demonstrates
/// how to create a menu bar for a document editor.
void main() => runApp(const RawMenuAnchorGroupApp());
class MenuItem {
const MenuItem(this.label, {this.leading, this.children});
final String label;
final Widget? leading;
final List<MenuItem>? children;
}
const List<MenuItem> menuItems = <MenuItem>[
MenuItem(
'File',
children: <MenuItem>[
MenuItem('New', leading: Icon(Icons.edit_document)),
MenuItem('Open', leading: Icon(Icons.folder)),
MenuItem('Print', leading: Icon(Icons.print)),
MenuItem('Share', leading: Icon(Icons.share)),
],
),
MenuItem(
'Edit',
children: <MenuItem>[
MenuItem('Undo', leading: Icon(Icons.undo)),
MenuItem('Redo', leading: Icon(Icons.redo)),
MenuItem('Cut', leading: Icon(Icons.cut)),
MenuItem('Copy', leading: Icon(Icons.copy)),
MenuItem('Paste', leading: Icon(Icons.paste)),
],
),
MenuItem(
'View',
children: <MenuItem>[
MenuItem('Zoom In', leading: Icon(Icons.zoom_in)),
MenuItem('Zoom Out', leading: Icon(Icons.zoom_out)),
MenuItem('Fit', leading: Icon(Icons.fullscreen)),
],
),
MenuItem(
'Tools',
children: <MenuItem>[
MenuItem('Spelling', leading: Icon(Icons.spellcheck)),
MenuItem('Grammar', leading: Icon(Icons.text_format)),
MenuItem('Thesaurus', leading: Icon(Icons.book_outlined)),
MenuItem('Dictionary', leading: Icon(Icons.book)),
],
),
];
class RawMenuAnchorGroupExample extends StatefulWidget {
const RawMenuAnchorGroupExample({super.key});
@override
State<RawMenuAnchorGroupExample> createState() => _RawMenuAnchorGroupExampleState();
}
class _RawMenuAnchorGroupExampleState extends State<RawMenuAnchorGroupExample> {
final MenuController controller = MenuController();
MenuItem? _selected;
List<FocusNode> focusNodes = List<FocusNode>.generate(
menuItems.length,
(int index) => FocusNode(),
);
@override
void dispose() {
for (final FocusNode focusNode in focusNodes) {
focusNode.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final TextStyle titleStyle = theme.textTheme.titleMedium!;
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
if (_selected != null) Text('Selected: ${_selected!.label}', style: titleStyle),
UnconstrainedBox(
clipBehavior: Clip.hardEdge,
child: RawMenuAnchorGroup(
controller: controller,
child: Row(
children: <Widget>[
for (int i = 0; i < menuItems.length; i++)
CustomSubmenu(
focusNode: focusNodes[i],
anchor: Builder(
builder: (BuildContext context) {
final MenuController submenuController = MenuController.maybeOf(context)!;
final MenuItem item = menuItems[i];
final ButtonStyle openBackground = MenuItemButton.styleFrom(
backgroundColor: const Color(0x0D1A1A1A),
);
return MergeSemantics(
child: Semantics(
expanded: controller.isOpen,
child: MenuItemButton(
style: submenuController.isOpen ? openBackground : null,
onHover: (bool value) {
// If any submenu in the menu bar is already open, other
// submenus should open on hover. Otherwise, blur the menu item
// button if the menu button is no longer hovered.
if (controller.isOpen) {
if (value) {
submenuController.open();
}
} else if (!value) {
Focus.of(context).unfocus();
}
},
onPressed: () {
if (submenuController.isOpen) {
submenuController.close();
} else {
submenuController.open();
}
},
leadingIcon: item.leading,
child: Text(item.label),
),
),
);
},
),
children: <Widget>[
for (final MenuItem child in menuItems[i].children ?? <MenuItem>[])
MenuItemButton(
onPressed: () {
setState(() {
_selected = child;
});
// Close the menu bar after a selection.
controller.close();
},
leadingIcon: child.leading,
child: Text(child.label),
),
],
),
],
),
),
),
],
),
);
}
}
class CustomSubmenu extends StatefulWidget {
const CustomSubmenu({
super.key,
required this.children,
required this.anchor,
required this.focusNode,
});
final List<Widget> children;
final Widget anchor;
final FocusNode focusNode;
static const Map<ShortcutActivator, Intent> _shortcuts = <ShortcutActivator, Intent>{
SingleActivator(LogicalKeyboardKey.gameButtonA): ActivateIntent(),
SingleActivator(LogicalKeyboardKey.escape): DismissIntent(),
SingleActivator(LogicalKeyboardKey.arrowDown): DirectionalFocusIntent(TraversalDirection.down),
SingleActivator(LogicalKeyboardKey.arrowUp): DirectionalFocusIntent(TraversalDirection.up),
};
@override
State<CustomSubmenu> createState() => _CustomSubmenuState();
}
class _CustomSubmenuState extends State<CustomSubmenu> {
final MenuController menuController = MenuController();
@override
Widget build(BuildContext context) {
return RawMenuAnchor(
controller: menuController,
childFocusNode: widget.focusNode,
overlayBuilder: (BuildContext context, RawMenuOverlayInfo info) {
return Positioned(
top: info.anchorRect.bottom + 4,
left: info.anchorRect.left,
// The overlay will be treated as a dialog.
child: Semantics(
scopesRoute: true,
explicitChildNodes: true,
child: TapRegion(
groupId: info.tapRegionGroupId,
onTapOutside: (PointerDownEvent event) {
MenuController.maybeOf(context)?.close();
},
child: FocusScope(
child: IntrinsicWidth(
child: Container(
clipBehavior: Clip.antiAlias,
constraints: const BoxConstraints(minWidth: 160),
padding: const EdgeInsets.symmetric(vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(6),
boxShadow: kElevationToShadow[4],
),
child: Shortcuts(
shortcuts: CustomSubmenu._shortcuts,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: widget.children,
),
),
),
),
),
),
),
);
},
child: widget.anchor,
);
}
}
class RawMenuAnchorGroupApp extends StatelessWidget {
const RawMenuAnchorGroupApp({super.key});
static const ButtonStyle menuButtonStyle = ButtonStyle(
splashFactory: InkSparkle.splashFactory,
iconSize: WidgetStatePropertyAll<double>(17),
overlayColor: WidgetStatePropertyAll<Color>(Color(0x0D1A1A1A)),
padding: WidgetStatePropertyAll<EdgeInsets>(EdgeInsets.symmetric(horizontal: 12)),
textStyle: WidgetStatePropertyAll<TextStyle>(TextStyle(fontSize: 14)),
visualDensity: VisualDensity(
horizontal: VisualDensity.minimumDensity,
vertical: VisualDensity.minimumDensity,
),
);
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData.from(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
).copyWith(menuButtonTheme: const MenuButtonThemeData(style: menuButtonStyle)),
home: const Scaffold(body: RawMenuAnchorGroupExample()),
);
}
}

View File

@@ -0,0 +1,101 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_api_samples/widgets/raw_menu_anchor/raw_menu_anchor.0.dart' as example;
import 'package:flutter_test/flutter_test.dart';
String get catLabel => example.Animal.cat.label;
String get kittenLabel => example.Animal.kitten.label;
String get felisCatusLabel => example.Animal.felisCatus.label;
String get dogLabel => example.Animal.dog.label;
void main() {
testWidgets('Menu opens and closes', (WidgetTester tester) async {
await tester.pumpWidget(const example.RawMenuAnchorApp());
final Finder button = find.text('Select One');
// Open the menu.
await tester.tap(button);
await tester.pump();
expect(find.text(catLabel), findsOneWidget);
expect(find.text(kittenLabel), findsOneWidget);
expect(find.text(felisCatusLabel), findsOneWidget);
expect(find.text(dogLabel), findsOneWidget);
expect(
tester.getRect(find.ancestor(of: find.text(catLabel), matching: find.byType(TapRegion))),
rectMoreOrLessEquals(const Rect.fromLTRB(447.2, 328.0, 662.3, 532.0), epsilon: 0.1),
);
// Close the menu.
await tester.tap(button);
await tester.pump();
expect(find.text(catLabel), findsNothing);
expect(find.text(kittenLabel), findsNothing);
expect(find.text(felisCatusLabel), findsNothing);
expect(find.text(dogLabel), findsNothing);
});
testWidgets('Can traverse menu', (WidgetTester tester) async {
await tester.pumpWidget(const example.RawMenuAnchorApp());
await tester.tap(find.text('Select One'));
await tester.pump();
expect(find.text(catLabel), findsOneWidget);
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
expect(primaryFocus?.debugLabel, contains(kittenLabel));
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); // Felis catus
expect(primaryFocus?.debugLabel, contains(felisCatusLabel));
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); // Dog
expect(primaryFocus?.debugLabel, contains(dogLabel));
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); // Felis catus
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); // Kitten
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); // Cat
await tester.sendKeyEvent(LogicalKeyboardKey.enter); // Select Cat
await tester.pump();
await tester.pump();
expect(find.text(catLabel), findsOneWidget);
expect(find.text(felisCatusLabel), findsNothing);
});
testWidgets('Check appears next to selected item', (WidgetTester tester) async {
await tester.pumpWidget(const example.RawMenuAnchorApp());
await tester.tap(find.text('Select One'));
await tester.pump();
// Select Kitten
await tester.tap(find.text(kittenLabel));
await tester.pump();
await tester.pump();
expect(find.text(catLabel), findsNothing);
await tester.tap(find.text(kittenLabel));
await tester.pump();
expect(
tester.getRect(find.ancestor(of: find.text(catLabel), matching: find.byType(TapRegion))),
rectMoreOrLessEquals(const Rect.fromLTRB(447.2, 328.0, 662.3, 532.0), epsilon: 0.1),
);
expect(
tester.widget(find.widgetWithIcon(MenuItemButton, Icons.check)),
equals(tester.widget(find.widgetWithText(MenuItemButton, kittenLabel))),
);
});
}

View File

@@ -0,0 +1,153 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/gestures.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_api_samples/widgets/raw_menu_anchor/raw_menu_anchor.1.dart' as example;
import 'package:flutter_test/flutter_test.dart';
Future<TestGesture> hoverOver(WidgetTester tester, Offset location) async {
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await gesture.moveTo(location);
await tester.pumpAndSettle();
return gesture;
}
void main() {
testWidgets('Initializes with correct number of menu items in expected position', (
WidgetTester tester,
) async {
await tester.pumpWidget(const example.RawMenuAnchorGroupApp());
expect(find.byType(RawMenuAnchorGroup).evaluate().length, 1);
for (final example.MenuItem item in example.menuItems) {
expect(find.text(item.label), findsOneWidget);
}
expect(find.byType(RawMenuAnchor).evaluate().length, 4);
expect(
tester.getRect(find.byType(RawMenuAnchorGroup).first),
const Rect.fromLTRB(233.0, 284.0, 567.0, 316.0),
);
});
testWidgets('Menu can be traversed', (WidgetTester tester) async {
await tester.pumpWidget(const example.RawMenuAnchorGroupApp());
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pump();
expect(primaryFocus?.debugLabel, contains('File'));
expect(find.text('New'), findsNothing);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump();
await tester.pump();
expect(primaryFocus?.debugLabel, contains('File'));
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
expect(primaryFocus?.debugLabel, contains('New'));
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
expect(primaryFocus?.debugLabel, contains('New'));
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
await tester.pump();
expect(primaryFocus?.debugLabel, contains('New'));
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
await tester.pump();
expect(primaryFocus?.debugLabel, contains('New'));
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
expect(primaryFocus?.debugLabel, contains('Share'));
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pump();
expect(primaryFocus?.debugLabel, contains('File'));
expect(find.text('Share'), findsNothing);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
expect(primaryFocus?.debugLabel, contains('Tools'));
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump();
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
expect(primaryFocus?.debugLabel, contains('Spelling'));
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
expect(primaryFocus?.debugLabel, contains('Grammar'));
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump();
await tester.pump();
expect(find.text('Selected: Grammar'), findsOneWidget);
});
testWidgets('Hover traversal opens submenus when the root menu is open', (
WidgetTester tester,
) async {
await tester.pumpWidget(const example.RawMenuAnchorGroupApp());
await hoverOver(tester, tester.getCenter(find.text('File')));
await tester.pump();
expect(find.text('New'), findsNothing);
await tester.tap(find.text('File'));
await tester.pump();
await tester.pump();
expect(find.text('New'), findsOneWidget);
await hoverOver(tester, tester.getCenter(find.text('Tools')));
await tester.pump();
expect(find.text('Spelling'), findsOneWidget);
await hoverOver(tester, Offset.zero);
await tester.pump();
expect(find.text('Spelling'), findsOneWidget);
expect(
WidgetsBinding.instance.focusManager.primaryFocus?.debugLabel,
'MenuItemButton(Text("Tools"))',
);
await hoverOver(tester, tester.getCenter(find.text('Tools')));
await tester.tap(find.text('Tools'));
await tester.pump();
await tester.pump();
expect(find.text('Spelling'), findsNothing);
expect(
WidgetsBinding.instance.focusManager.primaryFocus?.debugLabel,
'MenuItemButton(Text("Tools"))',
);
await hoverOver(tester, Offset.zero);
await tester.pump();
expect(
WidgetsBinding.instance.focusManager.primaryFocus?.debugLabel,
isNot('MenuItemButton(Text("Tools"))'),
);
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,900 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/// @docImport 'package:flutter/material.dart';
library;
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'actions.dart';
import 'basic.dart';
import 'focus_manager.dart';
import 'focus_traversal.dart';
import 'framework.dart';
import 'media_query.dart';
import 'overlay.dart';
import 'scroll_position.dart';
import 'scrollable.dart';
import 'shortcuts.dart';
import 'tap_region.dart';
// Examples can assume:
// late BuildContext context;
// late List<Widget> menuItems;
// late RawMenuOverlayInfo info;
const bool _kDebugMenus = false;
const Map<ShortcutActivator, Intent> _kMenuTraversalShortcuts = <ShortcutActivator, Intent>{
SingleActivator(LogicalKeyboardKey.gameButtonA): ActivateIntent(),
SingleActivator(LogicalKeyboardKey.escape): DismissIntent(),
SingleActivator(LogicalKeyboardKey.arrowDown): DirectionalFocusIntent(TraversalDirection.down),
SingleActivator(LogicalKeyboardKey.arrowUp): DirectionalFocusIntent(TraversalDirection.up),
SingleActivator(LogicalKeyboardKey.arrowLeft): DirectionalFocusIntent(TraversalDirection.left),
SingleActivator(LogicalKeyboardKey.arrowRight): DirectionalFocusIntent(TraversalDirection.right),
};
/// Anchor and menu information passed to [RawMenuAnchor].
@immutable
class RawMenuOverlayInfo {
/// Creates a [RawMenuOverlayInfo].
const RawMenuOverlayInfo({
required this.anchorRect,
required this.overlaySize,
required this.tapRegionGroupId,
this.position,
});
/// The position of the anchor widget that the menu is attached to, relative to
/// the nearest ancestor [Overlay] when [RawMenuAnchor.useRootOverlay] is false,
/// or the root [Overlay] when [RawMenuAnchor.useRootOverlay] is true.
final ui.Rect anchorRect;
/// The [Size] of the overlay that the menu is being shown in.
final ui.Size overlaySize;
/// The `position` argument passed to [MenuController.open].
///
/// The position should be used to offset the menu relative to the top-left
/// corner of the anchor.
final Offset? position;
/// The [TapRegion.groupId] of the [TapRegion] that wraps widgets in this menu
/// system.
final Object tapRegionGroupId;
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
if (other.runtimeType != runtimeType) {
return false;
}
return other is RawMenuOverlayInfo &&
other.anchorRect == anchorRect &&
other.overlaySize == overlaySize &&
other.position == position &&
other.tapRegionGroupId == tapRegionGroupId;
}
@override
int get hashCode {
return Object.hash(anchorRect, overlaySize, position, tapRegionGroupId);
}
}
/// The type of builder function used by [RawMenuAnchor] to build
/// the overlay attached to a [RawMenuAnchor].
///
/// The `context` is the context that the overlay is being built in.
///
/// The `info` describes the info of the menu overlay for the
/// [RawMenuAnchor] constructor.
typedef RawMenuAnchorOverlayBuilder =
Widget Function(BuildContext context, RawMenuOverlayInfo info);
/// 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);
// An inherited widget that provides the [RawMenuAnchor] to its descendants.
//
// Used to notify anchor descendants when the menu opens and closes, and to
// access the anchor's controller.
class _MenuControllerScope extends InheritedWidget {
const _MenuControllerScope({
required this.isOpen,
required this.controller,
required super.child,
});
final bool isOpen;
final MenuController controller;
@override
bool updateShouldNotify(_MenuControllerScope oldWidget) {
return isOpen != oldWidget.isOpen;
}
}
/// A widget that wraps a child and anchors a floating menu.
///
/// The child can be any widget, but is typically a button, a text field, or, in
/// the case of context menus, the entire screen.
///
/// The menu overlay of a [RawMenuAnchor] is shown by calling
/// [MenuController.open] on an attached [MenuController].
///
/// When a [RawMenuAnchor] is opened, [overlayBuilder] is called to construct
/// the menu contents within an [Overlay]. The [Overlay] allows the menu to
/// "float" on top of other widgets. The `info` argument passed to
/// [overlayBuilder] provides the anchor's [Rect], the [Size] of the overlay,
/// the [TapRegion.groupId] used by members of the menu system, and the
/// `position` argument passed to [MenuController.open].
///
/// If [MenuController.open] is called with a `position` argument, it will be
/// passed to the `info` argument of the `overlayBuilder` function.
///
/// Users are responsible for managing the positioning, semantics, and focus of
/// the menu.
///
/// {@tool dartpad}
///
/// This example uses a [RawMenuAnchor] to build an a basic select menu with
/// four items.
///
/// ** See code in examples/api/lib/widgets/raw_menu_anchor/raw_menu_anchor.0.dart **
/// {@end-tool}
class RawMenuAnchor extends StatefulWidget {
/// A [RawMenuAnchor] that delegates overlay construction to an [overlayBuilder].
///
/// The [overlayBuilder] should not be null.
const RawMenuAnchor({
super.key,
this.childFocusNode,
this.consumeOutsideTaps = false,
this.onOpen,
this.onClose,
this.useRootOverlay = false,
this.builder,
required this.controller,
required this.overlayBuilder,
this.child,
});
/// A callback that is invoked when the menu is opened.
final VoidCallback? onOpen;
/// A callback that is invoked when the menu is closed.
final VoidCallback? onClose;
/// A builder that builds the widget that this [RawMenuAnchor] surrounds.
///
/// Typically, this is a button used to open the menu by calling
/// [MenuController.open] on the `controller` passed to the builder.
///
/// If not supplied, then the [RawMenuAnchor] will be the size that its parent
/// allocates for it.
final RawMenuAnchorChildBuilder? builder;
/// The optional child to be passed to the [builder].
///
/// Supply this child if there is a portion of the widget tree built in
/// [builder] that doesn't depend on the `controller` or `context` supplied to
/// the [builder]. It will be more efficient, since Flutter doesn't then need
/// to rebuild this child when those change.
final Widget? child;
/// The [overlayBuilder] function is passed a [RawMenuOverlayInfo] object that
/// defines the anchor's [Rect], the [Size] of the overlay, the
/// [TapRegion.groupId] for the menu system, and the position [Offset] passed
/// to [MenuController.open].
///
/// To ensure taps are properly consumed, the
/// [RawMenuOverlayInfo.tapRegionGroupId] should be passed to a [TapRegion]
/// widget that wraps the menu panel.
///
/// ```dart
/// TapRegion(
/// groupId: info.tapRegionGroupId,
/// onTapOutside: (PointerDownEvent event) {
/// MenuController.maybeOf(context)?.close();
/// },
/// child: Column(children: menuItems),
/// )
/// ```
final RawMenuAnchorOverlayBuilder overlayBuilder;
/// {@template flutter.widgets.RawMenuAnchor.useRootOverlay}
/// Whether the menu panel should be rendered in the root [Overlay].
///
/// When true, the menu is mounted in the root overlay. Rendering the menu in
/// the root overlay prevents the menu from being obscured by other widgets.
///
/// When false, the menu is rendered in the nearest ancestor [Overlay].
///
/// Submenus will always use the same overlay as their top-level ancestor, so
/// setting a [useRootOverlay] value on a submenu will have no effect.
/// {@endtemplate}
///
/// Defaults to false on overlay menus.
final bool useRootOverlay;
/// The [FocusNode] attached to the widget that takes focus when the
/// menu is opened or closed.
///
/// If not supplied, the anchor will not retain focus when the menu is opened.
final FocusNode? childFocusNode;
/// Whether or not a tap event that closes the menu will be permitted to
/// continue on to the gesture arena.
///
/// If false, then tapping outside of a menu when the menu is open will both
/// close the menu, and allow the tap to participate in the gesture arena.
///
/// If true, then it will only close the menu, and the tap event will be
/// consumed.
///
/// Defaults to false.
final bool consumeOutsideTaps;
/// A [MenuController] that allows opening and closing of the menu from other
/// widgets.
final MenuController controller;
@override
State<RawMenuAnchor> createState() => _RawMenuAnchorState();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(ObjectFlagProperty<FocusNode>.has('focusNode', childFocusNode));
properties.add(
FlagProperty(
'useRootOverlay',
value: useRootOverlay,
ifFalse: 'use nearest overlay',
ifTrue: 'use root overlay',
),
);
}
}
// Base mixin that provides the common interface and state for both types of
// [RawMenuAnchor]s, [RawMenuAnchor] and [RawMenuAnchorGroup].
@optionalTypeArgs
mixin _RawMenuAnchorBaseMixin<T extends StatefulWidget> on State<T> {
final List<_RawMenuAnchorBaseMixin> _anchorChildren = <_RawMenuAnchorBaseMixin>[];
_RawMenuAnchorBaseMixin? _parent;
ScrollPosition? _scrollPosition;
Size? _viewSize;
/// Whether this [_RawMenuAnchorBaseMixin] is the top node of the menu tree.
@protected
bool get isRoot => _parent == null;
/// The [MenuController] that is used by the [_RawMenuAnchorBaseMixin].
///
/// If an overridding widget does not provide a [MenuController], then
/// [_RawMenuAnchorBaseMixin] will create and manage its own.
MenuController get menuController;
/// Whether this menu layer is open.
@protected
bool get isOpen;
/// The root of the menu tree that this [RawMenuAnchor] is in.
@protected
_RawMenuAnchorBaseMixin get root {
_RawMenuAnchorBaseMixin anchor = this;
while (anchor._parent != null) {
anchor = anchor._parent!;
}
return anchor;
}
@override
void initState() {
super.initState();
menuController._attach(this);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final _RawMenuAnchorBaseMixin? newParent = MenuController.maybeOf(context)?._anchor;
if (newParent != _parent) {
assert(
newParent != this,
'A MenuController should only be attached to one anchor at a time.',
);
_parent?._removeChild(this);
_parent = newParent;
_parent?._addChild(this);
}
_scrollPosition?.isScrollingNotifier.removeListener(_handleScroll);
_scrollPosition = Scrollable.maybeOf(context)?.position;
_scrollPosition?.isScrollingNotifier.addListener(_handleScroll);
final Size newSize = MediaQuery.sizeOf(context);
if (_viewSize != null && newSize != _viewSize) {
// Close the menus if the view changes size.
root.close();
}
_viewSize = newSize;
}
@override
void dispose() {
assert(_debugMenuInfo('Disposing of $this'));
if (isOpen) {
close(inDispose: true);
}
_parent?._removeChild(this);
_parent = null;
_anchorChildren.clear();
menuController._detach(this);
super.dispose();
}
void _addChild(_RawMenuAnchorBaseMixin child) {
assert(isRoot || _debugMenuInfo('Added root child: $child'));
assert(!_anchorChildren.contains(child));
_anchorChildren.add(child);
assert(_debugMenuInfo('Added:\n${child.widget.toStringDeep()}'));
assert(_debugMenuInfo('Tree:\n${widget.toStringDeep()}'));
}
void _removeChild(_RawMenuAnchorBaseMixin child) {
assert(isRoot || _debugMenuInfo('Removed root child: $child'));
assert(_anchorChildren.contains(child));
assert(_debugMenuInfo('Removing:\n${child.widget.toStringDeep()}'));
_anchorChildren.remove(child);
assert(_debugMenuInfo('Tree:\n${widget.toStringDeep()}'));
}
void _handleScroll() {
// If an ancestor scrolls, and we're a root anchor, then close the menus.
// Don't just close it on *any* scroll, since we want to be able to scroll
// menus themselves if they're too big for the view.
if (isRoot) {
close();
}
}
void _childChangedOpenState() {
_parent?._childChangedOpenState();
if (SchedulerBinding.instance.schedulerPhase != SchedulerPhase.persistentCallbacks) {
setState(() {
// Mark dirty now, but only if not in a build.
});
} else {
SchedulerBinding.instance.addPostFrameCallback((Duration timestamp) {
setState(() {
// Mark dirty
});
});
}
}
/// Open the menu, optionally at a position relative to the [RawMenuAnchor].
///
/// Call this when the menu should be shown to the user.
///
/// The optional `position` argument should specify the location of the menu in
/// the local coordinates of the [RawMenuAnchor].
@protected
void open({Offset? position});
/// Close the menu.
@protected
void close({bool inDispose = false});
@protected
void closeChildren({bool inDispose = false}) {
assert(_debugMenuInfo('Closing children of $this${inDispose ? ' (dispose)' : ''}'));
for (final _RawMenuAnchorBaseMixin child in List<_RawMenuAnchorBaseMixin>.from(
_anchorChildren,
)) {
child.close(inDispose: inDispose);
}
}
/// Handles taps outside of the menu surface.
///
/// By default, this closes this submenu's children.
@protected
void handleOutsideTap(PointerDownEvent pointerDownEvent) {
assert(_debugMenuInfo('Tapped Outside $menuController'));
closeChildren();
}
// Used to build the anchor widget in subclasses.
@protected
Widget buildAnchor(BuildContext context);
@override
@nonVirtual
Widget build(BuildContext context) {
return _MenuControllerScope(
isOpen: isOpen,
controller: menuController,
child: Actions(
actions: <Type, Action<Intent>>{
// Check if open to allow DismissIntent to bubble when the menu is
// closed.
if (isOpen) DismissIntent: DismissMenuAction(controller: menuController),
},
child: Builder(builder: buildAnchor),
),
);
}
@override
String toString({DiagnosticLevel? minLevel}) => describeIdentity(this);
}
class _RawMenuAnchorState extends State<RawMenuAnchor> with _RawMenuAnchorBaseMixin<RawMenuAnchor> {
// This is the global key that is used later to determine the bounding rect
// for the anchor's region that the CustomSingleChildLayout's delegate
// uses to determine where to place the menu on the screen and to avoid the
// view's edges.
final GlobalKey _anchorKey = GlobalKey<_RawMenuAnchorState>(
debugLabel: kReleaseMode ? null : 'MenuAnchor',
);
final OverlayPortalController _overlayController = OverlayPortalController(
debugLabel: kReleaseMode ? null : 'MenuAnchor controller',
);
Offset? _menuPosition;
bool get _isRootOverlayAnchor => _parent is! _RawMenuAnchorState;
// If we are a nested menu, we still want to use the same overlay as the
// root menu.
bool get useRootOverlay {
if (_parent case _RawMenuAnchorState(useRootOverlay: final bool useRoot)) {
return useRoot;
}
assert(_isRootOverlayAnchor);
return widget.useRootOverlay;
}
@override
bool get isOpen => _overlayController.isShowing;
@override
MenuController get menuController => widget.controller;
@override
void didUpdateWidget(RawMenuAnchor oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.controller != widget.controller) {
oldWidget.controller._detach(this);
widget.controller._attach(this);
}
}
@override
void open({Offset? position}) {
assert(menuController._anchor == this);
if (isOpen) {
if (position == _menuPosition) {
assert(_debugMenuInfo("Not opening $this because it's already open"));
// The menu is open and not being moved, so just return.
return;
}
// The menu is already open, but we need to move to another location, so
// close it first.
close();
}
assert(_debugMenuInfo('Opening $this at ${position ?? Offset.zero}'));
// Close all siblings.
_parent?.closeChildren();
assert(!_overlayController.isShowing);
_parent?._childChangedOpenState();
_menuPosition = position;
_overlayController.show();
if (_isRootOverlayAnchor) {
widget.childFocusNode?.requestFocus();
}
widget.onOpen?.call();
if (mounted && SchedulerBinding.instance.schedulerPhase != SchedulerPhase.persistentCallbacks) {
setState(() {
// Mark dirty to notify MenuController dependents.
});
}
}
// Close the menu.
//
// Call this when the menu should be closed. Has no effect if the menu is
// already closed.
@override
void close({bool inDispose = false}) {
assert(_debugMenuInfo('Closing $this'));
if (!isOpen) {
return;
}
closeChildren(inDispose: inDispose);
// Don't hide if we're in the middle of a build.
if (SchedulerBinding.instance.schedulerPhase != SchedulerPhase.persistentCallbacks) {
_overlayController.hide();
} else if (!inDispose) {
SchedulerBinding.instance.addPostFrameCallback((_) {
_overlayController.hide();
}, debugLabel: 'MenuAnchor.hide');
}
if (!inDispose) {
// Notify that _childIsOpen changed state, but only if not
// currently disposing.
_parent?._childChangedOpenState();
widget.onClose?.call();
if (mounted &&
SchedulerBinding.instance.schedulerPhase != SchedulerPhase.persistentCallbacks) {
setState(() {
// Mark dirty, but only if mounted and not in a build.
});
}
}
}
Widget _buildOverlay(BuildContext context) {
final BuildContext anchorContext = _anchorKey.currentContext!;
final RenderBox overlay =
Overlay.of(anchorContext, rootOverlay: useRootOverlay).context.findRenderObject()!
as RenderBox;
final RenderBox anchorBox = anchorContext.findRenderObject()! as RenderBox;
final ui.Offset upperLeft = anchorBox.localToGlobal(Offset.zero, ancestor: overlay);
final ui.Offset bottomRight = anchorBox.localToGlobal(
anchorBox.size.bottomRight(Offset.zero),
ancestor: overlay,
);
final RawMenuOverlayInfo info = RawMenuOverlayInfo(
anchorRect: Rect.fromPoints(upperLeft, bottomRight),
overlaySize: overlay.size,
position: _menuPosition,
tapRegionGroupId: root.menuController,
);
return widget.overlayBuilder(context, info);
}
@override
Widget buildAnchor(BuildContext context) {
final Widget child = Shortcuts(
includeSemantics: false,
shortcuts: _kMenuTraversalShortcuts,
child: TapRegion(
groupId: root.menuController,
consumeOutsideTaps: root.isOpen && widget.consumeOutsideTaps,
onTapOutside: handleOutsideTap,
child: Builder(
key: _anchorKey,
builder: (BuildContext context) {
return widget.builder?.call(context, menuController, widget.child) ??
widget.child ??
const SizedBox();
},
),
),
);
if (useRootOverlay) {
return OverlayPortal.targetsRootOverlay(
controller: _overlayController,
overlayChildBuilder: _buildOverlay,
child: child,
);
} else {
return OverlayPortal(
controller: _overlayController,
overlayChildBuilder: _buildOverlay,
child: child,
);
}
}
@override
String toString({DiagnosticLevel? minLevel}) {
return describeIdentity(this);
}
}
/// Creates a menu anchor that is always visible and is not displayed in an
/// [OverlayPortal].
///
/// A [RawMenuAnchorGroup] can be used to create a menu bar that handles
/// external taps and keyboard shortcuts, but defines no default focus or
/// keyboard traversal to enable more flexibility.
///
/// When a [MenuController] is given to a [RawMenuAnchorGroup],
/// - [MenuController.open] has no effect.
/// - [MenuController.close] closes all child [RawMenuAnchor]s that are open
/// - [MenuController.isOpen] reflects whether any child [RawMenuAnchor] is
/// open.
///
/// A [child] must be provided.
///
/// {@tool dartpad}
///
/// This example uses [RawMenuAnchorGroup] to build a menu bar with four
/// submenus. Hovering over menu items opens their respective submenus.
/// Selecting a menu item will close the menu and update the selected item text.
///
/// ** See code in examples/api/lib/widgets/raw_menu_anchor/raw_menu_anchor.1.dart **
/// {@end-tool}
///
/// See also:
/// * [MenuBar], which wraps this widget with standard layout and semantics and
/// focus management.
/// * [MenuAnchor], a menu anchor that follows the Material Design guidelines.
/// * [RawMenuAnchor], a widget that defines a region attached to a floating
/// submenu.
class RawMenuAnchorGroup extends StatefulWidget {
/// Creates a [RawMenuAnchorGroup].
const RawMenuAnchorGroup({super.key, required this.child, required this.controller});
/// The child displayed by the [RawMenuAnchorGroup].
///
/// To access the [MenuController] from the [child], place the child in a
/// builder and call [MenuController.maybeOf].
final Widget child;
/// An [MenuController] that allows the closing of the menu from other
/// widgets.
final MenuController controller;
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(ObjectFlagProperty<MenuController>.has('controller', controller));
}
@override
State<RawMenuAnchorGroup> createState() => _RawMenuAnchorGroupState();
}
class _RawMenuAnchorGroupState extends State<RawMenuAnchorGroup>
with _RawMenuAnchorBaseMixin<RawMenuAnchorGroup> {
@override
bool get isOpen => _anchorChildren.any((_RawMenuAnchorBaseMixin child) => child.isOpen);
@override
MenuController get menuController => widget.controller;
@override
void didUpdateWidget(RawMenuAnchorGroup oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.controller != widget.controller) {
oldWidget.controller._detach(this);
widget.controller._attach(this);
}
}
@override
void close({bool inDispose = false}) {
if (!isOpen) {
return;
}
closeChildren(inDispose: inDispose);
if (!inDispose) {
if (SchedulerBinding.instance.schedulerPhase != SchedulerPhase.persistentCallbacks) {
setState(() {
// Mark dirty, but only if mounted and not in a build.
});
} else {
SchedulerBinding.instance.addPostFrameCallback((Duration timestamp) {
if (mounted) {
setState(() {
// Mark dirty.
});
}
});
}
}
}
@override
void open({Offset? position}) {
assert(menuController._anchor == this);
// Menu nodes are always open, so this is a no-op.
return;
}
@override
Widget buildAnchor(BuildContext context) {
return TapRegion(
groupId: root.menuController,
onTapOutside: handleOutsideTap,
child: widget.child,
);
}
}
/// A controller used to manage a menu created by a [RawMenuAnchor], or
/// [RawMenuAnchorGroup].
///
/// A [MenuController] is used to control and interrogate a menu after it has
/// been created, with methods such as [open] and [close], and state accessors
/// like [isOpen].
///
/// [MenuController.maybeOf] can be used to retrieve a controller from the
/// [BuildContext] of a widget that is a descendant of a [MenuAnchor],
/// [MenuBar], [SubmenuButton], or [RawMenuAnchor]. Doing so will not establish
/// a dependency relationship.
///
/// [MenuController.maybeIsOpenOf] can be used to interrogate the state of a
/// menu from the [BuildContext] of a widget that is a descendant of a
/// [MenuAnchor]. Unlike [MenuController.maybeOf], this method will establish a
/// dependency relationship, so the calling widget will rebuild when the menu
/// opens and closes, and when the [MenuController] changes.
///
/// See also:
///
/// * [MenuAnchor], a menu anchor that follows the Material Design guidelines.
/// * [MenuBar], a widget that creates a menu bar that can take an optional
/// [MenuController].
/// * [SubmenuButton], a Material widget that has a button that manages a
/// submenu.
/// * [RawMenuAnchor], a generic widget that manages a submenu.
/// * [RawMenuAnchorGroup], a generic widget that wraps a group of submenus.
class MenuController {
/// The anchor that this controller controls.
///
/// This is set automatically when a [MenuController] is given to the anchor
/// it controls.
_RawMenuAnchorBaseMixin? _anchor;
/// Whether or not the menu associated with this [MenuController] is open.
bool get isOpen => _anchor?.isOpen ?? false;
/// Opens the menu that this [MenuController] is associated with.
///
/// If `position` is given, then the menu will open at the position given, in
/// the coordinate space of the root overlay.
///
/// If the menu's anchor point is scrolled by an ancestor, or the view changes
/// size, then any open menus will automatically close.
void open({Offset? position}) {
assert(_anchor != null);
_anchor!.open(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?.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(_RawMenuAnchorBaseMixin anchor) {
_anchor = anchor;
}
void _detach(_RawMenuAnchorBaseMixin anchor) {
if (_anchor == anchor) {
_anchor = null;
}
}
/// Returns the [MenuController] of the ancestor [RawMenuAnchor] or
/// [RawMenuAnchorGroup] nearest to the given `context`, if one exists.
/// Otherwise, returns null.
///
/// This method will not establish a dependency relationship, so the calling
/// widget will not rebuild when the menu opens and closes, nor when the
/// [MenuController] changes.
static MenuController? maybeOf(BuildContext context) {
return context.getInheritedWidgetOfExactType<_MenuControllerScope>()?.controller;
}
/// Returns the value of [MenuController.isOpen] of the ancestor
/// [RawMenuAnchor] or [RawMenuAnchorGroup] nearest to the given `context`, if
/// one exists. Otherwise, returns null.
///
/// This method will establish a dependency relationship, so the calling
/// widget will rebuild when the menu opens and closes.
static bool? maybeIsOpenOf(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<_MenuControllerScope>()?.isOpen;
}
@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.close();
}
@override
bool isEnabled(DismissIntent intent) {
return controller._anchor != null;
}
}
/// A debug print function, which should only be called within an assert, like
/// so:
///
/// assert(_debugMenuInfo('Debug Message'));
///
/// so that the call is entirely removed in release builds.
///
/// Enable debug printing by setting [_kDebugMenus] to true at the top of the
/// file.
bool _debugMenuInfo(String message, [Iterable<String>? details]) {
assert(() {
if (_kDebugMenus) {
debugPrint('MENU: $message');
if (details != null && details.isNotEmpty) {
for (final String detail in details) {
debugPrint(' $detail');
}
}
}
return true;
}());
// Return true so that it can be easily used inside of an assert.
return true;
}

View File

@@ -104,6 +104,7 @@ export 'src/widgets/pop_scope.dart';
export 'src/widgets/preferred_size.dart';
export 'src/widgets/primary_scroll_controller.dart';
export 'src/widgets/raw_keyboard_listener.dart';
export 'src/widgets/raw_menu_anchor.dart';
export 'src/widgets/reorderable_list.dart';
export 'src/widgets/restoration.dart';
export 'src/widgets/restoration_properties.dart';

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff