From 84a1733d7a709c5eb6e735241b6cccd19955d2b6 Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Mon, 28 Sep 2026 11:17:22 -0300 Subject: [PATCH 1/5] refactor: Rework `ScaffoldPage` --- .../screens/navigation/navigation_view.dart | 2 +- example/lib/screens/theming/colors.dart | 2 +- example/lib/screens/theming/icons.dart | 271 ++++++------ example/lib/screens/theming/reveal_focus.dart | 2 +- example/lib/screens/theming/typography.dart | 2 +- lib/src/controls/layout/page.dart | 386 ++++++++---------- .../navigation/navigation_view/body.dart | 87 ++-- test/scaffold_page.dart | 230 ++++++++++- 8 files changed, 569 insertions(+), 413 deletions(-) diff --git a/example/lib/screens/navigation/navigation_view.dart b/example/lib/screens/navigation/navigation_view.dart index 2d714b7d7..d86915c9b 100644 --- a/example/lib/screens/navigation/navigation_view.dart +++ b/example/lib/screens/navigation/navigation_view.dart @@ -509,7 +509,7 @@ class _NavigationBodyItem extends StatelessWidget { @override Widget build(final BuildContext context) { - return ScaffoldPage.withPadding( + return ScaffoldPage( header: PageHeader(title: Text(header ?? 'This is a header text')), content: content ?? diff --git a/example/lib/screens/theming/colors.dart b/example/lib/screens/theming/colors.dart index 373df45e6..764c45b14 100644 --- a/example/lib/screens/theming/colors.dart +++ b/example/lib/screens/theming/colors.dart @@ -27,7 +27,7 @@ class ColorsPage extends StatelessWidget { ); return ScaffoldPage.scrollable( header: const PageHeader(title: Text('Colors Showcase')), - bottomBar: const SizedBox( + footer: const SizedBox( width: double.infinity, child: InfoBar( title: Text('Tip:'), diff --git a/example/lib/screens/theming/icons.dart b/example/lib/screens/theming/icons.dart index 367c37469..2868cd166 100644 --- a/example/lib/screens/theming/icons.dart +++ b/example/lib/screens/theming/icons.dart @@ -117,7 +117,7 @@ class _IconsPageState extends State { ), ), ), - bottomBar: const SizedBox( + footer: const SizedBox( width: double.infinity, child: InfoBar( title: Text('Tip:'), @@ -126,167 +126,160 @@ class _IconsPageState extends State { ), ), ), - content: Padding( - padding: EdgeInsetsDirectional.only( - start: PageHeader.horizontalPadding(context), - end: PageHeader.horizontalPadding(context), - ), - child: CustomScrollView( - slivers: [ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsetsDirectional.only(bottom: 8), - child: CodeSnippetCard( - initiallyOpen: true, - codeSnippet: - '''const WindowsIcon( + content: CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsetsDirectional.only(bottom: 8), + child: CodeSnippetCard( + initiallyOpen: true, + codeSnippet: + '''const WindowsIcon( $prefix.$iconName, size: ${size!.toInt()}, color: Color(0x${color!.toARGB32().toRadixString(16).padLeft(8, '0')}), ),''', - child: Row( - spacing: 8, - children: [ - WindowsIcon(icon, size: size, color: color), - const Spacer(), - IntrinsicWidth( - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - InfoLabel( - label: 'Icon Color', - child: ComboBox( - placeholder: const Text('Icon Color'), - onChanged: (final c) => - setState(() => color = c), - value: color, - isExpanded: true, - items: [ - ComboBoxItem( - value: Colors.white, - child: Row( - children: [ - buildColorBox(Colors.white), - const SizedBox(width: 10), - const Text('White'), - ], - ), + child: Row( + spacing: 8, + children: [ + WindowsIcon(icon, size: size, color: color), + const Spacer(), + IntrinsicWidth( + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + InfoLabel( + label: 'Icon Color', + child: ComboBox( + placeholder: const Text('Icon Color'), + onChanged: (final c) => setState(() => color = c), + value: color, + isExpanded: true, + items: [ + ComboBoxItem( + value: Colors.white, + child: Row( + children: [ + buildColorBox(Colors.white), + const SizedBox(width: 10), + const Text('White'), + ], + ), + ), + ComboBoxItem( + value: const Color(0xE4000000), + child: Row( + children: [ + buildColorBox(const Color(0xE4000000)), + const SizedBox(width: 10), + const Text('Black'), + ], ), - ComboBoxItem( - value: const Color(0xE4000000), + ), + ...List.generate(Colors.accentColors.length, ( + final index, + ) { + final color = Colors.accentColors[index]; + return ComboBoxItem( + value: color, child: Row( children: [ - buildColorBox(const Color(0xE4000000)), + buildColorBox(color), const SizedBox(width: 10), - const Text('Black'), + Text(accentColorNames[index + 1]), ], ), - ), - ...List.generate(Colors.accentColors.length, ( - final index, - ) { - final color = Colors.accentColors[index]; - return ComboBoxItem( - value: color, - child: Row( - children: [ - buildColorBox(color), - const SizedBox(width: 10), - Text(accentColorNames[index + 1]), - ], - ), - ); - }), - ], - ), - ), - const SizedBox(height: 8), - InfoLabel( - label: 'Icon Size', - child: Slider( - value: size!, - onChanged: (final v) => setState(() { - size = v; + ); }), - min: 8, - max: 56, - label: '${size!.toInt()}', - style: const SliderThemeData( - margin: EdgeInsetsDirectional.zero, - ), + ], + ), + ), + const SizedBox(height: 8), + InfoLabel( + label: 'Icon Size', + child: Slider( + value: size!, + onChanged: (final v) => setState(() { + size = v; + }), + min: 8, + max: 56, + label: '${size!.toInt()}', + style: const SliderThemeData( + margin: EdgeInsetsDirectional.zero, ), ), - ], - ), + ), + ], ), - ], - ), + ), + ], ), ), ), - SliverGrid.builder( - gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 150, - mainAxisSpacing: 10, - crossAxisSpacing: 10, - ), - itemCount: entries.length, - itemBuilder: (final context, final index) { - final e = entries.elementAt(index); - return HoverButton( - onPressed: () async { - final copyText = '$prefix.${e.key}'; - await Clipboard.setData(ClipboardData(text: copyText)); + ), + SliverGrid.builder( + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 150, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + ), + itemCount: entries.length, + itemBuilder: (final context, final index) { + final e = entries.elementAt(index); + return HoverButton( + onPressed: () async { + final copyText = '$prefix.${e.key}'; + await Clipboard.setData(ClipboardData(text: copyText)); - if (context.mounted) showCopiedSnackbar(context, copyText); - }, - cursor: SystemMouseCursors.copy, - builder: (final context, final states) { - return FocusBorder( - focused: states.isFocused, - renderOutside: false, - child: Tooltip( - useMousePosition: false, - message: - '\nWindowsIcons.${e.key}\n(tap to copy to clipboard)\n', - child: RepaintBoundary( - child: AnimatedContainer( - duration: theme.fasterAnimationDuration, - decoration: BoxDecoration( - color: ButtonThemeData.uncheckedInputColor( - theme, - states, - transparentWhenNone: true, - ), - borderRadius: BorderRadius.circular(20), + if (context.mounted) showCopiedSnackbar(context, copyText); + }, + cursor: SystemMouseCursors.copy, + builder: (final context, final states) { + return FocusBorder( + focused: states.isFocused, + renderOutside: false, + child: Tooltip( + useMousePosition: false, + message: + '\nWindowsIcons.${e.key}\n(tap to copy to clipboard)\n', + child: RepaintBoundary( + child: AnimatedContainer( + duration: theme.fasterAnimationDuration, + decoration: BoxDecoration( + color: ButtonThemeData.uncheckedInputColor( + theme, + states, + transparentWhenNone: true, ), - padding: const EdgeInsetsDirectional.all(6), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(e.value, size: 40), - Padding( - padding: const EdgeInsetsDirectional.only( - top: 8, - ), - child: Text( - snakeCasetoSentenceCase(e.key), - textAlign: TextAlign.center, - overflow: TextOverflow.fade, - ), + borderRadius: BorderRadius.circular(20), + ), + padding: const EdgeInsetsDirectional.all(6), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(e.value, size: 40), + Padding( + padding: const EdgeInsetsDirectional.only( + top: 8, ), - ], - ), + child: Text( + snakeCasetoSentenceCase(e.key), + textAlign: TextAlign.center, + overflow: TextOverflow.fade, + ), + ), + ], ), ), ), - ); - }, - ); - }, - ), - ], - ), + ), + ); + }, + ); + }, + ), + ], ), ); } diff --git a/example/lib/screens/theming/reveal_focus.dart b/example/lib/screens/theming/reveal_focus.dart index 83ea1aab9..831221f45 100644 --- a/example/lib/screens/theming/reveal_focus.dart +++ b/example/lib/screens/theming/reveal_focus.dart @@ -13,7 +13,7 @@ class RevealFocusPage extends StatelessWidget with PageMixin { @override Widget build(final BuildContext context) { final theme = FluentTheme.of(context); - return ScaffoldPage.withPadding( + return ScaffoldPage( header: PageHeader( title: const Text('Reveal Focus'), commandBar: Button( diff --git a/example/lib/screens/theming/typography.dart b/example/lib/screens/theming/typography.dart index 700a22184..20caf3c37 100644 --- a/example/lib/screens/theming/typography.dart +++ b/example/lib/screens/theming/typography.dart @@ -32,7 +32,7 @@ class _TypographyPageState extends State { color ??= typography.display!.color; typography = typography.apply(displayColor: color); const Widget spacer = SizedBox(height: 4); - return ScaffoldPage.withPadding( + return ScaffoldPage( header: PageHeader( title: const Text('Typography showcase'), commandBar: SizedBox( diff --git a/lib/src/controls/layout/page.dart b/lib/src/controls/layout/page.dart index e5718a79b..d7f49c9e1 100644 --- a/lib/src/controls/layout/page.dart +++ b/lib/src/controls/layout/page.dart @@ -1,146 +1,198 @@ import 'package:fluent_ui/fluent_ui.dart'; import 'package:flutter/foundation.dart'; -/// The default vertical padding of the scaffold page. +/// The default vertical padding of a [ScaffoldPage]. const double kPageDefaultVerticalPadding = 24; -/// A page layout that follows Windows Fluent Design guidelines. +/// The horizontal padding used by a page at compact widths. +const double kPageCompactHorizontalPadding = 12; + +/// The horizontal padding used by a page at regular widths. +const double kPageDefaultHorizontalPadding = 24; + +/// A Fluent page content shell. +/// +/// [ScaffoldPage] owns the geometry of one page's content: an optional +/// [header], the flexible [content] region, and an optional [footer]. The +/// resolved page padding surrounds all three regions, so a header, body, and +/// footer always share the same content edges. /// -/// [ScaffoldPage] provides a consistent page structure with an optional -/// header, content area, and bottom bar. It handles padding and layout -/// automatically while being customizable. +/// The default padding is 12 logical pixels horizontally at compact widths +/// (640 logical pixels or less) and 24 logical pixels otherwise. It is 24 +/// logical pixels vertically. Supplying [padding] replaces these defaults; +/// every side of the supplied [EdgeInsetsGeometry] is applied exactly once. /// -/// ![ScaffoldPage content area](https://learn.microsoft.com/en-us/windows/apps/design/controls/images/nav-content.png) +/// [ScaffoldPage] does not make [content] scrollable. Use +/// [ScaffoldPage.scrollable] as a convenience for a page whose body is a +/// [ListView], or provide a [ListView], [CustomScrollView], or another +/// scrollable widget directly. +/// +/// The page does not apply safe-area padding automatically. This keeps the +/// shell suitable for desktop and for use below [NavigationView]. Keyboard +/// view insets are applied to the page's available height when +/// [resizeToAvoidBottomInset] is true. /// /// {@tool snippet} -/// This example shows a basic scaffold page: +/// A basic page with a header and page content: /// /// ```dart /// ScaffoldPage( -/// header: PageHeader(title: Text('Settings')), -/// content: Center(child: Text('Page content')), +/// header: const PageHeader(title: Text('Settings')), +/// content: const SettingsView(), /// ) /// ``` /// {@end-tool} /// -/// ## Page variants +/// {@tool snippet} +/// A page with commands and a persistent footer: /// -/// * [ScaffoldPage.scrollable] - Content is placed in a scrollable [ListView] -/// * [ScaffoldPage.withPadding] - Content has horizontal and vertical padding +/// ```dart +/// ScaffoldPage( +/// header: PageHeader( +/// title: const Text('Edit'), +/// commandBar: CommandBar(primaryItems: [...]), +/// ), +/// content: const EditorView(), +/// footer: const InfoBar(content: Text('Changes are saved automatically.')), +/// ) +/// ``` +/// {@end-tool} /// /// See also: /// -/// * [PageHeader], typically used for the [header] property -/// * [NavigationView], for app-level navigation structure -class ScaffoldPage extends StatefulWidget { - /// Creates a new scaffold page. +/// * [PageHeader], a Fluent page header with title and commands +/// * [NavigationView], which provides application-level navigation and Mica +class ScaffoldPage extends StatelessWidget { + /// Creates a page content shell. const ScaffoldPage({ super.key, this.header, this.content = const SizedBox.expand(), - this.bottomBar, + this.footer, this.padding, + this.backgroundColor, this.resizeToAvoidBottomInset = true, }); - /// Creates a scrollable page + /// Creates a page whose content is a scrollable [ListView]. /// - /// The default horizontal and vertical padding is added automatically + /// The list is the page body and uses the same outer page padding as the + /// regular constructor. Header and footer remain outside the list, so they + /// do not scroll away. Provide [padding] when the whole page needs custom + /// insets; add padding inside a list only when it is specific to that list. ScaffoldPage.scrollable({ required List children, super.key, this.header, - this.bottomBar, + this.footer, this.padding, + this.backgroundColor, ScrollController? scrollController, this.resizeToAvoidBottomInset = true, - }) : content = Builder( - builder: (context) { - return ListView( - controller: scrollController, - padding: - padding ?? - EdgeInsetsDirectional.only( - bottom: kPageDefaultVerticalPadding, - start: PageHeader.horizontalPadding(context), - end: PageHeader.horizontalPadding(context), - ), - children: children, - ); - }, + }) : content = ListView( + controller: scrollController, + padding: EdgeInsets.zero, + children: children, ); - /// Creates a page with padding applied to [content] - ScaffoldPage.withPadding({ - required Widget content, - super.key, - this.header, - this.bottomBar, - this.padding, - this.resizeToAvoidBottomInset = true, - }) : content = Builder( - builder: (context) { - return Padding( - padding: - padding ?? - EdgeInsetsDirectional.only( - bottom: kPageDefaultVerticalPadding, - start: PageHeader.horizontalPadding(context), - end: PageHeader.horizontalPadding(context), - ), - child: content, - ); - }, - ); - - /// The content of this page. The content area is where most of the information - /// for the selected nav category is displayed. + /// The primary content of the page. /// - /// ![Content Example](https://docs.microsoft.com/en-us/windows/uwp/design/controls-and-patterns/images/nav-content.png) + /// The content receives the flexible space between [header] and [footer]. + /// It is not implicitly scrollable. final Widget content; - /// The header of this page. Usually a [PageHeader] is used. - /// - /// ![Header example](https://docs.microsoft.com/en-us/windows/uwp/design/controls-and-patterns/images/nav-header.png) + /// The page header, usually a [PageHeader]. final Widget? header; - /// The bottom bar of this page. This is usually provided when the current - /// screen is small. + /// Persistent content below the primary page content. /// - /// Usually a [BottomNavigation] - final Widget? bottomBar; + /// This region is intended for page actions, status, wizard controls, or + /// other page-level content. It is not application navigation; use + /// [NavigationView] for that. + final Widget? footer; - /// The padding used by this widget. + /// The outer padding for the entire page content shell. /// - /// If null, [PageHeader.horizontalPadding] is used horizontally and - /// [kPageDefaultVerticalPadding] is used vertically - final EdgeInsets? padding; + /// The padding surrounds [header], [content], and [footer]. If null, the + /// page uses 12 logical pixels horizontally at widths up to 640 logical + /// pixels, 24 logical pixels horizontally at wider widths, and 24 logical + /// pixels on the top and bottom. Directional padding is recommended for RTL + /// layouts. + final EdgeInsetsGeometry? padding; - /// If true the body and the scaffold's floating widgets should size - /// themselves to avoid the onscreen keyboard whose height is defined by the - /// ambient MediaQuery's [MediaQueryData.viewInsets] bottom property. + /// The background color for the page's Mica surface. /// - /// For example, if there is an onscreen keyboard displayed above the - /// scaffold, the body can be resized to avoid overlapping the keyboard, which - /// prevents widgets inside the body from being obscured by the keyboard. + /// When null, a standalone page uses the theme Mica background and a page + /// below [NavigationView] is transparent so the application shell owns the + /// backdrop. Set this explicitly when a page needs its own surface. + final Color? backgroundColor; + + /// Whether the page should reduce its available height for the keyboard. /// - /// Defaults to true. + /// When true, [MediaQueryData.viewInsets.bottom] is applied below the page + /// shell, keeping the footer and page content above an onscreen keyboard. + /// The ambient [MediaQuery] is otherwise left unchanged. Defaults to true. final bool resizeToAvoidBottomInset; @override - State createState() => _ScaffoldPageState(); + Widget build(BuildContext context) { + assert(debugCheckHasFluentTheme(context)); + assert(debugCheckHasMediaQuery(context)); + + final navigationView = NavigationView.maybeOf(context); + return Mica( + backgroundColor: + backgroundColor ?? + (navigationView == null ? null : Colors.transparent), + child: LayoutBuilder( + builder: (context, constraints) { + final pagePadding = + padding ?? _defaultPagePadding(context, constraints); + final viewInsets = resizeToAvoidBottomInset + ? MediaQuery.viewInsetsOf(context) + : EdgeInsets.zero; + + return Padding( + padding: pagePadding, + child: Padding( + padding: EdgeInsetsDirectional.only(bottom: viewInsets.bottom), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ?header, + Expanded(child: content), + ?footer, + ], + ), + ), + ); + }, + ), + ); + } + + static EdgeInsetsGeometry _defaultPagePadding( + BuildContext context, + BoxConstraints constraints, + ) { + final width = constraints.hasBoundedWidth + ? constraints.maxWidth + : MediaQuery.widthOf(context); + final horizontal = width <= 640 + ? kPageCompactHorizontalPadding + : kPageDefaultHorizontalPadding; + return EdgeInsetsDirectional.symmetric( + horizontal: horizontal, + vertical: kPageDefaultVerticalPadding, + ); + } @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties - ..add( - DiagnosticsProperty( - 'padding', - padding, - defaultValue: kPageDefaultVerticalPadding, - ), - ) + ..add(DiagnosticsProperty('padding', padding)) + ..add(DiagnosticsProperty('backgroundColor', backgroundColor)) ..add( FlagProperty( 'resizeToAvoidBottomInset', @@ -152,149 +204,63 @@ class ScaffoldPage extends StatefulWidget { } } -class _ScaffoldPageState extends State { - final _bucket = PageStorageBucket(); - - @override - Widget build(BuildContext context) { - assert(debugCheckHasFluentTheme(context)); - assert(debugCheckHasMediaQuery(context)); - - final theme = FluentTheme.of(context); - final view = NavigationView.maybeOf(context); - - return Mica( - backgroundColor: view != null ? Colors.transparent : null, - child: PageStorage( - bucket: _bucket, - child: Padding( - padding: EdgeInsetsDirectional.only( - bottom: widget.resizeToAvoidBottomInset - ? MediaQuery.viewInsetsOf(context).bottom - : 0.0, - ), - child: Column( - children: [ - Expanded( - child: Container( - // we only show the scaffold background color if a [NavigationView] is - // not a parent widget of this page. this happens because, if a navigation - // view is not used, the page would be uncolored. - color: view == null ? theme.scaffoldBackgroundColor : null, - padding: widget.padding == null - ? const EdgeInsetsDirectional.only( - top: kPageDefaultVerticalPadding, - ) - : EdgeInsetsDirectional.only(top: widget.padding!.top), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (widget.header != null) widget.header!, - Expanded(child: widget.content), - ], - ), - ), - ), - if (widget.bottomBar != null) widget.bottomBar!, - ], - ), - ), - ), - ); - } -} - -/// The header of a page. +/// The header of a Fluent page. +/// +/// [PageHeader] describes the title and page-level commands. It does not +/// determine the outer page margins; [ScaffoldPage] supplies those margins so +/// custom headers and page content share one geometry contract. +/// +/// The [commandBar] may be any widget, although [CommandBar] is the usual +/// choice. At narrow widths the title and commands receive flexible +/// constraints rather than a fixed minimum command width, allowing controls +/// such as [CommandBar] to apply their own overflow behavior. /// /// See also: /// -/// * [ScaffoldPage], which creates a page that follows Windows-ui design -/// guidelines. -/// * [Typography.title], which is the default style used by the header -/// * [CommandBar], which provide quick access to common tasks on the page +/// * [ScaffoldPage], which places the header in the page shell +/// * [CommandBar], for Fluent page actions class PageHeader extends StatelessWidget { /// Creates a page header. - const PageHeader({ - super.key, - this.leading, - this.title, - this.commandBar, - this.padding, - }); + const PageHeader({super.key, this.leading, this.title, this.commandBar}); - /// The widget displayed before the [title] - /// - /// Usually an [Icon] widget. + /// The widget displayed before the [title]. final Widget? leading; - /// The title of this bar. - /// - /// ![Header Example](https://docs.microsoft.com/en-us/windows/uwp/design/controls-and-patterns/images/nav-header.png) - /// - /// Usually a [Text] widget. + /// The title of the page. final Widget? title; - /// A bar with a list of actions an user can take - /// - /// Usually a [CommandBar] widget. + /// The page actions, usually a [CommandBar]. final Widget? commandBar; - /// The horizontal padding applied to both sides of the page - /// - /// If not provided, the padding is calculated using the [horizontalPadding] - /// function, which gets the padding based on the screen width. - final double? padding; - - /// Gets the horizontal padding applied to the header based on the screen - /// width. - /// - /// If the screen is small, the padding is 12.0, otherwise it defaults to - /// [kPageDefaultVerticalPadding] - static double horizontalPadding(BuildContext context) { - assert(debugCheckHasMediaQuery(context)); - final screenWidth = MediaQuery.widthOf(context); - final isSmallScreen = screenWidth < 640.0; - final horizontalPadding = isSmallScreen - ? 12.0 - : kPageDefaultVerticalPadding; - return horizontalPadding; - } - @override Widget build(BuildContext context) { assert(debugCheckHasFluentTheme(context)); final theme = FluentTheme.of(context); - final horizontalPadding = padding ?? PageHeader.horizontalPadding(context); - - return Padding( - padding: EdgeInsetsDirectional.only( - bottom: 18, - start: leading != null ? 0 : horizontalPadding, - ), - child: Row( - children: [ - ?leading, - Expanded( - child: DefaultTextStyle.merge( - style: theme.typography.title, - child: title ?? const SizedBox(), - ), + final row = Row( + children: [ + if (leading != null) ...[leading!, const SizedBox(width: 12)], + Expanded( + child: DefaultTextStyle.merge( + style: theme.typography.title, + maxLines: 2, + child: title ?? const SizedBox(), ), - SizedBox(width: horizontalPadding), - if (commandBar != null) ...[ - Flexible( - child: ConstrainedBox( - constraints: const BoxConstraints(minWidth: 160), - child: Align( - alignment: AlignmentDirectional.centerEnd, - child: commandBar, - ), - ), + ), + if (commandBar != null) ...[ + const SizedBox(width: 12), + Flexible( + child: Align( + alignment: AlignmentDirectional.centerEnd, + child: commandBar, ), - SizedBox(width: horizontalPadding), - ], + ), ], - ), + ], + ); + + return Padding( + padding: const EdgeInsetsDirectional.only(bottom: 18), + child: row, ); } } diff --git a/lib/src/controls/navigation/navigation_view/body.dart b/lib/src/controls/navigation/navigation_view/body.dart index 410819191..ec3799e88 100644 --- a/lib/src/controls/navigation/navigation_view/body.dart +++ b/lib/src/controls/navigation/navigation_view/body.dart @@ -79,54 +79,51 @@ class _NavigationBodyState extends State<_NavigationBody> { final view = NavigationViewContext.of(context); final theme = FluentTheme.of(context); - 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); - } + return 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; + final isTop = view.displayMode == PaneDisplayMode.top; - if (isTop) { - return HorizontalSlidePageTransition( - animation: animation, - fromLeft: view.previousItemIndex > (view.pane?.selected ?? 0), - child: child, - ); - } + if (isTop) { + return HorizontalSlidePageTransition( + animation: animation, + 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!, - ), - ); - } - }(), - ), + 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!, + ), + ); + } + }(), ); } } diff --git a/test/scaffold_page.dart b/test/scaffold_page.dart index aca419484..978613ca4 100644 --- a/test/scaffold_page.dart +++ b/test/scaffold_page.dart @@ -4,28 +4,228 @@ import 'package:flutter_test/flutter_test.dart'; import 'app_test.dart'; void main() { - testWidgets( - 'viewInsets is considered when rendering the page when resizeToAvoidBottomInset is true', - (tester) async { - const viewInsets = EdgeInsets.only(top: 27); + testWidgets('default padding surrounds header, content, and footer', ( + tester, + ) async { + await tester.pumpWidget( + wrapApp( + child: const SizedBox( + width: 800, + height: 600, + child: ScaffoldPage( + header: SizedBox(key: Key('header'), height: 40), + content: SizedBox(key: Key('content')), + footer: SizedBox(key: Key('footer'), height: 40), + ), + ), + ), + ); + + final header = tester.getRect(find.byKey(const Key('header'))); + final content = tester.getRect(find.byKey(const Key('content'))); + final footer = tester.getRect(find.byKey(const Key('footer'))); + + expect(header, const Rect.fromLTWH(24, 24, 752, 40)); + expect(content.left, 24); + expect(content.right, 776); + expect(footer, const Rect.fromLTWH(24, 536, 752, 40)); + }); + + testWidgets('explicit padding applies every side exactly once', ( + tester, + ) async { + await tester.pumpWidget( + wrapApp( + child: const SizedBox( + width: 800, + height: 600, + child: ScaffoldPage( + padding: EdgeInsets.fromLTRB(7, 11, 13, 17), + header: SizedBox(key: Key('header'), height: 40), + content: SizedBox(key: Key('content')), + footer: SizedBox(key: Key('footer'), height: 40), + ), + ), + ), + ); + + expect( + tester.getRect(find.byKey(const Key('header'))), + const Rect.fromLTWH(7, 11, 780, 40), + ); + expect( + tester.getRect(find.byKey(const Key('footer'))), + const Rect.fromLTWH(7, 543, 780, 40), + ); + }); + + testWidgets('default horizontal padding adapts to the page width', ( + tester, + ) async { + Future contentOffset(double width) async { await tester.pumpWidget( wrapApp( + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: width, + height: 200, + child: const ScaffoldPage(content: SizedBox(key: Key('content'))), + ), + ), + ), + ); + return tester.getTopLeft(find.byKey(const Key('content'))); + } + + expect(await contentOffset(640), const Offset(12, 24)); + expect(await contentOffset(641), const Offset(24, 24)); + }); + + testWidgets('directional padding follows RTL', (tester) async { + await tester.pumpWidget( + wrapApp( + child: const Directionality( + textDirection: TextDirection.rtl, child: SizedBox( - height: 400, - child: MediaQuery( - data: const MediaQueryData(viewInsets: viewInsets), - child: ScaffoldPage( - content: Container(color: Colors.black, height: 300), + width: 800, + height: 200, + child: ScaffoldPage( + padding: EdgeInsetsDirectional.only(start: 7, end: 13), + content: SizedBox(key: Key('content')), + ), + ), + ), + ), + ); + + expect( + tester.getTopLeft(find.byKey(const Key('content'))), + const Offset(13, 0), + ); + expect(tester.getSize(find.byKey(const Key('content'))).width, 780); + }); + + testWidgets('scrollable uses the same page geometry as regular content', ( + tester, + ) async { + await tester.pumpWidget( + wrapApp( + child: SizedBox( + width: 800, + height: 600, + child: ScaffoldPage.scrollable( + header: const SizedBox(key: Key('header'), height: 40), + footer: const SizedBox(key: Key('footer'), height: 40), + children: const [SizedBox(height: 1000)], + ), + ), + ), + ); + + final list = tester.getRect(find.byType(ListView)); + expect(list, const Rect.fromLTWH(24, 64, 752, 472)); + expect( + tester.getRect(find.byKey(const Key('footer'))), + const Rect.fromLTWH(24, 536, 752, 40), + ); + }); + + testWidgets('viewInsets resize the page only when enabled', (tester) async { + Future contentHeight(bool resize) async { + await tester.pumpWidget( + wrapApp( + child: MediaQuery( + data: const MediaQueryData(viewInsets: EdgeInsets.only(bottom: 80)), + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + height: 300, + child: ScaffoldPage( + padding: EdgeInsets.zero, + resizeToAvoidBottomInset: resize, + content: const SizedBox(key: Key('content')), + ), ), ), ), ), ); + return tester.getSize(find.byKey(const Key('content'))).height; + } - expect( - tester.firstWidget(find.byType(Padding)).padding, - viewInsets, - ); - }, - ); + expect(await contentHeight(true), 220); + expect(await contentHeight(false), 300); + }); + + testWidgets('page remains transparent inside NavigationView', (tester) async { + await tester.pumpWidget( + wrapApp( + child: const SizedBox( + width: 800, + height: 600, + child: NavigationView(content: ScaffoldPage()), + ), + ), + ); + + final micaSurfaces = tester.widgetList(find.byType(Mica)); + expect( + micaSurfaces.any( + (surface) => surface.backgroundColor == Colors.transparent, + ), + isTrue, + ); + }); + + testWidgets('header remains usable at large text scales', (tester) async { + await tester.pumpWidget( + wrapApp( + child: const MediaQuery( + data: MediaQueryData(textScaler: TextScaler.linear(2)), + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 320, + height: 240, + child: ScaffoldPage( + header: PageHeader( + title: Text('A long page title'), + commandBar: SizedBox(width: 40, height: 40), + ), + content: SizedBox(), + ), + ), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + + testWidgets('footer is kept outside the scrollable body', (tester) async { + await tester.pumpWidget( + wrapApp( + child: SizedBox( + width: 400, + height: 300, + child: ScaffoldPage.scrollable( + footer: const SizedBox(key: Key('footer'), height: 48), + children: const [SizedBox(height: 1000)], + ), + ), + ), + ); + + expect( + find.descendant( + of: find.byType(ListView), + matching: find.byKey(const Key('footer')), + ), + findsNothing, + ); + expect(find.byKey(const Key('footer')), findsOneWidget); + }); } From f028f0e9bbe4653ecb408ce7ef90281fd6a20dc1 Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Mon, 28 Sep 2026 11:35:02 -0300 Subject: [PATCH 2/5] refactor: Rework `PageHeader` --- lib/src/controls/layout/page.dart | 107 ++++++++++++++--- test/page_header.dart | 191 ++++++++++++++++++++++++++++++ 2 files changed, 279 insertions(+), 19 deletions(-) create mode 100644 test/page_header.dart diff --git a/lib/src/controls/layout/page.dart b/lib/src/controls/layout/page.dart index d7f49c9e1..c905e67b8 100644 --- a/lib/src/controls/layout/page.dart +++ b/lib/src/controls/layout/page.dart @@ -204,6 +204,11 @@ class ScaffoldPage extends StatelessWidget { } } +const _pageHeaderCompactBreakpoint = 640.0; +const _pageHeaderHorizontalSpacing = 12.0; +const _pageHeaderCompactSpacing = 8.0; +const _pageHeaderBottomSpacing = 18.0; + /// The header of a Fluent page. /// /// [PageHeader] describes the title and page-level commands. It does not @@ -211,9 +216,10 @@ class ScaffoldPage extends StatelessWidget { /// custom headers and page content share one geometry contract. /// /// The [commandBar] may be any widget, although [CommandBar] is the usual -/// choice. At narrow widths the title and commands receive flexible -/// constraints rather than a fixed minimum command width, allowing controls -/// such as [CommandBar] to apply their own overflow behavior. +/// choice. At regular widths, the title and actions share a row. At compact +/// widths, actions move below the title and align to the logical end of the +/// page. This gives the title room to wrap without making assumptions about +/// the width or implementation of the action widget. /// /// See also: /// @@ -235,32 +241,95 @@ class PageHeader extends StatelessWidget { @override Widget build(BuildContext context) { assert(debugCheckHasFluentTheme(context)); + assert(debugCheckHasMediaQuery(context)); + + return LayoutBuilder( + builder: (context, constraints) { + final width = constraints.hasBoundedWidth + ? constraints.maxWidth + : MediaQuery.widthOf(context); + return Padding( + padding: const EdgeInsetsDirectional.only( + bottom: _pageHeaderBottomSpacing, + ), + child: _PageHeaderLayout( + leading: leading, + title: title, + commandBar: commandBar, + compact: width <= _pageHeaderCompactBreakpoint, + ), + ); + }, + ); + } +} + +class _PageHeaderLayout extends StatelessWidget { + const _PageHeaderLayout({ + required this.leading, + required this.title, + required this.commandBar, + required this.compact, + }); + + final Widget? leading; + final Widget? title; + final Widget? commandBar; + final bool compact; + + @override + Widget build(BuildContext context) { final theme = FluentTheme.of(context); - final row = Row( + final titleWidget = DefaultTextStyle.merge( + style: theme.typography.title, + child: title ?? const SizedBox(), + ); + + if (compact) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: _pageHeaderCompactSpacing, + children: [ + _TitleRow(leading: leading, title: titleWidget), + if (commandBar != null) + Align(alignment: AlignmentDirectional.centerEnd, child: commandBar), + ], + ); + } + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: _pageHeaderHorizontalSpacing, children: [ - if (leading != null) ...[leading!, const SizedBox(width: 12)], - Expanded( - child: DefaultTextStyle.merge( - style: theme.typography.title, - maxLines: 2, - child: title ?? const SizedBox(), - ), - ), - if (commandBar != null) ...[ - const SizedBox(width: 12), + ?leading, + Expanded(child: titleWidget), + if (commandBar != null) Flexible( child: Align( - alignment: AlignmentDirectional.centerEnd, + alignment: AlignmentDirectional.topEnd, child: commandBar, ), ), - ], ], ); + } +} + +class _TitleRow extends StatelessWidget { + const _TitleRow({required this.leading, required this.title}); + + final Widget? leading; + final Widget title; - return Padding( - padding: const EdgeInsetsDirectional.only(bottom: 18), - child: row, + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: _pageHeaderHorizontalSpacing, + children: [ + ?leading, + Expanded(child: title), + ], ); } } diff --git a/test/page_header.dart b/test/page_header.dart new file mode 100644 index 000000000..da10e606a --- /dev/null +++ b/test/page_header.dart @@ -0,0 +1,191 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'app_test.dart'; + +void main() { + testWidgets('renders a title without actions', (tester) async { + await tester.pumpWidget( + wrapApp( + child: const SizedBox( + width: 800, + height: 120, + child: PageHeader(title: Text('Settings', key: Key('title'))), + ), + ), + ); + + expect(find.byKey(const Key('title')), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('keeps title and actions in one row at regular widths', ( + tester, + ) async { + await tester.pumpWidget( + wrapApp( + child: const SizedBox( + width: 800, + height: 120, + child: PageHeader( + title: SizedBox(key: Key('title'), height: 40), + commandBar: SizedBox(key: Key('actions'), width: 120, height: 40), + ), + ), + ), + ); + + final title = tester.getRect(find.byKey(const Key('title'))); + final actions = tester.getRect(find.byKey(const Key('actions'))); + expect(actions.top, title.top); + expect(actions.left, greaterThan(title.right)); + expect(tester.takeException(), isNull); + }); + + testWidgets('moves actions below the title at compact widths', ( + tester, + ) async { + await tester.pumpWidget( + wrapApp( + child: const Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 320, + height: 160, + child: PageHeader( + title: SizedBox(key: Key('title'), height: 40), + commandBar: SizedBox(key: Key('actions'), width: 120, height: 40), + ), + ), + ), + ), + ); + + final title = tester.getRect(find.byKey(const Key('title'))); + final actions = tester.getRect(find.byKey(const Key('actions'))); + expect(actions.top, greaterThan(title.bottom)); + expect(actions.right, 320); + expect(tester.takeException(), isNull); + }); + + testWidgets('long titles and actions remain valid with text scaling', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(800, 3000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + for (final scale in [1.0, 1.5, 2.0]) { + await tester.pumpWidget( + wrapApp( + child: MediaQuery( + data: MediaQueryData(textScaler: TextScaler.linear(scale)), + child: const Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 320, + height: 1000, + child: PageHeader( + title: Text( + 'A deliberately long settings page title that should wrap', + ), + commandBar: SizedBox( + key: Key('actions'), + width: 140, + height: 40, + ), + ), + ), + ), + ), + ), + ); + + expect(tester.takeException(), isNull, reason: 'scale: $scale'); + } + }); + + testWidgets('RTL places trailing actions on the logical start side', ( + tester, + ) async { + await tester.pumpWidget( + wrapApp( + child: const Directionality( + textDirection: TextDirection.rtl, + child: SizedBox( + width: 800, + height: 120, + child: PageHeader( + title: SizedBox(key: Key('title'), width: 200, height: 40), + commandBar: SizedBox(key: Key('actions'), width: 120, height: 40), + ), + ), + ), + ), + ); + + final title = tester.getRect(find.byKey(const Key('title'))); + final actions = tester.getRect(find.byKey(const Key('actions'))); + expect(actions.right, lessThan(title.left)); + expect(tester.takeException(), isNull); + }); + + testWidgets('header title aligns with ScaffoldPage content', (tester) async { + await tester.pumpWidget( + wrapApp( + child: const SizedBox( + width: 800, + height: 300, + child: ScaffoldPage( + header: PageHeader(title: Text('Settings', key: Key('title'))), + content: SizedBox(key: Key('content')), + ), + ), + ), + ); + + expect( + tester.getTopLeft(find.byKey(const Key('title'))).dx, + tester.getTopLeft(find.byKey(const Key('content'))).dx, + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('command bar receives bounded width for overflow', ( + tester, + ) async { + await tester.pumpWidget( + wrapApp( + child: const SizedBox( + width: 320, + height: 200, + child: PageHeader( + title: Text('Actions'), + commandBar: CommandBar( + primaryItems: [ + CommandBarButton( + icon: Icon(FluentIcons.add), + label: Text('Add'), + onPressed: _noop, + ), + CommandBarButton( + icon: Icon(FluentIcons.delete), + label: Text('Delete'), + onPressed: _noop, + ), + CommandBarButton( + icon: Icon(FluentIcons.edit), + label: Text('Edit'), + onPressed: _noop, + ), + ], + ), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + }); +} + +void _noop() {} From 966e39ea8fa07fb1b45d259f6354cb2f94aa119d Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Mon, 28 Sep 2026 11:35:38 -0300 Subject: [PATCH 3/5] chore: Update changelog --- CHANGELOG.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ff3791cd..afa6217c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,36 @@ - refactor: Flutter 3.47.1 support ([#1353](https://github.com/bdlukaa/fluent_ui/pull/1353)) - migrate Material UI imports to the standalone `material_ui` package. [learn more](https://flutter.dev/blog/decoupling-material-cupertino) - fix: skip hidden `TitleBar` children when collecting semantics ([#1354](https://github.com/bdlukaa/fluent_ui/pull/1354)) +- refactor: Rework `ScaffoldPage` ([#1358](https://github.com/bdlukaa/fluent_ui/pull/1358)) + - `ScaffoldPage` is now a stateless page-content shell. Its `padding` applies consistently to the page's header, content, and footer, while the default horizontal padding follows Fluent adaptive margins (12 px in compact layouts and 24 px otherwise). + - `bottomBar` is now `footer`. + - `ScaffoldPage.withPadding` was removed. Use `ScaffoldPage(padding: ..., content: ...)` instead. + - `PageHeader.padding` and `PageHeader.horizontalPadding` were removed. Page-level spacing belongs to `ScaffoldPage`; remove calls to `PageHeader.horizontalPadding(context)` and configure `ScaffoldPage.padding` when custom spacing is needed. + - `PageHeader` no longer controls page geometry. At compact widths, its command bar moves below the title so long titles and large text scales can wrap without overflowing. + + Migration: + + ```dart + // Before + ScaffoldPage( + header: PageHeader( + title: const Text('Settings'), + padding: const EdgeInsets.symmetric(horizontal: 32), + ), + bottomBar: const CommandBar(items: []), + content: const SettingsView(), + ); + + // After + ScaffoldPage( + header: const PageHeader(title: Text('Settings')), + footer: const CommandBar(items: []), + padding: const EdgeInsets.symmetric(horizontal: 32), + content: const SettingsView(), + ); + ``` + + For scrollable pages, continue to use `ScaffoldPage.scrollable`; it now follows the same page-level padding semantics as the regular constructor. - fix: `ComboBox` no longer throws a layout assertion when opened while fully aligned to the bottom edge of the viewport ([#1350](https://github.com/bdlukaa/fluent_ui/issues/1350)) ## 4.16.1 From 64c44e40c0b1eb353ba83b5b6f9f5f2c3012ee52 Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Mon, 28 Sep 2026 11:51:00 -0300 Subject: [PATCH 4/5] fix: Scrolbar positioning --- lib/src/controls/layout/page.dart | 65 +++++++++++++++++++++++-------- test/scaffold_page.dart | 4 +- 2 files changed, 51 insertions(+), 18 deletions(-) diff --git a/lib/src/controls/layout/page.dart b/lib/src/controls/layout/page.dart index c905e67b8..1dde7ba79 100644 --- a/lib/src/controls/layout/page.dart +++ b/lib/src/controls/layout/page.dart @@ -72,7 +72,7 @@ class ScaffoldPage extends StatelessWidget { this.padding, this.backgroundColor, this.resizeToAvoidBottomInset = true, - }); + }) : _contentHasOwnHorizontalPadding = false; /// Creates a page whose content is a scrollable [ListView]. /// @@ -89,10 +89,27 @@ class ScaffoldPage extends StatelessWidget { this.backgroundColor, ScrollController? scrollController, this.resizeToAvoidBottomInset = true, - }) : content = ListView( - controller: scrollController, - padding: EdgeInsets.zero, - children: children, + }) : _contentHasOwnHorizontalPadding = true, + content = Builder( + builder: (context) { + return LayoutBuilder( + builder: (context, constraints) { + final pagePadding = + padding ?? _defaultPagePadding(context, constraints); + final resolvedPadding = pagePadding.resolve( + Directionality.of(context), + ); + return ListView( + controller: scrollController, + padding: EdgeInsets.only( + left: resolvedPadding.left, + right: resolvedPadding.right, + ), + children: children, + ); + }, + ); + }, ); /// The primary content of the page. @@ -134,6 +151,8 @@ class ScaffoldPage extends StatelessWidget { /// The ambient [MediaQuery] is otherwise left unchanged. Defaults to true. final bool resizeToAvoidBottomInset; + final bool _contentHasOwnHorizontalPadding; + @override Widget build(BuildContext context) { assert(debugCheckHasFluentTheme(context)); @@ -152,18 +171,32 @@ class ScaffoldPage extends StatelessWidget { ? MediaQuery.viewInsetsOf(context) : EdgeInsets.zero; + final resolvedPadding = pagePadding.resolve( + Directionality.of(context), + ); + final horizontalPadding = EdgeInsets.only( + left: resolvedPadding.left, + right: resolvedPadding.right, + ); + return Padding( - padding: pagePadding, - child: Padding( - padding: EdgeInsetsDirectional.only(bottom: viewInsets.bottom), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ?header, - Expanded(child: content), - ?footer, - ], - ), + padding: EdgeInsets.only( + top: resolvedPadding.top, + bottom: resolvedPadding.bottom + viewInsets.bottom, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (header != null) + Padding(padding: horizontalPadding, child: header), + Expanded( + child: _contentHasOwnHorizontalPadding + ? content + : Padding(padding: horizontalPadding, child: content), + ), + if (footer != null) + Padding(padding: horizontalPadding, child: footer), + ], ), ); }, diff --git a/test/scaffold_page.dart b/test/scaffold_page.dart index 978613ca4..5ed1ffe0a 100644 --- a/test/scaffold_page.dart +++ b/test/scaffold_page.dart @@ -124,7 +124,7 @@ void main() { ); final list = tester.getRect(find.byType(ListView)); - expect(list, const Rect.fromLTWH(24, 64, 752, 472)); + expect(list, const Rect.fromLTWH(0, 64, 800, 472)); expect( tester.getRect(find.byKey(const Key('footer'))), const Rect.fromLTWH(24, 536, 752, 40), @@ -188,7 +188,7 @@ void main() { alignment: Alignment.topLeft, child: SizedBox( width: 320, - height: 240, + height: 600, child: ScaffoldPage( header: PageHeader( title: Text('A long page title'), From bc3fdbd6a24ca82ecfaa9d42f640d230aaa0c20f Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Mon, 28 Sep 2026 11:57:25 -0300 Subject: [PATCH 5/5] fix: minimal pane titlebar sizing --- CHANGELOG.md | 1 + .../controls/navigation/navigation_view/title_bar.dart | 10 +++++----- lib/src/controls/navigation/navigation_view/view.dart | 7 ++++--- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index afa6217c7..fc6fd7d84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ - refactor: Flutter 3.47.1 support ([#1353](https://github.com/bdlukaa/fluent_ui/pull/1353)) - migrate Material UI imports to the standalone `material_ui` package. [learn more](https://flutter.dev/blog/decoupling-material-cupertino) - fix: skip hidden `TitleBar` children when collecting semantics ([#1354](https://github.com/bdlukaa/fluent_ui/pull/1354)) +- fix: `TitleBar` sizing on minimal pane ([#1308](https://github.com/bdlukaa/fluent_ui/issues/1308)) - refactor: Rework `ScaffoldPage` ([#1358](https://github.com/bdlukaa/fluent_ui/pull/1358)) - `ScaffoldPage` is now a stateless page-content shell. Its `padding` applies consistently to the page's header, content, and footer, while the default horizontal padding follows Fluent adaptive margins (12 px in compact layouts and 24 px otherwise). - `bottomBar` is now `footer`. diff --git a/lib/src/controls/navigation/navigation_view/title_bar.dart b/lib/src/controls/navigation/navigation_view/title_bar.dart index 08726051c..d07f22ff3 100644 --- a/lib/src/controls/navigation/navigation_view/title_bar.dart +++ b/lib/src/controls/navigation/navigation_view/title_bar.dart @@ -565,17 +565,17 @@ class _RenderTitleSubtitleOverflow extends RenderBox /// Skips hidden children when collecting semantics. /// - /// Hidden children are never laid out (see [performLayout]). If a semantics - /// walk visits a hidden child before it is laid out, Flutter asserts on - /// `_needsLayout`. Minimizing a maximized window with a screen reader - /// running triggers the assert. + /// Hidden children are never laid out (see [performLayout]). During a window + /// resize, a semantics walk can also run while a visible child is waiting for + /// its new constraints. Skipping both cases prevents Flutter from asserting + /// on `_needsLayout`. @override void visitChildrenForSemantics(RenderObjectVisitor visitor) { var child = firstChild; while (child != null) { final childParentData = child.parentData! as _TitleSubtitleOverflowParentData; - if (!childParentData.isHidden) { + if (!childParentData.isHidden && !child.debugNeedsLayout) { visitor(child); } child = childAfter(child); diff --git a/lib/src/controls/navigation/navigation_view/view.dart b/lib/src/controls/navigation/navigation_view/view.dart index d8dd5167d..228b8d86b 100644 --- a/lib/src/controls/navigation/navigation_view/view.dart +++ b/lib/src/controls/navigation/navigation_view/view.dart @@ -904,6 +904,7 @@ class NavigationViewState extends State { final localizations = FluentLocalizations.of(context); final openSize = pane.size?.openPaneWidth ?? kOpenNavigationPaneWidth; + final titleBarHeight = TitleBar.calculateHeight(context, widget.titleBar); return Stack( children: [ @@ -911,11 +912,11 @@ class NavigationViewState extends State { top: 0, start: 0, end: 0, - height: 38, + height: titleBarHeight, child: ColoredBox(color: fluentTheme.scaffoldBackgroundColor), ), PositionedDirectional( - top: 38, + top: titleBarHeight, start: 0, end: 0, bottom: 0, @@ -967,7 +968,7 @@ class NavigationViewState extends State { ), ), child: Padding( - padding: const EdgeInsetsDirectional.only(top: 38 + 6), + padding: EdgeInsetsDirectional.only(top: titleBarHeight + 6), child: _OpenNavigationPane( theme: theme, pane: pane,