diff --git a/AUTHORS b/AUTHORS index 33f57815e7d..1911504ef83 100644 --- a/AUTHORS +++ b/AUTHORS @@ -100,3 +100,4 @@ Jingyi Chen Junhua Lin <1075209054@qq.com> Tomasz Gucio Jason C.H +Hubert Jóźwiak \ 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 b839f347e2d..208e76326ad 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 @@ void main() { 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 @@ void main() { // 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 8261bd9a48f..d7cfcff1c80 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 _kNavBarShowLargeTitleThreshold = 10.0; const double _kNavBarEdgePadding = 16.0; +const double _kNavBarBottomPadding = 8.0; + const double _kNavBarBackButtonTapWidth = 50.0; /// Title text transfer fade. @@ -833,31 +835,27 @@ class _LargeTitleNavigationBarSliverDelegate 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 @@ class _LargeTitleNavigationBarSliverDelegate } } +/// 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 1725a20afbc..269e45b181f 100644 --- a/packages/flutter/test/cupertino/nav_bar_test.dart +++ b/packages/flutter/test/cupertino/nav_bar_test.dart @@ -441,8 +441,8 @@ void main() { 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 @@ void main() { 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 @@ void main() { 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 @@ void main() { 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 @@ void main() { 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: [ + 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: [ + 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: [ + 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 {