mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
Snap CupertinoSliverNavigationBar large title and bottom widget (#156381)
## Flutter (with this PR): https://github.com/user-attachments/assets/b0177c06-e4b5-4981-8ec3-62171a4f2547 ## Native: https://github.com/user-attachments/assets/6efa0825-d26c-4c20-81cc-c9fada6e15fc Fixes https://github.com/flutter/flutter/issues/126028 Fixes https://github.com/flutter/flutter/issues/23321
This commit is contained in:
parent
73da3b53bd
commit
83a0b85e43
@ -921,6 +921,7 @@ class CupertinoSliverNavigationBar extends StatefulWidget {
|
||||
// lose their own states.
|
||||
class _CupertinoSliverNavigationBarState extends State<CupertinoSliverNavigationBar> {
|
||||
late _NavigationBarStaticComponentsKeys keys;
|
||||
ScrollableState? _scrollableState;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@ -928,6 +929,53 @@ class _CupertinoSliverNavigationBarState extends State<CupertinoSliverNavigation
|
||||
keys = _NavigationBarStaticComponentsKeys();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_scrollableState?.position.isScrollingNotifier.removeListener(_handleScrollChange);
|
||||
_scrollableState = Scrollable.maybeOf(context);
|
||||
_scrollableState?.position.isScrollingNotifier.addListener(_handleScrollChange);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_scrollableState?.position != null) {
|
||||
_scrollableState?.position.isScrollingNotifier.removeListener(_handleScrollChange);
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleScrollChange() {
|
||||
final ScrollPosition? position = _scrollableState?.position;
|
||||
if (position == null || !position.hasPixels || position.pixels <= 0.0) {
|
||||
return;
|
||||
}
|
||||
|
||||
double? target;
|
||||
final bool canScrollBottom = widget.bottom != null && (widget.bottomMode == NavigationBarBottomMode.automatic || widget.bottomMode == null);
|
||||
final double bottomScrollOffset = canScrollBottom ? widget.bottom!.preferredSize.height : 0.0;
|
||||
|
||||
if (canScrollBottom && position.pixels < bottomScrollOffset) {
|
||||
target = position.pixels > bottomScrollOffset / 2
|
||||
? bottomScrollOffset
|
||||
: 0.0;
|
||||
}
|
||||
else if (position.pixels > bottomScrollOffset && position.pixels < bottomScrollOffset + _kNavBarLargeTitleHeightExtension) {
|
||||
target = position.pixels > bottomScrollOffset + (_kNavBarLargeTitleHeightExtension / 2)
|
||||
? bottomScrollOffset + _kNavBarLargeTitleHeightExtension
|
||||
: bottomScrollOffset;
|
||||
}
|
||||
|
||||
if (target != null) {
|
||||
position.animateTo(
|
||||
target,
|
||||
// Eyeballed on an iPhone 16 simulator running iOS 18.
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.fastEaseInToSlowEaseOut,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final _NavigationBarStaticComponents components = _NavigationBarStaticComponents(
|
||||
@ -1118,7 +1166,7 @@ class _LargeTitleNavigationBarSliverDelegate
|
||||
bottom: 0.0,
|
||||
child: SizedBox(
|
||||
height: bottomHeight * (1.0 - bottomShrinkFactor),
|
||||
child: bottom,
|
||||
child: ClipRect(child: bottom),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@ -2087,6 +2087,204 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('Large title snaps up to persistent nav bar when partially scrolled over halfway up', (WidgetTester tester) async {
|
||||
final ScrollController scrollController = ScrollController();
|
||||
const double largeTitleHeight = 52.0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
CupertinoApp(
|
||||
home: CustomScrollView(
|
||||
controller: scrollController,
|
||||
slivers: const <Widget>[
|
||||
CupertinoSliverNavigationBar(
|
||||
largeTitle: Text('Large title'),
|
||||
middle: Text('middle'),
|
||||
alwaysShowMiddle: false,
|
||||
),
|
||||
SliverFillRemaining(
|
||||
child: SizedBox(
|
||||
height: 1000.0,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final RenderAnimatedOpacity? renderOpacity = tester.element(find.text('middle')).findAncestorRenderObjectOfType<RenderAnimatedOpacity>();
|
||||
|
||||
// The middle widget is initially invisible.
|
||||
expect(renderOpacity?.opacity.value, 0.0);
|
||||
expect(scrollController.offset, 0.0);
|
||||
|
||||
// Scroll a little over the halfway point.
|
||||
final TestGesture scrollGesture = await tester.startGesture(tester.getCenter(find.byType(Scrollable)));
|
||||
await scrollGesture.moveBy(const Offset(0.0, -(largeTitleHeight / 2) - 1));
|
||||
await scrollGesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Expect the large title to snap to the persistent app bar.
|
||||
expect(scrollController.position.pixels, largeTitleHeight);
|
||||
expect(renderOpacity?.opacity.value, 1.0);
|
||||
});
|
||||
|
||||
testWidgets('Large title snaps back to extended height when partially scrolled halfway up or less', (WidgetTester tester) async {
|
||||
final ScrollController scrollController = ScrollController();
|
||||
const double largeTitleHeight = 52.0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
CupertinoApp(
|
||||
home: CustomScrollView(
|
||||
controller: scrollController,
|
||||
slivers: const <Widget>[
|
||||
CupertinoSliverNavigationBar(
|
||||
largeTitle: Text('Large title'),
|
||||
middle: Text('middle'),
|
||||
alwaysShowMiddle: false,
|
||||
),
|
||||
SliverFillRemaining(
|
||||
child: SizedBox(
|
||||
height: 1000.0,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final RenderAnimatedOpacity? renderOpacity = tester.element(find.text('middle')).findAncestorRenderObjectOfType<RenderAnimatedOpacity>();
|
||||
|
||||
expect(renderOpacity?.opacity.value, 0.0);
|
||||
expect(scrollController.offset, 0.0);
|
||||
|
||||
// Scroll to the halfway point.
|
||||
final TestGesture scrollGesture = await tester.startGesture(tester.getCenter(find.byType(Scrollable)));
|
||||
await scrollGesture.moveBy(const Offset(0.0, -(largeTitleHeight / 2)));
|
||||
await scrollGesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Expect the large title to snap back to its extended height.
|
||||
expect(scrollController.position.pixels, 0.0);
|
||||
expect(renderOpacity?.opacity.value, 0.0);
|
||||
});
|
||||
|
||||
testWidgets('Large title and bottom snap up when partially scrolled over halfway up in automatic mode', (WidgetTester tester) async {
|
||||
final ScrollController scrollController = ScrollController();
|
||||
const double largeTitleHeight = 52.0;
|
||||
const double bottomHeight = 100.0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
CupertinoApp(
|
||||
home: CustomScrollView(
|
||||
controller: scrollController,
|
||||
slivers: const <Widget>[
|
||||
CupertinoSliverNavigationBar(
|
||||
largeTitle: Text('Large title'),
|
||||
middle: Text('middle'),
|
||||
alwaysShowMiddle: false,
|
||||
bottom: PreferredSize(
|
||||
preferredSize: Size.fromHeight(bottomHeight),
|
||||
child: Placeholder(),
|
||||
),
|
||||
bottomMode: NavigationBarBottomMode.automatic,
|
||||
),
|
||||
SliverFillRemaining(
|
||||
child: SizedBox(
|
||||
height: 1000.0,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final RenderAnimatedOpacity? renderOpacity = tester.element(find.text('middle')).findAncestorRenderObjectOfType<RenderAnimatedOpacity>();
|
||||
final Finder bottomFinder = find.byType(Placeholder);
|
||||
|
||||
expect(renderOpacity?.opacity.value, 0.0);
|
||||
expect(scrollController.offset, 0.0);
|
||||
|
||||
// Scroll to just past the halfway point of the bottom widget.
|
||||
final TestGesture scrollGesture1 = await tester.startGesture(tester.getCenter(find.byType(Scrollable)));
|
||||
await scrollGesture1.moveBy(const Offset(0.0, -(bottomHeight / 2) - 1));
|
||||
await scrollGesture1.up();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Expect the bottom to snap up to the large title.
|
||||
expect(scrollController.position.pixels, bottomHeight);
|
||||
expect(tester.getBottomLeft(bottomFinder).dy - tester.getTopLeft(bottomFinder).dy, 0.0);
|
||||
expect(renderOpacity?.opacity.value, 0.0);
|
||||
|
||||
// Scroll to just past the halfway point of the large title.
|
||||
final TestGesture scrollGesture2 = await tester.startGesture(tester.getCenter(find.byType(Scrollable)));
|
||||
await scrollGesture2.moveBy(const Offset(0.0, -(largeTitleHeight / 2) - 1));
|
||||
await scrollGesture2.up();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Expect the large title to snap up to the persistent nav bar.
|
||||
expect(scrollController.position.pixels, bottomHeight + largeTitleHeight);
|
||||
expect(renderOpacity?.opacity.value, 1.0);
|
||||
});
|
||||
|
||||
testWidgets('Large title and bottom snap down when partially scrolled halfway up or less in automatic mode', (WidgetTester tester) async {
|
||||
final ScrollController scrollController = ScrollController();
|
||||
const double largeTitleHeight = 52.0;
|
||||
const double bottomHeight = 100.0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
CupertinoApp(
|
||||
home: CustomScrollView(
|
||||
controller: scrollController,
|
||||
slivers: const <Widget>[
|
||||
CupertinoSliverNavigationBar(
|
||||
largeTitle: Text('Large title'),
|
||||
middle: Text('middle'),
|
||||
alwaysShowMiddle: false,
|
||||
bottom: PreferredSize(
|
||||
preferredSize: Size.fromHeight(bottomHeight),
|
||||
child: Placeholder(),
|
||||
),
|
||||
bottomMode: NavigationBarBottomMode.automatic,
|
||||
),
|
||||
SliverFillRemaining(
|
||||
child: SizedBox(
|
||||
height: 1000.0,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final RenderAnimatedOpacity? renderOpacity = tester.element(find.text('middle')).findAncestorRenderObjectOfType<RenderAnimatedOpacity>();
|
||||
final Finder bottomFinder = find.byType(Placeholder);
|
||||
|
||||
expect(renderOpacity?.opacity.value, 0.0);
|
||||
expect(scrollController.offset, 0.0);
|
||||
|
||||
// Scroll to the halfway point of the bottom widget.
|
||||
final TestGesture scrollGesture1 = await tester.startGesture(tester.getCenter(find.byType(Scrollable)));
|
||||
await scrollGesture1.moveBy(const Offset(0.0, -bottomHeight / 2));
|
||||
await scrollGesture1.up();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Expect the bottom to snap back to its extended height.
|
||||
expect(scrollController.position.pixels, 0.0);
|
||||
expect(tester.getBottomLeft(bottomFinder).dy - tester.getTopLeft(bottomFinder).dy, bottomHeight);
|
||||
expect(renderOpacity?.opacity.value, 0.0);
|
||||
|
||||
// Scroll to the halfway point of the large title.
|
||||
final TestGesture scrollGesture2 = await tester.startGesture(tester.getCenter(find.byType(Scrollable)));
|
||||
await scrollGesture2.moveBy(const Offset(0.0, -(bottomHeight + largeTitleHeight / 2)));
|
||||
await scrollGesture2.up();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Expect the large title to snap back to its extended height, which is the
|
||||
// same scroll offset as the fully-shrunk bottom widget.
|
||||
expect(scrollController.position.pixels, bottomHeight);
|
||||
expect(renderOpacity?.opacity.value, 0.0);
|
||||
});
|
||||
|
||||
testWidgets('CupertinoNavigationBar with bottom widget', (WidgetTester tester) async {
|
||||
const double persistentHeight = 44.0;
|
||||
const double bottomHeight = 10.0;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user