Condense nav bar large title in landscape mode (#166956)

## Rotation demo



https://github.com/user-attachments/assets/b59d6875-dff7-4b40-9525-565dfd8a2554



### Portrait mode .automatic



https://github.com/user-attachments/assets/88f4f3a2-0f13-4c92-b601-20c20e13f7dc



### Landscape mode .automatic



https://github.com/user-attachments/assets/dd5e2373-82e3-41fc-8e83-4002ce5e848e



### Portrait mode .always



https://github.com/user-attachments/assets/623d131a-f71b-430d-b84c-0b4519919f56



### Landscape mode .always



https://github.com/user-attachments/assets/5980e8fe-a981-482d-9f77-97f9ab7495c7



Fixes [CupertinoSliverNavigationBar doesn't become compact in landscape
mode](https://github.com/flutter/flutter/issues/39254)

<details>
<summary>Sample code</summary>

```dart

import 'package:flutter/cupertino.dart';

void main() => runApp(const NavBarBlueApp());

class NavBarBlueApp extends StatelessWidget {
  const NavBarBlueApp({super.key});

  @override
  Widget build(BuildContext context) {
    return CupertinoApp(
      theme: CupertinoThemeData(),
      home: MainPage(),
    );
  }
}

class MainPage extends StatelessWidget {
  const MainPage({super.key});

  @override
  Widget build(BuildContext context) {
    return CupertinoPageScaffold(
      child: SafeArea(
        child: CustomScrollView(
          slivers: [
            CupertinoSliverNavigationBar.search(
              stretch: true,
              searchField: CupertinoSearchTextField(
                  suffixMode: OverlayVisibilityMode.always,
                  suffixIcon: Icon(
                    CupertinoIcons.mic_solid,
                  )),
              largeTitle: Text('Lists'),
              bottomMode: NavigationBarBottomMode.always,
            ),
            SliverList(
              delegate: SliverChildBuilderDelegate(
                (BuildContext context, int index) {
                  return CupertinoListTile(
                    title: Text('Entry $index'),
                  );
                },
                childCount: 20,
              ),
            ),
          ],
        ),
      ),
    );
  }
}


```

</details>
This commit is contained in:
Victor Sanni 2025-04-30 21:42:56 -07:00 committed by GitHub
parent 651ff0a8f2
commit 96d1b99211
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 141 additions and 15 deletions

View File

@ -12,6 +12,10 @@ void main() {
// The point is to mainly test the cupertino icons that we don't have a
// dependency against in the flutter/cupertino package directly.
// Set window orientation to portrait.
tester.view.physicalSize = const Size(2400.0, 3000.0);
addTearDown(tester.view.reset);
final Future<ByteData> font = rootBundle.load(
'packages/cupertino_icons/assets/CupertinoIcons.ttf',
);

View File

@ -8,10 +8,16 @@ import 'package:flutter_test/flutter_test.dart';
const Offset dragUp = Offset(0.0, -150.0);
void setWindowToPortrait(WidgetTester tester, {Size size = const Size(2400.0, 3000.0)}) {
tester.view.physicalSize = size;
addTearDown(tester.view.reset);
}
void main() {
testWidgets('Collapse and expand CupertinoSliverNavigationBar changes title position', (
WidgetTester tester,
) async {
setWindowToPortrait(tester);
await tester.pumpWidget(const example.SliverNavBarApp());
// Large title is visible and at lower position.
@ -29,6 +35,7 @@ void main() {
testWidgets('Middle widget is visible in both collapsed and expanded states', (
WidgetTester tester,
) async {
setWindowToPortrait(tester);
await tester.pumpWidget(const example.SliverNavBarApp());
// Navigate to a page that has both middle and large titles.
@ -55,6 +62,7 @@ void main() {
testWidgets('CupertinoSliverNavigationBar with previous route has back button', (
WidgetTester tester,
) async {
setWindowToPortrait(tester);
await tester.pumpWidget(const example.SliverNavBarApp());
// Navigate to a page that has back button

View File

@ -9,10 +9,16 @@ import 'package:flutter_test/flutter_test.dart';
const Offset titleDragUp = Offset(0.0, -100.0);
const Offset bottomDragUp = Offset(0.0, -50.0);
void setWindowToPortrait(WidgetTester tester, {Size size = const Size(2400.0, 3000.0)}) {
tester.view.physicalSize = size;
addTearDown(tester.view.reset);
}
void main() {
testWidgets('Collapse and expand CupertinoSliverNavigationBar changes title position', (
WidgetTester tester,
) async {
setWindowToPortrait(tester);
await tester.pumpWidget(const example.SliverNavBarApp());
// Large title is visible and at lower position.
@ -28,6 +34,7 @@ void main() {
});
testWidgets('Search field is hidden in bottom automatic mode', (WidgetTester tester) async {
setWindowToPortrait(tester);
await tester.pumpWidget(const example.SliverNavBarApp());
// Navigate to a page with bottom automatic mode.
@ -64,6 +71,7 @@ void main() {
});
testWidgets('Search field is always shown in bottom always mode', (WidgetTester tester) async {
setWindowToPortrait(tester);
await tester.pumpWidget(const example.SliverNavBarApp());
// Navigate to a page with bottom always mode.
@ -92,6 +100,7 @@ void main() {
});
testWidgets('Opens the search view when the search field is tapped', (WidgetTester tester) async {
setWindowToPortrait(tester);
await tester.pumpWidget(const example.SliverNavBarApp());
// Navigate to a page with a search field.
@ -131,6 +140,7 @@ void main() {
testWidgets('CupertinoSliverNavigationBar with previous route has back button', (
WidgetTester tester,
) async {
setWindowToPortrait(tester);
await tester.pumpWidget(const example.SliverNavBarApp());
// Navigate to the first page.

View File

@ -8,8 +8,14 @@ import 'package:flutter_test/flutter_test.dart';
const Offset dragUp = Offset(0.0, -150.0);
void setWindowToPortrait(WidgetTester tester, {Size size = const Size(2400.0, 3000.0)}) {
tester.view.physicalSize = size;
addTearDown(tester.view.reset);
}
void main() {
testWidgets('CupertinoSliverNavigationBar bottom widget', (WidgetTester tester) async {
setWindowToPortrait(tester);
await tester.pumpWidget(const example.SliverNavBarApp());
final Finder preferredSize = find.byType(PreferredSize);
@ -24,6 +30,7 @@ void main() {
testWidgets('Collapse and expand CupertinoSliverNavigationBar changes title position', (
WidgetTester tester,
) async {
setWindowToPortrait(tester);
await tester.pumpWidget(const example.SliverNavBarApp());
// Large title is visible and at lower position.
@ -41,6 +48,7 @@ void main() {
testWidgets('Middle widget is visible in both collapsed and expanded states', (
WidgetTester tester,
) async {
setWindowToPortrait(tester);
await tester.pumpWidget(const example.SliverNavBarApp());
// Navigate to a page that has both middle and large titles.
@ -67,6 +75,7 @@ void main() {
testWidgets('CupertinoSliverNavigationBar with previous route has back button', (
WidgetTester tester,
) async {
setWindowToPortrait(tester);
await tester.pumpWidget(const example.SliverNavBarApp());
// Navigate to a page that has a back button.

View File

@ -1149,11 +1149,13 @@ class _CupertinoSliverNavigationBarState extends State<CupertinoSliverNavigation
late _NavigationBarStaticComponentsKeys keys;
ScrollableState? _scrollableState;
_NavigationBarSearchField? preferredSizeSearchField;
Widget? effectiveMiddle;
late AnimationController _animationController;
late CurvedAnimation _searchAnimation;
late Animation<double> persistentHeightAnimation;
late Animation<double> largeTitleHeightAnimation;
bool searchIsActive = false;
bool isPortrait = true;
@override
void initState() {
@ -1169,6 +1171,14 @@ class _CupertinoSliverNavigationBarState extends State<CupertinoSliverNavigation
@override
void didChangeDependencies() {
super.didChangeDependencies();
isPortrait = MediaQuery.orientationOf(context) == Orientation.portrait;
final Tween<double> largeTitleHeightTween = Tween<double>(
begin: isPortrait ? _kNavBarLargeTitleHeightExtension : 0.0,
end: 0.0,
);
largeTitleHeightAnimation = largeTitleHeightTween.animate(_animationController);
effectiveMiddle = widget.middle ?? (isPortrait ? null : widget.largeTitle);
_scrollableState?.position.isScrollingNotifier.removeListener(_handleScrollChange);
_scrollableState = Scrollable.maybeOf(context);
_scrollableState?.position.isScrollingNotifier.addListener(_handleScrollChange);
@ -1203,11 +1213,6 @@ class _CupertinoSliverNavigationBarState extends State<CupertinoSliverNavigation
);
persistentHeightAnimation = persistentHeightTween.animate(_animationController)
..addStatusListener(_handleSearchFieldStatusChanged);
final Tween<double> largeTitleHeightTween = Tween<double>(
begin: _kNavBarLargeTitleHeightExtension,
end: 0.0,
);
largeTitleHeightAnimation = largeTitleHeightTween.animate(_animationController);
}
void _handleScrollChange() {
@ -1221,16 +1226,17 @@ class _CupertinoSliverNavigationBarState extends State<CupertinoSliverNavigation
widget.bottomMode == NavigationBarBottomMode.always ? 0.0 : _bottomHeight;
final bool canScrollBottom =
(widget._searchable || widget.bottom != null) && bottomScrollOffset > 0.0;
final double effectiveLargeTitleHeight = isPortrait ? _kNavBarLargeTitleHeightExtension : 0.0;
// Snap the scroll view to a target determined by the navigation bar's
// position.
if (canScrollBottom && position.pixels < bottomScrollOffset) {
target = position.pixels > bottomScrollOffset / 2 ? bottomScrollOffset : 0.0;
} else if (position.pixels > bottomScrollOffset &&
position.pixels < bottomScrollOffset + _kNavBarLargeTitleHeightExtension) {
position.pixels < bottomScrollOffset + effectiveLargeTitleHeight) {
target =
position.pixels > bottomScrollOffset + (_kNavBarLargeTitleHeightExtension / 2)
? bottomScrollOffset + _kNavBarLargeTitleHeightExtension
position.pixels > bottomScrollOffset + (effectiveLargeTitleHeight / 2)
? bottomScrollOffset + effectiveLargeTitleHeight
: bottomScrollOffset;
}
@ -1280,7 +1286,7 @@ class _CupertinoSliverNavigationBarState extends State<CupertinoSliverNavigation
automaticallyImplyLeading: widget.automaticallyImplyLeading,
automaticallyImplyTitle: widget.automaticallyImplyTitle,
previousPageTitle: widget.previousPageTitle,
userMiddle: _animationController.isAnimating ? const Text('') : widget.middle,
userMiddle: _animationController.isAnimating ? const Text('') : effectiveMiddle,
userTrailing:
widget.trailing != null
? Visibility(visible: !searchIsActive, child: widget.trailing!)
@ -1304,7 +1310,7 @@ class _CupertinoSliverNavigationBarState extends State<CupertinoSliverNavigation
: widget.bottom) ??
const SizedBox.shrink(),
padding: widget.padding,
large: true,
large: isPortrait,
staticBar: false, // This one scrolls.
context: context,
);
@ -1318,7 +1324,7 @@ class _CupertinoSliverNavigationBarState extends State<CupertinoSliverNavigation
delegate: _LargeTitleNavigationBarSliverDelegate(
keys: keys,
components: components,
userMiddle: widget.middle,
userMiddle: effectiveMiddle,
backgroundColor:
CupertinoDynamicColor.maybeResolve(widget.backgroundColor, context) ??
CupertinoTheme.of(context).barBackgroundColor,
@ -1331,7 +1337,7 @@ class _CupertinoSliverNavigationBarState extends State<CupertinoSliverNavigation
heroTag: widget.heroTag,
persistentHeight: persistentHeightAnimation.value + MediaQuery.paddingOf(context).top,
largeTitleHeight: largeTitleHeightAnimation.value,
alwaysShowMiddle: widget.alwaysShowMiddle && widget.middle != null,
alwaysShowMiddle: widget.alwaysShowMiddle && effectiveMiddle != null,
stretchConfiguration:
widget.stretch && !searchIsActive ? OverScrollHeaderStretchConfiguration() : null,
enableBackgroundFilterBlur: widget.enableBackgroundFilterBlur,

View File

@ -16,6 +16,11 @@ import '../widgets/semantics_tester.dart';
int count = 0;
void setWindowToPortrait(WidgetTester tester, {Size size = const Size(2400.0, 3000.0)}) {
tester.view.physicalSize = size;
addTearDown(tester.view.reset);
}
void main() {
testWidgets('Middle still in center with asymmetrical actions', (WidgetTester tester) async {
await tester.pumpWidget(
@ -305,6 +310,7 @@ void main() {
});
testWidgets('Can specify custom brightness', (WidgetTester tester) async {
setWindowToPortrait(tester);
await tester.pumpWidget(
const CupertinoApp(
home: CupertinoNavigationBar(
@ -498,6 +504,7 @@ void main() {
});
testWidgets('Large title nav bar scrolls', (WidgetTester tester) async {
setWindowToPortrait(tester);
final ScrollController scrollController = ScrollController();
addTearDown(scrollController.dispose);
await tester.pumpWidget(
@ -573,6 +580,7 @@ void main() {
});
testWidgets('User specified middle is always visible in sliver', (WidgetTester tester) async {
setWindowToPortrait(tester);
final ScrollController scrollController = ScrollController();
addTearDown(scrollController.dispose);
final Key segmentedControlsKey = UniqueKey();
@ -629,6 +637,7 @@ void main() {
testWidgets(
'User specified middle is only visible when sliver is collapsed if alwaysShowMiddle is false',
(WidgetTester tester) async {
setWindowToPortrait(tester);
final ScrollController scrollController = ScrollController();
addTearDown(scrollController.dispose);
await tester.pumpWidget(
@ -682,6 +691,7 @@ void main() {
);
testWidgets('Small title can be overridden', (WidgetTester tester) async {
setWindowToPortrait(tester);
final ScrollController scrollController = ScrollController();
addTearDown(scrollController.dispose);
await tester.pumpWidget(
@ -1171,6 +1181,7 @@ void main() {
});
testWidgets('Sliver large title golden', (WidgetTester tester) async {
setWindowToPortrait(tester);
await tester.pumpWidget(
CupertinoApp(
home: RepaintBoundary(
@ -1736,6 +1747,7 @@ void main() {
(WidgetTester tester) async {
const Text trailingText = Text('Bar Button');
const Text titleText = Text('Large Title');
setWindowToPortrait(tester);
await tester.pumpWidget(
CupertinoApp(
@ -1874,6 +1886,7 @@ void main() {
testWidgets(
'CupertinoSliverNavigationBar magnifies upon over-scroll and shrinks back once over-scroll ends',
(WidgetTester tester) async {
setWindowToPortrait(tester);
const Text titleText = Text('Large Title');
await tester.pumpWidget(
@ -1985,6 +1998,7 @@ void main() {
const double largeTitleHeight = 44.0;
const double bottomHeight = 10.0;
final ScrollController controller = ScrollController();
setWindowToPortrait(tester);
await tester.pumpWidget(
CupertinoApp(
@ -2042,6 +2056,7 @@ void main() {
const double persistentHeight = 44.0;
const double largeTitleHeight = 44.0;
const double bottomHeight = 10.0;
setWindowToPortrait(tester);
await tester.pumpWidget(
CupertinoApp(
@ -2119,6 +2134,7 @@ void main() {
) async {
const double bottomHeight = 10.0;
const double bottomDisplacement = 96.0;
setWindowToPortrait(tester);
await tester.pumpWidget(
CupertinoApp(
@ -2166,6 +2182,7 @@ void main() {
final ScrollController scrollController = ScrollController();
addTearDown(scrollController.dispose);
const double largeTitleHeight = 52.0;
setWindowToPortrait(tester);
await tester.pumpWidget(
CupertinoApp(
@ -2212,6 +2229,7 @@ void main() {
final ScrollController scrollController = ScrollController();
addTearDown(scrollController.dispose);
const double largeTitleHeight = 52.0;
setWindowToPortrait(tester);
await tester.pumpWidget(
CupertinoApp(
@ -2258,6 +2276,7 @@ void main() {
addTearDown(scrollController.dispose);
const double largeTitleHeight = 52.0;
const double bottomHeight = 100.0;
setWindowToPortrait(tester);
await tester.pumpWidget(
CupertinoApp(
@ -2323,6 +2342,7 @@ void main() {
addTearDown(scrollController.dispose);
const double largeTitleHeight = 52.0;
const double bottomHeight = 100.0;
setWindowToPortrait(tester);
await tester.pumpWidget(
CupertinoApp(
@ -2392,6 +2412,7 @@ void main() {
addTearDown(scrollController.dispose);
const double largeTitleHeight = 52.0;
const double bottomHeight = 100.0;
setWindowToPortrait(tester);
await tester.pumpWidget(
CupertinoApp(
@ -2459,6 +2480,7 @@ void main() {
final ScrollController scrollController = ScrollController();
addTearDown(scrollController.dispose);
const double largeTitleHeight = 52.0;
setWindowToPortrait(tester);
await tester.pumpWidget(
CupertinoApp(
@ -2542,6 +2564,7 @@ void main() {
testWidgets('CupertinoSliverNavigationBar.search field collapses nav bar on tap', (
WidgetTester tester,
) async {
setWindowToPortrait(tester);
await tester.pumpWidget(
const CupertinoApp(
home: CustomScrollView(
@ -2609,8 +2632,7 @@ void main() {
});
testWidgets('CupertinoSliverNavigationBar.search golden tests', (WidgetTester tester) async {
await tester.binding.setSurfaceSize(const Size(390, 850));
addTearDown(() => tester.binding.setSurfaceSize(null));
setWindowToPortrait(tester);
await tester.pumpWidget(
const CupertinoApp(
home: RepaintBoundary(
@ -2672,6 +2694,7 @@ void main() {
});
testWidgets('onSearchableBottomTap callback', (WidgetTester tester) async {
setWindowToPortrait(tester);
const Color activeSearchColor = Color(0x0000000A);
const Color inactiveSearchColor = Color(0x0000000B);
bool isSearchActive = false;
@ -2750,6 +2773,7 @@ void main() {
testWidgets(
'CupertinoSliverNavigationBar.search large title and cancel buttons fade during search animation',
(WidgetTester tester) async {
setWindowToPortrait(tester);
await tester.pumpWidget(
const CupertinoApp(
home: CustomScrollView(
@ -2826,6 +2850,52 @@ void main() {
expect(cancelOpacity.opacity.value, 0.0);
},
);
testWidgets('Large title is hidden if middle is provided in landscape mode', (
WidgetTester tester,
) async {
const String largeTitle = 'Large title';
const String middle = 'Middle';
await tester.pumpWidget(
const CupertinoApp(
home: CustomScrollView(
slivers: <Widget>[
CupertinoSliverNavigationBar.search(
largeTitle: Text(largeTitle),
middle: Text(middle),
searchField: CupertinoSearchTextField(),
),
SliverFillRemaining(child: SizedBox(height: 1000.0)),
],
),
),
);
expect(find.text(largeTitle), findsNothing);
expect(find.text(middle), findsOneWidget);
expect(find.byType(CupertinoSearchTextField), findsOneWidget);
});
testWidgets('Large title is shown in middle position in landscape mode', (
WidgetTester tester,
) async {
const String largeTitle = 'Large title';
await tester.pumpWidget(
const CupertinoApp(
home: CustomScrollView(
slivers: <Widget>[
CupertinoSliverNavigationBar.search(
largeTitle: Text(largeTitle),
searchField: CupertinoSearchTextField(),
),
SliverFillRemaining(child: SizedBox(height: 1000.0)),
],
),
),
);
expect(find.text(largeTitle), findsOneWidget);
expect(find.byType(CupertinoSearchTextField), findsOneWidget);
});
}
class _ExpectStyles extends StatelessWidget {

View File

@ -132,6 +132,11 @@ void checkOpacity(WidgetTester tester, Finder finder, double opacity) {
);
}
void setWindowToPortrait(WidgetTester tester, {Size size = const Size(2400.0, 3000.0)}) {
tester.view.physicalSize = size;
addTearDown(tester.view.reset);
}
void main() {
testWidgets('Bottom middle moves between middle and back label', (WidgetTester tester) async {
await startTransitionBetween(tester, fromTitle: 'Page 1');
@ -666,6 +671,7 @@ void main() {
testWidgets('Middle is not shown if alwaysShowMiddle is false and the nav bar is expanded', (
WidgetTester tester,
) async {
setWindowToPortrait(tester);
const Widget userMiddle = Placeholder();
await startTransitionBetween(
tester,
@ -987,6 +993,7 @@ void main() {
});
testWidgets('Bottom large title moves to top back label', (WidgetTester tester) async {
setWindowToPortrait(tester);
await startTransitionBetween(
tester,
from: const CupertinoSliverNavigationBar(),
@ -1045,6 +1052,7 @@ void main() {
testWidgets('Bottom CupertinoSliverNavigationBar.bottom fades and slides out from the left', (
WidgetTester tester,
) async {
setWindowToPortrait(tester);
await startTransitionBetween(
tester,
from: const CupertinoSliverNavigationBar(
@ -1081,6 +1089,7 @@ void main() {
testWidgets('Bottom CupertinoNavigationBar.bottom fades and slides out from the left', (
WidgetTester tester,
) async {
setWindowToPortrait(tester);
await startTransitionBetween(
tester,
from: const CupertinoNavigationBar(
@ -1117,6 +1126,7 @@ void main() {
testWidgets(
'CupertinoSliverNavigationBar.bottom clips its contents mid-transition when scrolled',
(WidgetTester tester) async {
setWindowToPortrait(tester);
await tester.pumpWidget(
CupertinoApp(
builder: (BuildContext context, Widget? navigator) {
@ -1233,6 +1243,7 @@ void main() {
);
testWidgets('Long title turns into the word back mid transition', (WidgetTester tester) async {
setWindowToPortrait(tester);
await startTransitionBetween(
tester,
from: const CupertinoSliverNavigationBar(),
@ -1289,6 +1300,7 @@ void main() {
testWidgets('Bottom large title and top back label transitions their font', (
WidgetTester tester,
) async {
setWindowToPortrait(tester);
await startTransitionBetween(
tester,
from: const CupertinoSliverNavigationBar(),
@ -1397,6 +1409,7 @@ void main() {
});
testWidgets('Top large title fades in and slides in from the right', (WidgetTester tester) async {
setWindowToPortrait(tester);
await startTransitionBetween(
tester,
to: const CupertinoSliverNavigationBar(),
@ -1427,6 +1440,7 @@ void main() {
testWidgets('Top large title fades in and slides in from the left in RTL', (
WidgetTester tester,
) async {
setWindowToPortrait(tester);
await startTransitionBetween(
tester,
to: const CupertinoSliverNavigationBar(),
@ -1460,6 +1474,7 @@ void main() {
) async {
const double horizontalPadding = 16.0; // _kNavBarEdgePadding
const double height = 30.0;
setWindowToPortrait(tester);
await startTransitionBetween(
tester,
toTitle: 'Page 2',
@ -1503,7 +1518,7 @@ void main() {
// The nav bar bottom is horizontally aligned to the large title.
expect(
tester.getTopLeft(flying(tester, find.byType(Placeholder))).dx,
largeTitleOffset.dx - horizontalPadding,
moreOrLessEquals(largeTitleOffset.dx - horizontalPadding, epsilon: 0.01),
);
});
@ -1581,6 +1596,7 @@ void main() {
) async {
int bottomBuildTimes = 0;
int topBuildTimes = 0;
setWindowToPortrait(tester);
await startTransitionBetween(
tester,
from: CupertinoNavigationBar(

View File

@ -51,6 +51,9 @@ void main() {
});
testWidgets('Large title auto-populates with title', (WidgetTester tester) async {
// Set window orientation to portrait.
tester.view.physicalSize = const Size(2400.0, 3000.0);
addTearDown(tester.view.reset);
await tester.pumpWidget(const CupertinoApp(home: Placeholder()));
tester