diff --git a/examples/api/lib/cupertino/nav_bar/cupertino_navigation_bar.2.dart b/examples/api/lib/cupertino/nav_bar/cupertino_navigation_bar.2.dart new file mode 100644 index 00000000000..243ecfc9751 --- /dev/null +++ b/examples/api/lib/cupertino/nav_bar/cupertino_navigation_bar.2.dart @@ -0,0 +1,63 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/cupertino.dart'; + +/// Flutter code sample for [CupertinoNavigationBar.large]. + +void main() => runApp(const NavBarApp()); + +class NavBarApp extends StatelessWidget { + const NavBarApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + theme: CupertinoThemeData(brightness: Brightness.light), + home: NavBarExample(), + ); + } +} + +class NavBarExample extends StatefulWidget { + const NavBarExample({super.key}); + + @override + State createState() => _NavBarExampleState(); +} + +class _NavBarExampleState extends State { + int _count = 0; + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar.large( + largeTitle: Text('Large Sample'), + ), + child: SafeArea( + child: Center( + child: Column( + children: [ + const Spacer(), + const Text('You have pushed the button this many times:'), + Text( + '$_count', + style: CupertinoTheme.of(context).textTheme.navLargeTitleTextStyle, + ), + const Spacer(), + Padding( + padding: const EdgeInsets.all(15.0), + child: CupertinoButton.filled( + onPressed: () => setState(() => _count++), + child: const Text('Increment'), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/examples/api/test/cupertino/nav_bar/cupertino_navigation_bar.2_test.dart b/examples/api/test/cupertino/nav_bar/cupertino_navigation_bar.2_test.dart new file mode 100644 index 00000000000..25201826ac0 --- /dev/null +++ b/examples/api/test/cupertino/nav_bar/cupertino_navigation_bar.2_test.dart @@ -0,0 +1,25 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/cupertino.dart'; +import 'package:flutter_api_samples/cupertino/nav_bar/cupertino_navigation_bar.2.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('CupertinoNavigationBar is large', (WidgetTester tester) async { + await tester.pumpWidget( + const example.NavBarApp(), + ); + + final Finder navBarFinder = find.byType(CupertinoNavigationBar); + expect(navBarFinder, findsOneWidget); + expect(find.text('Large Sample'), findsOneWidget); + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + await tester.tap(find.text('Increment')); + await tester.pump(); + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + }); +} diff --git a/packages/flutter/lib/src/cupertino/nav_bar.dart b/packages/flutter/lib/src/cupertino/nav_bar.dart index 46592e1abd8..6f7108ecf80 100644 --- a/packages/flutter/lib/src/cupertino/nav_bar.dart +++ b/packages/flutter/lib/src/cupertino/nav_bar.dart @@ -219,26 +219,40 @@ bool _isTransitionable(BuildContext context) { /// An iOS-styled navigation bar. /// -/// The navigation bar is a toolbar that minimally consists of a widget, normally -/// a page title, in the [middle] of the toolbar. +/// The navigation bar is a toolbar that minimally consists of a widget, +/// normally a page title. /// -/// It also supports a [leading] and [trailing] widget before and after the -/// [middle] widget while keeping the [middle] widget centered. +/// It also supports [leading] and [trailing] widgets on either end of the +/// toolbar, typically for actions and navigation. /// /// The [leading] widget will automatically be a back chevron icon button (or a /// close button in case of a fullscreen dialog) to pop the current route if none /// is provided and [automaticallyImplyLeading] is true (true by default). /// -/// The [middle] widget will automatically be a title text from the current -/// [CupertinoPageRoute] if none is provided and [automaticallyImplyMiddle] is -/// true (true by default). -/// -/// It should be placed at top of the screen and automatically accounts for -/// the OS's status bar. +/// This toolbar should be placed at top of the screen where it will +/// automatically account for the OS's status bar. /// /// If the given [backgroundColor]'s opacity is not 1.0 (which is the case by /// default), it will produce a blurring effect to the content behind it. /// +/// ### Layout options +/// +/// While the [CupertinoSliverNavigationBar] can dynamically change size and +/// layout in response to scrolling, this static version can reflect the same +/// large (expanded) layout, or the small (collapsed) layout. +/// +/// The default constructor will display the collapsed version of the +/// [CupertinoSliverNavigationBar]. The [middle] widget will automatically be a +/// title text from the current [CupertinoPageRoute] if none is provided and +/// [automaticallyImplyMiddle] is true (true by default). +/// +/// Using the [CupertinoNavigationBar.large] constructor will display the +/// expanded version of [CupertinoSliverNavigationBar]. The [largeTitle] widget +/// will automatically be a title text from the current [CupertinoPageRoute] if +/// none is provided and `automaticallyImplyTitle` is true (true by default). +/// +/// ### Transitions +/// /// When [transitionBetweenRoutes] is true, this navigation bar will transition /// on top of the routes instead of inside them if the route being transitioned /// to also has a [CupertinoNavigationBar] or a [CupertinoSliverNavigationBar] @@ -264,6 +278,14 @@ bool _isTransitionable(BuildContext context) { /// ** See code in examples/api/lib/cupertino/nav_bar/cupertino_navigation_bar.0.dart ** /// {@end-tool} /// +/// {@tool dartpad} +/// This example shows the resulting layout from [CupertinoNavigationBar.large] +/// constructor, showing a large title similar to the expanded state of +/// [CupertinoSliverNavigationBar]. +/// +/// ** See code in examples/api/lib/cupertino/nav_bar/cupertino_navigation_bar.2.dart ** +/// {@end-tool} +/// /// See also: /// /// * [CupertinoPageScaffold], a page layout helper typically hosting the @@ -272,7 +294,16 @@ bool _isTransitionable(BuildContext context) { /// scrolling list and that supports iOS-11-style large titles. /// * class CupertinoNavigationBar extends StatefulWidget implements ObstructingPreferredSizeWidget { - /// Creates a navigation bar in the iOS style. + /// Creates a static iOS style navigation bar, with a centered [middle] title. + /// + /// Similar to the collapsed state of [CupertinoSliverNavigationBar], which + /// can dynamically change size in response to scrolling. + /// + /// See also: + /// + /// * [CupertinoNavigationBar.large], which creates a static iOS style + /// navigation bar with a [largeTitle], similar to the expanded state of + /// [CupertinoSliverNavigationBar]. const CupertinoNavigationBar({ super.key, this.leading, @@ -290,12 +321,65 @@ class CupertinoNavigationBar extends StatefulWidget implements ObstructingPrefer this.transitionBetweenRoutes = true, this.heroTag = _defaultHeroTag, this.bottom, - }) : assert( + }) : largeTitle = null, + assert( !transitionBetweenRoutes || identical(heroTag, _defaultHeroTag), 'Cannot specify a heroTag override if this navigation bar does not ' 'transition due to transitionBetweenRoutes = false.', ); + /// Creates a static iOS style navigation bar, with a left aligned [largeTitle]. + /// + /// Similar to the expanded state of [CupertinoSliverNavigationBar], which + /// can dynamically change size in response to scrolling. + /// + /// See also: + /// + /// * [CupertinoNavigationBar]'s base constructor, which creates a static + /// iOS style navigation bar with [middle], similar to the collapsed state + /// of [CupertinoSliverNavigationBar]. + const CupertinoNavigationBar.large({ + super.key, + this.largeTitle, + this.leading, + this.automaticallyImplyLeading = true, + bool automaticallyImplyTitle = true, + this.previousPageTitle, + this.trailing, + this.border = _kDefaultNavBarBorder, + this.backgroundColor, + this.automaticBackgroundVisibility = true, + this.enableBackgroundFilterBlur = true, + this.brightness, + this.padding, + this.transitionBetweenRoutes = true, + this.heroTag = _defaultHeroTag, + this.bottom, + }) : middle = null, + automaticallyImplyMiddle = automaticallyImplyTitle, + assert( + !transitionBetweenRoutes || identical(heroTag, _defaultHeroTag), + 'Cannot specify a heroTag override if this navigation bar does not ' + 'transition due to transitionBetweenRoutes = false.', + ); + + /// The navigation bar's title, when using [CupertinoNavigationBar.large]. + /// + /// If null and `automaticallyImplyTitle` is true, an appropriate [Text] + /// title will be created if the current route is a [CupertinoPageRoute] and + /// has a `title`. + /// + /// This property is null for the base [CupertinoNavigationBar] constructor, + /// which shows a collapsed navigation bar and uses [middle] for the title + /// instead. + /// + /// See also: + /// + /// * [CupertinoSliverNavigationBar.largeTitle], a similar property + /// in the expanded state of [CupertinoSliverNavigationBar], which can + /// dynamically change size in response to scrolling. + final Widget? largeTitle; + /// {@template flutter.cupertino.CupertinoNavigationBar.leading} /// Widget to place at the start of the navigation bar. Normally a back button /// for a normal page or a cancel button for full page dialogs. @@ -342,12 +426,20 @@ class CupertinoNavigationBar extends StatefulWidget implements ObstructingPrefer /// {@endtemplate} final String? previousPageTitle; - /// Widget to place in the middle of the navigation bar. Normally a title or - /// a segmented control. + /// The navigation bar's default title. /// /// If null and [automaticallyImplyMiddle] is true, an appropriate [Text] /// title will be created if the current route is a [CupertinoPageRoute] and /// has a `title`. + /// + /// This property is null for the [CupertinoNavigationBar.large] constructor, + /// which shows an expanded navigation bar and uses [largeTitle] instead. + /// + /// See also: + /// + /// * [CupertinoSliverNavigationBar.middle], a similar property + /// in the collapsed state of [CupertinoSliverNavigationBar], which can + /// dynamically change size in response to scrolling. final Widget? middle; /// {@template flutter.cupertino.CupertinoNavigationBar.trailing} @@ -570,20 +662,32 @@ class _CupertinoNavigationBarState extends State { @override Widget build(BuildContext context) { - final Color backgroundColor = - CupertinoDynamicColor.maybeResolve(widget.backgroundColor, context) ?? CupertinoTheme.of(context).barBackgroundColor; + // The static navigation bar does not expand or collapse (see CupertinoSliverNavigationBar), + // it will either display the collapsed nav bar with middle, or the expanded with largeTitle. + assert(widget.middle == null || widget.largeTitle == null); + + final Color backgroundColor = CupertinoDynamicColor.maybeResolve( + widget.backgroundColor, + context, + ) ?? CupertinoTheme.of(context).barBackgroundColor; final Color? parentPageScaffoldBackgroundColor = CupertinoPageScaffoldBackgroundColor.maybeOf(context); final Border? initialBorder = widget.automaticBackgroundVisibility && parentPageScaffoldBackgroundColor != null ? _kTransparentNavBarBorder : widget.border; - final Border? effectiveBorder = widget.border == null ? null : Border.lerp(initialBorder, widget.border, _scrollAnimationValue,); + final Border? effectiveBorder = widget.border == null + ? null + : Border.lerp(initialBorder, widget.border, _scrollAnimationValue); final Color effectiveBackgroundColor = widget.automaticBackgroundVisibility && parentPageScaffoldBackgroundColor != null ? Color.lerp(parentPageScaffoldBackgroundColor, backgroundColor, _scrollAnimationValue) ?? backgroundColor : backgroundColor; + final double bottomHeight = widget.bottom?.preferredSize.height ?? 0.0; + final double persistentHeight = _kNavBarPersistentHeight + bottomHeight + MediaQuery.paddingOf(context).top; + final double largeHeight = persistentHeight + _kNavBarLargeTitleHeightExtension; + final _NavigationBarStaticComponents components = _NavigationBarStaticComponents( keys: keys, route: ModalRoute.of(context), @@ -594,26 +698,69 @@ class _CupertinoNavigationBarState extends State { userMiddle: widget.middle, userTrailing: widget.trailing, padding: widget.padding, - userLargeTitle: null, - large: false, + userLargeTitle: widget.largeTitle, + large: widget.largeTitle != null, + staticBar: true, // This one does not scroll ); - final Widget navBar = _wrapWithBackground( + // Standard persistent components + Widget navBar = _PersistentNavigationBar( + components: components, + padding: widget.padding, + middleVisible: widget.largeTitle == null, + ); + + if (widget.largeTitle != null) { + // Large nav bar + navBar = ConstrainedBox( + constraints: BoxConstraints(maxHeight: largeHeight), + child: Column( + children: [ + navBar, + Expanded( + child: Padding( + padding: const EdgeInsetsDirectional.only( + start: _kNavBarEdgePadding, + bottom: _kNavBarBottomPadding + ), + child: Semantics( + header: true, + child: DefaultTextStyle( + style: CupertinoTheme.of(context) + .textTheme + .navLargeTitleTextStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + child: _LargeTitle(child: components.largeTitle), + ), + ), + ), + ), + if (widget.bottom != null) SizedBox(height: bottomHeight, child: widget.bottom), + ], + ), + ); + } else { + // Small nav bar + navBar = ConstrainedBox( + constraints: BoxConstraints(maxHeight: persistentHeight), + child: Column( + children: [ + navBar, + if (widget.bottom != null) SizedBox(height: bottomHeight, child: widget.bottom), + ], + ), + ); + } + + navBar = _wrapWithBackground( border: effectiveBorder, backgroundColor: effectiveBackgroundColor, brightness: widget.brightness, enableBackgroundFilterBlur: widget.enableBackgroundFilterBlur, child: DefaultTextStyle( style: CupertinoTheme.of(context).textTheme.textStyle, - child: Column( - children: [ - _PersistentNavigationBar( - components: components, - padding: widget.padding, - ), - if (widget.bottom != null) widget.bottom!, - ], - ), + child: navBar, ), ); @@ -638,10 +785,10 @@ class _CupertinoNavigationBarState extends State { backgroundColor: effectiveBackgroundColor, backButtonTextStyle: CupertinoTheme.of(context).textTheme.navActionTextStyle, titleTextStyle: CupertinoTheme.of(context).textTheme.navTitleTextStyle, - largeTitleTextStyle: null, + largeTitleTextStyle: CupertinoTheme.of(context).textTheme.navLargeTitleTextStyle, border: effectiveBorder, hasUserMiddle: widget.middle != null, - largeExpanded: false, + largeExpanded: widget.largeTitle != null, child: navBar, ), ); @@ -990,6 +1137,7 @@ class _CupertinoSliverNavigationBarState extends State? route, }) { Widget? middleContent = userMiddle; + if (large && staticBar) { + // Static bar only displays the middle, or the large, not both. + // A scrolling bar creates both middle and large to transition between. + return null; + } + if (large) { middleContent ??= userLargeTitle; } diff --git a/packages/flutter/test/cupertino/nav_bar_test.dart b/packages/flutter/test/cupertino/nav_bar_test.dart index 61b5a5ec078..09535cad6b6 100644 --- a/packages/flutter/test/cupertino/nav_bar_test.dart +++ b/packages/flutter/test/cupertino/nav_bar_test.dart @@ -34,6 +34,23 @@ void main() { expect(tester.getCenter(find.text('Title')).dx, 400.0); }); + testWidgets('largeTitle is aligned with asymmetrical actions', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: CupertinoNavigationBar.large( + leading: CupertinoButton( + onPressed: null, + child: Text('Something'), + ), + largeTitle: Text('Title'), + ), + ), + ); + + expect(tester.getCenter(find.text('Title')).dx, greaterThan(110.0)); + expect(tester.getCenter(find.text('Title')).dx, lessThan(111.0)); + }); + testWidgets('Middle still in center with back button', (WidgetTester tester) async { await tester.pumpWidget( const CupertinoApp( @@ -58,6 +75,30 @@ void main() { expect(tester.getCenter(find.text('Page 2')).dx, 400.0); }); + testWidgets('largeTitle still aligned with back button', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: CupertinoNavigationBar.large( + largeTitle: Text('Title'), + ), + ), + ); + + tester.state(find.byType(Navigator)).push(CupertinoPageRoute( + builder: (BuildContext context) { + return const CupertinoNavigationBar.large( + largeTitle: Text('Page 2'), + ); + }, + )); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 600)); + + expect(tester.getCenter(find.text('Page 2')).dx, greaterThan(129.0)); + expect(tester.getCenter(find.text('Page 2')).dx, lessThan(130.0)); + }); + testWidgets('Opaque background does not add blur effects, non-opaque background adds blur effects', (WidgetTester tester) async { const CupertinoDynamicColor background = CupertinoDynamicColor.withBrightness( color: Color(0xFFE5E5E5), @@ -1011,6 +1052,27 @@ void main() { semantics.dispose(); }); + testWidgets('Large CupertinoNavigationBar has semantics', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + + await tester.pumpWidget(CupertinoApp( + home: CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar.large( + largeTitle: Text('Fixed Title'), + ), + child: Container(), + ), + )); + + expect(semantics.nodesWith( + label: 'Fixed Title', + flags: [SemanticsFlag.isHeader], + textDirection: TextDirection.ltr, + ), hasLength(1)); + + semantics.dispose(); + }); + testWidgets('Border can be overridden in sliver nav bar', (WidgetTester tester) async { await tester.pumpWidget( const CupertinoApp( @@ -1050,59 +1112,96 @@ void main() { expect(bottom.color, const Color(0xFFAABBCC)); }); - testWidgets( - 'Standard title golden', - (WidgetTester tester) async { - await tester.pumpWidget( - const CupertinoApp( - home: RepaintBoundary( - child: CupertinoPageScaffold( - navigationBar: CupertinoNavigationBar( + testWidgets('Static standard title golden', (WidgetTester tester) async { + await tester.pumpWidget(const CupertinoApp( + home: RepaintBoundary( + child: CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + middle: Text('Bling bling'), + ), + child: Center(), + ), + ), + )); + + await expectLater( + find.byType(RepaintBoundary).last, + matchesGoldenFile('nav_bar_test.standard_title.png'), + ); + }); + + testWidgets('Static large title golden', (WidgetTester tester) async { + await tester.pumpWidget(const CupertinoApp( + home: RepaintBoundary( + child: CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar.large( + largeTitle: Text('Bling bling'), + ), + child: Center(), + ), + ), + )); + + await expectLater( + find.byType(RepaintBoundary).last, + matchesGoldenFile('nav_bar_test.large_title.png'), + ); + }); + + testWidgets('Sliver large title golden', (WidgetTester tester) async { + await tester.pumpWidget(CupertinoApp( + home: RepaintBoundary( + child: CupertinoPageScaffold( + child: CustomScrollView( + slivers: [ + const CupertinoSliverNavigationBar( + largeTitle: Text('Bling bling'), + ), + SliverToBoxAdapter( + child: Container( + height: 1200.0, + ), + ), + ], + ), + ), + ), + )); + + await expectLater( + find.byType(RepaintBoundary).last, + matchesGoldenFile('nav_bar_test.sliver.large_title.png'), + ); + }); + + testWidgets('Sliver middle title golden', (WidgetTester tester) async { + await tester.pumpWidget(CupertinoApp( + home: RepaintBoundary( + child: CupertinoPageScaffold( + child: CustomScrollView( + slivers: [ + const CupertinoSliverNavigationBar( middle: Text('Bling bling'), + largeTitle: Text('Bling bling'), ), - child: Center(), - ), + SliverToBoxAdapter( + child: Container( + height: 1200.0, + ), + ), + ], ), ), - ); + ), + )); + await tester.drag(find.byType(Scrollable), const Offset(0.0, -250.0)); + await tester.pump(); - await expectLater( - find.byType(RepaintBoundary).last, - matchesGoldenFile('nav_bar_test.standard_title.png'), - ); - }, - ); - - testWidgets( - 'Large title golden', - (WidgetTester tester) async { - await tester.pumpWidget( - CupertinoApp( - home: RepaintBoundary( - child: CupertinoPageScaffold( - child: CustomScrollView( - slivers: [ - const CupertinoSliverNavigationBar( - largeTitle: Text('Bling bling'), - ), - SliverToBoxAdapter( - child: Container( - height: 1200.0, - ), - ), - ], - ), - ), - ), - ), - ); - - await expectLater( - find.byType(RepaintBoundary).last, - matchesGoldenFile('nav_bar_test.large_title.png'), - ); - }, - ); + await expectLater( + find.byType(RepaintBoundary).last, + matchesGoldenFile('nav_bar_test.sliver.middle_title.png'), + ); + }); testWidgets( 'Nav bar background is transparent if `automaticBackgroundVisibility` is true and has no content scrolled under it',