Unverified Commit ef40e3ea authored by ivirtex's avatar ivirtex Committed by GitHub

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
parent 2ffc5bc1
......@@ -100,3 +100,4 @@ Jingyi Chen <jingyichen@link.cuhk.edu.cn>
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
......@@ -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 {
......
......@@ -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
......
......@@ -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: <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 {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment