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>
|
Junhua Lin <1075209054@qq.com>
|
||||||
Tomasz Gucio <tgucio@gmail.com>
|
Tomasz Gucio <tgucio@gmail.com>
|
||||||
Jason C.H <ctrysbita@outlook.com>
|
Jason C.H <ctrysbita@outlook.com>
|
||||||
|
Hubert Jóźwiak <hjozwiakdx@gmail.com>
|
@ -20,7 +20,7 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// Large title is hidden and at higher position.
|
// 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 {
|
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.
|
// 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('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 {
|
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 _kNavBarEdgePadding = 16.0;
|
||||||
|
|
||||||
|
const double _kNavBarBottomPadding = 8.0;
|
||||||
|
|
||||||
const double _kNavBarBackButtonTapWidth = 50.0;
|
const double _kNavBarBackButtonTapWidth = 50.0;
|
||||||
|
|
||||||
/// Title text transfer fade.
|
/// Title text transfer fade.
|
||||||
@ -833,31 +835,27 @@ class _LargeTitleNavigationBarSliverDelegate
|
|||||||
right: 0.0,
|
right: 0.0,
|
||||||
bottom: 0.0,
|
bottom: 0.0,
|
||||||
child: ClipRect(
|
child: ClipRect(
|
||||||
// The large title starts at the persistent bar.
|
child: Padding(
|
||||||
// It's aligned with the bottom of the sliver and expands clipped
|
padding: const EdgeInsetsDirectional.only(
|
||||||
// and behind the persistent bar.
|
start: _kNavBarEdgePadding,
|
||||||
child: OverflowBox(
|
bottom: _kNavBarBottomPadding
|
||||||
minHeight: 0.0,
|
),
|
||||||
maxHeight: double.infinity,
|
child: SafeArea(
|
||||||
alignment: AlignmentDirectional.bottomStart,
|
top: false,
|
||||||
child: Padding(
|
bottom: false,
|
||||||
padding: const EdgeInsetsDirectional.only(
|
child: AnimatedOpacity(
|
||||||
start: _kNavBarEdgePadding,
|
opacity: showLargeTitle ? 1.0 : 0.0,
|
||||||
bottom: 8.0, // Bottom has a different padding.
|
duration: _kNavBarTitleFadeDuration,
|
||||||
),
|
child: Semantics(
|
||||||
child: SafeArea(
|
header: true,
|
||||||
top: false,
|
child: DefaultTextStyle(
|
||||||
bottom: false,
|
style: CupertinoTheme.of(context)
|
||||||
child: AnimatedOpacity(
|
.textTheme
|
||||||
opacity: showLargeTitle ? 1.0 : 0.0,
|
.navLargeTitleTextStyle,
|
||||||
duration: _kNavBarTitleFadeDuration,
|
maxLines: 1,
|
||||||
child: Semantics(
|
overflow: TextOverflow.ellipsis,
|
||||||
header: true,
|
child: _LargeTitle(
|
||||||
child: DefaultTextStyle(
|
child: components.largeTitle,
|
||||||
style: CupertinoTheme.of(context).textTheme.navLargeTitleTextStyle,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
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.
|
/// The top part of the navigation bar that's never scrolled away.
|
||||||
///
|
///
|
||||||
/// Consists of the entire navigation bar without background and border when used
|
/// 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.
|
1.0, // The larger font title is visible.
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(tester.getTopLeft(find.widgetWithText(OverflowBox, 'Title')).dy, 44.0);
|
expect(tester.getTopLeft(find.widgetWithText(ClipRect, 'Title').first).dy, 44.0);
|
||||||
expect(tester.getSize(find.widgetWithText(OverflowBox, 'Title')).height, 52.0);
|
expect(tester.getSize(find.widgetWithText(ClipRect, 'Title').first).height, 52.0);
|
||||||
|
|
||||||
scrollController.jumpTo(600.0);
|
scrollController.jumpTo(600.0);
|
||||||
await tester.pump(); // Once to trigger the opacity animation.
|
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.getTopLeft(find.byType(NavigationToolbar)).dy, 0.0);
|
||||||
expect(tester.getSize(find.byType(NavigationToolbar)).height, 44.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.
|
// 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 {
|
testWidgets('User specified middle is always visible in sliver', (WidgetTester tester) async {
|
||||||
@ -517,8 +517,8 @@ void main() {
|
|||||||
expect(find.text('Title'), findsOneWidget);
|
expect(find.text('Title'), findsOneWidget);
|
||||||
expect(tester.getCenter(find.byKey(segmentedControlsKey)).dx, 400.0);
|
expect(tester.getCenter(find.byKey(segmentedControlsKey)).dx, 400.0);
|
||||||
|
|
||||||
expect(tester.getTopLeft(find.widgetWithText(OverflowBox, 'Title')).dy, 44.0);
|
expect(tester.getTopLeft(find.widgetWithText(ClipRect, 'Title').first).dy, 44.0);
|
||||||
expect(tester.getSize(find.widgetWithText(OverflowBox, 'Title')).height, 52.0);
|
expect(tester.getSize(find.widgetWithText(ClipRect, 'Title').first).height, 52.0);
|
||||||
|
|
||||||
scrollController.jumpTo(600.0);
|
scrollController.jumpTo(600.0);
|
||||||
await tester.pump(); // Once to trigger the opacity animation.
|
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.getTopLeft(find.byType(NavigationToolbar)).dy, 0.0);
|
||||||
expect(tester.getSize(find.byType(NavigationToolbar)).height, 44.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 {
|
testWidgets('Auto back/close button', (WidgetTester tester) async {
|
||||||
@ -1405,6 +1405,150 @@ void main() {
|
|||||||
expect(find.text('Page 1'), findsOneWidget);
|
expect(find.text('Page 1'), findsOneWidget);
|
||||||
expect(find.text('Page 2'), findsNothing);
|
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 {
|
class _ExpectStyles extends StatelessWidget {
|
||||||
|
Loading…
Reference in New Issue
Block a user