1// Copyright 2014 The Flutter Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5// ignore_for_file: public_member_api_docs
6
7import 'dart:ui' as ui;
8
9import 'package:flutter/material.dart';
10import 'package:flutter/semantics.dart';
11
12/// Flutter code sample for a [RawMenuAnchor] that animates a nested menu using
13/// [RawMenuAnchor.onOpenRequested] and [RawMenuAnchor.onCloseRequested].
14void main() {
15 runApp(const RawMenuAnchorSubmenuAnimationApp());
16}
17
18/// Signature for the function that builds a [Menu]'s contents.
19///
20/// The [animationStatus] parameter indicates the current state of the menu
21/// animation, which can be used to adjust the appearance of the menu panel.
22typedef MenuPanelBuilder = Widget Function(BuildContext context, AnimationStatus animationStatus);
23
24/// Signature for the function that builds a [Menu]'s anchor button.
25///
26/// The [MenuController] can be used to open and close the menu.
27///
28/// The [animationStatus] indicates the current state of the menu animation,
29/// which can be used to adjust the appearance of the menu panel.
30typedef MenuButtonBuilder =
31 Widget Function(
32 BuildContext context,
33 MenuController controller,
34 AnimationStatus animationStatus,
35 );
36
37class RawMenuAnchorSubmenuAnimationExample extends StatelessWidget {
38 const RawMenuAnchorSubmenuAnimationExample({super.key});
39
40 @override
41 Widget build(BuildContext context) {
42 return Menu(
43 panelBuilder: (BuildContext context, AnimationStatus animationStatus) {
44 final MenuController rootMenuController = MenuController.maybeOf(context)!;
45 return Align(
46 alignment: Alignment.topRight,
47 child: Column(
48 children: <Widget>[
49 for (int i = 0; i < 4; i++)
50 Menu(
51 panelBuilder: (BuildContext context, AnimationStatus status) {
52 return SizedBox(
53 height: 120,
54 width: 120,
55 child: Center(
56 child: Text('Panel $i:\n${status.name}', textAlign: TextAlign.center),
57 ),
58 );
59 },
60 buttonBuilder:
61 (
62 BuildContext context,
63 MenuController controller,
64 AnimationStatus animationStatus,
65 ) {
66 return MenuItemButton(
67 onFocusChange: (bool focused) {
68 if (focused) {
69 rootMenuController.closeChildren();
70 controller.open();
71 }
72 },
73 onPressed: () {
74 if (!animationStatus.isForwardOrCompleted) {
75 rootMenuController.closeChildren();
76 controller.open();
77 } else {
78 controller.close();
79 }
80 },
81 trailingIcon: const Icon(Icons.arrow_forward),
82 child: Text('Submenu $i'),
83 );
84 },
85 ),
86 ],
87 ),
88 );
89 },
90 buttonBuilder:
91 (BuildContext context, MenuController controller, AnimationStatus animationStatus) {
92 return FilledButton(
93 onPressed: () {
94 if (animationStatus.isForwardOrCompleted) {
95 controller.close();
96 } else {
97 controller.open();
98 }
99 },
100 child: const Text('Menu'),
101 );
102 },
103 );
104 }
105}
106
107class Menu extends StatefulWidget {
108 const Menu({super.key, required this.panelBuilder, required this.buttonBuilder});
109 final MenuPanelBuilder panelBuilder;
110 final MenuButtonBuilder buttonBuilder;
111
112 @override
113 State<Menu> createState() => MenuState();
114}
115
116class MenuState extends State<Menu> with SingleTickerProviderStateMixin {
117 final MenuController menuController = MenuController();
118 late final AnimationController animationController;
119 late final CurvedAnimation animation;
120 bool get isSubmenu => MenuController.maybeOf(context) != null;
121 AnimationStatus get animationStatus => animationController.status;
122
123 @override
124 void initState() {
125 super.initState();
126 animationController =
127 AnimationController(vsync: this, duration: const Duration(milliseconds: 200))
128 ..addStatusListener((AnimationStatus status) {
129 if (mounted) {
130 setState(() {
131 // Rebuild to reflect animation status changes.
132 });
133 }
134 });
135
136 animation = CurvedAnimation(parent: animationController, curve: Curves.easeOutQuart);
137 }
138
139 @override
140 void dispose() {
141 animationController.dispose();
142 animation.dispose();
143 super.dispose();
144 }
145
146 void _handleMenuOpenRequest(Offset? position, VoidCallback showOverlay) {
147 // Mount or reposition the menu before animating the menu open.
148 showOverlay();
149
150 if (animationStatus.isForwardOrCompleted) {
151 // If the menu is already open or opening, the animation is already
152 // running forward.
153 return;
154 }
155
156 // Animate the menu into view.
157 animationController.forward();
158 }
159
160 void _handleMenuCloseRequest(VoidCallback hideOverlay) {
161 if (!animationStatus.isForwardOrCompleted) {
162 // If the menu is already closed or closing, do nothing.
163 return;
164 }
165
166 // Animate the menu's children out of view.
167 menuController.closeChildren();
168
169 // Animate the menu out of view.
170 animationController.reverse().whenComplete(hideOverlay);
171 }
172
173 @override
174 Widget build(BuildContext context) {
175 return Semantics(
176 role: SemanticsRole.menu,
177 child: RawMenuAnchor(
178 controller: menuController,
179 onOpenRequested: _handleMenuOpenRequest,
180 onCloseRequested: _handleMenuCloseRequest,
181 overlayBuilder: (BuildContext context, RawMenuOverlayInfo info) {
182 final ui.Offset position = isSubmenu
183 ? info.anchorRect.topRight
184 : info.anchorRect.bottomLeft;
185 final ColorScheme colorScheme = ColorScheme.of(context);
186 return Positioned(
187 top: position.dy,
188 left: position.dx,
189 child: Semantics(
190 explicitChildNodes: true,
191 scopesRoute: true,
192 // Remove focus while the menu is closing.
193 child: ExcludeFocus(
194 excluding: !animationStatus.isForwardOrCompleted,
195 child: TapRegion(
196 groupId: info.tapRegionGroupId,
197 onTapOutside: (PointerDownEvent event) {
198 menuController.close();
199 },
200 child: FadeTransition(
201 opacity: animation,
202 child: Material(
203 elevation: 8,
204 clipBehavior: Clip.antiAlias,
205 borderRadius: BorderRadius.circular(8),
206 shadowColor: colorScheme.shadow,
207 child: SizeTransition(
208 axisAlignment: position.dx < 0 ? 1 : -1,
209 sizeFactor: animation,
210 fixedCrossAxisSizeFactor: 1.0,
211 child: widget.panelBuilder(context, animationStatus),
212 ),
213 ),
214 ),
215 ),
216 ),
217 ),
218 );
219 },
220 builder: (BuildContext context, MenuController controller, Widget? child) {
221 return widget.buttonBuilder(context, controller, animationStatus);
222 },
223 ),
224 );
225 }
226}
227
228class RawMenuAnchorSubmenuAnimationApp extends StatelessWidget {
229 const RawMenuAnchorSubmenuAnimationApp({super.key});
230
231 @override
232 Widget build(BuildContext context) {
233 return MaterialApp(
234 theme: ThemeData.from(
235 colorScheme: ColorScheme.fromSeed(
236 seedColor: Colors.blue,
237 dynamicSchemeVariant: DynamicSchemeVariant.vibrant,
238 ),
239 ),
240 home: const Scaffold(body: Center(child: RawMenuAnchorSubmenuAnimationExample())),
241 );
242 }
243}
244