mirror of
https://github.com/flutter/flutter.git
synced 2025-06-03 00:51:18 +00:00
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
This commit is contained in:
parent
2ffc5bc17e
commit
ef40e3ea6f
1
AUTHORS
1
AUTHORS
@ -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>
|
@ -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 {
|
||||
|
Loading…
Reference in New Issue
Block a user