Victor Sanni 2024-10-18 12:18:08 -07:00 committed by GitHub
parent 73da3b53bd
commit 83a0b85e43
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 247 additions and 1 deletions

View File

@ -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),
),
),
],

View File

@ -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;