Add `CupertinoSliverNavigationBar` large title magnification on over scroll (#110127)
* Add magnification of CupertinoSliverNavigationBar large title
* Fix padding in maximum scale computation
* Apply magnification by using RenderBox
* Do not pass key to the superclass constructor
* Use `clampDouble` instead of `clamp` extension method
* Remove trailing whitespaces to make linter happy
* Name test variables more precisely
* Move transform computation to `performLayout` and implement `hitTestChildren`
* Address comments
* Address comments
* Address comments
* Update comment about scale
* Fix hit-testing
* Fix hit-testing again
* Make linter happy
* Implement magnifying without using LayoutBuilder
* Remove trailing spaces
* Add hit-testing of the large title
* Remove whitespaces
* Fix scale computation and some tests
* Fix remaining tests
* Refactor and fix comments
* Update comments
diff --git a/AUTHORS b/AUTHORS
index 33f5781..1911504 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -100,3 +100,4 @@
Junhua Lin <1075209054@qq.com>
Tomasz Gucio <tgucio@gmail.com>
Jason C.H <ctrysbita@outlook.com>
+Hubert Jóźwiak <hjozwiakdx@gmail.com>
\ No newline at end of file
diff --git a/examples/api/test/cupertino/nav_bar/cupertino_sliver_nav_bar.0_test.dart b/examples/api/test/cupertino/nav_bar/cupertino_sliver_nav_bar.0_test.dart
index b839f34..208e763 100644
--- a/examples/api/test/cupertino/nav_bar/cupertino_sliver_nav_bar.0_test.dart
+++ b/examples/api/test/cupertino/nav_bar/cupertino_sliver_nav_bar.0_test.dart
@@ -20,7 +20,7 @@
await tester.pumpAndSettle();
// Large title is hidden and at higher position.
- expect(tester.getBottomLeft(find.text('Contacts').first).dy, 36.0);
+ expect(tester.getBottomLeft(find.text('Contacts').first).dy, 36.0 + 8.0); // Static part + _kNavBarBottomPadding.
});
testWidgets('Middle widget is visible in both collapsed and expanded states', (WidgetTester tester) async {
@@ -43,7 +43,7 @@
// Large title is hidden and middle title is visible.
expect(tester.getBottomLeft(find.text('Contacts Group').first).dy, 30.5);
- expect(tester.getBottomLeft(find.text('Family').first).dy, 36.0);
+ expect(tester.getBottomLeft(find.text('Family').first).dy, 36.0 + 8.0); // Static part + _kNavBarBottomPadding.
});
testWidgets('CupertinoSliverNavigationBar with previous route has back button', (WidgetTester tester) async {
diff --git a/packages/flutter/lib/src/cupertino/nav_bar.dart b/packages/flutter/lib/src/cupertino/nav_bar.dart
index 8261bd9..d7cfcff 100644
--- a/packages/flutter/lib/src/cupertino/nav_bar.dart
+++ b/packages/flutter/lib/src/cupertino/nav_bar.dart
@@ -33,6 +33,8 @@
const double _kNavBarEdgePadding = 16.0;
+const double _kNavBarBottomPadding = 8.0;
+
const double _kNavBarBackButtonTapWidth = 50.0;
/// Title text transfer fade.
@@ -833,31 +835,27 @@
right: 0.0,
bottom: 0.0,
child: ClipRect(
- // The large title starts at the persistent bar.
- // It's aligned with the bottom of the sliver and expands clipped
- // and behind the persistent bar.
- child: OverflowBox(
- minHeight: 0.0,
- maxHeight: double.infinity,
- alignment: AlignmentDirectional.bottomStart,
- child: Padding(
- padding: const EdgeInsetsDirectional.only(
- start: _kNavBarEdgePadding,
- bottom: 8.0, // Bottom has a different padding.
- ),
- child: SafeArea(
- top: false,
- bottom: false,
- child: AnimatedOpacity(
- opacity: showLargeTitle ? 1.0 : 0.0,
- duration: _kNavBarTitleFadeDuration,
- child: Semantics(
- header: true,
- child: DefaultTextStyle(
- style: CupertinoTheme.of(context).textTheme.navLargeTitleTextStyle,
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- child: components.largeTitle!,
+ child: Padding(
+ padding: const EdgeInsetsDirectional.only(
+ start: _kNavBarEdgePadding,
+ bottom: _kNavBarBottomPadding
+ ),
+ child: SafeArea(
+ top: false,
+ bottom: false,
+ child: AnimatedOpacity(
+ opacity: showLargeTitle ? 1.0 : 0.0,
+ duration: _kNavBarTitleFadeDuration,
+ child: Semantics(
+ header: true,
+ child: DefaultTextStyle(
+ style: CupertinoTheme.of(context)
+ .textTheme
+ .navLargeTitleTextStyle,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ child: _LargeTitle(
+ child: components.largeTitle,
),
),
),
@@ -921,6 +919,123 @@
}
}
+/// The large title of the navigation bar.
+///
+/// Magnifies on over-scroll when [CupertinoSliverNavigationBar.stretch]
+/// parameter is true.
+class _LargeTitle extends SingleChildRenderObjectWidget {
+ const _LargeTitle({ super.child });
+
+ @override
+ _RenderLargeTitle createRenderObject(BuildContext context) {
+ return _RenderLargeTitle(alignment: AlignmentDirectional.bottomStart.resolve(Directionality.of(context)));
+ }
+
+ @override
+ void updateRenderObject(BuildContext context, _RenderLargeTitle renderObject) {
+ renderObject.alignment = AlignmentDirectional.bottomStart.resolve(Directionality.of(context));
+ }
+}
+
+class _RenderLargeTitle extends RenderShiftedBox {
+ _RenderLargeTitle({
+ required Alignment alignment,
+ }) : _alignment = alignment,
+ super(null);
+
+ Alignment get alignment => _alignment;
+ Alignment _alignment;
+ set alignment(Alignment value) {
+ if (_alignment == value) {
+ return;
+ }
+ _alignment = value;
+
+ markNeedsLayout();
+ }
+
+ double _scale = 1.0;
+
+ @override
+ void performLayout() {
+ final RenderBox? child = this.child;
+ Size childSize = Size.zero;
+
+ size = constraints.biggest;
+
+ if (child == null) {
+ return;
+ }
+
+ final BoxConstraints childConstriants = constraints.widthConstraints().loosen();
+ child.layout(childConstriants, parentUsesSize: true);
+
+ final double maxScale = child.size.width != 0.0
+ ? clampDouble(constraints.maxWidth / child.size.width, 1.0, 1.1)
+ : 1.1;
+ _scale = clampDouble(
+ 1.0 + (constraints.maxHeight - (_kNavBarLargeTitleHeightExtension - _kNavBarBottomPadding)) / (_kNavBarLargeTitleHeightExtension - _kNavBarBottomPadding) * 0.03,
+ 1.0,
+ maxScale,
+ );
+
+ childSize = child.size * _scale;
+ final BoxParentData childParentData = child.parentData! as BoxParentData;
+ childParentData.offset = alignment.alongOffset(size - childSize as Offset);
+ }
+
+ @override
+ void applyPaintTransform(RenderBox child, Matrix4 transform) {
+ assert(child == this.child);
+
+ super.applyPaintTransform(child, transform);
+
+ transform.scale(_scale, _scale);
+ }
+
+ @override
+ void paint(PaintingContext context, Offset offset) {
+ final RenderBox? child = this.child;
+
+ if (child == null) {
+ layer = null;
+ } else {
+ final BoxParentData childParentData = child.parentData! as BoxParentData;
+
+ layer = context.pushTransform(
+ needsCompositing,
+ offset + childParentData.offset,
+ Matrix4.diagonal3Values(_scale, _scale, 1.0),
+ (PaintingContext context, Offset offset) => context.paintChild(child, offset),
+ oldLayer: layer as TransformLayer?,
+ );
+ }
+ }
+
+ @override
+ bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
+ final RenderBox? child = this.child;
+
+ if (child == null) {
+ return false;
+ }
+
+ final Offset childOffset = (child.parentData! as BoxParentData).offset;
+
+ final Matrix4 transform = Matrix4.identity()
+ ..scale(1.0/_scale, 1.0/_scale, 1.0)
+ ..translate(-childOffset.dx, -childOffset.dy);
+
+ return result.addWithRawTransform(
+ transform: transform,
+ position: position,
+ hitTest: (BoxHitTestResult result, Offset transformed) {
+ return child.hitTest(result, position: transformed);
+ }
+ );
+ }
+}
+
/// The top part of the navigation bar that's never scrolled away.
///
/// Consists of the entire navigation bar without background and border when used
diff --git a/packages/flutter/test/cupertino/nav_bar_test.dart b/packages/flutter/test/cupertino/nav_bar_test.dart
index 1725a20..269e45b 100644
--- a/packages/flutter/test/cupertino/nav_bar_test.dart
+++ b/packages/flutter/test/cupertino/nav_bar_test.dart
@@ -441,8 +441,8 @@
1.0, // The larger font title is visible.
]);
- expect(tester.getTopLeft(find.widgetWithText(OverflowBox, 'Title')).dy, 44.0);
- expect(tester.getSize(find.widgetWithText(OverflowBox, 'Title')).height, 52.0);
+ expect(tester.getTopLeft(find.widgetWithText(ClipRect, 'Title').first).dy, 44.0);
+ expect(tester.getSize(find.widgetWithText(ClipRect, 'Title').first).height, 52.0);
scrollController.jumpTo(600.0);
await tester.pump(); // Once to trigger the opacity animation.
@@ -470,9 +470,9 @@
expect(tester.getTopLeft(find.byType(NavigationToolbar)).dy, 0.0);
expect(tester.getSize(find.byType(NavigationToolbar)).height, 44.0);
- expect(tester.getTopLeft(find.widgetWithText(OverflowBox, 'Title')).dy, 44.0);
+ expect(tester.getTopLeft(find.widgetWithText(ClipRect, 'Title').first).dy, 44.0);
// The OverflowBox is squished with the text in it.
- expect(tester.getSize(find.widgetWithText(OverflowBox, 'Title')).height, 0.0);
+ expect(tester.getSize(find.widgetWithText(ClipRect, 'Title').first).height, 0.0);
});
testWidgets('User specified middle is always visible in sliver', (WidgetTester tester) async {
@@ -517,8 +517,8 @@
expect(find.text('Title'), findsOneWidget);
expect(tester.getCenter(find.byKey(segmentedControlsKey)).dx, 400.0);
- expect(tester.getTopLeft(find.widgetWithText(OverflowBox, 'Title')).dy, 44.0);
- expect(tester.getSize(find.widgetWithText(OverflowBox, 'Title')).height, 52.0);
+ expect(tester.getTopLeft(find.widgetWithText(ClipRect, 'Title').first).dy, 44.0);
+ expect(tester.getSize(find.widgetWithText(ClipRect, 'Title').first).height, 52.0);
scrollController.jumpTo(600.0);
await tester.pump(); // Once to trigger the opacity animation.
@@ -639,7 +639,7 @@
expect(tester.getTopLeft(find.byType(NavigationToolbar)).dy, 0.0);
expect(tester.getSize(find.byType(NavigationToolbar)).height, 44.0);
- expect(tester.getBottomLeft(find.text('Title')).dy, 44.0 - 8.0); // Extension gone, (static part - padding) left.
+ expect(tester.getBottomLeft(find.text('Title')).dy, 44.0); // Extension gone.
});
testWidgets('Auto back/close button', (WidgetTester tester) async {
@@ -1405,6 +1405,150 @@
expect(find.text('Page 1'), findsOneWidget);
expect(find.text('Page 2'), findsNothing);
});
+
+ testWidgets(
+ 'CupertinoSliverNavigationBar magnifies upon over-scroll and shrinks back once over-scroll ends',
+ (WidgetTester tester) async {
+ const Text titleText = Text('Large Title');
+
+ await tester.pumpWidget(
+ CupertinoApp(
+ home: CupertinoPageScaffold(
+ child: CustomScrollView(
+ slivers: <Widget>[
+ const CupertinoSliverNavigationBar(
+ largeTitle: titleText,
+ stretch: true,
+ ),
+ SliverToBoxAdapter(
+ child: Container(
+ height: 1200.0,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+
+ final Finder titleTextFinder = find.byWidget(titleText).first;
+
+ // Gets the height of the large title
+ final Offset initialLargeTitleTextOffset =
+ tester.getBottomLeft(titleTextFinder) -
+ tester.getTopLeft(titleTextFinder);
+
+ // Drag for overscroll
+ await tester.drag(find.byType(Scrollable), const Offset(0.0, 150.0));
+ await tester.pump();
+
+ final Offset magnifiedTitleTextOffset =
+ tester.getBottomLeft(titleTextFinder) -
+ tester.getTopLeft(titleTextFinder);
+
+ expect(
+ magnifiedTitleTextOffset.dy.abs(),
+ greaterThan(initialLargeTitleTextOffset.dy.abs()),
+ );
+
+ // Ensure title text retracts to original size after releasing gesture
+ await tester.pumpAndSettle();
+
+ final Offset finalTitleTextOffset = tester.getBottomLeft(titleTextFinder) -
+ tester.getTopLeft(titleTextFinder);
+
+ expect(
+ finalTitleTextOffset.dy.abs(),
+ initialLargeTitleTextOffset.dy.abs(),
+ );
+ },
+ );
+
+ testWidgets(
+ 'CupertinoSliverNavigationBar large title text does not get clipped when magnified',
+ (WidgetTester tester) async {
+ const Text titleText = Text('Very very very long large title');
+
+ await tester.pumpWidget(
+ CupertinoApp(
+ home: CupertinoPageScaffold(
+ child: CustomScrollView(
+ slivers: <Widget>[
+ const CupertinoSliverNavigationBar(
+ largeTitle: titleText,
+ stretch: true,
+ ),
+ SliverToBoxAdapter(
+ child: Container(
+ height: 1200.0,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+
+ final Finder titleTextFinder = find.byWidget(titleText).first;
+
+ // Gets the width of the large title
+ final Offset initialLargeTitleTextOffset =
+ tester.getBottomLeft(titleTextFinder) -
+ tester.getBottomRight(titleTextFinder);
+
+ // Drag for overscroll
+ await tester.drag(find.byType(Scrollable), const Offset(0.0, 150.0));
+ await tester.pump();
+
+ final Offset magnifiedTitleTextOffset =
+ tester.getBottomLeft(titleTextFinder) -
+ tester.getBottomRight(titleTextFinder);
+
+ expect(
+ magnifiedTitleTextOffset.dx.abs(),
+ equals(initialLargeTitleTextOffset.dx.abs()),
+ );
+ },
+ );
+
+ testWidgets(
+ 'CupertinoSliverNavigationBar large title can be hit tested when magnified',
+ (WidgetTester tester) async {
+ final ScrollController scrollController = ScrollController();
+
+ await tester.pumpWidget(
+ CupertinoApp(
+ home: CupertinoPageScaffold(
+ child: CustomScrollView(
+ controller: scrollController,
+ slivers: <Widget>[
+ const CupertinoSliverNavigationBar(
+ largeTitle: Text('Large title'),
+ stretch: true,
+ ),
+ SliverToBoxAdapter(
+ child: Container(
+ height: 1200.0,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+
+ final Finder largeTitleFinder = find.text('Large title').first;
+
+ // Drag for overscroll
+ await tester.drag(find.byType(Scrollable), const Offset(0.0, 250.0));
+
+ // Hold position of the scroll view, so the Scrollable unblocks the hit-testing
+ scrollController.position.hold(() {});
+ await tester.pumpAndSettle();
+
+ expect(largeTitleFinder.hitTestable(), findsOneWidget);
+ },
+ );
}
class _ExpectStyles extends StatelessWidget {