From 699c8e318db489f915f893aa287e8e21e6d1134f Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Mon, 28 Sep 2026 13:35:49 -0300 Subject: [PATCH 1/3] refactor: Improve NavigationView behavior - Add adaptive thresholds, invocation callbacks, and custom item content - Support non-selecting items and independent tooltip and semantics labels - Preserve only pages that request automatic keep-alive - Fix pane overflow and toggle behavior across display modes --- CHANGELOG.md | 5 + .../screens/navigation/navigation_view.dart | 18 + lib/l10n/generated/fluent_localizations.dart | 2 +- lib/l10n/intl_en.arb | 4 +- .../navigation/navigation_view/body.dart | 230 +++++-- .../navigation/navigation_view/pane.dart | 133 +++- .../navigation_view/pane_items.dart | 213 +++++-- .../navigation/navigation_view/title_bar.dart | 6 +- .../navigation/navigation_view/view.dart | 233 +++++-- test/navigation_view_test.dart | 602 ++++++++++++++++++ 10 files changed, 1272 insertions(+), 174 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec041e502..ea3c76aa3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ - refactor: Progress Widgets Update - fix: `ProgressBar` and `ProgressRing` do not consume excessive CPU ([#1270](https://github.com/bdlukaa/fluent_ui/issues/1270)) - fix: `ProgressBar` correctly handle small parents ([#1270](https://github.com/bdlukaa/fluent_ui/issues/969)) +- refactor: `NavigationView` + - feat: configurable adaptive thresholds, coherent pane open/close APIs, item invocation events, non-selecting items, custom item content builders, and independent tooltip and semantics labels + - fix: preserve pages that opt into `AutomaticKeepAliveClientMixin` without retaining every navigation page + - fix: prevent pane item overflow throughout compact, expanded, and minimal resize transitions + - fix: honor `NavigationPane.toggleable` without reserving a hidden toggle-button slot ## 4.16.1 diff --git a/example/lib/screens/navigation/navigation_view.dart b/example/lib/screens/navigation/navigation_view.dart index 2d714b7d7..4e7e088ad 100644 --- a/example/lib/screens/navigation/navigation_view.dart +++ b/example/lib/screens/navigation/navigation_view.dart @@ -348,6 +348,24 @@ class _NavigationViewPageState extends State ), onTap: () => debugPrint('Tapped track orders'), ), + PaneItem( + icon: const WindowsIcon(WindowsIcons.people), + title: const Text('Teams layout'), + tooltip: 'Teams-style custom item content', + body: const _NavigationBodyItem( + header: 'Custom item content', + content: Text( + 'The contentBuilder changes visual layout without replacing navigation behavior.', + ), + ), + contentBuilder: (context, data) => Padding( + padding: const EdgeInsetsDirectional.symmetric(vertical: 6), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [?data.icon, ?data.title], + ), + ), + ), PaneItem( icon: const WindowsIcon(WindowsIcons.disable_updates), title: const Text('Disabled Item'), diff --git a/lib/l10n/generated/fluent_localizations.dart b/lib/l10n/generated/fluent_localizations.dart index f9067072f..890e51fdd 100644 --- a/lib/l10n/generated/fluent_localizations.dart +++ b/lib/l10n/generated/fluent_localizations.dart @@ -172,7 +172,7 @@ abstract class FluentLocalizations { Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'), ]; - /// The tooltip for the back button on [NavigationAppBar]. + /// The tooltip for navigation back buttons. /// /// In en, this message translates to: /// **'Back'** diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index b7c83c14c..8d223435f 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -2,7 +2,7 @@ "@@locale": "en", "backButtonTooltip": "Back", "@backButtonTooltip": { - "description": "The tooltip for the back button on [NavigationAppBar]." + "description": "The tooltip for navigation back buttons." }, "closeButtonLabel": "Close", "@closeButtonLabel": { @@ -18,7 +18,7 @@ }, "openNavigationTooltip": "Open Navigation", "@openNavigationTooltip": { - "description": "The tooltip for the toogle navigation button." + "description": "The tooltip for the toggle navigation button." }, "clickToSearch": "Click to search", "@clickToSearch": { diff --git a/lib/src/controls/navigation/navigation_view/body.dart b/lib/src/controls/navigation/navigation_view/body.dart index 410819191..b0e3cd088 100644 --- a/lib/src/controls/navigation/navigation_view/body.dart +++ b/lib/src/controls/navigation/navigation_view/body.dart @@ -5,7 +5,6 @@ part of 'view.dart'; /// /// See also: /// * [NavigationView], used alongside this to navigate through pages -/// * [NavigationAppBar], the app top bar class _NavigationBody extends StatefulWidget { /// Creates a navigation body. /// @@ -18,7 +17,7 @@ class _NavigationBody extends StatefulWidget { this.animationDuration, }); - final ValueKey? itemKey; + final ValueKey itemKey; final NavigationContentBuilder? paneBodyBuilder; @@ -73,94 +72,193 @@ class _NavigationBody extends StatefulWidget { } class _NavigationBodyState extends State<_NavigationBody> { + final Map _pages = {}; + final Set _keptAlive = {}; + Object? _currentKey; + + void _keepAlive(Object key) => _keptAlive.add(key); + + void _releaseKeepAlive(Object key) { + if (!_keptAlive.remove(key) || key == _currentKey) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && key != _currentKey && !_keptAlive.contains(key)) { + setState(() => _pages.remove(key)); + } + }); + } + + Widget _buildPage(BuildContext context, NavigationViewContext view) { + final paneBodyBuilder = widget.paneBodyBuilder; + if (paneBodyBuilder != null) { + return paneBodyBuilder.call( + view.pane?.selected != null ? view.pane!.selectedItem : null, + view.pane?.selected != null + ? FocusTraversalGroup(child: view.pane!.selectedItem.body!) + : null, + ); + } + return FocusTraversalGroup( + policy: WidgetOrderTraversalPolicy(), + child: view.pane!.selectedItem.body!, + ); + } + @override Widget build(BuildContext context) { assert(debugCheckHasFluentTheme(context)); final view = NavigationViewContext.of(context); final theme = FluentTheme.of(context); + if (widget.paneBodyBuilder != null) { + return ColoredBox( + color: theme.scaffoldBackgroundColor, + child: _buildPage(context, view), + ); + } + + final pane = view.pane!; + final validPageKeys = {}; + for (final (index, item) in pane.effectiveItems.indexed) { + final itemKey = item.key ?? index; + validPageKeys.add(itemKey); + if (_pages.containsKey(itemKey)) { + _pages[itemKey] = FocusTraversalGroup( + policy: WidgetOrderTraversalPolicy(), + child: item.body!, + ); + } + } + _pages.removeWhere((pageKey, _) => !validPageKeys.contains(pageKey)); + _keptAlive.removeWhere((pageKey) => !validPageKeys.contains(pageKey)); + + final key = widget.itemKey.value; + final previous = _currentKey; + if (previous != null && previous != key && !_keptAlive.contains(previous)) { + _pages.remove(previous); + } + _currentKey = key; + _pages[key] = _buildPage(context, view); + + final duration = widget.animationDuration ?? theme.fastAnimationDuration; + final curve = widget.animationCurve ?? theme.animationCurve; + return ColoredBox( color: theme.scaffoldBackgroundColor, - child: AnimatedSwitcher( - switchInCurve: widget.animationCurve ?? theme.animationCurve, - switchOutCurve: widget.animationCurve ?? theme.animationCurve, - duration: widget.animationDuration ?? theme.fastAnimationDuration, - reverseDuration: - (widget.animationDuration ?? theme.fastAnimationDuration) ~/ 2, - layoutBuilder: (child, children) { - return SizedBox(child: child); - }, - transitionBuilder: (child, animation) { - if (widget.transitionBuilder != null) { - return widget.transitionBuilder!(child, animation); - } - - final isTop = view.displayMode == PaneDisplayMode.top; - - if (isTop) { - return HorizontalSlidePageTransition( - animation: animation, + child: Stack( + fit: StackFit.expand, + children: [ + for (final entry in _pages.entries) + _NavigationPageEntry( + key: ValueKey(entry.key), + active: entry.key == key, + duration: duration, + curve: curve, + transitionBuilder: widget.transitionBuilder, + isTop: view.displayMode == PaneDisplayMode.top, fromLeft: view.previousItemIndex > (view.pane?.selected ?? 0), - child: child, - ); - } - - return EntrancePageTransition(animation: animation, child: child); - }, - child: () { - final paneBodyBuilder = widget.paneBodyBuilder; - if (paneBodyBuilder != null) { - return paneBodyBuilder.call( - view.pane?.selected != null ? view.pane!.selectedItem : null, - view.pane?.selected != null - ? FocusTraversalGroup(child: view.pane!.selectedItem.body!) - : null, - ); - } else { - return _KeepAlivePage( - key: ValueKey('nav_page_${view.pane?.selected}'), - child: FocusTraversalGroup( - policy: WidgetOrderTraversalPolicy(), - child: view.pane!.selectedItem.body!, - ), - ); - } - }(), + onKeepAlive: () => _keepAlive(entry.key), + onReleaseKeepAlive: () => _releaseKeepAlive(entry.key), + child: entry.value, + ), + ], ), ); } } -/// A wrapper widget that enables keep-alive functionality for navigation pages -/// and isolates repaints. -/// -/// This widget: -/// - Uses [AutomaticKeepAliveClientMixin] to help preserve the state -/// of navigation pages when switching between them -/// - Wraps content in [RepaintBoundary] to prevent deeply nested child -/// repaints from causing the entire navigation body to repaint -/// (fixes https://github.com/bdlukaa/fluent_ui/issues/1180) -/// -/// For full state preservation, the page widget itself should also implement -/// [AutomaticKeepAliveClientMixin]. -class _KeepAlivePage extends StatefulWidget { - const _KeepAlivePage({required this.child, super.key}); +class _NavigationPageEntry extends StatefulWidget { + const _NavigationPageEntry({ + required this.active, + required this.duration, + required this.curve, + required this.transitionBuilder, + required this.isTop, + required this.fromLeft, + required this.onKeepAlive, + required this.onReleaseKeepAlive, + required this.child, + super.key, + }); + final bool active; + final Duration duration; + final Curve curve; + final AnimatedSwitcherTransitionBuilder? transitionBuilder; + final bool isTop; + final bool fromLeft; + final VoidCallback onKeepAlive; + final VoidCallback onReleaseKeepAlive; final Widget child; @override - State<_KeepAlivePage> createState() => _KeepAlivePageState(); + State<_NavigationPageEntry> createState() => _NavigationPageEntryState(); } -class _KeepAlivePageState extends State<_KeepAlivePage> - with AutomaticKeepAliveClientMixin { +class _NavigationPageEntryState extends State<_NavigationPageEntry> + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + duration: widget.duration, + value: widget.active ? 1 : 0, + ); + Listenable? _keepAliveHandle; + @override - bool get wantKeepAlive => true; + void didUpdateWidget(_NavigationPageEntry oldWidget) { + super.didUpdateWidget(oldWidget); + _controller.duration = widget.duration; + if (widget.active && !oldWidget.active) _controller.forward(from: 0); + } + + @override + void dispose() { + _keepAliveHandle?.removeListener(widget.onReleaseKeepAlive); + _controller.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { - super.build(context); - return RepaintBoundary(child: widget.child); + final child = NotificationListener( + onNotification: (notification) { + if (!identical(_keepAliveHandle, notification.handle)) { + _keepAliveHandle?.removeListener(widget.onReleaseKeepAlive); + _keepAliveHandle = notification.handle + ..addListener(widget.onReleaseKeepAlive); + widget.onKeepAlive(); + } + return true; + }, + child: RepaintBoundary(child: widget.child), + ); + + return Offstage( + offstage: !widget.active, + child: TickerMode( + enabled: widget.active, + child: AnimatedBuilder( + animation: _controller, + child: child, + builder: (context, child) { + final animation = CurvedAnimation( + parent: _controller, + curve: widget.curve, + ); + if (widget.transitionBuilder case final builder?) { + return builder(child!, animation); + } + if (widget.isTop) { + return HorizontalSlidePageTransition( + animation: animation, + fromLeft: widget.fromLeft, + child: child!, + ); + } + return EntrancePageTransition(animation: animation, child: child!); + }, + ), + ), + ); } } diff --git a/lib/src/controls/navigation/navigation_view/pane.dart b/lib/src/controls/navigation/navigation_view/pane.dart index f1ca927e1..44986be05 100644 --- a/lib/src/controls/navigation/navigation_view/pane.dart +++ b/lib/src/controls/navigation/navigation_view/pane.dart @@ -6,6 +6,28 @@ const double kCompactNavigationPaneWidth = 50; /// The width of the Open Navigation Pane const double kOpenNavigationPaneWidth = 320; +/// The default maximum width for the minimal automatic display mode. +const double kCompactModeThresholdWidth = 640; + +/// The default minimum width for the expanded automatic display mode. +const double kExpandedModeThresholdWidth = 1008; + +/// Called when a pane item is invoked. +typedef PaneItemInvokedCallback = void Function(PaneItemInvokedEventArgs args); + +/// Details about an invoked [PaneItem]. +@immutable +class PaneItemInvokedEventArgs { + /// Creates pane item invocation details. + const PaneItemInvokedEventArgs({required this.item, required this.index}); + + /// The item that was invoked. + final PaneItem item; + + /// The effective selectable index, or `null` for a non-selecting item. + final int? index; +} + /// You can use the PaneDisplayMode property to configure different /// navigation styles, or display modes, for the NavigationView /// @@ -81,6 +103,7 @@ class NavigationPane with Diagnosticable { this.selected, this.onChanged, this.onItemPressed, + this.onItemInvoked, this.size, this.header, this.items = const [], @@ -88,6 +111,8 @@ class NavigationPane with Diagnosticable { this.autoSuggestBox, this.autoSuggestBoxReplacement, this.displayMode = PaneDisplayMode.auto, + this.compactModeThresholdWidth = kCompactModeThresholdWidth, + this.expandedModeThresholdWidth = kExpandedModeThresholdWidth, this.toggleable = true, this.customPane, this.toggleButton = const PaneToggleButton(), @@ -101,6 +126,14 @@ class NavigationPane with Diagnosticable { }) : assert( selected == null || !selected.isNegative, 'The selected index must not be negative', + ), + assert( + compactModeThresholdWidth >= 0, + 'compactModeThresholdWidth must not be negative', + ), + assert( + expandedModeThresholdWidth >= compactModeThresholdWidth, + 'expandedModeThresholdWidth must be greater than or equal to compactModeThresholdWidth', ) { _buildHierarchy(items); _buildHierarchy(footerItems); @@ -118,7 +151,24 @@ class NavigationPane with Diagnosticable { /// [PaneDisplayMode.auto] is used by default. final PaneDisplayMode displayMode; - /// Whether the pane can be toggled or not. + /// The largest width that uses [PaneDisplayMode.minimal] in auto mode. + /// + /// A width greater than this value and less than + /// [expandedModeThresholdWidth] uses [PaneDisplayMode.compact]. Explicit + /// display modes ignore this value. + final double compactModeThresholdWidth; + + /// The smallest width that uses [PaneDisplayMode.expanded] in auto mode. + /// + /// Explicit display modes ignore this value. + final double expandedModeThresholdWidth; + + /// Whether the pane can be opened interactively. + /// + /// When false, the default toggle button and its layout slot are removed. + /// Compact mode remains compact and minimal mode remains closed. + /// This differs from [toggleButton] being null, which only hides the default + /// control and still allows programmatic pane opening. /// /// This is used when the current display mod is [PaneDisplayMode.compact]. /// If `false`, the pane will always be closed. @@ -205,9 +255,18 @@ class NavigationPane with Diagnosticable { /// Called when the current index changes. final ValueChanged? onChanged; - /// Called when an item is pressed. + /// Called when a selectable item is pressed. + /// + /// Unlike earlier releases, this also fires when the selected item is + /// invoked again. Prefer [onItemInvoked] when the item is needed. final ValueChanged? onItemPressed; + /// Called whenever an enabled [PaneItem] is invoked. + /// + /// This is called before [onChanged]. Re-invoking the selected item calls + /// this callback without calling [onChanged] again. + final PaneItemInvokedCallback? onItemInvoked; + /// The scroll controller used by the pane when [displayMode] is /// [PaneDisplayMode.compact] and [PaneDisplayMode.expanded]. /// @@ -298,17 +357,23 @@ class NavigationPane with Diagnosticable { bool canChangeTo(NavigationPaneItem item) { final index = effectiveIndexOf(item); - if (index.isNegative) return false; - - return index != selected; + return item is PaneItem && + item.selectsOnInvoked && + !index.isNegative && + index != selected; } - /// Changes the selected item to [item]. + /// Invokes [item] and requests selection when appropriate. void changeTo(NavigationPaneItem item) { - if (!canChangeTo(item)) return; + if (item is! PaneItem || !item.enabled) return; + final index = effectiveIndexOf(item); - if (!index.isNegative) onItemPressed?.call(index); - if (selected != index && !index.isNegative) onChanged?.call(index); + final effectiveIndex = index.isNegative ? null : index; + onItemInvoked?.call( + PaneItemInvokedEventArgs(item: item, index: effectiveIndex), + ); + if (effectiveIndex != null) onItemPressed?.call(effectiveIndex); + if (canChangeTo(item)) onChanged?.call(index); } /// A list of all of the items displayed on this pane. @@ -429,6 +494,9 @@ class NavigationPane with Diagnosticable { return other is NavigationPane && other.key == key && other.displayMode == displayMode && + other.compactModeThresholdWidth == compactModeThresholdWidth && + other.expandedModeThresholdWidth == expandedModeThresholdWidth && + other.toggleable == toggleable && other.customPane == customPane && other.toggleButton == toggleButton && other.size == size && @@ -440,6 +508,7 @@ class NavigationPane with Diagnosticable { other.selected == selected && other.onChanged == onChanged && other.onItemPressed == onItemPressed && + other.onItemInvoked == onItemInvoked && other.scrollController == scrollController && other.indicator == indicator && other.acrylicDisabled == acrylicDisabled; @@ -449,6 +518,9 @@ class NavigationPane with Diagnosticable { int get hashCode { return key.hashCode ^ displayMode.hashCode ^ + compactModeThresholdWidth.hashCode ^ + expandedModeThresholdWidth.hashCode ^ + toggleable.hashCode ^ customPane.hashCode ^ toggleButton.hashCode ^ size.hashCode ^ @@ -460,6 +532,7 @@ class NavigationPane with Diagnosticable { selected.hashCode ^ onChanged.hashCode ^ onItemPressed.hashCode ^ + onItemInvoked.hashCode ^ scrollController.hashCode ^ indicator.hashCode ^ acrylicDisabled.hashCode; @@ -650,12 +723,42 @@ class NavigationPaneWidgetData { /// /// ```dart /// class CustomNavigationPane extends NavigationPaneWidget { -/// CustomNavigationPane(); -/// /// @override /// Widget build(BuildContext context, NavigationPaneWidgetData data) { +/// return Row( +/// children: [ +/// SizedBox( +/// width: 80, +/// child: ListView( +/// controller: data.scrollController, +/// children: [ +/// for (final item in data.pane.effectiveItems) +/// Button( +/// onPressed: () => data.pane.changeTo(item), +/// child: item.title ?? const SizedBox.shrink(), +/// ), +/// ], +/// ), +/// ), +/// Expanded(child: data.content), +/// ], +/// ); /// } /// } +/// +/// NavigationView( +/// pane: NavigationPane( +/// customPane: CustomNavigationPane(), +/// selected: 0, +/// items: [ +/// PaneItem( +/// icon: const Icon(FluentIcons.home), +/// title: const Text('Home'), +/// body: const Text('Home page'), +/// ), +/// ], +/// ), +/// ); /// ``` abstract class NavigationPaneWidget { /// Builds the custom navigation pane. @@ -711,12 +814,8 @@ class _TopNavigationPaneState extends State<_TopNavigationPane> { } void _onPressed(PaneItem item) { - if (widget.pane.canChangeTo(item)) { - widget.pane.changeTo(item); - if (overflowController.isOpen) { - Navigator.of(context).pop(); - } - } + widget.pane.changeTo(item); + if (overflowController.isOpen) Navigator.of(context).pop(); } Widget _buildItem(NavigationPaneItem item, double height) { diff --git a/lib/src/controls/navigation/navigation_view/pane_items.dart b/lib/src/controls/navigation/navigation_view/pane_items.dart index f7d923ca4..727b7b2f3 100644 --- a/lib/src/controls/navigation/navigation_view/pane_items.dart +++ b/lib/src/controls/navigation/navigation_view/pane_items.dart @@ -63,11 +63,53 @@ class _PaneItemContext extends InheritedWidget { @override bool updateShouldNotify(_PaneItemContext oldWidget) { - // Update children if item or depth changes. - return item != oldWidget.item || depth != oldWidget.depth; + return item != oldWidget.item || + index != oldWidget.index || + isSelected != oldWidget.isSelected || + depth != oldWidget.depth; } } +/// Builds the visual content of a [PaneItem]. +typedef PaneItemContentBuilder = + Widget Function(BuildContext context, PaneItemContentData data); + +/// Data supplied to a [PaneItemContentBuilder]. +@immutable +class PaneItemContentData { + /// Creates pane item content data. + const PaneItemContentData({ + required this.displayMode, + required this.selected, + required this.depth, + required this.icon, + required this.title, + required this.infoBadge, + required this.trailing, + }); + + /// The effective visual display mode for this layout frame. + final PaneDisplayMode displayMode; + + /// Whether the item is selected. + final bool selected; + + /// The hierarchy depth, where zero is a root item. + final int depth; + + /// The configured icon. + final Widget? icon; + + /// The configured visual title. + final Widget? title; + + /// The configured information badge. + final Widget? infoBadge; + + /// The configured trailing content. + final Widget? trailing; +} + /// The item used by [NavigationView] to display the tiles. /// /// On [PaneDisplayMode.compact], only [icon] is displayed, and [title] is @@ -98,6 +140,11 @@ class PaneItem extends NavigationPaneItem { this.selectedTileColor, this.onTap, this.enabled = true, + this.selectsOnInvoked = true, + this.tooltip, + this.automaticTooltip = true, + this.semanticLabel, + this.contentBuilder, }); /// The title used by this item. If the display mode is top @@ -170,6 +217,34 @@ class PaneItem extends NavigationPaneItem { /// * [HoverButton.forceEnabled] final bool enabled; + /// Whether invoking this item requests selection. + /// + /// Set this to false for commands or external destinations that should not + /// replace the current navigation destination. [PaneItemAction] is a + /// convenience for an action-only item. + final bool selectsOnInvoked; + + /// Overrides the compact and top-overflow tooltip text. + /// + /// When null and [automaticTooltip] is true, text is derived from [title]. + final String? tooltip; + + /// Whether to derive a tooltip from [title] when [tooltip] is null. + /// + /// Set this to false with a null [tooltip] to suppress the tooltip. + final bool automaticTooltip; + + /// The accessibility label for this item. + /// + /// When null, text is derived from [title]. + final String? semanticLabel; + + /// Builds the visual arrangement inside the standard item interaction shell. + /// + /// Selection, focus, semantics, indicator, hover, press, keyboard invocation, + /// hierarchy, and history behavior remain managed by [NavigationView]. + final PaneItemContentBuilder? contentBuilder; + /// Builds the pane item widget for display in the navigation pane. /// /// This method handles all display modes ([PaneDisplayMode.compact], @@ -212,6 +287,7 @@ class PaneItem extends NavigationPaneItem { ); final titleText = title?._getProperty() ?? ''; + final effectiveTooltip = tooltip ?? (automaticTooltip ? titleText : null); final baseStyle = title?._getProperty() ?? const TextStyle(); final isTop = mode == PaneDisplayMode.top; @@ -245,7 +321,8 @@ class PaneItem extends NavigationPaneItem { builder: (context, states) { final shouldShowTooltip = ((isTop && !showTextOnTop) || isCompact) && - titleText.isNotEmpty && + effectiveTooltip != null && + effectiveTooltip.isNotEmpty && !states.isDisabled; final textStyle = () { final style = !isTop @@ -272,8 +349,24 @@ class PaneItem extends NavigationPaneItem { ) : null; - Widget result() { - switch (mode) { + Widget buildContent(PaneDisplayMode contentMode) { + final customContent = contentBuilder; + if (customContent != null) { + return customContent( + context, + PaneItemContentData( + displayMode: contentMode, + selected: selected, + depth: depth, + icon: icon, + title: title, + infoBadge: infoBadge, + trailing: trailing, + ), + ); + } + + switch (contentMode) { case PaneDisplayMode.compact: return Container( key: key, @@ -297,36 +390,28 @@ class PaneItem extends NavigationPaneItem { ); case PaneDisplayMode.minimal: case PaneDisplayMode.expanded: - final shouldShowTrailing = !maybeView.isTransitioning; - return ConstrainedBox( key: key, constraints: BoxConstraints(minHeight: paneItemMinHeight), - child: ClipRect( - child: Row( - children: [ - SizedBox(width: depth * 28), + child: Row( + children: [ + SizedBox(width: depth * 28), + Padding( + padding: theme.iconPadding ?? EdgeInsetsDirectional.zero, + child: Center(child: icon), + ), + Expanded(child: textResult ?? const SizedBox.shrink()), + if (infoBadge != null) Padding( - padding: - theme.iconPadding ?? EdgeInsetsDirectional.zero, - child: Center(child: icon), + padding: const EdgeInsetsDirectional.only(end: 8), + child: infoBadge, ), - - Expanded(child: textResult ?? const SizedBox.shrink()), - if (shouldShowTrailing) ...[ - if (infoBadge != null) - Padding( - padding: const EdgeInsetsDirectional.only(end: 8), - child: infoBadge, - ), - if (trailing != null) - IconTheme.merge( - data: const IconThemeData(size: 16), - child: trailing!, - ), - ], - ], - ), + if (trailing != null) + IconTheme.merge( + data: const IconThemeData(size: 16), + child: trailing!, + ), + ], ), ); case PaneDisplayMode.top: @@ -366,8 +451,33 @@ class PaneItem extends NavigationPaneItem { } } + Widget result() { + if (mode != PaneDisplayMode.expanded) return buildContent(mode); + + return LayoutBuilder( + builder: (context, constraints) { + final minimumExpandedWidth = depth * 28 + 130; + const threshold = kOpenNavigationPaneWidth / 1.5; + final showsExpandedContent = + constraints.maxWidth >= + (minimumExpandedWidth > threshold + ? minimumExpandedWidth + : threshold); + return buildContent( + showsExpandedContent + ? PaneDisplayMode.expanded + : PaneDisplayMode.compact, + ); + }, + ); + } + + final effectiveSemanticLabel = + semanticLabel ?? (titleText.isEmpty ? null : titleText); return Semantics( - label: titleText.isEmpty ? null : titleText, + container: effectiveSemanticLabel != null, + excludeSemantics: effectiveSemanticLabel != null, + label: effectiveSemanticLabel, selected: selected, child: Container( // TODO(bdlukaa): Put this into the theme @@ -409,7 +519,10 @@ class PaneItem extends NavigationPaneItem { renderOutside: false, child: shouldShowTooltip ? Tooltip( - richMessage: title?._getProperty(), + message: tooltip, + richMessage: tooltip == null + ? title?._getProperty() + : null, style: TooltipThemeData(textStyle: baseStyle), child: result(), ) @@ -458,6 +571,11 @@ class PaneItem extends NavigationPaneItem { WidgetStateProperty? selectedTileColor, VoidCallback? onTap, bool? enabled, + bool? selectsOnInvoked, + String? tooltip, + bool? automaticTooltip, + String? semanticLabel, + PaneItemContentBuilder? contentBuilder, }) { return PaneItem( title: title ?? this.title, @@ -472,6 +590,11 @@ class PaneItem extends NavigationPaneItem { selectedTileColor: selectedTileColor ?? this.selectedTileColor, onTap: onTap ?? this.onTap, enabled: enabled ?? this.enabled, + selectsOnInvoked: selectsOnInvoked ?? this.selectsOnInvoked, + tooltip: tooltip ?? this.tooltip, + automaticTooltip: automaticTooltip ?? this.automaticTooltip, + semanticLabel: semanticLabel ?? this.semanticLabel, + contentBuilder: contentBuilder ?? this.contentBuilder, ); } @@ -520,7 +643,7 @@ class PaneItem extends NavigationPaneItem { }, ) : const SizedBox.shrink(), - onPressed: () => onItemPressed?.call(this), + onPressed: enabled ? () => onItemPressed?.call(this) : null, trailing: () { if (infoBadge != null && trailing == null) { return infoBadge; @@ -685,6 +808,11 @@ class PaneItemAction extends PaneItem { super.tileColor, super.trailing, super.enabled = true, + super.tooltip, + super.automaticTooltip = true, + super.semanticLabel, + super.contentBuilder, + super.selectsOnInvoked = false, }); } @@ -932,21 +1060,16 @@ class __PaneItemExpanderState extends State<_PaneItemExpander> return _MenuFlyoutPaneItemExpander( item: item, onPressed: () { - if (viewData.pane?.canChangeTo(item) ?? false) { - Navigator.pop(context); - widget.onPressed?.call(); - item.onTap?.call(); - viewData.pane?.changeTo(item); - } + Navigator.pop(context); + viewData.pane?.changeTo(item); + item.onTap?.call(); }, onItemPressed: widget.onItemPressed ?? (item) { - if (viewData.pane?.canChangeTo(item) ?? false) { - Navigator.pop(context); - item.onTap?.call(); - viewData.pane?.changeTo(item); - } + Navigator.pop(context); + viewData.pane?.changeTo(item); + item.onTap?.call(); }, ); } else if (item is PaneItem) { @@ -955,8 +1078,8 @@ class __PaneItemExpanderState extends State<_PaneItemExpander> onPressed: () { Navigator.pop(context); widget.onItemPressed?.call(item); - item.onTap?.call(); viewData.pane?.changeTo(item); + item.onTap?.call(); }, isSelected: body.pane!.isSelected(item), ); diff --git a/lib/src/controls/navigation/navigation_view/title_bar.dart b/lib/src/controls/navigation/navigation_view/title_bar.dart index 08726051c..d0fb26212 100644 --- a/lib/src/controls/navigation/navigation_view/title_bar.dart +++ b/lib/src/controls/navigation/navigation_view/title_bar.dart @@ -623,7 +623,9 @@ class PaneToggleButton extends StatelessWidget { @override Widget build(BuildContext context) { final view = NavigationView.dataOf(context); + final navigationView = NavigationView.of(context); final fluentTheme = FluentTheme.of(context); + final localizations = FluentLocalizations.of(context); final paneItemHeight = clampDouble( kPaneItemMinHeight + fluentTheme.visualDensity.baseSizeAdjustment.dy, 0, @@ -640,7 +642,9 @@ class PaneToggleButton extends StatelessWidget { maxHeight: paneItemHeight, ), child: Tooltip( - message: 'Toggle navigation', + message: navigationView.isPaneOpen + ? localizations.closeNavigationTooltip + : localizations.openNavigationTooltip, child: IconButton( icon: const Icon(WindowsIcons.global_nav_button), onPressed: diff --git a/lib/src/controls/navigation/navigation_view/view.dart b/lib/src/controls/navigation/navigation_view/view.dart index d8dd5167d..8c6270fa6 100644 --- a/lib/src/controls/navigation/navigation_view/view.dart +++ b/lib/src/controls/navigation/navigation_view/view.dart @@ -1,6 +1,7 @@ import 'package:fluent_ui/fluent_ui.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; part 'body.dart'; part 'indicators.dart'; @@ -40,7 +41,7 @@ typedef NavigationContentBuilder = /// int selectedIndex = 0; /// /// NavigationView( -/// appBar: NavigationAppBar( +/// titleBar: const TitleBar( /// title: Text('My App'), /// ), /// pane: NavigationPane( @@ -80,9 +81,11 @@ typedef NavigationContentBuilder = /// * Width 641-1007px: Compact mode (icons only) /// * Width <= 640px: Minimal mode (hamburger menu) /// -/// ## Router integration +/// ## Navigation and routing /// -/// For apps using `go_router` or similar routers, use [paneBodyBuilder] to +/// [NavigationView] manages navigation UI, selection, and optional local +/// selection history. It does not replace Flutter's `Navigator`, `Router`, or +/// routing packages. For router-owned content, use [paneBodyBuilder] to /// integrate with your routing system: /// /// {@tool snippet} @@ -100,10 +103,24 @@ typedef NavigationContentBuilder = /// ``` /// {@end-tool} /// +/// ## Page state +/// +/// Only the selected [PaneItem.body] is mounted by default. A stateful page can +/// opt into retention with [AutomaticKeepAliveClientMixin], `super.build`, and +/// `wantKeepAlive == true`. When [paneBodyBuilder] is supplied, that builder is +/// responsible for body state management. +/// +/// ## Local back history +/// +/// Selection changes are recorded locally. Call +/// `NavigationView.of(context).pop()` to select the previous pane item. This is +/// separate from `Navigator.pop()`, which pops a Flutter route. An open compact +/// or minimal pane is closed before local history is changed. +/// /// See also: /// /// * [NavigationPane], the pane configuration for the navigation view -/// * [NavigationAppBar], the app bar displayed at the top +/// * [TitleBar], a customizable window title bar /// * [PaneItem], an item in the navigation pane /// * [TabView], for tab-based navigation /// * @@ -245,6 +262,13 @@ class NavigationView extends StatefulWidget { State createState() => NavigationViewState(); } +class _NavigationHistoryEntry { + const _NavigationHistoryEntry({required this.key, required this.index}); + + final Key? key; + final int index; +} + class NavigationViewState extends State { ScrollController? _paneScrollController; @@ -278,6 +302,7 @@ class NavigationViewState extends State { /// Always false if the current display mode is not minimal. bool get isMinimalPaneOpen => _minimalPaneOpen; set isMinimalPaneOpen(bool open) { + if (open && !(widget.pane?.toggleable ?? true)) return; if (_displayMode == PaneDisplayMode.minimal) { if (_minimalPaneOpen != open) { setState(() => _minimalPaneOpen = open); @@ -309,6 +334,7 @@ class NavigationViewState extends State { } set compactOverlayOpen(bool value) { + if (value && !(widget.pane?.toggleable ?? true)) return; if (value == _compactOverlayOpen) return; if ([ PaneDisplayMode.expanded, @@ -328,17 +354,87 @@ class NavigationViewState extends State { _compactOverlayOpen = false; } - final List _history = []; + final List<_NavigationHistoryEntry> _history = []; bool _isBackNavigation = false; - /// Pops the current item from the history. + /// Whether the navigation pane is currently open. + /// + /// Expanded panes are always open. Top panes cannot be opened. Compact and + /// minimal modes report whether their overlay is open. + bool get isPaneOpen => switch (_displayMode) { + PaneDisplayMode.expanded => true, + PaneDisplayMode.compact => compactOverlayOpen, + PaneDisplayMode.minimal => isMinimalPaneOpen, + PaneDisplayMode.top || PaneDisplayMode.auto => false, + }; + + /// Opens the compact or minimal overlay pane. + /// + /// This does nothing when [NavigationPane.toggleable] is false, in expanded + /// mode (already open), or in top mode. + void openPane() { + if (!(widget.pane?.toggleable ?? true)) return; + switch (_displayMode) { + case PaneDisplayMode.compact: + compactOverlayOpen = true; + case PaneDisplayMode.minimal: + isMinimalPaneOpen = true; + case PaneDisplayMode.expanded: + case PaneDisplayMode.top: + case PaneDisplayMode.auto: + return; + } + } + + /// Closes the compact or minimal overlay pane. + void closePane() { + switch (_displayMode) { + case PaneDisplayMode.compact: + compactOverlayOpen = false; + case PaneDisplayMode.minimal: + isMinimalPaneOpen = false; + case PaneDisplayMode.expanded: + case PaneDisplayMode.top: + case PaneDisplayMode.auto: + return; + } + } + + int? _resolveHistoryEntry(_NavigationHistoryEntry entry) { + final pane = widget.pane; + if (pane == null) return null; + if (entry.key != null) { + final index = pane.effectiveItems.indexWhere( + (item) => item.key == entry.key, + ); + if (!index.isNegative) return index; + return null; + } + return entry.index < pane.effectiveItems.length ? entry.index : null; + } + + /// Pops the current item from the local selection history. + /// + /// An open compact or minimal overlay is closed before history is changed. void pop() { - if (_history.isEmpty) return; + if (isPaneOpen && + (_displayMode == PaneDisplayMode.compact || + _displayMode == PaneDisplayMode.minimal)) { + closePane(); + return; + } if (widget.pane?.onChanged == null) return; - final previous = _history.last; - _isBackNavigation = true; - widget.pane!.onChanged!(previous); + while (_history.isNotEmpty) { + final previous = _resolveHistoryEntry(_history.last); + if (previous == null || previous == widget.pane?.selected) { + _history.removeLast(); + continue; + } + _isBackNavigation = true; + widget.pane!.onChanged!(previous); + return; + } } int _previousItemIndex = -1; @@ -375,13 +471,30 @@ class NavigationViewState extends State { } if (oldWidget.pane?.selected != widget.pane?.selected) { - if (_isBackNavigation) { - _history.removeLast(); - _isBackNavigation = false; - } else if (oldWidget.pane?.selected != null) { - _history.add(oldWidget.pane!.selected!); + final oldItems = oldWidget.pane?.effectiveItems ?? const []; + final newItems = widget.pane?.effectiveItems ?? const []; + final oldIndex = oldWidget.pane?.selected; + final newIndex = widget.pane?.selected; + final oldItem = oldIndex != null && oldIndex < oldItems.length + ? oldItems[oldIndex] + : null; + final newItem = newIndex != null && newIndex < newItems.length + ? newItems[newIndex] + : null; + final sameKeyedItem = + oldItem?.key != null && oldItem?.key == newItem?.key; + + if (!sameKeyedItem) { + if (_isBackNavigation) { + _history.removeLast(); + _isBackNavigation = false; + } else if (oldItem != null && oldIndex != null) { + _history.add( + _NavigationHistoryEntry(key: oldItem.key, index: oldIndex), + ); + } + _updatePreviousItemIndex(oldIndex ?? -1); } - _updatePreviousItemIndex(oldWidget.pane?.selected ?? -1); WidgetsBinding.instance.addPostFrameCallback((_) { // We need to run this after the frame to ensure the [_selectedItemKey] @@ -429,7 +542,10 @@ class NavigationViewState extends State { /// /// If the display mode is not compact, this does nothing. void toggleCompactOpenMode() { - if (_displayMode != PaneDisplayMode.compact) return; + if (_displayMode != PaneDisplayMode.compact || + !(widget.pane?.toggleable ?? true)) { + return; + } compactOverlayOpen = !compactOverlayOpen; widget.onDisplayModeChanged?.call( compactOverlayOpen ? PaneDisplayMode.expanded : PaneDisplayMode.compact, @@ -443,7 +559,10 @@ class NavigationViewState extends State { /// /// If the display mode is not minimal, this does nothing. void toggleMinimalPane() { - if (_displayMode != PaneDisplayMode.minimal) return; + if (_displayMode != PaneDisplayMode.minimal || + !(widget.pane?.toggleable ?? true)) { + return; + } isMinimalPaneOpen = !isMinimalPaneOpen; } @@ -456,6 +575,7 @@ class NavigationViewState extends State { /// * [toggleCompactOpenMode], to toggle the compact open mode /// * [toggleMinimalPane], to toggle the minimal pane open mode void togglePane() { + if (!(widget.pane?.toggleable ?? true)) return; switch (_displayMode) { case PaneDisplayMode.compact: case PaneDisplayMode.expanded: @@ -470,7 +590,10 @@ class NavigationViewState extends State { /// The position of the toggle pane button. PaneToggleButtonPosition get toggleButtonPosition { - if (widget.pane?.toggleButton == null) return PaneToggleButtonPosition.none; + if (!(widget.pane?.toggleable ?? true) || + widget.pane?.toggleButton == null) { + return PaneToggleButtonPosition.none; + } final preferredPosition = widget.pane?.toggleButtonPosition ?? PaneToggleButtonPreferredPosition.auto; @@ -531,16 +654,19 @@ class NavigationViewState extends State { /// - Width 641-1007px: Compact mode (icons only) /// - Width <= 640px: Minimal mode (hamburger menu) void _resolveDisplayMode(BoxConstraints constraints) { - final paneDisplayMode = widget.pane?.displayMode ?? PaneDisplayMode.auto; + final pane = widget.pane; + final paneDisplayMode = pane?.displayMode ?? PaneDisplayMode.auto; if (paneDisplayMode == PaneDisplayMode.auto) { var width = constraints.biggest.width; if (width.isInfinite) width = MediaQuery.widthOf(context); PaneDisplayMode autoDisplayMode; - if (width <= 640) { + if (width <= + (pane?.compactModeThresholdWidth ?? kCompactModeThresholdWidth)) { autoDisplayMode = PaneDisplayMode.minimal; - } else if (width >= 1008) { + } else if (width >= + (pane?.expandedModeThresholdWidth ?? kExpandedModeThresholdWidth)) { autoDisplayMode = PaneDisplayMode.expanded; } else { autoDisplayMode = PaneDisplayMode.compact; @@ -590,10 +716,13 @@ class NavigationViewState extends State { late Widget paneResult; if (widget.pane != null) { final pane = widget.pane!; + final selectedItem = pane.selected != null ? pane.selectedItem : null; final body = ClipRect( key: _contentKey, child: _NavigationBody( - itemKey: ValueKey(pane.selected ?? -1), + itemKey: ValueKey( + selectedItem?.key ?? pane.selected ?? -1, + ), transitionBuilder: widget.transitionBuilder, paneBodyBuilder: widget.paneBodyBuilder, animationCurve: theme.animationCurve, @@ -724,20 +853,31 @@ class NavigationViewState extends State { return const SizedBox.shrink(); } - return Mica( - backgroundColor: theme.backgroundColor, - child: NavigationViewContext( - displayMode: compactOverlayOpen - ? PaneDisplayMode.expanded - : _displayMode, - isMinimalPaneOpen: isMinimalPaneOpen, - isCompactOverlayOpen: compactOverlayOpen, - pane: widget.pane, - previousItemIndex: _previousItemIndex, - isTransitioning: _isTransitioning, - toggleButtonPosition: toggleButtonPosition, - canPop: _history.isNotEmpty, - child: paneResult, + return CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.escape): () { + if (isPaneOpen && + (_displayMode == PaneDisplayMode.compact || + _displayMode == PaneDisplayMode.minimal)) { + closePane(); + } + }, + }, + child: Mica( + backgroundColor: theme.backgroundColor, + child: NavigationViewContext( + displayMode: compactOverlayOpen + ? PaneDisplayMode.expanded + : _displayMode, + isMinimalPaneOpen: isMinimalPaneOpen, + isCompactOverlayOpen: compactOverlayOpen, + pane: widget.pane, + previousItemIndex: _previousItemIndex, + isTransitioning: _isTransitioning, + toggleButtonPosition: toggleButtonPosition, + canPop: _history.isNotEmpty, + child: paneResult, + ), ), ); }, @@ -769,6 +909,7 @@ class NavigationViewState extends State { _compactOverlayOpen; final openSize = pane.size?.openPaneWidth ?? kOpenNavigationPaneWidth; + final paneTopPadding = TitleBar.calculateHeight(context, widget.titleBar); final noOverlayRequired = constraints.maxWidth / 2.5 > openSize; final openedWithoutOverlay = @@ -862,7 +1003,7 @@ class NavigationViewState extends State { margin: const EdgeInsetsDirectional.symmetric( vertical: 1, ), - padding: const EdgeInsetsDirectional.only(top: 38), + padding: EdgeInsetsDirectional.only(top: paneTopPadding), child: _OpenNavigationPane( theme: theme, pane: pane, @@ -877,7 +1018,7 @@ class NavigationViewState extends State { key: _overlayKey, backgroundColor: theme.backgroundColor, child: Padding( - padding: const EdgeInsetsDirectional.only(top: 38), + padding: EdgeInsetsDirectional.only(top: paneTopPadding), child: _CompactNavigationPane( pane: pane, onOpenSearch: widget.onOpenSearch, @@ -904,6 +1045,12 @@ class NavigationViewState extends State { final localizations = FluentLocalizations.of(context); final openSize = pane.size?.openPaneWidth ?? kOpenNavigationPaneWidth; + final showPaneToggle = + toggleButtonPosition == PaneToggleButtonPosition.pane; + final titleBarHeight = TitleBar.calculateHeight(context, widget.titleBar); + final topOffset = titleBarHeight > (showPaneToggle ? 38.0 : 0.0) + ? titleBarHeight + : (showPaneToggle ? 38.0 : 0.0); return Stack( children: [ @@ -911,11 +1058,11 @@ class NavigationViewState extends State { top: 0, start: 0, end: 0, - height: 38, + height: topOffset, child: ColoredBox(color: fluentTheme.scaffoldBackgroundColor), ), PositionedDirectional( - top: 38, + top: topOffset, start: 0, end: 0, bottom: 0, @@ -967,7 +1114,7 @@ class NavigationViewState extends State { ), ), child: Padding( - padding: const EdgeInsetsDirectional.only(top: 38 + 6), + padding: EdgeInsetsDirectional.only(top: titleBarHeight), child: _OpenNavigationPane( theme: theme, pane: pane, @@ -983,6 +1130,8 @@ class NavigationViewState extends State { ), ), ), + if (showPaneToggle && !isMinimalPaneOpen) + PositionedDirectional(top: 0, start: 0, child: pane.toggleButton!), ?widget.titleBar, ], ); diff --git a/test/navigation_view_test.dart b/test/navigation_view_test.dart index d350efb6a..360e70b67 100644 --- a/test/navigation_view_test.dart +++ b/test/navigation_view_test.dart @@ -1155,4 +1155,606 @@ void main() { }, ); }); + + testWidgets('auto mode honors default and custom threshold boundaries', ( + tester, + ) async { + final key = GlobalKey(); + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1400, 700); + addTearDown(tester.view.reset); + + Future pumpAt( + double width, { + double compact = kCompactModeThresholdWidth, + double expanded = kExpandedModeThresholdWidth, + }) async { + await tester.pumpWidget( + FluentApp( + home: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: width, + height: 600, + child: NavigationView( + key: key, + pane: NavigationPane( + selected: 0, + compactModeThresholdWidth: compact, + expandedModeThresholdWidth: expanded, + items: [ + PaneItem( + icon: const Icon(FluentIcons.home), + title: const Text('Home'), + body: const SizedBox(), + ), + ], + ), + ), + ), + ), + ), + ); + await tester.pump(); + } + + for (final (width, mode) in [ + (640.0, PaneDisplayMode.minimal), + (641.0, PaneDisplayMode.compact), + (1007.0, PaneDisplayMode.compact), + (1008.0, PaneDisplayMode.expanded), + ]) { + await pumpAt(width); + expect(key.currentState!.displayMode, mode); + } + + await pumpAt(700, compact: 700, expanded: 900); + expect(key.currentState!.displayMode, PaneDisplayMode.minimal); + await pumpAt(701, compact: 700, expanded: 900); + expect(key.currentState!.displayMode, PaneDisplayMode.compact); + await pumpAt(900, compact: 700, expanded: 900); + expect(key.currentState!.displayMode, PaneDisplayMode.expanded); + }); + + testWidgets( + 'invocation precedes selection and supports non-selecting items', + (tester) async { + var selected = 0; + final events = []; + final actionFocusNode = FocusNode(); + addTearDown(actionFocusNode.dispose); + + await tester.pumpWidget( + FluentApp( + home: StatefulBuilder( + builder: (context, setState) => NavigationView( + pane: NavigationPane( + selected: selected, + displayMode: PaneDisplayMode.expanded, + onItemInvoked: (args) => events.add( + 'invoke:${(args.item.title! as Text).data}:${args.index}', + ), + onChanged: (index) { + events.add('select:$index'); + setState(() => selected = index); + }, + items: [ + PaneItem( + icon: const Icon(FluentIcons.home), + title: const Text('Home'), + body: const SizedBox(), + ), + PaneItem( + icon: const Icon(FluentIcons.settings), + title: const Text('Settings'), + body: const SizedBox(), + ), + PaneItem( + icon: const Icon(FluentIcons.open_in_new_window), + title: const Text('External'), + body: const SizedBox(), + focusNode: actionFocusNode, + selectsOnInvoked: false, + ), + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Settings')); + await tester.pumpAndSettle(); + expect(events, ['invoke:Settings:1', 'select:1']); + + events.clear(); + await tester.tap(find.text('Settings')); + await tester.pump(); + expect(events, ['invoke:Settings:1']); + + events.clear(); + actionFocusNode.requestFocus(); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + expect(events, ['invoke:External:2']); + expect(selected, 1); + await tester.pump(const Duration(milliseconds: 200)); + }, + ); + + testWidgets('local history survives nested, footer, and dynamic items', ( + tester, + ) async { + final key = GlobalKey(); + var selected = 0; + late StateSetter rebuild; + var includeNested = true; + + Widget buildApp() => FluentApp( + home: StatefulBuilder( + builder: (context, setState) { + rebuild = setState; + return NavigationView( + key: key, + pane: NavigationPane( + selected: selected, + displayMode: PaneDisplayMode.expanded, + onChanged: (index) => setState(() => selected = index), + items: [ + PaneItem( + key: const ValueKey('a'), + icon: const Icon(FluentIcons.home), + title: const Text('A'), + body: const Text('Page A'), + ), + PaneItemExpander( + key: const ValueKey('group'), + icon: const Icon(FluentIcons.folder), + title: const Text('Group'), + initiallyExpanded: true, + items: [ + if (includeNested) + PaneItem( + key: const ValueKey('b'), + icon: const Icon(FluentIcons.document), + title: const Text('B'), + body: const Text('Page B'), + ), + ], + ), + ], + footerItems: [ + PaneItem( + key: const ValueKey('c'), + icon: const Icon(FluentIcons.settings), + title: const Text('C'), + body: const Text('Page C'), + ), + ], + ), + ); + }, + ), + ); + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + await tester.tap(find.text('B')); + await tester.pumpAndSettle(); + await tester.tap(find.text('C')); + await tester.pumpAndSettle(); + + key.currentState!.pop(); + await tester.pumpAndSettle(); + expect(selected, 1); + key.currentState!.pop(); + await tester.pumpAndSettle(); + expect(selected, 0); + + await tester.tap(find.text('B')); + await tester.pumpAndSettle(); + await tester.tap(find.text('C')); + await tester.pumpAndSettle(); + await tester.tap(find.text('C')); + await tester.pump(const Duration(milliseconds: 200)); + rebuild(() { + includeNested = false; + selected = 1; + }); + await tester.pumpAndSettle(); + + key.currentState!.pop(); + await tester.pumpAndSettle(); + expect(selected, 0); + }); + + testWidgets('toggleable false removes and disables the pane toggle', ( + tester, + ) async { + final key = GlobalKey(); + await tester.pumpWidget( + FluentApp( + home: NavigationView( + key: key, + titleBar: const TitleBar(isBackButtonVisible: false), + pane: NavigationPane( + selected: 0, + displayMode: PaneDisplayMode.compact, + toggleable: false, + items: [ + PaneItem( + icon: const Icon(FluentIcons.home), + title: const Text('Home'), + body: const SizedBox(), + ), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(PaneToggleButton), findsNothing); + expect(key.currentState!.isPaneOpen, isFalse); + key.currentState!.openPane(); + await tester.pumpAndSettle(); + expect(key.currentState!.isPaneOpen, isFalse); + + await tester.pumpWidget( + FluentApp( + home: NavigationView( + key: key, + pane: NavigationPane( + selected: 0, + displayMode: PaneDisplayMode.minimal, + toggleable: false, + items: [ + PaneItem( + icon: const Icon(FluentIcons.home), + title: const Text('Home'), + body: const SizedBox.expand(key: ValueKey('minimal-body')), + ), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(find.byKey(const ValueKey('minimal-body'))).dy, 0); + }); + + testWidgets('a null toggle button only hides the control', (tester) async { + final key = GlobalKey(); + await tester.pumpWidget( + FluentApp( + home: NavigationView( + key: key, + pane: NavigationPane( + selected: 0, + displayMode: PaneDisplayMode.compact, + toggleButton: null, + items: [ + PaneItem( + icon: const Icon(FluentIcons.home), + title: const Text('Home'), + body: const SizedBox(), + ), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(PaneToggleButton), findsNothing); + key.currentState!.openPane(); + await tester.pumpAndSettle(); + expect(key.currentState!.isPaneOpen, isTrue); + }); + + testWidgets('Escape closes an open overlay before navigation', ( + tester, + ) async { + final key = GlobalKey(); + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + FluentApp( + home: NavigationView( + key: key, + pane: NavigationPane( + selected: 0, + displayMode: PaneDisplayMode.compact, + items: [ + PaneItem( + icon: const Icon(FluentIcons.home), + title: const Text('Home'), + body: const SizedBox(), + focusNode: focusNode, + ), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + key.currentState!.openPane(); + await tester.pumpAndSettle(); + expect(key.currentState!.isPaneOpen, isTrue); + + focusNode.requestFocus(); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(key.currentState!.isPaneOpen, isFalse); + }); + + testWidgets('only pages requesting keep alive retain state', (tester) async { + final keepAliveLifecycle = _PageLifecycle(); + final disposableLifecycle = _PageLifecycle(); + var selected = 0; + + await tester.pumpWidget( + FluentApp( + home: StatefulBuilder( + builder: (context, setState) => NavigationView( + pane: NavigationPane( + selected: selected, + onChanged: (index) => setState(() => selected = index), + displayMode: PaneDisplayMode.expanded, + items: [ + PaneItem( + key: const ValueKey('kept'), + icon: const Icon(FluentIcons.home), + title: const Text('Kept'), + body: _LifecyclePage( + lifecycle: keepAliveLifecycle, + keepAlive: true, + ), + ), + PaneItem( + key: const ValueKey('disposable'), + icon: const Icon(FluentIcons.settings), + title: const Text('Disposable'), + body: _LifecyclePage(lifecycle: disposableLifecycle), + ), + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(keepAliveLifecycle.initCount, 1); + + await tester.tap(find.text('Kept state: 0')); + await tester.tap(find.text('Disposable')); + await tester.pumpAndSettle(); + expect(keepAliveLifecycle.disposeCount, 0); + expect(disposableLifecycle.initCount, 1); + + await tester.tap(find.text('Kept')); + await tester.pumpAndSettle(); + expect(find.text('Kept state: 1'), findsOneWidget); + expect(disposableLifecycle.disposeCount, 1); + + await tester.tap(find.text('Disposable')); + await tester.pumpAndSettle(); + expect(disposableLifecycle.initCount, 2); + }); + + testWidgets('tooltip policy and semantic labels are independent', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + await tester.pumpWidget( + FluentApp( + home: NavigationView( + pane: NavigationPane( + selected: 0, + displayMode: PaneDisplayMode.compact, + items: [ + PaneItem( + icon: const Icon(FluentIcons.home), + title: const Text('Short'), + semanticLabel: 'Descriptive home destination', + tooltip: 'Custom home tooltip', + body: const SizedBox(), + ), + PaneItem( + icon: const Icon(FluentIcons.settings), + title: const Text('No tooltip'), + automaticTooltip: false, + body: const SizedBox(), + ), + PaneItem( + icon: const Icon(FluentIcons.info), + title: const Text('Automatic tooltip'), + body: const SizedBox(), + ), + PaneItem( + icon: const Icon(FluentIcons.blocked), + title: const Text('Disabled tooltip'), + body: const SizedBox(), + enabled: false, + ), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.bySemanticsLabel('Descriptive home destination'), + findsOneWidget, + ); + final tooltips = tester.widgetList(find.byType(Tooltip)).toList(); + expect( + tooltips.any((tooltip) => tooltip.message == 'Custom home tooltip'), + isTrue, + ); + expect( + tooltips.any( + (tooltip) => tooltip.richMessage?.toPlainText() == 'Automatic tooltip', + ), + isTrue, + ); + expect( + tooltips.any( + (tooltip) => tooltip.richMessage?.toPlainText() == 'No tooltip', + ), + isFalse, + ); + expect( + tooltips.any( + (tooltip) => tooltip.richMessage?.toPlainText() == 'Disabled tooltip', + ), + isFalse, + ); + semantics.dispose(); + }); + + testWidgets('custom item content keeps standard invocation behavior', ( + tester, + ) async { + var selected = 0; + var invocations = 0; + await tester.pumpWidget( + FluentApp( + home: StatefulBuilder( + builder: (context, setState) => NavigationView( + pane: NavigationPane( + selected: selected, + onItemInvoked: (_) => invocations++, + onChanged: (index) => setState(() => selected = index), + displayMode: PaneDisplayMode.expanded, + items: [ + PaneItem( + icon: const Icon(FluentIcons.home), + title: const Text('Home'), + body: const SizedBox(), + ), + PaneItem( + icon: const Icon(FluentIcons.people), + title: const Text('Teams'), + body: const SizedBox(), + contentBuilder: (context, data) => Column( + mainAxisSize: MainAxisSize.min, + children: [?data.icon, ?data.title], + ), + ), + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Teams')); + await tester.pumpAndSettle(); + expect(selected, 1); + expect(invocations, 1); + }); + + testWidgets('auto resize transitions never overflow complex pane items', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + tester.view.physicalSize = const Size(1100, 700); + + await tester.pumpWidget( + FluentApp( + home: NavigationView( + pane: NavigationPane( + selected: 0, + items: [ + PaneItem( + icon: const Icon(FluentIcons.home), + title: const Text( + 'A very long navigation destination that must remain valid', + ), + infoBadge: const InfoBadge(source: Text('99')), + trailing: const Icon(FluentIcons.chevron_right), + body: const SizedBox(), + ), + PaneItemExpander( + icon: const Icon(FluentIcons.folder), + title: const Text('A deeply nested destination'), + initiallyExpanded: true, + items: [ + PaneItem( + icon: const Icon(FluentIcons.document), + title: const Text('A long nested destination'), + infoBadge: const InfoBadge(source: Text('4')), + trailing: const Icon(FluentIcons.chevron_right), + body: const SizedBox(), + ), + ], + ), + ], + ), + ), + ), + ); + + for (final width in [1007.0, 900.0, 641.0, 640.0, 700.0, 1008.0]) { + tester.view.physicalSize = Size(width, 700); + for (var frame = 0; frame < 8; frame++) { + await tester.pump(const Duration(milliseconds: 20)); + expect(tester.takeException(), isNull); + } + } + }); +} + +class _PageLifecycle { + int initCount = 0; + int disposeCount = 0; +} + +class _LifecyclePage extends StatefulWidget { + const _LifecyclePage({required this.lifecycle, this.keepAlive = false}); + + final _PageLifecycle lifecycle; + final bool keepAlive; + + @override + State<_LifecyclePage> createState() => _LifecyclePageState(); +} + +class _LifecyclePageState extends State<_LifecyclePage> + with AutomaticKeepAliveClientMixin { + int value = 0; + + @override + void initState() { + super.initState(); + widget.lifecycle.initCount++; + } + + @override + void dispose() { + widget.lifecycle.disposeCount++; + super.dispose(); + } + + @override + Widget build(BuildContext context) { + super.build(context); + final label = widget.keepAlive ? 'Kept' : 'Disposable'; + return Center( + child: Button( + onPressed: () => setState(() => value++), + child: Text('$label state: $value'), + ), + ); + } + + @override + bool get wantKeepAlive => widget.keepAlive; } From ac2c7a59d7f20661698467ea17800b6a8c99d214 Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Mon, 28 Sep 2026 13:38:14 -0300 Subject: [PATCH 2/3] chore: update changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea3c76aa3..ac5e897da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ - refactor: Progress Widgets Update - fix: `ProgressBar` and `ProgressRing` do not consume excessive CPU ([#1270](https://github.com/bdlukaa/fluent_ui/issues/1270)) - fix: `ProgressBar` correctly handle small parents ([#1270](https://github.com/bdlukaa/fluent_ui/issues/969)) -- refactor: `NavigationView` +- refactor: `NavigationView` ([https://github.com/bdlukaa/fluent_ui/pull/1361](#1361)) - feat: configurable adaptive thresholds, coherent pane open/close APIs, item invocation events, non-selecting items, custom item content builders, and independent tooltip and semantics labels - fix: preserve pages that opt into `AutomaticKeepAliveClientMixin` without retaining every navigation page - fix: prevent pane item overflow throughout compact, expanded, and minimal resize transitions From 36eb635353d5bfb9c3cafc2df9458ef56939187f Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Mon, 28 Sep 2026 15:27:05 -0300 Subject: [PATCH 3/3] fix: tests --- test/navigation_view_test.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/test/navigation_view_test.dart b/test/navigation_view_test.dart index 360e70b67..0e92cdd3a 100644 --- a/test/navigation_view_test.dart +++ b/test/navigation_view_test.dart @@ -1,4 +1,5 @@ import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:material_ui/material_ui.dart' as material;