From 04cbc81705fa7a9183d64fed6b21995e15f65267 Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Fri, 31 Jan 2025 20:43:46 -0300 Subject: [PATCH 01/22] fix: Correctly build target if position is not null --- lib/src/controls/flyouts/flyout.dart | 37 ++++++++++++---------------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/lib/src/controls/flyouts/flyout.dart b/lib/src/controls/flyouts/flyout.dart index f36189dcb..473ec854a 100644 --- a/lib/src/controls/flyouts/flyout.dart +++ b/lib/src/controls/flyouts/flyout.dart @@ -569,6 +569,7 @@ class FlyoutController with ChangeNotifier { final theme = FluentTheme.of(context); transitionDuration ??= theme.fastAnimationDuration; + reverseTransitionDuration ??= transitionDuration; final navigator = navigatorKey ?? Navigator.of(context); @@ -576,26 +577,20 @@ class FlyoutController with ChangeNotifier { final Size targetSize; final Rect targetRect; - if (position != null) { - targetOffset = position; - targetSize = Size.zero; - targetRect = Rect.zero; - } else { - final navigatorBox = navigator.context.findRenderObject() as RenderBox; - - final targetBox = context.findRenderObject() as RenderBox; - targetSize = targetBox.size; - targetOffset = targetBox.localToGlobal( - Offset.zero, - ancestor: navigatorBox, - ) + - Offset(0, targetSize.height); - targetRect = targetBox.localToGlobal( - Offset.zero, - ancestor: navigatorBox, - ) & - targetSize; - } + final navigatorBox = navigator.context.findRenderObject() as RenderBox; + + final targetBox = context.findRenderObject() as RenderBox; + targetSize = targetBox.size; + targetOffset = targetBox.localToGlobal( + Offset.zero, + ancestor: navigatorBox, + ) + + Offset(0, targetSize.height); + targetRect = targetBox.localToGlobal( + Offset.zero, + ancestor: navigatorBox, + ) & + targetSize; _open = true; notifyListeners(); @@ -672,7 +667,7 @@ class FlyoutController with ChangeNotifier { child: SafeArea( child: CustomSingleChildLayout( delegate: _FlyoutPositionDelegate( - targetOffset: targetOffset, + targetOffset: position ?? targetOffset, targetSize: position == null ? targetSize : Size.zero, autoModeConfiguration: autoModeConfiguration, placementMode: placementMode, From 64bd76b2184839c817132e1b8a589765770a8127 Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Fri, 31 Jan 2025 20:44:50 -0300 Subject: [PATCH 02/22] feat: Flyout reverse transition duration --- lib/src/controls/flyouts/flyout.dart | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/src/controls/flyouts/flyout.dart b/lib/src/controls/flyouts/flyout.dart index 473ec854a..09bb8001d 100644 --- a/lib/src/controls/flyouts/flyout.dart +++ b/lib/src/controls/flyouts/flyout.dart @@ -527,7 +527,11 @@ class FlyoutController with ChangeNotifier { /// /// [transitionDuration] configures the duration of the transition animation. /// By default, [FluentThemeData.fastAnimationDuration] is used. Set to [Duration.zero] - /// to disable transitions at all + /// to disable transitions at all. + /// + /// [reverseTransitionDuration] configures the duration of the reverse transition + /// animation. By default, [transitionDuration] is used. Set to [Duration.zero] + /// to disable transitions at all. /// /// [position] lets you position the flyout anywhere on the screen, making it /// possible to create context menus. If provided, [placementMode] is ignored. @@ -556,6 +560,7 @@ class FlyoutController with ChangeNotifier { NavigatorState? navigatorKey, FlyoutTransitionBuilder? transitionBuilder, Duration? transitionDuration, + Duration? reverseTransitionDuration, Offset? position, RouteSettings? settings, GestureRecognizer? barrierRecognizer, @@ -600,7 +605,7 @@ class FlyoutController with ChangeNotifier { final result = await navigator.push(PageRouteBuilder( opaque: false, transitionDuration: transitionDuration, - reverseTransitionDuration: transitionDuration, + reverseTransitionDuration: reverseTransitionDuration, settings: settings, fullscreenDialog: true, pageBuilder: (context, animation, secondary) { From 674d0d9d4a7c1649ca5ef4fdd45314a66b992159 Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Fri, 31 Jan 2025 20:45:20 -0300 Subject: [PATCH 03/22] feat: HoverButton onPointerEnter and onPointerExit callbacks --- .../inputs/buttons/hyperlink_button.dart | 26 +++++++++++-------- lib/src/controls/utils/hover_button.dart | 14 +++++++++- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/lib/src/controls/inputs/buttons/hyperlink_button.dart b/lib/src/controls/inputs/buttons/hyperlink_button.dart index 0a1da3e16..3e5b2de16 100644 --- a/lib/src/controls/inputs/buttons/hyperlink_button.dart +++ b/lib/src/controls/inputs/buttons/hyperlink_button.dart @@ -31,17 +31,7 @@ class HyperlinkButton extends BaseButton { final theme = FluentTheme.of(context); return ButtonStyle( - backgroundColor: WidgetStateProperty.resolveWith((states) { - if (states.isDisabled) { - return theme.resources.subtleFillColorDisabled; - } else if (states.isPressed) { - return theme.resources.subtleFillColorTertiary; - } else if (states.isHovered) { - return theme.resources.subtleFillColorSecondary; - } else { - return theme.resources.subtleFillColorTransparent; - } - }), + backgroundColor: backgroundColor(theme), shape: WidgetStatePropertyAll( RoundedRectangleBorder(borderRadius: BorderRadius.circular(4.0)), ), @@ -69,4 +59,18 @@ class HyperlinkButton extends BaseButton { assert(debugCheckHasFluentTheme(context)); return ButtonTheme.of(context).hyperlinkButtonStyle; } + + static WidgetStateProperty backgroundColor(FluentThemeData theme) { + return WidgetStateProperty.resolveWith((states) { + if (states.isDisabled) { + return theme.resources.subtleFillColorDisabled; + } else if (states.isPressed) { + return theme.resources.subtleFillColorTertiary; + } else if (states.isHovered) { + return theme.resources.subtleFillColorSecondary; + } else { + return theme.resources.subtleFillColorTransparent; + } + }); + } } diff --git a/lib/src/controls/utils/hover_button.dart b/lib/src/controls/utils/hover_button.dart index b60410896..bd6328e3e 100644 --- a/lib/src/controls/utils/hover_button.dart +++ b/lib/src/controls/utils/hover_button.dart @@ -1,6 +1,7 @@ import 'package:fluent_ui/fluent_ui.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; typedef WidgetStateWidgetBuilder = Widget Function( BuildContext, @@ -52,6 +53,8 @@ class HoverButton extends StatefulWidget { this.onHorizontalDragEnd, this.gestures = const {}, this.onFocusTap, + this.onPointerEnter, + this.onPointerExit, this.onFocusChange, this.autofocus = false, this.actionsEnabled = true, @@ -103,6 +106,9 @@ class HoverButton extends StatefulWidget { /// [focusEnabled] must not be `false` for this to work final VoidCallback? onFocusTap; + final PointerEnterEventListener? onPointerEnter; + final PointerExitEventListener? onPointerExit; + final WidgetStateWidgetBuilder builder; /// {@macro flutter.widgets.Focus.focusNode} @@ -322,16 +328,22 @@ class _HoverButtonState extends State { onShowHoverHighlight: (v) { if (mounted) setState(() => _hovering = v); }, - child: w, + child: MouseRegion( + onEnter: widget.onPointerEnter, + onExit: widget.onPointerExit, + child: w, + ), ); } else { w = MouseRegion( cursor: widget.cursor ?? MouseCursor.defer, onEnter: (e) { if (mounted) setState(() => _hovering = true); + widget.onPointerEnter?.call(e); }, onExit: (e) { if (mounted) setState(() => _hovering = false); + widget.onPointerExit?.call(e); }, child: w, ); From edf69023880b1fc9fd0074b2a8ef0c8d284ec2e6 Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Fri, 31 Jan 2025 20:45:45 -0300 Subject: [PATCH 04/22] feat: MenuBar --- lib/fluent_ui.dart | 1 + lib/src/controls/flyouts/menu.dart | 4 +- lib/src/controls/flyouts/menu_bar.dart | 171 +++++++++++++++++++++++++ 3 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 lib/src/controls/flyouts/menu_bar.dart diff --git a/lib/fluent_ui.dart b/lib/fluent_ui.dart index 9efecb3af..2ded7bdd2 100644 --- a/lib/fluent_ui.dart +++ b/lib/fluent_ui.dart @@ -41,6 +41,7 @@ export 'src/app.dart'; export 'src/controls/flyouts/content.dart'; export 'src/controls/flyouts/content_manager.dart'; export 'src/controls/flyouts/flyout.dart'; +export 'src/controls/flyouts/menu_bar.dart'; export 'src/controls/flyouts/menu.dart'; export 'src/controls/form/auto_suggest_box.dart'; export 'src/controls/form/combo_box.dart'; diff --git a/lib/src/controls/flyouts/menu.dart b/lib/src/controls/flyouts/menu.dart index c1bdc4786..30403365e 100644 --- a/lib/src/controls/flyouts/menu.dart +++ b/lib/src/controls/flyouts/menu.dart @@ -374,7 +374,7 @@ class ToggleMenuFlyoutItem extends MenuFlyoutItem { super.trailing, required this.value, required this.onChanged, - super.closeAfterClick = false, + super.closeAfterClick, }) : super( leading: Icon( value ? FluentIcons.check_mark : null, @@ -414,7 +414,7 @@ class RadioMenuFlyoutItem extends MenuFlyoutItem { required this.value, required this.groupValue, required this.onChanged, - super.closeAfterClick = false, + super.closeAfterClick, }) : super( leading: Icon( value == groupValue ? FluentIcons.radio_bullet : null, diff --git a/lib/src/controls/flyouts/menu_bar.dart b/lib/src/controls/flyouts/menu_bar.dart new file mode 100644 index 000000000..d5c1a6d8a --- /dev/null +++ b/lib/src/controls/flyouts/menu_bar.dart @@ -0,0 +1,171 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter/foundation.dart'; + +/// Represents a top-level menu in a [MenuBar] control. +class MenuBarItem { + /// The text label of the menu. + final String title; + + /// The collection of commands for this item. + final List items; + + /// Creates a menu bar item. + const MenuBarItem({ + required this.title, + required this.items, + }); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is! MenuBarItem) return false; + return title == other.title && items == other.items; + } + + @override + int get hashCode => Object.hash(title, items); +} + +/// Use a Menu Bar to show a set of multiple top-level menus in a horizontal row. +/// +/// ![MenuBar example](https://learn.microsoft.com/en-us/windows/apps/design/controls/images/menu-bar-submenu.png) +/// +/// See also: +/// +/// * +/// * +/// * [MenuBarItem], the items used in this menu bar +/// * [MenuFlyout], a popup that shows a list of items +/// * [CommandBar], a toolbar that provides a customizable layout for commands +class MenuBar extends StatefulWidget with Diagnosticable { + final List items; + + /// Creates a fluent-styled menu bar. + MenuBar({ + super.key, + required this.items, + }) : assert(items.isNotEmpty, 'items must not be empty'); + + @override + State createState() => _MenuBarState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(IterableProperty('items', items)); + } +} + +class _MenuBarState extends State { + final _controller = FlyoutController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + bool _locked = false; + MenuBarItem? _currentOpenItem; + Future _showFlyout(BuildContext context, [MenuBarItem? item]) async { + if (_locked) return; + _locked = true; + final textDirection = Directionality.of(context); + final navigator = Navigator.of(context); + item ??= widget.items.first; + + // Checks the position of the item itself. Context is the MenuBarItem button. + // Position needs to be checked before the flyout is closed, otherwise an + // error will be thrown. + final RenderBox renderBox = context.findRenderObject() as RenderBox; + final position = renderBox.localToGlobal( + Offset.zero, + ancestor: navigator.context.findRenderObject(), + ); + final size = renderBox.size; + + if (_controller.isOpen) { + _controller.close(); + if (_currentOpenItem == item) { + _currentOpenItem = null; + return; + } + // Waits for the reverse transition duration. + // + // Even though the duration is zero, it is necessary to wait for the + // transition to finish before showing the next flyout. Otherwise, the + // flyout will fail to show due to [_locked]. This has a similar effect + // to moving this task to the next frame or using a [Future.microtask]. + await Future.delayed(Duration.zero); + } + + _locked = false; + _currentOpenItem = item; + final future = _controller.showFlyout( + buildTarget: true, + placementMode: FlyoutPlacementMode.bottomLeft.resolve(textDirection), + reverseTransitionDuration: Duration.zero, + barrierColor: Colors.transparent, + additionalOffset: 0.0, + position: Offset(position.dx, position.dy + size.height), + builder: (context) { + return MenuFlyout(items: item!.items); + }, + ); + setState(() {}); + await future; + if (mounted) setState(() {}); + _currentOpenItem = null; + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasFluentTheme(context)); + assert(debugCheckHasDirectionality(context)); + + final theme = FluentTheme.of(context); + + return FlyoutTarget( + controller: _controller, + child: SizedBox( + height: 40.0, + child: Row(children: [ + for (final item in widget.items) + Builder( + key: ValueKey(item), + builder: (context) { + return HoverButton( + onPressed: () { + _locked = false; + _showFlyout(context, item); + }, + onPointerEnter: _controller.isOpen + ? (_) { + if (_currentOpenItem != item) { + _showFlyout(context, item); + } + } + : null, + builder: (context, states) { + return Container( + padding: const EdgeInsetsDirectional.symmetric( + horizontal: 10.0, + vertical: 4.0, + ), + margin: const EdgeInsetsDirectional.all(4.0), + decoration: BoxDecoration( + color: HyperlinkButton.backgroundColor(theme) + .resolve(states), + borderRadius: BorderRadius.circular(4.0), + ), + child: Text(item.title), + ); + }, + ); + }, + ), + ]), + ), + ); + } +} From c535aae9d95324ad682e3667be5f8ede5e699c8d Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Fri, 31 Jan 2025 20:46:07 -0300 Subject: [PATCH 05/22] feat(example): MenuBar example --- example/lib/main.dart | 15 +++ example/lib/routes/popups.dart | 1 + example/lib/screens/popups/menu_bar.dart | 129 +++++++++++++++++++++++ 3 files changed, 145 insertions(+) create mode 100644 example/lib/screens/popups/menu_bar.dart diff --git a/example/lib/main.dart b/example/lib/main.dart index b06d0a77f..6082ac91c 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -301,6 +301,12 @@ class _MyHomePageState extends State with WindowListener { title: const Text('ContentDialog'), body: const SizedBox.shrink(), ), + PaneItem( + key: const ValueKey('/popups/menu_bar'), + icon: const Icon(FluentIcons.expand_menu), + title: const Text('MenuBar'), + body: const SizedBox.shrink(), + ), PaneItem( key: const ValueKey('/popups/tooltip'), icon: const Icon(FluentIcons.hint_text), @@ -941,6 +947,15 @@ final router = GoRouter(navigatorKey: rootNavigatorKey, routes: [ ), ), + /// MenuBar + GoRoute( + path: '/popups/menu_bar', + builder: (context, state) => DeferredWidget( + surfaces.loadLibrary, + () => popups.MenuBarPage(), + ), + ), + /// Tooltip GoRoute( path: '/popups/tooltip', diff --git a/example/lib/routes/popups.dart b/example/lib/routes/popups.dart index f853111d9..b0c7258cc 100644 --- a/example/lib/routes/popups.dart +++ b/example/lib/routes/popups.dart @@ -1,3 +1,4 @@ export '../screens/popups/content_dialog.dart'; +export '../screens/popups/menu_bar.dart'; export '../screens/popups/flyout.dart'; export '../screens/popups/tooltip.dart'; diff --git a/example/lib/screens/popups/menu_bar.dart b/example/lib/screens/popups/menu_bar.dart new file mode 100644 index 000000000..5a5e15bc6 --- /dev/null +++ b/example/lib/screens/popups/menu_bar.dart @@ -0,0 +1,129 @@ +import 'package:example/widgets/card_highlight.dart'; +import 'package:example/widgets/page.dart'; +import 'package:fluent_ui/fluent_ui.dart'; + +class MenuBarPage extends StatefulWidget { + const MenuBarPage({super.key}); + + @override + State createState() => _MenuBarPageState(); +} + +class _MenuBarPageState extends State with PageMixin { + var _orientation = 'landscape'; + var _iconSize = 'medium_icons'; + + @override + Widget build(BuildContext context) { + return ScaffoldPage.scrollable( + header: const PageHeader(title: Text('MenuBar')), + children: [ + const Text( + 'A MenuBar is a horizontal list of items that can be clicked to show a menu flyout. It is used to provide a list of options to the user.', + ), + subtitle(content: const Text('A simple MenuBar')), + CardHighlight( + codeSnippet: '''''', + child: MenuBar( + items: [ + MenuBarItem(title: 'File', items: [ + MenuFlyoutItem(text: const Text('New'), onPressed: () {}), + MenuFlyoutItem(text: const Text('Open'), onPressed: () {}), + MenuFlyoutItem(text: const Text('Save'), onPressed: () {}), + MenuFlyoutItem(text: const Text('Exit'), onPressed: () {}), + ]), + MenuBarItem(title: 'Edit', items: [ + MenuFlyoutItem(text: const Text('Cut'), onPressed: () {}), + MenuFlyoutItem(text: const Text('Copy'), onPressed: () {}), + MenuFlyoutItem(text: const Text('Paste'), onPressed: () {}), + ]), + MenuBarItem(title: 'Help', items: [ + MenuFlyoutItem(text: const Text('About'), onPressed: () {}), + ]), + ], + ), + ), + subtitle( + content: + const Text('MenuBar with submenus, separators and radio items'), + ), + CardHighlight( + codeSnippet: '''''', + child: MenuBar( + items: [ + MenuBarItem(title: 'File', items: [ + MenuFlyoutSubItem( + text: const Text('New'), + items: (context) { + return [ + MenuFlyoutItem( + text: const Text('Plain Text Documents'), + onPressed: () {}, + ), + MenuFlyoutItem( + text: const Text('Rich Text Documents'), + onPressed: () {}, + ), + MenuFlyoutItem( + text: const Text('Other Formats'), + onPressed: () {}, + ), + ]; + }, + ), + MenuFlyoutItem(text: const Text('Open'), onPressed: () {}), + MenuFlyoutItem(text: const Text('Save'), onPressed: () {}), + const MenuFlyoutSeparator(), + MenuFlyoutItem(text: const Text('Exit'), onPressed: () {}), + ]), + MenuBarItem(title: 'Edit', items: [ + MenuFlyoutItem(text: const Text('Undo'), onPressed: () {}), + MenuFlyoutItem(text: const Text('Cut'), onPressed: () {}), + MenuFlyoutItem(text: const Text('Copy'), onPressed: () {}), + MenuFlyoutItem(text: const Text('Paste'), onPressed: () {}), + ]), + MenuBarItem(title: 'View', items: [ + MenuFlyoutItem(text: const Text('Output'), onPressed: () {}), + const MenuFlyoutSeparator(), + RadioMenuFlyoutItem( + text: const Text('Landscape'), + value: 'landscape', + groupValue: _orientation, + onChanged: (v) => setState(() => _orientation = v), + ), + RadioMenuFlyoutItem( + text: const Text('Portrait'), + value: 'portrait', + groupValue: _orientation, + onChanged: (v) => setState(() => _orientation = v), + ), + const MenuFlyoutSeparator(), + RadioMenuFlyoutItem( + text: const Text('Small icons'), + value: 'small_icons', + groupValue: _iconSize, + onChanged: (v) => setState(() => _iconSize = v), + ), + RadioMenuFlyoutItem( + text: const Text('Medium icons'), + value: 'medium_icons', + groupValue: _iconSize, + onChanged: (v) => setState(() => _iconSize = v), + ), + RadioMenuFlyoutItem( + text: const Text('Large icons'), + value: 'large_icons', + groupValue: _iconSize, + onChanged: (v) => setState(() => _iconSize = v), + ), + ]), + MenuBarItem(title: 'Help', items: [ + MenuFlyoutItem(text: const Text('About'), onPressed: () {}), + ]), + ], + ), + ), + ], + ); + } +} From 00762760fc0f980c4b8043b44df7111e79f30fb0 Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Fri, 31 Jan 2025 23:06:24 -0300 Subject: [PATCH 06/22] fix: FlyoutContent border and MenuFlyout item spacing --- lib/src/controls/flyouts/content.dart | 68 ++++++++++++------- lib/src/controls/flyouts/menu.dart | 33 +++++---- .../navigation/navigation_view/pane.dart | 4 +- .../navigation_view/pane_items.dart | 3 +- 4 files changed, 64 insertions(+), 44 deletions(-) diff --git a/lib/src/controls/flyouts/content.dart b/lib/src/controls/flyouts/content.dart index b103c9684..f95cbf818 100644 --- a/lib/src/controls/flyouts/content.dart +++ b/lib/src/controls/flyouts/content.dart @@ -15,7 +15,7 @@ class FlyoutContent extends StatelessWidget { this.shape, this.padding = const EdgeInsets.all(8.0), this.shadowColor = Colors.black, - this.elevation = 8, + this.elevation = 8.0, this.constraints, this.useAcrylic = true, }); @@ -36,7 +36,7 @@ class FlyoutContent extends StatelessWidget { /// Defaults to 8.0 on each side final EdgeInsetsGeometry padding; - /// The color of the shadow. Not used if [elevation] is 0 + /// The color of the shadow. Not used if [elevation] is 0.0. /// /// Defaults to black. final Color shadowColor; @@ -46,7 +46,7 @@ class FlyoutContent extends StatelessWidget { /// /// See also: /// - /// * [shadowColor] + /// * [shadowColor], the color of the elevation shadow. final double elevation; /// Additional constraints to apply to the child. @@ -58,39 +58,59 @@ class FlyoutContent extends StatelessWidget { @override Widget build(BuildContext context) { assert(debugCheckHasFluentTheme(context)); + assert(debugCheckHasDirectionality(context)); final theme = FluentTheme.of(context); + final textDirection = Directionality.of(context); final resolvedShape = shape ?? RoundedRectangleBorder( - borderRadius: BorderRadius.circular(6.0), + borderRadius: BorderRadius.circular(8.0), side: BorderSide( width: 1, - color: theme.inactiveBackgroundColor, + color: theme.resources.surfaceStrokeColorFlyout, ), ); - return PhysicalModel( - elevation: elevation, - color: Colors.transparent, - shadowColor: shadowColor, - child: Acrylic( - tintAlpha: !useAcrylic ? 1.0 : null, - shape: resolvedShape, - child: Container( - constraints: constraints, - decoration: ShapeDecoration( - color: - color ?? theme.menuColor.withValues(alpha: kMenuColorOpacity), - shape: resolvedShape, - ), - padding: padding, - child: DefaultTextStyle.merge( - style: theme.typography.body, - child: child, - ), + final resolvedBorderRadius = () { + if (resolvedShape is RoundedRectangleBorder) { + return resolvedShape.borderRadius; + } else if (resolvedShape is ContinuousRectangleBorder) { + return resolvedShape.borderRadius; + } else if (resolvedShape is BeveledRectangleBorder) { + return resolvedShape.borderRadius; + } else { + return null; + } + }(); + + final content = Acrylic( + tintAlpha: !useAcrylic ? 1.0 : null, + shape: resolvedShape, + child: Container( + constraints: constraints, + decoration: ShapeDecoration( + color: color ?? theme.menuColor.withValues(alpha: kMenuColorOpacity), + shape: resolvedShape, + ), + padding: padding, + child: DefaultTextStyle.merge( + style: theme.typography.body, + child: child, ), ), ); + + if (elevation > 0.0) { + return PhysicalModel( + elevation: elevation, + color: Colors.transparent, + borderRadius: resolvedBorderRadius?.resolve(textDirection), + shadowColor: shadowColor, + child: content, + ); + } + + return content; } } diff --git a/lib/src/controls/flyouts/menu.dart b/lib/src/controls/flyouts/menu.dart index 30403365e..449304d4e 100644 --- a/lib/src/controls/flyouts/menu.dart +++ b/lib/src/controls/flyouts/menu.dart @@ -3,6 +3,11 @@ import 'dart:async'; import 'package:fluent_ui/fluent_ui.dart'; import 'package:flutter/foundation.dart'; +const kDefaultMenuItemMargin = EdgeInsetsDirectional.symmetric( + horizontal: 4.0, + vertical: 2.0, +); + /// Menu flyouts are used in menu and context menu scenarios to display a list /// of commands or options when requested by the user. A menu flyout shows a /// single, inline, top-level menu that can have menu items and sub-menus. @@ -23,7 +28,7 @@ class MenuFlyout extends StatefulWidget { this.shadowColor = Colors.black, this.elevation = 8.0, this.constraints, - this.padding = const EdgeInsetsDirectional.only(top: 8.0), + this.itemMargin = kDefaultMenuItemMargin, }); /// {@template fluent_ui.flyouts.menu.items} @@ -55,12 +60,8 @@ class MenuFlyout extends StatefulWidget { /// Additional constraints to apply to the child. final BoxConstraints? constraints; - /// The padding applied the [items], with correct handling when scrollable - final EdgeInsetsGeometry? padding; - - static const EdgeInsetsGeometry itemsPadding = EdgeInsets.symmetric( - horizontal: 8.0, - ); + /// The spacing between the items. + final EdgeInsetsGeometry itemMargin; @override State createState() => _MenuFlyoutState(); @@ -102,12 +103,11 @@ class _MenuFlyoutState extends State { elevation: widget.elevation, shadowColor: widget.shadowColor, shape: widget.shape, - padding: EdgeInsets.zero, + padding: const EdgeInsetsDirectional.symmetric(vertical: 2.0), useAcrylic: DisableAcrylic.of(context) != null, child: ScrollConfiguration( behavior: const _MenuScrollBehavior(), child: SingleChildScrollView( - padding: widget.padding, child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, @@ -121,7 +121,10 @@ class _MenuFlyoutState extends State { } return KeyedSubtree( key: item.key, - child: item.build(context), + child: Padding( + padding: widget.itemMargin, + child: item.build(context), + ), ); }), ), @@ -287,10 +290,10 @@ class MenuFlyoutItem extends MenuFlyoutItemBase { @override Widget build(BuildContext context) { final size = Flyout.of(context).size; - return Container( + return SizedBox( width: size.isEmpty ? null : size.width, - padding: MenuFlyout.itemsPadding, child: FlyoutListTile( + margin: EdgeInsets.zero, selected: selected, showSelectedIndicator: false, icon: leading ?? @@ -609,7 +612,7 @@ class _MenuFlyoutSubItemState extends State<_MenuFlyoutSubItem> color: menuFlyout?.color, constraints: menuFlyout?.constraints, elevation: menuFlyout?.elevation ?? 8.0, - padding: menuFlyout?.padding, + itemMargin: menuFlyout?.itemMargin ?? kDefaultMenuItemMargin, shadowColor: menuFlyout?.shadowColor ?? Colors.black, shape: menuFlyout?.shape, items: widget.items(context), @@ -669,7 +672,7 @@ class _SubItemPositionDelegate extends SingleChildLayoutDelegate { Offset getPositionForChild(Size rootSize, Size flyoutSize) { var x = parentRect.left + parentRect.size.width - - MenuFlyout.itemsPadding.horizontal / 2; + kDefaultMenuItemMargin.horizontal / 2; // if the flyout will overflow the screen on the right final willOverflowX = x + flyoutSize.width + margin > rootSize.width; @@ -683,7 +686,7 @@ class _SubItemPositionDelegate extends SingleChildLayoutDelegate { if (willOverflowX) { final rightX = parentRect.left - flyoutSize.width + - MenuFlyout.itemsPadding.horizontal / 2; + kDefaultMenuItemMargin.horizontal / 2; if (rightX > margin) { x = rightX; } else { diff --git a/lib/src/controls/navigation/navigation_view/pane.dart b/lib/src/controls/navigation/navigation_view/pane.dart index 47f25e024..b4d3c78e4 100644 --- a/lib/src/controls/navigation/navigation_view/pane.dart +++ b/lib/src/controls/navigation/navigation_view/pane.dart @@ -896,9 +896,7 @@ class _MenuFlyoutPaneItem extends MenuFlyoutItemBase { return Container( width: size.isEmpty ? null : size.width, - padding: MenuFlyout.itemsPadding - // the scrollbar padding - .add(const EdgeInsetsDirectional.only(end: 4.0)) + padding: const EdgeInsetsDirectional.only(end: 4.0) .add(padding ?? EdgeInsets.zero), height: 36.0, color: ButtonThemeData.uncheckedInputColor( diff --git a/lib/src/controls/navigation/navigation_view/pane_items.dart b/lib/src/controls/navigation/navigation_view/pane_items.dart index b048733bb..73d8611ae 100644 --- a/lib/src/controls/navigation/navigation_view/pane_items.dart +++ b/lib/src/controls/navigation/navigation_view/pane_items.dart @@ -929,9 +929,8 @@ class _PaneItemExpanderMenuItem extends MenuFlyoutItemBase { final theme = FluentTheme.of(context); final navigationTheme = NavigationPaneTheme.of(context); final size = Flyout.of(context).size; - return Container( + return SizedBox( width: size.isEmpty ? null : size.width, - padding: MenuFlyout.itemsPadding, child: HoverButton( onPressed: item.enabled ? onPressed : null, forceEnabled: item.enabled, From bd9381365a3ff81facba018aa068da77391a65a5 Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Fri, 31 Jan 2025 23:06:57 -0300 Subject: [PATCH 07/22] fix: MenuBar flyout transition and positioning --- bin/dictionary_generator.dart | 2 +- lib/src/controls/flyouts/menu_bar.dart | 31 ++++++++++++++++++++------ 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/bin/dictionary_generator.dart b/bin/dictionary_generator.dart index 05e7e77de..e67458793 100644 --- a/bin/dictionary_generator.dart +++ b/bin/dictionary_generator.dart @@ -29,7 +29,7 @@ class ResourceDictionary with Diagnosticable { '''; /// How to generate the resources: -/// - Go on `https://github.com/microsoft/microsoft-ui-xaml/blob/main/dev/CommonStyles/Common_themeresources_any.xaml` +/// - Go on `https://github.com/microsoft/microsoft-ui-xaml/blob/main/src/controls/dev/CommonStyles/Common_themeresources_any.xaml` /// - Copy the colors under and paste them on [defaultResourceDirectionary] /// - Copy the colors under and paste them on [lightResourceDictionary] /// - Run the generator with `dart bin/dictionary_generator.dart.dart` while being diff --git a/lib/src/controls/flyouts/menu_bar.dart b/lib/src/controls/flyouts/menu_bar.dart index d5c1a6d8a..383230c61 100644 --- a/lib/src/controls/flyouts/menu_bar.dart +++ b/lib/src/controls/flyouts/menu_bar.dart @@ -59,6 +59,12 @@ class MenuBar extends StatefulWidget with Diagnosticable { class _MenuBarState extends State { final _controller = FlyoutController(); + static const barPadding = EdgeInsetsDirectional.symmetric( + horizontal: 10.0, + vertical: 4.0, + ); + static const barMargin = EdgeInsetsDirectional.all(4.0); + @override void dispose() { _controller.dispose(); @@ -101,16 +107,30 @@ class _MenuBarState extends State { _locked = false; _currentOpenItem = item; + final resolvedBarMargin = barMargin.resolve(textDirection); final future = _controller.showFlyout( buildTarget: true, placementMode: FlyoutPlacementMode.bottomLeft.resolve(textDirection), reverseTransitionDuration: Duration.zero, barrierColor: Colors.transparent, - additionalOffset: 0.0, - position: Offset(position.dx, position.dy + size.height), + position: Offset( + position.dx + resolvedBarMargin.left, + position.dy + size.height - resolvedBarMargin.bottom, + ), builder: (context) { return MenuFlyout(items: item!.items); }, + transitionBuilder: (context, animation, placement, child) { + return ClipRect( + child: SlideTransition( + position: Tween( + begin: const Offset(0.0, -1.0), + end: Offset.zero, + ).animate(animation), + child: child, + ), + ); + }, ); setState(() {}); await future; @@ -148,11 +168,8 @@ class _MenuBarState extends State { : null, builder: (context, states) { return Container( - padding: const EdgeInsetsDirectional.symmetric( - horizontal: 10.0, - vertical: 4.0, - ), - margin: const EdgeInsetsDirectional.all(4.0), + padding: barPadding, + margin: barMargin, decoration: BoxDecoration( color: HyperlinkButton.backgroundColor(theme) .resolve(states), From e18cfc5834bc925cb2d673e654c3099cea06f8d0 Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Fri, 31 Jan 2025 23:10:58 -0300 Subject: [PATCH 08/22] fix: Menu flyout sub item position overflowing --- lib/src/controls/flyouts/menu.dart | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/lib/src/controls/flyouts/menu.dart b/lib/src/controls/flyouts/menu.dart index 449304d4e..93a391b31 100644 --- a/lib/src/controls/flyouts/menu.dart +++ b/lib/src/controls/flyouts/menu.dart @@ -670,9 +670,7 @@ class _SubItemPositionDelegate extends SingleChildLayoutDelegate { @override Offset getPositionForChild(Size rootSize, Size flyoutSize) { - var x = parentRect.left + - parentRect.size.width - - kDefaultMenuItemMargin.horizontal / 2; + var x = parentRect.left + parentRect.size.width; // if the flyout will overflow the screen on the right final willOverflowX = x + flyoutSize.width + margin > rootSize.width; @@ -684,9 +682,7 @@ class _SubItemPositionDelegate extends SingleChildLayoutDelegate { // // otherwise, we position the flyout at the end of the screen if (willOverflowX) { - final rightX = parentRect.left - - flyoutSize.width + - kDefaultMenuItemMargin.horizontal / 2; + final rightX = parentRect.left - flyoutSize.width; if (rightX > margin) { x = rightX; } else { @@ -710,7 +706,9 @@ class _SubItemPositionDelegate extends SingleChildLayoutDelegate { } @override - bool shouldRelayout(covariant SingleChildLayoutDelegate oldDelegate) { - return true; + bool shouldRelayout(covariant _SubItemPositionDelegate oldDelegate) { + return oldDelegate.parentRect != parentRect || + oldDelegate.parentSize != parentSize || + oldDelegate.margin != margin; } } From 3440bddcbeb1763403f834eb0b60cd67126307fa Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Fri, 31 Jan 2025 23:45:47 -0300 Subject: [PATCH 09/22] fix: No longer require 2 frames to calculate flyout layout size --- lib/src/controls/flyouts/content_manager.dart | 25 ++--- lib/src/controls/flyouts/flyout.dart | 1 + lib/src/controls/flyouts/menu.dart | 72 +++++++------- .../navigation/navigation_view/pane.dart | 44 ++++----- .../navigation_view/pane_items.dart | 97 +++++++++---------- lib/src/controls/surfaces/commandbar.dart | 16 +-- lib/src/controls/surfaces/list_tile.dart | 4 +- 7 files changed, 123 insertions(+), 136 deletions(-) diff --git a/lib/src/controls/flyouts/content_manager.dart b/lib/src/controls/flyouts/content_manager.dart index 0e6ca1465..a1f47f7db 100644 --- a/lib/src/controls/flyouts/content_manager.dart +++ b/lib/src/controls/flyouts/content_manager.dart @@ -16,6 +16,7 @@ class Flyout extends StatefulWidget { final double margin; final Duration transitionDuration; + final Duration reverseTransitionDuration; /// Create a flyout. const Flyout({ @@ -27,6 +28,7 @@ class Flyout extends StatefulWidget { this.additionalOffset = 0.0, this.margin = 0.0, this.transitionDuration = Duration.zero, + this.reverseTransitionDuration = Duration.zero, }); /// Gets the current flyout info @@ -45,9 +47,6 @@ class Flyout extends StatefulWidget { class FlyoutState extends State { final _key = GlobalKey(debugLabel: 'FlyoutState key'); - /// The current size of the flyout - Size size = Size.zero; - /// The flyout in the beggining of the flyout tree GlobalKey get rootFlyout => widget.rootFlyout!; @@ -60,20 +59,12 @@ class FlyoutState extends State { /// The duration of the transition animation Duration get transitionDuration => widget.transitionDuration; - @override - void initState() { - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - final context = _key.currentContext; - if (context == null) return; - final box = context.findRenderObject() as RenderBox; - setState(() => size = box.size); - }); - super.initState(); - } + /// The duration of the reverse transition animation + Duration get reverseTransitionDuration => widget.reverseTransitionDuration; - /// Closes the current open flyout + /// Closes the current open flyout. /// - /// If the current flyout is a sub menu, the submenu is closed + /// If the current flyout is a sub menu, the submenu is closed. void close() { if (widget.menuKey != null) { MenuInfoProvider.of(context).remove(widget.menuKey!); @@ -91,7 +82,9 @@ class FlyoutState extends State { Widget build(BuildContext context) { return KeyedSubtree( key: _key, - child: Builder(builder: widget.builder), + child: IntrinsicWidth( + child: Builder(builder: widget.builder), + ), ); } } diff --git a/lib/src/controls/flyouts/flyout.dart b/lib/src/controls/flyouts/flyout.dart index 09bb8001d..3fad7af8c 100644 --- a/lib/src/controls/flyouts/flyout.dart +++ b/lib/src/controls/flyouts/flyout.dart @@ -688,6 +688,7 @@ class FlyoutController with ChangeNotifier { additionalOffset: additionalOffset, margin: margin, transitionDuration: transitionDuration!, + reverseTransitionDuration: reverseTransitionDuration!, root: navigator, builder: (context) { final parentBox = diff --git a/lib/src/controls/flyouts/menu.dart b/lib/src/controls/flyouts/menu.dart index 93a391b31..69f1ba107 100644 --- a/lib/src/controls/flyouts/menu.dart +++ b/lib/src/controls/flyouts/menu.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:fluent_ui/fluent_ui.dart'; import 'package:flutter/foundation.dart'; +const kDefaultMenuPadding = EdgeInsetsDirectional.symmetric(vertical: 2.0); const kDefaultMenuItemMargin = EdgeInsetsDirectional.symmetric( horizontal: 4.0, vertical: 2.0, @@ -99,11 +100,11 @@ class _MenuFlyoutState extends State { Widget content = FlyoutContent( color: widget.color, - constraints: widget.constraints, + constraints: widget.constraints ?? kFlyoutMinConstraints, elevation: widget.elevation, shadowColor: widget.shadowColor, shape: widget.shape, - padding: const EdgeInsetsDirectional.symmetric(vertical: 2.0), + padding: kDefaultMenuPadding, useAcrylic: DisableAcrylic.of(context) != null, child: ScrollConfiguration( behavior: const _MenuScrollBehavior(), @@ -289,32 +290,28 @@ class MenuFlyoutItem extends MenuFlyoutItemBase { @override Widget build(BuildContext context) { - final size = Flyout.of(context).size; - return SizedBox( - width: size.isEmpty ? null : size.width, - child: FlyoutListTile( - margin: EdgeInsets.zero, - selected: selected, - showSelectedIndicator: false, - icon: leading ?? - () { - if (_useIconPlaceholder) return const Icon(null); - return null; - }(), - text: text, - trailing: IconTheme.merge( - data: const IconThemeData(size: 12.0), - child: trailing ?? const SizedBox.shrink(), - ), - onPressed: onPressed == null - ? null - : () { - if (closeAfterClick) Navigator.of(context).maybePop(); - onPressed?.call(); - }, - onLongPress: onLongPress, - focusNode: focusNode, + return FlyoutListTile( + margin: EdgeInsets.zero, + selected: selected, + showSelectedIndicator: false, + icon: leading ?? + () { + if (_useIconPlaceholder) return const Icon(null); + return null; + }(), + text: text, + trailing: IconTheme.merge( + data: const IconThemeData(size: 12.0), + child: trailing ?? const SizedBox.shrink(), ), + onPressed: onPressed == null + ? null + : () { + if (closeAfterClick) Navigator.of(context).maybePop(); + onPressed?.call(); + }, + onLongPress: onLongPress, + focusNode: focusNode, ); } } @@ -337,15 +334,10 @@ class MenuFlyoutSeparator extends MenuFlyoutItemBase { @override Widget build(BuildContext context) { - final size = Flyout.of(context).size; - - return SizedBox( - width: size.width, - child: const Padding( - padding: EdgeInsetsDirectional.only(bottom: 5.0), - child: Divider( - style: DividerThemeData(horizontalMargin: EdgeInsets.zero), - ), + return const Padding( + padding: EdgeInsetsDirectional.only(bottom: 5.0), + child: Divider( + style: DividerThemeData(horizontalMargin: EdgeInsets.zero), ), ); } @@ -522,6 +514,14 @@ class _MenuFlyoutSubItemState extends State<_MenuFlyoutSubItem> Timer? showTimer; + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final parent = Flyout.of(context); + transitionController.duration = parent.transitionDuration; + transitionController.reverseDuration = parent.reverseTransitionDuration; + } + @override void dispose() { transitionController.dispose(); diff --git a/lib/src/controls/navigation/navigation_view/pane.dart b/lib/src/controls/navigation/navigation_view/pane.dart index b4d3c78e4..ac78c6b7b 100644 --- a/lib/src/controls/navigation/navigation_view/pane.dart +++ b/lib/src/controls/navigation/navigation_view/pane.dart @@ -855,7 +855,6 @@ class _MenuFlyoutPaneItem extends MenuFlyoutItemBase { @override Widget build(BuildContext context) { assert(debugCheckHasFluentTheme(context)); - final size = Flyout.of(context).size; final theme = NavigationPaneTheme.of(context); final fluentTheme = FluentTheme.of(context); final view = InheritedNavigationView.of(context); @@ -895,7 +894,6 @@ class _MenuFlyoutPaneItem extends MenuFlyoutItemBase { : const SizedBox.shrink(); return Container( - width: size.isEmpty ? null : size.width, padding: const EdgeInsetsDirectional.only(end: 4.0) .add(padding ?? EdgeInsets.zero), height: 36.0, @@ -935,8 +933,7 @@ class _MenuFlyoutPaneItem extends MenuFlyoutItemBase { child: Center(child: item.icon), ), ), - Flexible( - fit: size.isEmpty ? FlexFit.loose : FlexFit.tight, + Expanded( child: textResult, ), if (item.infoBadge != null) item.infoBadge!, @@ -1011,12 +1008,10 @@ class _MenuFlyoutPaneItemExpanderState @override Widget build(BuildContext context) { - final size = Flyout.of(context).size; assert(debugCheckHasFluentTheme(context)); final theme = FluentTheme.of(context); return SizedBox( - width: size.isEmpty ? null : size.width, child: Column(mainAxisSize: MainAxisSize.min, children: [ _MenuFlyoutPaneItem( item: widget.item, @@ -1036,25 +1031,24 @@ class _MenuFlyoutPaneItemExpanderState child: const Icon(FluentIcons.chevron_down, size: 10.0), ), ).build(context), - if (!size.isEmpty) - AnimatedSize( - duration: theme.fastAnimationDuration, - curve: Curves.easeIn, - child: !_open - ? const SizedBox() - : Column( - mainAxisSize: MainAxisSize.min, - children: widget.item.items.map((item) { - return _buildMenuPaneItem( - context, - item, - widget.onItemPressed, - paneItemPadding: - const EdgeInsetsDirectional.only(start: 24.0), - ).build(context); - }).toList(), - ), - ), + AnimatedSize( + duration: theme.fastAnimationDuration, + curve: Curves.easeIn, + child: !_open + ? const SizedBox() + : Column( + mainAxisSize: MainAxisSize.min, + children: widget.item.items.map((item) { + return _buildMenuPaneItem( + context, + item, + widget.onItemPressed, + paneItemPadding: + const EdgeInsetsDirectional.only(start: 24.0), + ).build(context); + }).toList(), + ), + ), ]), ); } diff --git a/lib/src/controls/navigation/navigation_view/pane_items.dart b/lib/src/controls/navigation/navigation_view/pane_items.dart index 73d8611ae..4a190bb98 100644 --- a/lib/src/controls/navigation/navigation_view/pane_items.dart +++ b/lib/src/controls/navigation/navigation_view/pane_items.dart @@ -928,62 +928,57 @@ class _PaneItemExpanderMenuItem extends MenuFlyoutItemBase { assert(debugCheckHasFluentTheme(context)); final theme = FluentTheme.of(context); final navigationTheme = NavigationPaneTheme.of(context); - final size = Flyout.of(context).size; - return SizedBox( - width: size.isEmpty ? null : size.width, - child: HoverButton( - onPressed: item.enabled ? onPressed : null, - forceEnabled: item.enabled, - builder: (context, states) { - final textStyle = (isSelected - ? navigationTheme.selectedTextStyle?.resolve(states) - : navigationTheme.unselectedTextStyle?.resolve(states)) ?? - const TextStyle(); - final iconTheme = IconThemeData( - color: textStyle.color ?? - (isSelected - ? navigationTheme.selectedIconColor?.resolve(states) - : navigationTheme.unselectedIconColor?.resolve(states)), - size: textStyle.fontSize ?? 16.0, - ); - return Container( - padding: const EdgeInsets.symmetric( - horizontal: 10.0, - vertical: 8.0, + return HoverButton( + onPressed: item.enabled ? onPressed : null, + forceEnabled: item.enabled, + builder: (context, states) { + final textStyle = (isSelected + ? navigationTheme.selectedTextStyle?.resolve(states) + : navigationTheme.unselectedTextStyle?.resolve(states)) ?? + const TextStyle(); + final iconTheme = IconThemeData( + color: textStyle.color ?? + (isSelected + ? navigationTheme.selectedIconColor?.resolve(states) + : navigationTheme.unselectedIconColor?.resolve(states)), + size: textStyle.fontSize ?? 16.0, + ); + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 10.0, + vertical: 8.0, + ), + margin: const EdgeInsetsDirectional.only(bottom: 4.0), + decoration: BoxDecoration( + color: ButtonThemeData.uncheckedInputColor( + theme, + states, + transparentWhenNone: true, ), - margin: const EdgeInsetsDirectional.only(bottom: 4.0), - decoration: BoxDecoration( - color: ButtonThemeData.uncheckedInputColor( - theme, - states, - transparentWhenNone: true, + borderRadius: BorderRadius.circular(6.0), + ), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Padding( + padding: const EdgeInsetsDirectional.only(end: 12.0), + child: IconTheme.merge( + data: iconTheme, + child: item.icon, ), - borderRadius: BorderRadius.circular(6.0), ), - child: Row(mainAxisSize: MainAxisSize.min, children: [ - Padding( - padding: const EdgeInsetsDirectional.only(end: 12.0), - child: IconTheme.merge( - data: iconTheme, - child: item.icon, - ), + Expanded( + child: DefaultTextStyle( + style: textStyle, + child: item.title ?? const SizedBox.shrink(), ), - Flexible( - fit: size.isEmpty ? FlexFit.loose : FlexFit.tight, - child: DefaultTextStyle( - style: textStyle, - child: item.title ?? const SizedBox.shrink(), - ), + ), + if (item.infoBadge != null) + Padding( + padding: const EdgeInsetsDirectional.only(start: 8.0), + child: item.infoBadge!, ), - if (item.infoBadge != null) - Padding( - padding: const EdgeInsetsDirectional.only(start: 8.0), - child: item.infoBadge!, - ), - ]), - ); - }, - ), + ]), + ); + }, ); } } diff --git a/lib/src/controls/surfaces/commandbar.dart b/lib/src/controls/surfaces/commandbar.dart index 52a8ecaac..ceb628c11 100644 --- a/lib/src/controls/surfaces/commandbar.dart +++ b/lib/src/controls/surfaces/commandbar.dart @@ -212,16 +212,20 @@ class CommandBarState extends State { : FlyoutPlacementMode.right) .resolve(Directionality.of(context)), ), + additionalOffset: 0.0, builder: (context) { return FlyoutContent( + padding: kDefaultMenuPadding, constraints: const BoxConstraints(maxWidth: 200.0), - padding: const EdgeInsetsDirectional.only(top: 8.0), - child: ListView( - shrinkWrap: true, + child: Column( + mainAxisSize: MainAxisSize.min, children: allSecondaryItems.map((item) { - return item.build( - context, - CommandBarItemDisplayMode.inSecondary, + return Padding( + padding: kDefaultMenuItemMargin, + child: item.build( + context, + CommandBarItemDisplayMode.inSecondary, + ), ); }).toList(), ), diff --git a/lib/src/controls/surfaces/list_tile.dart b/lib/src/controls/surfaces/list_tile.dart index 005250b12..73f7ddf24 100644 --- a/lib/src/controls/surfaces/list_tile.dart +++ b/lib/src/controls/surfaces/list_tile.dart @@ -294,8 +294,8 @@ class ListTile extends StatelessWidget { ), margin: const EdgeInsets.symmetric(horizontal: 4.0, vertical: 2.0), - child: Flyout(builder: (context) { - final tileHeight = Flyout.of(context).size.height; + child: LayoutBuilder(builder: (context, constraints) { + final tileHeight = constraints.minHeight; return Row(children: [ if (selectionMode == ListTileSelectionMode.none) placeholder From 73c9eaf387ad77c8e6da7492c4c578c8eaf23fbb Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Sat, 1 Feb 2025 00:04:28 -0300 Subject: [PATCH 10/22] feat: Flyout horizontal offset --- lib/src/controls/flyouts/flyout.dart | 34 ++++++++++++++++------------ 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/lib/src/controls/flyouts/flyout.dart b/lib/src/controls/flyouts/flyout.dart index 3fad7af8c..a3e77c045 100644 --- a/lib/src/controls/flyouts/flyout.dart +++ b/lib/src/controls/flyouts/flyout.dart @@ -514,7 +514,11 @@ class FlyoutController with ChangeNotifier { /// bounds of the closest [Navigator]. If false, the flyout may overflow the /// screen on all sides. Defaults to `true` /// - /// [additionalOffset] is the offset of the flyout around the attached target + /// [additionalOffset] is the offset of the flyout around the attached target. + /// This value can not be negative. + /// + /// [horizontalOffset] is the horizontal offset of the flyout around the + /// attached target. Defaults to 0.0. /// /// [margin] is the margin of the flyout to the root bounds /// @@ -555,6 +559,7 @@ class FlyoutController with ChangeNotifier { bool forceAvailableSpace = false, bool shouldConstrainToRootBounds = true, double additionalOffset = 8.0, + double horizontalOffset = 0.0, double margin = 8.0, Color? barrierColor, NavigatorState? navigatorKey, @@ -590,7 +595,7 @@ class FlyoutController with ChangeNotifier { Offset.zero, ancestor: navigatorBox, ) + - Offset(0, targetSize.height); + Offset(horizontalOffset, targetSize.height); targetRect = targetBox.localToGlobal( Offset.zero, ancestor: navigatorBox, @@ -611,28 +616,29 @@ class FlyoutController with ChangeNotifier { pageBuilder: (context, animation, secondary) { transitionBuilder ??= (context, animation, placementMode, flyout) { switch (placementMode) { - case FlyoutPlacementMode.bottomCenter: - case FlyoutPlacementMode.bottomLeft: - case FlyoutPlacementMode.bottomRight: - return SlideTransition( - position: Tween( - begin: const Offset(0, -0.05), - end: const Offset(0, 0), - ).animate(animation), - child: flyout, - ); case FlyoutPlacementMode.topCenter: case FlyoutPlacementMode.topLeft: case FlyoutPlacementMode.topRight: return SlideTransition( position: Tween( - begin: const Offset(0, 0.05), + begin: const Offset(0, 0.15), end: const Offset(0, 0), ).animate(animation), child: flyout, ); + case FlyoutPlacementMode.bottomCenter: + case FlyoutPlacementMode.bottomLeft: + case FlyoutPlacementMode.bottomRight: default: - return flyout; + return ClipRect( + child: SlideTransition( + position: Tween( + begin: const Offset(0.0, -0.15), + end: Offset.zero, + ).animate(animation), + child: flyout, + ), + ); } }; From dd3281d14c64a84cc615856aa3114ca676c56cf9 Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Sat, 1 Feb 2025 00:22:35 -0300 Subject: [PATCH 11/22] chore: MenuBar example snippets --- example/lib/screens/popups/menu_bar.dart | 111 ++++++++++++++++++++++- 1 file changed, 109 insertions(+), 2 deletions(-) diff --git a/example/lib/screens/popups/menu_bar.dart b/example/lib/screens/popups/menu_bar.dart index 5a5e15bc6..c82e14f3c 100644 --- a/example/lib/screens/popups/menu_bar.dart +++ b/example/lib/screens/popups/menu_bar.dart @@ -23,7 +23,37 @@ class _MenuBarPageState extends State with PageMixin { ), subtitle(content: const Text('A simple MenuBar')), CardHighlight( - codeSnippet: '''''', + codeSnippet: '''var _orientation = 'landscape'; +var _iconSize = 'medium_icons';'' + +MenuBar( + items: [ + MenuFlyoutSubItem( + text: const Text('New'), + items: (context) { + return [ + MenuFlyoutItem( + text: const Text('Plain Text Documents'), + onPressed: () {}, + ), + MenuFlyoutItem( + text: const Text('Rich Text Documents'), + onPressed: () {}, + ), + MenuFlyoutItem( + text: const Text('Other Formats'), + onPressed: () {}, + ), + ]; + }, + ), + MenuFlyoutItem(text: const Text('Open'), onPressed: () {}), + MenuFlyoutItem(text: const Text('Save'), onPressed: () {}), + const MenuFlyoutSeparator(), + MenuFlyoutItem(text: const Text('Exit'), onPressed: () {}), + ] +) +''', child: MenuBar( items: [ MenuBarItem(title: 'File', items: [ @@ -48,7 +78,84 @@ class _MenuBarPageState extends State with PageMixin { const Text('MenuBar with submenus, separators and radio items'), ), CardHighlight( - codeSnippet: '''''', + codeSnippet: '''var _orientation = 'landscape'; +var _iconSize = 'medium_icons';'' + +MenuBar( + items: [ + MenuBarItem(title: 'File', items: [ + MenuFlyoutSubItem( + text: const Text('New'), + items: (context) { + return [ + MenuFlyoutItem( + text: const Text('Plain Text Documents'), + onPressed: () {}, + ), + MenuFlyoutItem( + text: const Text('Rich Text Documents'), + onPressed: () {}, + ), + MenuFlyoutItem( + text: const Text('Other Formats'), + onPressed: () {}, + ), + ]; + }, + ), + MenuFlyoutItem(text: const Text('Open'), onPressed: () {}), + MenuFlyoutItem(text: const Text('Save'), onPressed: () {}), + const MenuFlyoutSeparator(), + MenuFlyoutItem(text: const Text('Exit'), onPressed: () {}), + ]), + MenuBarItem(title: 'Edit', items: [ + MenuFlyoutItem(text: const Text('Undo'), onPressed: () {}), + MenuFlyoutItem(text: const Text('Cut'), onPressed: () {}), + MenuFlyoutItem(text: const Text('Copy'), onPressed: () {}), + MenuFlyoutItem(text: const Text('Paste'), onPressed: () {}), + ]), + MenuBarItem(title: 'View', items: [ + MenuFlyoutItem(text: const Text('Output'), onPressed: () {}), + const MenuFlyoutSeparator(), + RadioMenuFlyoutItem( + text: const Text('Landscape'), + value: 'landscape', + groupValue: _orientation, + onChanged: (v) => setState(() => _orientation = v), + ), + RadioMenuFlyoutItem( + text: const Text('Portrait'), + value: 'portrait', + groupValue: _orientation, + onChanged: (v) => setState(() => _orientation = v), + ), + const MenuFlyoutSeparator(), + RadioMenuFlyoutItem( + text: const Text('Small icons'), + value: 'small_icons', + groupValue: _iconSize, + onChanged: (v) => setState(() => _iconSize = v), + ), + RadioMenuFlyoutItem( + text: const Text('Medium icons'), + value: 'medium_icons', + groupValue: _iconSize, + onChanged: (v) => setState(() => _iconSize = v), + ), + RadioMenuFlyoutItem( + text: const Text('Large icons'), + value: 'large_icons', + groupValue: _iconSize, + onChanged: (v) => setState(() => _iconSize = v), + ), + ]), + MenuBarItem(title: 'Help', items: [ + MenuFlyoutItem(text: const Text('About'), onPressed: () {}), + ]), + ] + ] +) +''', child: MenuBar( items: [ MenuBarItem(title: 'File', items: [ From c81ca029d54afb353c0fe77a82081d2a8a02171f Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Sat, 1 Feb 2025 00:22:48 -0300 Subject: [PATCH 12/22] fix: FlyoutContent min constraints --- lib/src/controls/flyouts/content.dart | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/lib/src/controls/flyouts/content.dart b/lib/src/controls/flyouts/content.dart index f95cbf818..97a568081 100644 --- a/lib/src/controls/flyouts/content.dart +++ b/lib/src/controls/flyouts/content.dart @@ -1,5 +1,8 @@ import 'package:fluent_ui/fluent_ui.dart'; +/// Eyeballed value from Windows Home 11. +const kFlyoutMinConstraints = BoxConstraints(minWidth: 118); + /// The content of the flyout /// /// See also: @@ -16,7 +19,7 @@ class FlyoutContent extends StatelessWidget { this.padding = const EdgeInsets.all(8.0), this.shadowColor = Colors.black, this.elevation = 8.0, - this.constraints, + this.constraints = kFlyoutMinConstraints, this.useAcrylic = true, }); @@ -49,8 +52,10 @@ class FlyoutContent extends StatelessWidget { /// * [shadowColor], the color of the elevation shadow. final double elevation; - /// Additional constraints to apply to the child. - final BoxConstraints? constraints; + /// Constraints to apply to the child. + /// + /// Defaults to [kFlyoutMinConstraints]. + final BoxConstraints constraints; /// Whether the background will be an [Acrylic]. final bool useAcrylic; @@ -180,7 +185,6 @@ class FlyoutListTile extends StatelessWidget { @override Widget build(BuildContext context) { assert(debugCheckHasFluentTheme(context)); - final size = Flyout.maybeOf(context)?.size; return HoverButton( key: key, @@ -225,10 +229,7 @@ class FlyoutListTile extends StatelessWidget { child: icon!, ), ), - Flexible( - fit: size == null || size.isEmpty - ? FlexFit.loose - : FlexFit.tight, + Expanded( child: Padding( padding: const EdgeInsetsDirectional.only(end: 10.0), child: DefaultTextStyle.merge( From db8fd5f1e48bf3ef173776378617c947b1b5aee1 Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Sat, 1 Feb 2025 00:51:21 -0300 Subject: [PATCH 13/22] fix: Correctly passing the flyout placement mode to transition builder --- lib/src/controls/flyouts/flyout.dart | 430 ++++++++++++------ lib/src/controls/flyouts/menu_bar.dart | 47 +- lib/src/controls/form/selection_controls.dart | 2 +- 3 files changed, 306 insertions(+), 173 deletions(-) diff --git a/lib/src/controls/flyouts/flyout.dart b/lib/src/controls/flyouts/flyout.dart index a3e77c045..49ddfc0d8 100644 --- a/lib/src/controls/flyouts/flyout.dart +++ b/lib/src/controls/flyouts/flyout.dart @@ -3,7 +3,6 @@ import 'dart:math' as math; import 'package:fluent_ui/fluent_ui.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; -import 'package:flutter/rendering.dart'; /// Defines constants that specify the preferred location for positioning a /// flyout derived control relative to a visual element. @@ -151,6 +150,7 @@ enum FlyoutPlacementMode { FlyoutAutoConfiguration configuration, ) { assert(this == FlyoutPlacementMode.auto); + assert(configuration.autoAvailableSpace != null); // as = available space @@ -284,6 +284,7 @@ class _FlyoutPositionDelegate extends SingleChildLayoutDelegate { required this.margin, required this.shouldConstrainToRootBounds, required this.forceAvailableSpace, + required this.onAutoModeChange, }); final Offset targetOffset; @@ -298,6 +299,8 @@ class _FlyoutPositionDelegate extends SingleChildLayoutDelegate { final bool forceAvailableSpace; + final ValueChanged onAutoModeChange; + @override BoxConstraints getConstraintsForChild(BoxConstraints constraints) { if (forceAvailableSpace) { @@ -346,6 +349,10 @@ class _FlyoutPositionDelegate extends SingleChildLayoutDelegate { ); } + if (autoPlacementMode != placementMode) { + onAutoModeChange(autoPlacementMode!); + } + double clampHorizontal(double x) { if (!shouldConstrainToRootBounds) return x; @@ -428,7 +435,7 @@ class _FlyoutPositionDelegate extends SingleChildLayoutDelegate { } @override - bool shouldRelayout(_FlyoutPositionDelegate oldDelegate) { + bool shouldRelayout(covariant _FlyoutPositionDelegate oldDelegate) { return targetOffset != oldDelegate.targetOffset || placementMode != oldDelegate.placementMode; } @@ -537,6 +544,8 @@ class FlyoutController with ChangeNotifier { /// animation. By default, [transitionDuration] is used. Set to [Duration.zero] /// to disable transitions at all. /// + /// [transitionCurve] configures the curve of the transition animation. + /// /// [position] lets you position the flyout anywhere on the screen, making it /// possible to create context menus. If provided, [placementMode] is ignored. /// @@ -566,6 +575,7 @@ class FlyoutController with ChangeNotifier { FlyoutTransitionBuilder? transitionBuilder, Duration? transitionDuration, Duration? reverseTransitionDuration, + Curve transitionCurve = Curves.linear, Offset? position, RouteSettings? settings, GestureRecognizer? barrierRecognizer, @@ -621,7 +631,7 @@ class FlyoutController with ChangeNotifier { case FlyoutPlacementMode.topRight: return SlideTransition( position: Tween( - begin: const Offset(0, 0.15), + begin: const Offset(0, 0.25), end: const Offset(0, 0), ).animate(animation), child: flyout, @@ -642,150 +652,35 @@ class FlyoutController with ChangeNotifier { } }; - return MenuInfoProvider( - builder: (context, rootSize, menus, keys) { - assert(menus.length == keys.length); - - final barrier = ColoredBox( - color: barrierColor ?? Colors.black.withValues(alpha: 0.3), - ); - - Widget box = Stack(children: [ - if (barrierRecognizer != null) - Positioned.fill( - child: Listener( - behavior: HitTestBehavior.opaque, - onPointerDown: (event) { - barrierRecognizer.addPointer(event); - }, - child: barrier, - ), - ) - else if (barrierDismissible) - Positioned.fill( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: barrierDismissible ? navigator.pop : null, - child: barrier, - ), - ), - if (buildTarget) - Positioned.fromRect( - rect: targetRect, - child: _attachState!.build(context), - ), - Positioned.fill( - child: SafeArea( - child: CustomSingleChildLayout( - delegate: _FlyoutPositionDelegate( - targetOffset: position ?? targetOffset, - targetSize: position == null ? targetSize : Size.zero, - autoModeConfiguration: autoModeConfiguration, - placementMode: placementMode, - defaultPreferred: position == null - ? FlyoutPlacementMode.topCenter - : FlyoutPlacementMode.bottomLeft, - margin: margin, - shouldConstrainToRootBounds: shouldConstrainToRootBounds, - forceAvailableSpace: forceAvailableSpace, - ), - child: Flyout( - rootFlyout: flyoutKey, - additionalOffset: additionalOffset, - margin: margin, - transitionDuration: transitionDuration!, - reverseTransitionDuration: reverseTransitionDuration!, - root: navigator, - builder: (context) { - final parentBox = - context.findAncestorRenderObjectOfType< - RenderCustomSingleChildLayoutBox>()!; - final delegate = - parentBox.delegate as _FlyoutPositionDelegate; - - final realPlacementMode = delegate.autoPlacementMode ?? - delegate.placementMode; - final flyout = Padding( - key: flyoutKey, - padding: - realPlacementMode._getAdditionalOffsetPosition( - position == null ? additionalOffset : 0.0, - ), - child: builder(context), - ); - - return transitionBuilder!( - context, - animation, - realPlacementMode, - flyout, - ); - }, - ), - ), - ), - ), - ...menus, - ]); - - if (dismissOnPointerMoveAway) { - box = MouseRegion( - onHover: (hover) { - if (flyoutKey.currentContext == null) return; - - final navigatorBox = - navigator.context.findRenderObject() as RenderBox; - - // the flyout box needs to be fetched at each [onHover] because the - // flyout size may change (a MenuFlyout, for example) - final flyoutBox = - flyoutKey.currentContext!.findRenderObject() as RenderBox; - final flyoutRect = flyoutBox.localToGlobal( - Offset.zero, - ancestor: navigatorBox, - ) & - flyoutBox.size; - final menusRects = keys.map((key) { - if (key.currentContext == null) return Rect.zero; - - final menuBox = - key.currentContext!.findRenderObject() as RenderBox; - return menuBox.localToGlobal( - Offset.zero, - ancestor: navigatorBox, - ) & - menuBox.size; - }); - - if (!flyoutRect.contains(hover.position) && - !targetRect.contains(hover.position) && - !menusRects - .any((rect) => rect.contains(hover.position))) { - navigator.pop(); - } - }, - child: box, - ); - } - - if (dismissWithEsc) { - box = Actions( - actions: {DismissIntent: _DismissAction(navigator.pop)}, - child: FocusScope( - autofocus: true, - child: box, - ), - ); - } - - return FadeTransition( - opacity: CurvedAnimation( - curve: Curves.ease, - parent: animation, - ), - child: box, - ); - }, + return _FlyoutPage( + navigator: navigator, + targetRect: targetRect, + attachState: _attachState, + targetOffset: targetOffset, + targetSize: targetSize, + flyoutKey: flyoutKey, + navigatorBox: navigatorBox, + barrierColor: barrierColor, + barrierRecognizer: barrierRecognizer, + barrierDismissible: barrierDismissible, + dismissWithEsc: dismissWithEsc, + dismissOnPointerMoveAway: dismissOnPointerMoveAway, + placementMode: placementMode, + autoModeConfiguration: autoModeConfiguration, + forceAvailableSpace: forceAvailableSpace, + shouldConstrainToRootBounds: shouldConstrainToRootBounds, + additionalOffset: additionalOffset, + margin: margin, + transitionBuilder: transitionBuilder, + animation: CurvedAnimation( + curve: transitionCurve, + parent: animation, + ), + transitionDuration: transitionDuration, + reverseTransitionDuration: reverseTransitionDuration, + position: position, + builder: builder, + buildTarget: buildTarget, ); }, )); @@ -807,6 +702,247 @@ class FlyoutController with ChangeNotifier { } } +/// This is a hacky way to calculate the automatic flyout mode and pass it to +/// the [transitionBuilder] callback. +/// +/// First, it renders the flyout with a [CustomSingleChildLayout] and, with a +/// callback, gets the parsed mode. Then, it rebuilds only the actual flyout +/// widget using a [StatefulBuilder] and a [GlobalKey], passing the parsed mode +/// to the [transitionBuilder] callback. +class _FlyoutPage extends StatefulWidget { + const _FlyoutPage({ + required this.navigator, + required this.targetRect, + required _FlyoutTargetState? attachState, + required this.targetOffset, + required this.targetSize, + required this.flyoutKey, + required this.navigatorBox, + required this.barrierColor, + required this.barrierRecognizer, + required this.barrierDismissible, + required this.dismissWithEsc, + required this.dismissOnPointerMoveAway, + required this.placementMode, + required this.autoModeConfiguration, + required this.forceAvailableSpace, + required this.shouldConstrainToRootBounds, + required this.additionalOffset, + required this.margin, + required this.transitionBuilder, + required this.animation, + required this.transitionDuration, + required this.reverseTransitionDuration, + required this.position, + required this.builder, + required this.buildTarget, + }) : _attachState = attachState; + + final NavigatorState navigator; + final Rect targetRect; + final _FlyoutTargetState? _attachState; + final Offset targetOffset; + final Size targetSize; + final GlobalKey> flyoutKey; + final RenderBox navigatorBox; + final Color? barrierColor; + final GestureRecognizer? barrierRecognizer; + final bool barrierDismissible; + final bool dismissWithEsc; + final bool dismissOnPointerMoveAway; + final FlyoutPlacementMode placementMode; + final FlyoutAutoConfiguration? autoModeConfiguration; + final bool forceAvailableSpace; + final bool shouldConstrainToRootBounds; + final double additionalOffset; + final double margin; + final FlyoutTransitionBuilder? transitionBuilder; + final Animation animation; + final Duration? transitionDuration; + final Duration? reverseTransitionDuration; + final Offset? position; + final WidgetBuilder builder; + final bool buildTarget; + + @override + State<_FlyoutPage> createState() => _FlyoutPageState(); +} + +class _FlyoutPageState extends State<_FlyoutPage> { + FlyoutPlacementMode? _autoMode; + + final _key = GlobalKey(); + + @override + Widget build(BuildContext context) { + return MenuInfoProvider(builder: (context, rootSize, menus, keys) { + assert(menus.length == keys.length); + + final barrier = ColoredBox( + color: widget.barrierColor ?? Colors.black.withValues(alpha: 0.3), + ); + + Widget box = Stack(children: [ + if (widget.barrierRecognizer != null) + Positioned.fill( + child: Listener( + behavior: HitTestBehavior.opaque, + onPointerDown: (event) { + widget.barrierRecognizer!.addPointer(event); + }, + child: barrier, + ), + ) + else if (widget.barrierDismissible) + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.barrierDismissible ? widget.navigator.pop : null, + child: barrier, + ), + ), + if (widget.buildTarget) + Positioned.fromRect( + rect: widget.targetRect, + child: widget._attachState!.build(context), + ), + Positioned.fill( + child: SafeArea( + child: CustomSingleChildLayout( + delegate: _FlyoutPositionDelegate( + targetOffset: widget.position ?? widget.targetOffset, + targetSize: + widget.position == null ? widget.targetSize : Size.zero, + autoModeConfiguration: widget.autoModeConfiguration, + placementMode: widget.placementMode, + defaultPreferred: widget.position == null + ? FlyoutPlacementMode.topCenter + : FlyoutPlacementMode.bottomLeft, + margin: widget.margin, + shouldConstrainToRootBounds: widget.shouldConstrainToRootBounds, + forceAvailableSpace: widget.forceAvailableSpace, + onAutoModeChange: (mode) { + _autoMode = mode; + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + _key.currentState?.setState(() {}); + }); + }, + ), + child: StatefulBuilder( + key: _key, + builder: (context, setState) { + return Flyout( + rootFlyout: widget.flyoutKey, + additionalOffset: widget.additionalOffset, + margin: widget.margin, + transitionDuration: widget.transitionDuration!, + reverseTransitionDuration: + widget.reverseTransitionDuration!, + root: widget.navigator, + builder: (context) { + FlyoutPlacementMode realPlacementMode = + widget.placementMode; + if (widget.placementMode == FlyoutPlacementMode.auto) { + if (_autoMode == null) { + return Visibility( + visible: false, + maintainSize: true, + maintainAnimation: true, + maintainState: true, + child: widget.builder(context), + ); + } else { + realPlacementMode = _autoMode!; + } + } else { + realPlacementMode = widget.placementMode; + } + final flyout = Padding( + key: widget.flyoutKey, + padding: realPlacementMode._getAdditionalOffsetPosition( + widget.position == null + ? widget.additionalOffset + : 0.0, + ), + child: widget.builder(context), + ); + + return widget.transitionBuilder!( + context, + widget.animation, + realPlacementMode, + flyout, + ); + }, + ); + }, + ), + ), + ), + ), + ...menus, + ]); + + if (widget.dismissOnPointerMoveAway) { + box = MouseRegion( + onHover: (hover) { + if (widget.flyoutKey.currentContext == null) return; + + final navigatorBox = + widget.navigator.context.findRenderObject() as RenderBox; + + // the flyout box needs to be fetched at each [onHover] because the + // flyout size may change (a MenuFlyout, for example) + final flyoutBox = widget.flyoutKey.currentContext! + .findRenderObject() as RenderBox; + final flyoutRect = flyoutBox.localToGlobal( + Offset.zero, + ancestor: navigatorBox, + ) & + flyoutBox.size; + final menusRects = keys.map((key) { + if (key.currentContext == null) return Rect.zero; + + final menuBox = + key.currentContext!.findRenderObject() as RenderBox; + return menuBox.localToGlobal( + Offset.zero, + ancestor: navigatorBox, + ) & + menuBox.size; + }); + + if (!flyoutRect.contains(hover.position) && + !widget.targetRect.contains(hover.position) && + !menusRects.any((rect) => rect.contains(hover.position))) { + widget.navigator.pop(); + } + }, + child: box, + ); + } + + if (widget.dismissWithEsc) { + box = Actions( + actions: {DismissIntent: _DismissAction(widget.navigator.pop)}, + child: FocusScope( + autofocus: true, + child: box, + ), + ); + } + + return FadeTransition( + opacity: CurvedAnimation( + curve: Curves.ease, + parent: widget.animation, + ), + child: box, + ); + }); + } +} + class _DismissAction extends DismissAction { _DismissAction(this.onDismiss); diff --git a/lib/src/controls/flyouts/menu_bar.dart b/lib/src/controls/flyouts/menu_bar.dart index 383230c61..fab81bf33 100644 --- a/lib/src/controls/flyouts/menu_bar.dart +++ b/lib/src/controls/flyouts/menu_bar.dart @@ -77,7 +77,6 @@ class _MenuBarState extends State { if (_locked) return; _locked = true; final textDirection = Directionality.of(context); - final navigator = Navigator.of(context); item ??= widget.items.first; // Checks the position of the item itself. Context is the MenuBarItem button. @@ -86,10 +85,8 @@ class _MenuBarState extends State { final RenderBox renderBox = context.findRenderObject() as RenderBox; final position = renderBox.localToGlobal( Offset.zero, - ancestor: navigator.context.findRenderObject(), + ancestor: this.context.findRenderObject(), ); - final size = renderBox.size; - if (_controller.isOpen) { _controller.close(); if (_currentOpenItem == item) { @@ -110,27 +107,17 @@ class _MenuBarState extends State { final resolvedBarMargin = barMargin.resolve(textDirection); final future = _controller.showFlyout( buildTarget: true, - placementMode: FlyoutPlacementMode.bottomLeft.resolve(textDirection), + placementMode: FlyoutPlacementMode.auto, + autoModeConfiguration: FlyoutAutoConfiguration( + preferredMode: FlyoutPlacementMode.bottomLeft.resolve(textDirection), + ), + additionalOffset: 0.0, + horizontalOffset: position.dx + resolvedBarMargin.left, reverseTransitionDuration: Duration.zero, barrierColor: Colors.transparent, - position: Offset( - position.dx + resolvedBarMargin.left, - position.dy + size.height - resolvedBarMargin.bottom, - ), builder: (context) { return MenuFlyout(items: item!.items); }, - transitionBuilder: (context, animation, placement, child) { - return ClipRect( - child: SlideTransition( - position: Tween( - begin: const Offset(0.0, -1.0), - end: Offset.zero, - ).animate(animation), - child: child, - ), - ); - }, ); setState(() {}); await future; @@ -145,10 +132,17 @@ class _MenuBarState extends State { final theme = FluentTheme.of(context); - return FlyoutTarget( - controller: _controller, - child: SizedBox( - height: 40.0, + return Container( + height: 40.0, + padding: EdgeInsetsDirectional.only( + top: barMargin.top, + bottom: barMargin.bottom, + ), + // align to the center so that the flyout is directly connected to the buttons + // not the bar. + alignment: AlignmentDirectional.centerStart, + child: FlyoutTarget( + controller: _controller, child: Row(children: [ for (final item in widget.items) Builder( @@ -169,7 +163,10 @@ class _MenuBarState extends State { builder: (context, states) { return Container( padding: barPadding, - margin: barMargin, + margin: EdgeInsetsDirectional.only( + start: barMargin.start, + end: barMargin.end, + ), decoration: BoxDecoration( color: HyperlinkButton.backgroundColor(theme) .resolve(states), diff --git a/lib/src/controls/form/selection_controls.dart b/lib/src/controls/form/selection_controls.dart index 0f528d5b2..abe10b84b 100644 --- a/lib/src/controls/form/selection_controls.dart +++ b/lib/src/controls/form/selection_controls.dart @@ -443,7 +443,7 @@ class _FluentTextSelectionControlsToolbarState TargetPlatform.android || TargetPlatform.iOS || TargetPlatform.fuchsia => - Offset(100, 100), + const Offset(100, 100), TargetPlatform.windows || TargetPlatform.macOS || TargetPlatform.linux => From 40f13c96bdf1eea4e44a50a7da5e689e4b6d0af9 Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Sat, 1 Feb 2025 01:20:07 -0300 Subject: [PATCH 14/22] fix: Menu sub items have the same transition as their parents --- lib/src/controls/flyouts/content_manager.dart | 20 ++++++++++++------- lib/src/controls/flyouts/flyout.dart | 11 +++++----- lib/src/controls/flyouts/menu.dart | 10 +++++++--- 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/lib/src/controls/flyouts/content_manager.dart b/lib/src/controls/flyouts/content_manager.dart index a1f47f7db..d5829af34 100644 --- a/lib/src/controls/flyouts/content_manager.dart +++ b/lib/src/controls/flyouts/content_manager.dart @@ -18,17 +18,20 @@ class Flyout extends StatefulWidget { final Duration transitionDuration; final Duration reverseTransitionDuration; + final FlyoutTransitionBuilder transitionBuilder; + /// Create a flyout. const Flyout({ super.key, required this.builder, - this.root, - this.rootFlyout, - this.menuKey, - this.additionalOffset = 0.0, - this.margin = 0.0, - this.transitionDuration = Duration.zero, - this.reverseTransitionDuration = Duration.zero, + required this.root, + required this.rootFlyout, + required this.menuKey, + required this.additionalOffset, + required this.margin, + required this.transitionDuration, + required this.reverseTransitionDuration, + required this.transitionBuilder, }); /// Gets the current flyout info @@ -62,6 +65,9 @@ class FlyoutState extends State { /// The duration of the reverse transition animation Duration get reverseTransitionDuration => widget.reverseTransitionDuration; + /// The transition builder + FlyoutTransitionBuilder get transitionBuilder => widget.transitionBuilder; + /// Closes the current open flyout. /// /// If the current flyout is a sub menu, the submenu is closed. diff --git a/lib/src/controls/flyouts/flyout.dart b/lib/src/controls/flyouts/flyout.dart index 49ddfc0d8..befe88658 100644 --- a/lib/src/controls/flyouts/flyout.dart +++ b/lib/src/controls/flyouts/flyout.dart @@ -671,11 +671,8 @@ class FlyoutController with ChangeNotifier { shouldConstrainToRootBounds: shouldConstrainToRootBounds, additionalOffset: additionalOffset, margin: margin, - transitionBuilder: transitionBuilder, - animation: CurvedAnimation( - curve: transitionCurve, - parent: animation, - ), + transitionBuilder: transitionBuilder!, + animation: CurvedAnimation(curve: transitionCurve, parent: animation), transitionDuration: transitionDuration, reverseTransitionDuration: reverseTransitionDuration, position: position, @@ -756,7 +753,7 @@ class _FlyoutPage extends StatefulWidget { final bool shouldConstrainToRootBounds; final double additionalOffset; final double margin; - final FlyoutTransitionBuilder? transitionBuilder; + final FlyoutTransitionBuilder transitionBuilder; final Animation animation; final Duration? transitionDuration; final Duration? reverseTransitionDuration; @@ -839,6 +836,8 @@ class _FlyoutPageState extends State<_FlyoutPage> { reverseTransitionDuration: widget.reverseTransitionDuration!, root: widget.navigator, + menuKey: null, + transitionBuilder: widget.transitionBuilder, builder: (context) { FlyoutPlacementMode realPlacementMode = widget.placementMode; diff --git a/lib/src/controls/flyouts/menu.dart b/lib/src/controls/flyouts/menu.dart index 69f1ba107..02b753ef4 100644 --- a/lib/src/controls/flyouts/menu.dart +++ b/lib/src/controls/flyouts/menu.dart @@ -603,11 +603,15 @@ class _MenuFlyoutSubItemState extends State<_MenuFlyoutSubItem> additionalOffset: parent.additionalOffset, margin: parent.margin, transitionDuration: parent.transitionDuration, + reverseTransitionDuration: parent.reverseTransitionDuration, + transitionBuilder: parent.transitionBuilder, root: parent.widget.root, builder: (context) { - Widget w = FadeTransition( - opacity: transitionController, - child: MenuFlyout( + var w = parent.transitionBuilder.call( + context, + transitionController, + FlyoutPlacementMode.bottomCenter, + MenuFlyout( key: menuKey, color: menuFlyout?.color, constraints: menuFlyout?.constraints, From 6212e3ae92756cd444c7b242f45971df169ab572 Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Sat, 1 Feb 2025 01:25:58 -0300 Subject: [PATCH 15/22] feat: Add debugFillProperties --- lib/src/controls/flyouts/flyout.dart | 2 +- lib/src/controls/flyouts/menu.dart | 62 ++++++++++++++++++++++---- lib/src/controls/flyouts/menu_bar.dart | 9 +++- 3 files changed, 62 insertions(+), 11 deletions(-) diff --git a/lib/src/controls/flyouts/flyout.dart b/lib/src/controls/flyouts/flyout.dart index befe88658..610b4a667 100644 --- a/lib/src/controls/flyouts/flyout.dart +++ b/lib/src/controls/flyouts/flyout.dart @@ -866,7 +866,7 @@ class _FlyoutPageState extends State<_FlyoutPage> { child: widget.builder(context), ); - return widget.transitionBuilder!( + return widget.transitionBuilder( context, widget.animation, realPlacementMode, diff --git a/lib/src/controls/flyouts/menu.dart b/lib/src/controls/flyouts/menu.dart index 02b753ef4..6326ede0f 100644 --- a/lib/src/controls/flyouts/menu.dart +++ b/lib/src/controls/flyouts/menu.dart @@ -188,7 +188,7 @@ class _MenuScrollBehavior extends FluentScrollBehavior { /// * [MenuFlyoutSubItem], which represents a menu item that displays a /// sub-menu in a [MenuFlyout] /// * [MenuFlyoutItemBuilder], which renders the given widget in the items list -abstract class MenuFlyoutItemBase { +abstract class MenuFlyoutItemBase with Diagnosticable { final Key? key; const MenuFlyoutItemBase({this.key}); @@ -288,6 +288,29 @@ class MenuFlyoutItem extends MenuFlyoutItemBase { bool _useIconPlaceholder = false; + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(FlagProperty( + 'selected', + value: selected, + ifTrue: 'selected', + )); + properties.add(FlagProperty( + 'closeAfterClick', + value: closeAfterClick, + ifFalse: 'keeps open', + )); + properties + .add(ObjectFlagProperty.has('onPressed', onPressed)); + properties + .add(ObjectFlagProperty.has('onLongPress', onLongPress)); + properties.add(DiagnosticsProperty('focusNode', focusNode)); + properties.add(DiagnosticsProperty('leading', leading)); + properties.add(DiagnosticsProperty('text', text)); + properties.add(DiagnosticsProperty('trailing', trailing)); + } + @override Widget build(BuildContext context) { return FlyoutListTile( @@ -377,6 +400,14 @@ class ToggleMenuFlyoutItem extends MenuFlyoutItem { ), onPressed: onChanged == null ? null : () => onChanged(!value), ); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('value', value)); + properties.add( + ObjectFlagProperty?>.has('onChanged', onChanged)); + } } /// Represents a menu item that is mutually exclusive with other radio menu @@ -392,7 +423,7 @@ class ToggleMenuFlyoutItem extends MenuFlyoutItem { /// sub-menu in a [MenuFlyout] /// * [ToggleMenuFlyoutItem], which represents a menu item that a user can /// change between two states, checked or unchecked -class RadioMenuFlyoutItem extends MenuFlyoutItem { +class RadioMenuFlyoutItem extends MenuFlyoutItem { /// The value of the item. final T value; @@ -417,16 +448,29 @@ class RadioMenuFlyoutItem extends MenuFlyoutItem { ), onPressed: onChanged == null ? null : () => onChanged(value), ); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('value', value)); + properties.add(DiagnosticsProperty('groupValue', groupValue)); + properties + .add(ObjectFlagProperty?>.has('onChanged', onChanged)); + } } -enum SubItemShowBehavior { +@Deprecated('Use SubItemShowAction instead.') +typedef SubItemShowBehavior = SubItemShowAction; + +/// Represents the action that will show the sub-menu in a [MenuFlyoutSubItem]. +enum SubItemShowAction { /// Whether the sub-menu will be shown on item press press, /// Whether the sub-menu will be shown on item hover /// /// This is the default behavior. - hover, + hover; } typedef MenuItemsBuilder = List Function( @@ -455,7 +499,7 @@ class MenuFlyoutSubItem extends MenuFlyoutItem { required super.text, super.trailing = const Icon(FluentIcons.chevron_right), required this.items, - this.showBehavior = SubItemShowBehavior.hover, + this.showBehavior = SubItemShowAction.hover, this.showHoverDelay = const Duration(milliseconds: 450), }) : super(onPressed: null); @@ -473,12 +517,12 @@ class MenuFlyoutSubItem extends MenuFlyoutItem { /// Represent which user action will show the sub-menu. /// - /// Defaults to [SubItemShowBehavior.hover] - final SubItemShowBehavior showBehavior; + /// Defaults to [SubItemShowAction.hover] + final SubItemShowAction showBehavior; /// The sub-menu will be only shown after this delay /// - /// Only applied if [showBehavior] is [SubItemShowBehavior.hover] + /// Only applied if [showBehavior] is [SubItemShowAction.hover] final Duration showHoverDelay; bool disableAcyrlic = false; @@ -555,7 +599,7 @@ class _MenuFlyoutSubItemState extends State<_MenuFlyoutSubItem> }, ).build(context); - if (widget.item.showBehavior == SubItemShowBehavior.hover) { + if (widget.item.showBehavior == SubItemShowAction.hover) { return MouseRegion( onEnter: (event) { showTimer = Timer(widget.item.showHoverDelay, () { diff --git a/lib/src/controls/flyouts/menu_bar.dart b/lib/src/controls/flyouts/menu_bar.dart index fab81bf33..f7670872f 100644 --- a/lib/src/controls/flyouts/menu_bar.dart +++ b/lib/src/controls/flyouts/menu_bar.dart @@ -2,7 +2,7 @@ import 'package:fluent_ui/fluent_ui.dart'; import 'package:flutter/foundation.dart'; /// Represents a top-level menu in a [MenuBar] control. -class MenuBarItem { +class MenuBarItem with Diagnosticable { /// The text label of the menu. final String title; @@ -24,6 +24,13 @@ class MenuBarItem { @override int get hashCode => Object.hash(title, items); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(StringProperty('title', title)); + properties.add(IterableProperty('items', items)); + } } /// Use a Menu Bar to show a set of multiple top-level menus in a horizontal row. From 387b2c3f529226cf0fda51145128869db4c03b99 Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Sat, 1 Feb 2025 01:36:06 -0300 Subject: [PATCH 16/22] chore: Update changelog --- CHANGELOG.md | 33 +++++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c186e56f0..ffbca046d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,25 +2,38 @@ - fix: hide Tab's close button when `onClosed` is null - feat: Add `TextBox.cursorOpacityAnimates` (defaults to `FluentThemeData.cursorOpacityAnimates`, which defaults to `false`); default setting improves CPU/GPU efficiency while TextBox has focus ([#1164](https://github.com/bdlukaa/fluent_ui/issues/1164)) -- fix: `DatePicker` selectable range matches the one between `startDate` and `endDate`. [#1170](https://github.com/bdlukaa/fluent_ui/issues/1170) -- fix: `ScaffoldPage` has a built-in color if no parent `NavigationView` is found. ([#1168](https://github.com/bdlukaa/fluent_ui/issues/1168)) -- feat: Added `CommandBarButton.closeAfterClick` ([#1149](https://github.com/bdlukaa/fluent_ui/issues/1149)) +- fix: `DatePicker` selectable range matches the one between `startDate` and `endDate` ([#1170](https://github.com/bdlukaa/fluent_ui/issues/1170)) +- fix: `ScaffoldPage` has a built-in color if no parent `NavigationView` is found ([#1168](https://github.com/bdlukaa/fluent_ui/issues/1168)) - feat: Added `FlyoutController.showFlyout.buildTarget` ([#1173](https://github.com/bdlukaa/fluent_ui/issues/1173)) + Primary items of the command bar are now accessible through the secondary flyout. -- fix: `CommandBar` secondary menu preferred placement mode ([#1174](https://github.com/bdlukaa/fluent_ui/pull/1174)) +- feat: Added these options to `FlyoutController` ([#1178](https://github.com/bdlukaa/fluent_ui/pull/1178)) + - `horizontalOffset`, which manipulates how the flyout will be positioned horizontally; + - `reverseTransitionDuration`, which sets the duration of the reverse transition; + - `transitionCurve`, which sets the curve of the transition; - feat: Added `FlyoutController.close` ([#1174](https://github.com/bdlukaa/fluent_ui/pull/1174)) +- fix: Use the correct placement mode in transition when automatic mode is selected ([#1178](https://github.com/bdlukaa/fluent_ui/pull/1178)) +- feat: `FlyoutContent` and `MenuFlyout` now match their native counterparts ([#1178](https://github.com/bdlukaa/fluent_ui/pull/1178)) +- fix: Jitter when displaying flyouts and sub flyouts ([#1014](https://github.com/bdlukaa/fluent_ui/issues/1014)) +- fix: Menu sub items have the same transition as their parents ([#1178](https://github.com/bdlukaa/fluent_ui/pull/1178)) +- feat: Implemented [`MenuBar`](https://bdlukaa.github.io/fluent_ui/#/surfaces/menu_bar) ([#1107](https://github.com/bdlukaa/fluent_ui/issues/1107)) +- `SubItemShowBehavior` was renamed to `SubItemShowAction` ([#1178](https://github.com/bdlukaa/fluent_ui/pull/1178)) +- feat: Added `CommandBarButton.closeAfterClick` ([#1149](https://github.com/bdlukaa/fluent_ui/issues/1149)) +- fix: `CommandBar` secondary menu preferred placement mode ([#1174](https://github.com/bdlukaa/fluent_ui/pull/1174)) - feat: `CommandBarState` is now accessible, making it possible to open/close the secondary flyout programmatically ([#1174](https://github.com/bdlukaa/fluent_ui/pull/1174)) + ```dart final commandBarKey = GlobalKey(); CommandBar( - key: commandBarKey, + key: commandBarKey, ..., ), - + commandBarKey.currentState?.toggleSecondaryMenu(); commandBarKey.currentState?.secondaryFlyoutController.close(); ``` + - fix: `TextBox` alignment no longer depend on decoration ([#1027](https://github.com/bdlukaa/fluent_ui/issues/1027)) - feat: Added `TextBox.undoController` and implemented "Undo" action in toolbar options ([#1175](https://github.com/bdlukaa/fluent_ui/pull/1175)) - feat: Handle `TextBox` context menu controls on mobile platforms ([#1022](https://github.com/bdlukaa/fluent_ui/issues/1022)) @@ -36,8 +49,9 @@ - feat: Use a `Decoration` instead of `Color` in `NavigationAppBar` ([#1118](https://github.com/bdlukaa/fluent_ui/issues/1118)) - feat: Add `EditableComboBox.inputFormatters` ([#1041](https://github.com/bdlukaa/fluent_ui/issues/1041)) - **BREAKING** feat: `TextBox.decoration` and `TextBox.foregroundDecoration` are now of type `WidgetStateProperty` ([#987](https://github.com/bdlukaa/fluent_ui/pull/987)) - + Before: + ```dart TextBox( decoration: BoxDecoration( @@ -50,6 +64,7 @@ ``` After: + ```dart TextBox( decoration: WidgetStateProperty.all(BoxDecoration( @@ -60,12 +75,13 @@ )), ), ``` + - feat: Add `TabView.gestures`, which allows the manipulation of the tab gestures ([#1138](https://github.com/bdlukaa/fluent_ui/issues/1138)) - feat: Add `DropDownButton.style` ([#1139](https://github.com/bdlukaa/fluent_ui/issues/1139)) - feat: Possibility to open date and time pickers programatically ([#1142](https://github.com/bdlukaa/fluent_ui/issues/1142)) - fix: `TimePicker` hour offset - feat: Add `ColorPicker` ([#1152](https://github.com/bdlukaa/fluent_ui/pull/1152)) -- fix: `NumberBox` initial value formatting ([#1153](https://github.com/bdlukaa/fluent_ui/issues/1153)) +- fix: `NumberBox` initial value formatting ([#1153](https://github.com/bdlukaa/fluent_ui/issues/1153)) - fix: `NumberBox` incrementing/decrementing when not focused ([#1124](https://github.com/bdlukaa/fluent_ui/issues/1124)) - fix: `NumberBox` text is correctly when there are no visible actions ([#1150](https://github.com/bdlukaa/fluent_ui/issues/1150)) @@ -75,6 +91,7 @@ - feat: Add `TabView.stripBuilder` ([#1106](https://github.com/bdlukaa/fluent_ui/issues/1106)) - fix: Correctly apply `EditableComboBox.style` ([#1121](https://github.com/bdlukaa/fluent_ui/pull/1121)) - feat: Add `BreadcrumbBar.chevronIconBuilder` and `BreadcrumbBar.chevronIconSize` ([#1111](https://github.com/bdlukaa/fluent_ui/issues/1111)) + * fix: Consider object translation on Menu Flyouts ([#1104](https://github.com/bdlukaa/fluent_ui/issues/1104)) * fix: Correctly disable `DropDownButton` items if `onPressed` is not provided ([#1116](https://github.com/bdlukaa/fluent_ui/issues/1116#issuecomment-2347153074)) * feat: Add `ToggleMenuFlyoutItem` and `RadioMenuFlyoutItem` ([#1108](https://github.com/bdlukaa/fluent_ui/issues/1108)) From dde4fb0655ca3812bce1047a0989dd8322123771 Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Sat, 1 Feb 2025 01:53:10 -0300 Subject: [PATCH 17/22] feat: Assign keys to bar items --- lib/src/controls/flyouts/menu_bar.dart | 132 ++++++++++++++++++------- 1 file changed, 95 insertions(+), 37 deletions(-) diff --git a/lib/src/controls/flyouts/menu_bar.dart b/lib/src/controls/flyouts/menu_bar.dart index f7670872f..982d152a5 100644 --- a/lib/src/controls/flyouts/menu_bar.dart +++ b/lib/src/controls/flyouts/menu_bar.dart @@ -1,5 +1,6 @@ import 'package:fluent_ui/fluent_ui.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; /// Represents a top-level menu in a [MenuBar] control. class MenuBarItem with Diagnosticable { @@ -72,6 +73,34 @@ class _MenuBarState extends State { ); static const barMargin = EdgeInsetsDirectional.all(4.0); + final Map _keys = {}; + GlobalKey keyOf(MenuBarItem item) { + if (_controller.isOpen) { + final menuBar = context.findAncestorStateOfType<_MenuBarState>()!; + return menuBar._keys[item] ??= GlobalKey(); + } else { + return _keys[item] ??= GlobalKey(); + } + } + + MenuBarItem previous(MenuBarItem current) { + assert(widget.items.isNotEmpty); + final index = widget.items.indexOf(current); + if (index == 0) { + return widget.items.last; + } + return widget.items[index - 1]; + } + + MenuBarItem next(MenuBarItem current) { + assert(widget.items.isNotEmpty); + final index = widget.items.indexOf(current); + if (index == widget.items.length - 1) { + return widget.items.first; + } + return widget.items[index + 1]; + } + @override void dispose() { _controller.dispose(); @@ -80,7 +109,11 @@ class _MenuBarState extends State { bool _locked = false; MenuBarItem? _currentOpenItem; - Future _showFlyout(BuildContext context, [MenuBarItem? item]) async { + Future _showFlyout( + BuildContext context, [ + MenuBarItem? item, + bool closeIfOpen = false, + ]) async { if (_locked) return; _locked = true; final textDirection = Directionality.of(context); @@ -95,9 +128,11 @@ class _MenuBarState extends State { ancestor: this.context.findRenderObject(), ); if (_controller.isOpen) { - _controller.close(); + if (closeIfOpen) _controller.close(); if (_currentOpenItem == item) { _currentOpenItem = null; + _locked = false; + if (mounted) setState(() {}); return; } // Waits for the reverse transition duration. @@ -150,42 +185,65 @@ class _MenuBarState extends State { alignment: AlignmentDirectional.centerStart, child: FlyoutTarget( controller: _controller, - child: Row(children: [ - for (final item in widget.items) - Builder( - key: ValueKey(item), - builder: (context) { - return HoverButton( - onPressed: () { - _locked = false; - _showFlyout(context, item); - }, - onPointerEnter: _controller.isOpen - ? (_) { - if (_currentOpenItem != item) { - _showFlyout(context, item); + child: Focus( + canRequestFocus: false, + onKeyEvent: (node, event) { + if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + final current = _currentOpenItem ?? widget.items.first; + final nextItem = next(current); + _showFlyout(keyOf(nextItem).currentContext!, nextItem); + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + final current = _currentOpenItem ?? widget.items.last; + final previousItem = previous(current); + _showFlyout(keyOf(previousItem).currentContext!, previousItem); + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + }, + child: Row(children: [ + for (final item in widget.items) + Builder( + key: _controller.isOpen ? null : keyOf(item), + builder: (context) { + return HoverButton( + onPressed: () { + _locked = false; + _showFlyout(context, item); + }, + onPointerEnter: _controller.isOpen + ? (_) { + if (_currentOpenItem != item) { + _showFlyout(context, item); + } } - } - : null, - builder: (context, states) { - return Container( - padding: barPadding, - margin: EdgeInsetsDirectional.only( - start: barMargin.start, - end: barMargin.end, - ), - decoration: BoxDecoration( - color: HyperlinkButton.backgroundColor(theme) - .resolve(states), - borderRadius: BorderRadius.circular(4.0), - ), - child: Text(item.title), - ); - }, - ); - }, - ), - ]), + : null, + builder: (context, states) { + return Padding( + padding: EdgeInsetsDirectional.only( + start: barMargin.start, + end: barMargin.end, + ), + child: FocusBorder( + focused: states.isFocused, + child: Container( + padding: barPadding, + decoration: BoxDecoration( + color: HyperlinkButton.backgroundColor(theme) + .resolve(states), + borderRadius: BorderRadius.circular(4.0), + ), + child: Text(item.title), + ), + ), + ); + }, + ); + }, + ), + ]), + ), ), ); } From 252310fed91cf62ae61b405f2b1e57a4f82a2d98 Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Sat, 1 Feb 2025 02:33:11 -0300 Subject: [PATCH 18/22] feat: Expose open and close methods --- example/lib/screens/popups/menu_bar.dart | 66 ++++++++++++- lib/src/controls/flyouts/flyout.dart | 6 ++ lib/src/controls/flyouts/menu_bar.dart | 113 ++++++++++++++++------- 3 files changed, 152 insertions(+), 33 deletions(-) diff --git a/example/lib/screens/popups/menu_bar.dart b/example/lib/screens/popups/menu_bar.dart index c82e14f3c..d0b9f778f 100644 --- a/example/lib/screens/popups/menu_bar.dart +++ b/example/lib/screens/popups/menu_bar.dart @@ -12,6 +12,7 @@ class MenuBarPage extends StatefulWidget { class _MenuBarPageState extends State with PageMixin { var _orientation = 'landscape'; var _iconSize = 'medium_icons'; + final _programaticallyKey = GlobalKey(); @override Widget build(BuildContext context) { @@ -152,8 +153,7 @@ MenuBar( MenuBarItem(title: 'Help', items: [ MenuFlyoutItem(text: const Text('About'), onPressed: () {}), ]), - ] - ] + ], ) ''', child: MenuBar( @@ -230,6 +230,68 @@ MenuBar( ], ), ), + subtitle(content: const Text('Open a MenuBar programatically')), + description( + content: const Text( + 'You can open a MenuBar programatically using a global key.', + ), + ), + CardHighlight( + codeSnippet: ''' +final key = GlobalKey(); + +MenuBar( + key: key, + items: [ + MenuBarItem(title: 'File', items: [ + MenuFlyoutItem(text: const Text('New'), onPressed: () {}), + MenuFlyoutItem(text: const Text('Open'), onPressed: () {}), + MenuFlyoutItem(text: const Text('Save'), onPressed: () {}), + MenuFlyoutItem(text: const Text('Exit'), onPressed: () {}), + ]), + MenuBarItem(title: 'Edit', items: [ + MenuFlyoutItem(text: const Text('Cut'), onPressed: () {}), + MenuFlyoutItem(text: const Text('Copy'), onPressed: () {}), + MenuFlyoutItem(text: const Text('Paste'), onPressed: () {}), + ]), + MenuBarItem(title: 'Help', items: [ + MenuFlyoutItem(text: const Text('About'), onPressed: () {}), + ]), + ], +), + +key.currentState?.showItemAt(items1); +''', + child: Row(children: [ + Expanded( + child: MenuBar( + key: _programaticallyKey, + items: [ + MenuBarItem(title: 'File', items: [ + MenuFlyoutItem(text: const Text('New'), onPressed: () {}), + MenuFlyoutItem(text: const Text('Open'), onPressed: () {}), + MenuFlyoutItem(text: const Text('Save'), onPressed: () {}), + MenuFlyoutItem(text: const Text('Exit'), onPressed: () {}), + ]), + MenuBarItem(title: 'Edit', items: [ + MenuFlyoutItem(text: const Text('Cut'), onPressed: () {}), + MenuFlyoutItem(text: const Text('Copy'), onPressed: () {}), + MenuFlyoutItem(text: const Text('Paste'), onPressed: () {}), + ]), + MenuBarItem(title: 'Help', items: [ + MenuFlyoutItem(text: const Text('About'), onPressed: () {}), + ]), + ], + ), + ), + Button( + onPressed: () { + _programaticallyKey.currentState?.showItemAt(1); + }, + child: const Text('Open MenuBar'), + ), + ]), + ), ], ); } diff --git a/lib/src/controls/flyouts/flyout.dart b/lib/src/controls/flyouts/flyout.dart index 610b4a667..74864a540 100644 --- a/lib/src/controls/flyouts/flyout.dart +++ b/lib/src/controls/flyouts/flyout.dart @@ -456,6 +456,12 @@ class FlyoutController with ChangeNotifier { /// Whether this flyout controller is attached to any [FlyoutTarget] bool get isAttached => _attachState != null; + /// The state of the attached [FlyoutTarget] + State get attachState { + _ensureAttached(); + return _attachState!; + } + /// Attaches this controller to a [FlyoutTarget] widget. /// /// If already attached, the current state is detached and replaced by the diff --git a/lib/src/controls/flyouts/menu_bar.dart b/lib/src/controls/flyouts/menu_bar.dart index 982d152a5..a5acc2e6b 100644 --- a/lib/src/controls/flyouts/menu_bar.dart +++ b/lib/src/controls/flyouts/menu_bar.dart @@ -55,7 +55,7 @@ class MenuBar extends StatefulWidget with Diagnosticable { }) : assert(items.isNotEmpty, 'items must not be empty'); @override - State createState() => _MenuBarState(); + State createState() => MenuBarState(); @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { @@ -64,7 +64,7 @@ class MenuBar extends StatefulWidget with Diagnosticable { } } -class _MenuBarState extends State { +class MenuBarState extends State { final _controller = FlyoutController(); static const barPadding = EdgeInsetsDirectional.symmetric( @@ -74,9 +74,11 @@ class _MenuBarState extends State { static const barMargin = EdgeInsetsDirectional.all(4.0); final Map _keys = {}; - GlobalKey keyOf(MenuBarItem item) { + GlobalKey? _keyOf(MenuBarItem item) { if (_controller.isOpen) { - final menuBar = context.findAncestorStateOfType<_MenuBarState>()!; + final menuBar = _controller.attachState.context + .findAncestorStateOfType(); + if (menuBar == null) return null; return menuBar._keys[item] ??= GlobalKey(); } else { return _keys[item] ??= GlobalKey(); @@ -109,10 +111,15 @@ class _MenuBarState extends State { bool _locked = false; MenuBarItem? _currentOpenItem; + + /// The currently open item in the menu bar. + /// + /// If null, no item is open. + MenuBarItem? get currentOpenItem => _currentOpenItem; Future _showFlyout( BuildContext context, [ MenuBarItem? item, - bool closeIfOpen = false, + bool closeIfOpen = true, ]) async { if (_locked) return; _locked = true; @@ -128,20 +135,14 @@ class _MenuBarState extends State { ancestor: this.context.findRenderObject(), ); if (_controller.isOpen) { - if (closeIfOpen) _controller.close(); if (_currentOpenItem == item) { + if (closeIfOpen) closeFlyout(); _currentOpenItem = null; _locked = false; if (mounted) setState(() {}); return; } - // Waits for the reverse transition duration. - // - // Even though the duration is zero, it is necessary to wait for the - // transition to finish before showing the next flyout. Otherwise, the - // flyout will fail to show due to [_locked]. This has a similar effect - // to moving this task to the next frame or using a [Future.microtask]. - await Future.delayed(Duration.zero); + await closeFlyout(); } _locked = false; @@ -163,8 +164,56 @@ class _MenuBarState extends State { ); setState(() {}); await future; - if (mounted) setState(() {}); _currentOpenItem = null; + if (mounted) setState(() {}); + } + + /// Close the currently open flyout. + /// + /// If no flyout is open, this method does nothing. + Future closeFlyout() async { + if (_controller.isOpen) { + _controller.close(); + // Waits for the reverse transition duration. + // + // Even though the duration is zero, it is necessary to wait for the + // transition to finish before showing the next flyout. Otherwise, the + // flyout will fail to show due to [_locked]. This has a similar effect + // to moving this task to the next frame or using a [Future.microtask]. + await Future.delayed(Duration.zero); + setState(() {}); + } + } + + /// Show the flyout of the given item. + /// + /// If the item is not in the menu bar, a [StateError] will be thrown. + /// + /// [closeIfOpen] determines whether the flyout should be closed if it is + /// already open. Defaults to `true`. + Future showItem(MenuBarItem item, [bool closeIfOpen = true]) { + final key = _keyOf(item); + if (key == null) { + throw StateError('The item is not in the menu bar.'); + } + final context = key.currentContext; + if (context == null) { + throw StateError('The item is not in the widget tree.'); + } + return _showFlyout(context, item); + } + + /// Show the flyout of the item at the given index. + /// + /// If the index is out of range, a [RangeError] will be thrown. + /// + /// [closeIfOpen] determines whether the flyout should be closed if it is + /// already open. Defaults to `true`. + Future showItemAt(int index, [bool closeIfOpen = true]) { + if (index < 0 || index >= widget.items.length) { + throw RangeError.range(index, 0, widget.items.length - 1); + } + return showItem(widget.items[index], closeIfOpen); } @override @@ -217,15 +266,17 @@ class _MenuBarState extends State { if (_currentOpenItem != item) { _showFlyout(context, item); } - } - : null, - builder: (context, states) { - return Padding( - padding: EdgeInsetsDirectional.only( - start: barMargin.start, - end: barMargin.end, - ), - child: FocusBorder( + : null, + onFocusChange: (focused) { + if (focused && _controller.isOpen) { + _showFlyout(context, item); + } + }, + builder: (context, states) { + if (isSelected) { + states = {...states, WidgetState.hovered}; + } + return FocusBorder( focused: states.isFocused, child: Container( padding: barPadding, @@ -236,14 +287,14 @@ class _MenuBarState extends State { ), child: Text(item.title), ), - ), - ); - }, - ); - }, - ), - ]), - ), + ); + }, + ); + }, + ), + ]), + ); + }), ), ); } From 772875c3431b902c26c70fdce03aa210664ac232 Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Sat, 1 Feb 2025 02:34:48 -0300 Subject: [PATCH 19/22] fix: The flyout menu bar is invisble --- lib/src/controls/flyouts/content_manager.dart | 5 ++ lib/src/controls/flyouts/menu_bar.dart | 59 +++++++++---------- 2 files changed, 34 insertions(+), 30 deletions(-) diff --git a/lib/src/controls/flyouts/content_manager.dart b/lib/src/controls/flyouts/content_manager.dart index d5829af34..b0c9e00de 100644 --- a/lib/src/controls/flyouts/content_manager.dart +++ b/lib/src/controls/flyouts/content_manager.dart @@ -113,6 +113,11 @@ class MenuInfoProvider extends StatefulWidget { return context.findAncestorStateOfType()!; } + /// Gets the current state of the sub menus of the root flyout + static MenuInfoProviderState? maybeOf(BuildContext context) { + return context.findAncestorStateOfType(); + } + @override State createState() => MenuInfoProviderState(); } diff --git a/lib/src/controls/flyouts/menu_bar.dart b/lib/src/controls/flyouts/menu_bar.dart index a5acc2e6b..2f4a71230 100644 --- a/lib/src/controls/flyouts/menu_bar.dart +++ b/lib/src/controls/flyouts/menu_bar.dart @@ -234,37 +234,36 @@ class MenuBarState extends State { alignment: AlignmentDirectional.centerStart, child: FlyoutTarget( controller: _controller, - child: Focus( - canRequestFocus: false, - onKeyEvent: (node, event) { - if (event.logicalKey == LogicalKeyboardKey.arrowRight) { - final current = _currentOpenItem ?? widget.items.first; - final nextItem = next(current); - _showFlyout(keyOf(nextItem).currentContext!, nextItem); - return KeyEventResult.handled; - } else if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { - final current = _currentOpenItem ?? widget.items.last; - final previousItem = previous(current); - _showFlyout(keyOf(previousItem).currentContext!, previousItem); - return KeyEventResult.handled; - } + child: Builder(builder: (context) { + // Do not use the [Flyout] object because it is only available for the + // flyout content. [MenuInfoProvider] is available for the entire Flyout + // popup. + final flyout = MenuInfoProvider.maybeOf(context); - return KeyEventResult.ignored; - }, - child: Row(children: [ - for (final item in widget.items) - Builder( - key: _controller.isOpen ? null : keyOf(item), - builder: (context) { - return HoverButton( - onPressed: () { - _locked = false; - _showFlyout(context, item); - }, - onPointerEnter: _controller.isOpen - ? (_) { - if (_currentOpenItem != item) { - _showFlyout(context, item); + /// The flyout menu bar must be invisible because it has transparent + /// components, which can lead to visual inconsistencies. + return Visibility.maintain( + visible: flyout == null, + child: Row(children: [ + for (final item in widget.items) + Builder( + key: _controller.isOpen ? null : _keyOf(item), + builder: (context) { + final isSelected = _currentOpenItem == item; + return HoverButton( + margin: EdgeInsetsDirectional.only( + start: barMargin.start, + end: barMargin.end, + ), + onPressed: () { + _locked = false; + _showFlyout(context, item); + }, + onPointerEnter: _controller.isOpen + ? (_) { + if (_currentOpenItem != item) { + _showFlyout(context, item); + } } : null, onFocusChange: (focused) { From a41f4805c756fe6876dca28e1e79ef5dd604608a Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Sat, 1 Feb 2025 02:36:14 -0300 Subject: [PATCH 20/22] chore: Remove unused methods --- lib/src/controls/flyouts/menu_bar.dart | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/lib/src/controls/flyouts/menu_bar.dart b/lib/src/controls/flyouts/menu_bar.dart index 2f4a71230..dbbbfc7fd 100644 --- a/lib/src/controls/flyouts/menu_bar.dart +++ b/lib/src/controls/flyouts/menu_bar.dart @@ -85,24 +85,6 @@ class MenuBarState extends State { } } - MenuBarItem previous(MenuBarItem current) { - assert(widget.items.isNotEmpty); - final index = widget.items.indexOf(current); - if (index == 0) { - return widget.items.last; - } - return widget.items[index - 1]; - } - - MenuBarItem next(MenuBarItem current) { - assert(widget.items.isNotEmpty); - final index = widget.items.indexOf(current); - if (index == widget.items.length - 1) { - return widget.items.first; - } - return widget.items[index + 1]; - } - @override void dispose() { _controller.dispose(); @@ -237,7 +219,7 @@ class MenuBarState extends State { child: Builder(builder: (context) { // Do not use the [Flyout] object because it is only available for the // flyout content. [MenuInfoProvider] is available for the entire Flyout - // popup. + // popup. This is only available after [FlyoutTarget]. final flyout = MenuInfoProvider.maybeOf(context); /// The flyout menu bar must be invisible because it has transparent From 7533f8ecff79f4a1029f1eab1968d2878697090d Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Sat, 1 Feb 2025 02:40:42 -0300 Subject: [PATCH 21/22] feat: Add TapRegion to MenuBar components --- lib/src/controls/flyouts/menu_bar.dart | 145 +++++++++++++------------ 1 file changed, 76 insertions(+), 69 deletions(-) diff --git a/lib/src/controls/flyouts/menu_bar.dart b/lib/src/controls/flyouts/menu_bar.dart index dbbbfc7fd..d6b7c3089 100644 --- a/lib/src/controls/flyouts/menu_bar.dart +++ b/lib/src/controls/flyouts/menu_bar.dart @@ -141,7 +141,10 @@ class MenuBarState extends State { reverseTransitionDuration: Duration.zero, barrierColor: Colors.transparent, builder: (context) { - return MenuFlyout(items: item!.items); + return TapRegion( + groupId: MenuBar, + child: MenuFlyout(items: item!.items), + ); }, ); setState(() {}); @@ -205,77 +208,81 @@ class MenuBarState extends State { final theme = FluentTheme.of(context); - return Container( - height: 40.0, - padding: EdgeInsetsDirectional.only( - top: barMargin.top, - bottom: barMargin.bottom, - ), - // align to the center so that the flyout is directly connected to the buttons - // not the bar. - alignment: AlignmentDirectional.centerStart, - child: FlyoutTarget( - controller: _controller, - child: Builder(builder: (context) { - // Do not use the [Flyout] object because it is only available for the - // flyout content. [MenuInfoProvider] is available for the entire Flyout - // popup. This is only available after [FlyoutTarget]. - final flyout = MenuInfoProvider.maybeOf(context); + return TapRegion( + groupId: MenuBar, + onTapOutside: (_) => closeFlyout(), + child: Container( + height: 40.0, + padding: EdgeInsetsDirectional.only( + top: barMargin.top, + bottom: barMargin.bottom, + ), + // align to the center so that the flyout is directly connected to the buttons + // not the bar. + alignment: AlignmentDirectional.centerStart, + child: FlyoutTarget( + controller: _controller, + child: Builder(builder: (context) { + // Do not use the [Flyout] object because it is only available for the + // flyout content. [MenuInfoProvider] is available for the entire Flyout + // popup. This is only available after [FlyoutTarget]. + final flyout = MenuInfoProvider.maybeOf(context); - /// The flyout menu bar must be invisible because it has transparent - /// components, which can lead to visual inconsistencies. - return Visibility.maintain( - visible: flyout == null, - child: Row(children: [ - for (final item in widget.items) - Builder( - key: _controller.isOpen ? null : _keyOf(item), - builder: (context) { - final isSelected = _currentOpenItem == item; - return HoverButton( - margin: EdgeInsetsDirectional.only( - start: barMargin.start, - end: barMargin.end, - ), - onPressed: () { - _locked = false; - _showFlyout(context, item); - }, - onPointerEnter: _controller.isOpen - ? (_) { - if (_currentOpenItem != item) { - _showFlyout(context, item); - } - } - : null, - onFocusChange: (focused) { - if (focused && _controller.isOpen) { + /// The flyout menu bar must be invisible because it has transparent + /// components, which can lead to visual inconsistencies. + return Visibility.maintain( + visible: flyout == null, + child: Row(children: [ + for (final item in widget.items) + Builder( + key: _controller.isOpen ? null : _keyOf(item), + builder: (context) { + final isSelected = _currentOpenItem == item; + return HoverButton( + margin: EdgeInsetsDirectional.only( + start: barMargin.start, + end: barMargin.end, + ), + onPressed: () { + _locked = false; _showFlyout(context, item); - } - }, - builder: (context, states) { - if (isSelected) { - states = {...states, WidgetState.hovered}; - } - return FocusBorder( - focused: states.isFocused, - child: Container( - padding: barPadding, - decoration: BoxDecoration( - color: HyperlinkButton.backgroundColor(theme) - .resolve(states), - borderRadius: BorderRadius.circular(4.0), + }, + onPointerEnter: _controller.isOpen + ? (_) { + if (_currentOpenItem != item) { + _showFlyout(context, item); + } + } + : null, + onFocusChange: (focused) { + if (focused && _controller.isOpen) { + _showFlyout(context, item); + } + }, + builder: (context, states) { + if (isSelected) { + states = {...states, WidgetState.hovered}; + } + return FocusBorder( + focused: states.isFocused, + child: Container( + padding: barPadding, + decoration: BoxDecoration( + color: HyperlinkButton.backgroundColor(theme) + .resolve(states), + borderRadius: BorderRadius.circular(4.0), + ), + child: Text(item.title), ), - child: Text(item.title), - ), - ); - }, - ); - }, - ), - ]), - ); - }), + ); + }, + ); + }, + ), + ]), + ); + }), + ), ), ); } From 72600ebfc8b74a340f83be37a74271cc58135ffa Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Sat, 1 Feb 2025 03:13:41 -0300 Subject: [PATCH 22/22] feat: Close Flyouts when window metrics change --- CHANGELOG.md | 3 +- lib/src/controls/flyouts/flyout.dart | 70 +++++++++++++++++++++------- 2 files changed, 55 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ffbca046d..86ed493db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,8 @@ - fix: Jitter when displaying flyouts and sub flyouts ([#1014](https://github.com/bdlukaa/fluent_ui/issues/1014)) - fix: Menu sub items have the same transition as their parents ([#1178](https://github.com/bdlukaa/fluent_ui/pull/1178)) - feat: Implemented [`MenuBar`](https://bdlukaa.github.io/fluent_ui/#/surfaces/menu_bar) ([#1107](https://github.com/bdlukaa/fluent_ui/issues/1107)) -- `SubItemShowBehavior` was renamed to `SubItemShowAction` ([#1178](https://github.com/bdlukaa/fluent_ui/pull/1178)) +- chore: `SubItemShowBehavior` was renamed to `SubItemShowAction` ([#1178](https://github.com/bdlukaa/fluent_ui/pull/1178)) +- feat: Flyouts are closed when the window size changes ([#1178](https://github.com/bdlukaa/fluent_ui/pull/1178)) - feat: Added `CommandBarButton.closeAfterClick` ([#1149](https://github.com/bdlukaa/fluent_ui/issues/1149)) - fix: `CommandBar` secondary menu preferred placement mode ([#1174](https://github.com/bdlukaa/fluent_ui/pull/1174)) - feat: `CommandBarState` is now accessible, making it possible to open/close the secondary flyout programmatically ([#1174](https://github.com/bdlukaa/fluent_ui/pull/1174)) diff --git a/lib/src/controls/flyouts/flyout.dart b/lib/src/controls/flyouts/flyout.dart index 74864a540..d9ebed24d 100644 --- a/lib/src/controls/flyouts/flyout.dart +++ b/lib/src/controls/flyouts/flyout.dart @@ -449,9 +449,12 @@ typedef FlyoutTransitionBuilder = Widget Function( ); /// Controls the state of a flyout -class FlyoutController with ChangeNotifier { +class FlyoutController with ChangeNotifier, WidgetsBindingObserver { + FlyoutController() { + WidgetsBinding.instance.addObserver(this); + } + _FlyoutTargetState? _attachState; - bool _open = false; /// Whether this flyout controller is attached to any [FlyoutTarget] bool get isAttached => _attachState != null; @@ -489,7 +492,16 @@ class FlyoutController with ChangeNotifier { /// See also: /// /// * [showFlyout], which opens the flyout - bool get isOpen => _open; + bool get isOpen => _route != null; + + PageRouteBuilder? _route; + + /// Make sure the flyout is open. + void _ensureOpen() { + assert(isOpen, 'The flyout must be open'); + } + + NavigatorState? _currentNavigator; /// Shows a flyout. /// @@ -597,13 +609,14 @@ class FlyoutController with ChangeNotifier { transitionDuration ??= theme.fastAnimationDuration; reverseTransitionDuration ??= transitionDuration; - final navigator = navigatorKey ?? Navigator.of(context); + _currentNavigator = navigatorKey ?? Navigator.of(context); final Offset targetOffset; final Size targetSize; final Rect targetRect; - final navigatorBox = navigator.context.findRenderObject() as RenderBox; + final navigatorBox = + _currentNavigator!.context.findRenderObject() as RenderBox; final targetBox = context.findRenderObject() as RenderBox; targetSize = targetBox.size; @@ -618,12 +631,8 @@ class FlyoutController with ChangeNotifier { ) & targetSize; - _open = true; - notifyListeners(); - final flyoutKey = GlobalKey(); - - final result = await navigator.push(PageRouteBuilder( + _route = PageRouteBuilder( opaque: false, transitionDuration: transitionDuration, reverseTransitionDuration: reverseTransitionDuration, @@ -659,7 +668,7 @@ class FlyoutController with ChangeNotifier { }; return _FlyoutPage( - navigator: navigator, + navigator: _currentNavigator!, targetRect: targetRect, attachState: _attachState, targetOffset: targetOffset, @@ -686,9 +695,12 @@ class FlyoutController with ChangeNotifier { buildTarget: buildTarget, ); }, - )); + ); + notifyListeners(); + final result = + await _currentNavigator!.push(_route! as PageRouteBuilder); - _open = false; + _route = _currentNavigator = null; notifyListeners(); return result; @@ -697,11 +709,35 @@ class FlyoutController with ChangeNotifier { /// Closes the flyout. /// /// The flyout must be open, otherwise an error is thrown. - void close() { + /// + /// If any other route is pushed above the Flyout, this route is likely to + /// be closed. It is a good practice to close the flyout before pushing new + /// routes. + /// + /// If [force] is true, the flyout is removed from the navigator stack without + /// completing the transition. + void close([bool force = false]) { _ensureAttached(); - assert(_open); - if (!_open) return; // safe for release - Navigator.of(_attachState!.context).pop(); + _ensureOpen(); + if (_route == null) return; // safe for release + if (force) { + _currentNavigator!.removeRoute(_route!); + } else { + _currentNavigator!.maybePop(); + } + + _route = _currentNavigator = null; + } + + @override + void didChangeMetrics() { + if (isOpen) close(); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); } }