| 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 | |
| 7 | import 'dart:ui' as ui; |
| 8 | |
| 9 | import 'package:flutter/material.dart'; |
| 10 | import 'package:flutter/semantics.dart'; |
| 11 | |
| 12 | /// Flutter code sample for a [RawMenuAnchor] that animates a nested menu using |
| 13 | /// [RawMenuAnchor.onOpenRequested] and [RawMenuAnchor.onCloseRequested]. |
| 14 | void 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. |
| 22 | typedef 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. |
| 30 | typedef MenuButtonBuilder = |
| 31 | Widget Function( |
| 32 | BuildContext context, |
| 33 | MenuController controller, |
| 34 | AnimationStatus animationStatus, |
| 35 | ); |
| 36 | |
| 37 | class 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 | |
| 107 | class 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 | |
| 116 | class 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 | |
| 228 | class 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 | |