ScrollEndNotification example: auto-scroll based on RenderSliver constraints and geometry (#143538)

Adds a new ScrollNotificationEnd example that demonstrates how to trigger an auto-scroll based on an individual sliver's `SliverConstraints` and `SliverGeometry`. 

Then new example auto-scrolls one special "aligned item" sliver to the top or bottom of the viewport, whenever it's partially visible (because it overlaps the top or bottom of the viewport). This example differs from the existing ScrollEndNotification example because the layout of the to-be aligned sliver is retrieved from its `RenderSliver` via a
GlobalKey.  The new example does not rely on all of the list items having the same extent.
This commit is contained in:
Hans Muller 2024-07-02 11:46:19 -07:00 committed by GitHub
parent 9527259c70
commit acb41f3174
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 252 additions and 0 deletions

View File

@ -0,0 +1,188 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
void main() {
runApp(const SliverAutoScrollExampleApp());
}
class SliverAutoScrollExampleApp extends StatelessWidget {
const SliverAutoScrollExampleApp({ super.key });
@override
Widget build(BuildContext context) {
return const MaterialApp(home: SliverAutoScrollExample());
}
}
class SliverAutoScrollExample extends StatefulWidget {
const SliverAutoScrollExample({ super.key });
@override
State<SliverAutoScrollExample> createState() => _SliverAutoScrollExampleState();
}
class _SliverAutoScrollExampleState extends State<SliverAutoScrollExample> {
final GlobalKey alignedItemKey = GlobalKey();
late final ScrollController scrollController;
late double lastScrollOffset;
@override
void initState() {
super.initState();
scrollController = ScrollController();
}
@override
void dispose() {
scrollController.dispose();
super.dispose();
}
void autoScrollTo(double offset) {
scrollController.position.animateTo(
offset,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
// After an interactive scroll ends, if the alignedItem is partially visible
// at the top or bottom of the viewport, then auto-scroll so that it's
// completely visible. To accomodate mouse-wheel scrolls and other small
// adjustments, scrolls that change the scroll offset by less than
// the alignedItem's extent don't trigger an auto-scroll.
void maybeAutoScrollAlignedItem(RenderSliver alignedItem) {
final SliverConstraints constraints = alignedItem.constraints;
final SliverGeometry geometry = alignedItem.geometry!;
final double sliverOffset = constraints.scrollOffset;
if ((scrollController.offset - lastScrollOffset).abs() <= geometry.maxPaintExtent) {
// Ignore scrolls that are smaller than the aligned item's extent.
return;
}
final double overflow = geometry.maxPaintExtent - geometry.paintExtent;
if (overflow > 0 && overflow < geometry.scrollExtent) { // indicates partial visibility
if (sliverOffset > 0) {
autoScrollTo(constraints.precedingScrollExtent); // top
} else if (sliverOffset == 0) {
autoScrollTo(scrollController.offset + overflow); // bottom
}
}
}
// Calls maybeAutoScrollAlignedItem in a post-frame callback so that
// auto-scrolls are triggered _after_ the current scroll activity
// has completed. Otherwise auto-scrolling would be a no-op.
bool handleScrollNotification(ScrollNotification notification) {
if (notification is ScrollStartNotification) {
lastScrollOffset = scrollController.offset;
}
if (notification is ScrollEndNotification) {
SchedulerBinding.instance.addPostFrameCallback((Duration duration) {
final RenderSliver? sliver =
alignedItemKey.currentContext?.findAncestorRenderObjectOfType<RenderSliver>();
if (sliver != null && sliver.geometry != null) {
maybeAutoScrollAlignedItem(sliver);
}
});
}
return false;
}
@override
Widget build(BuildContext context) {
const EdgeInsets horizontalPadding = EdgeInsets.symmetric(horizontal: 8);
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: NotificationListener<ScrollNotification>(
onNotification: handleScrollNotification,
child: Scrollbar(
controller: scrollController,
thumbVisibility: true,
child: CustomScrollView(
controller: scrollController,
slivers: <Widget>[
const SliverPadding(
padding: horizontalPadding,
sliver: ItemList(itemCount: 15),
),
SliverPadding(
padding: horizontalPadding,
sliver: BigOrangeSliver(sliverChildKey: alignedItemKey),
),
const SliverPadding(
padding: horizontalPadding,
sliver: ItemList(itemCount: 25),
),
],
),
),
),
),
),
);
}
}
// A big list item that's easy to spot. The provided key is assigned to
// the aligned sliver's child so that we can find the this item's RenderSliver
// later with BuildContext.findAncestorRenderObjectOfType.
class BigOrangeSliver extends StatelessWidget {
const BigOrangeSliver({ super.key, required this.sliverChildKey });
final Key sliverChildKey;
@override
Widget build(BuildContext context) {
return SliverToBoxAdapter(
child: Card(
key: sliverChildKey,
color: Colors.orange,
child: const SizedBox(
width: 300,
child: ListTile(
textColor: Colors.white,
title: Padding(
padding: EdgeInsets.symmetric(vertical: 32),
child: Text('Aligned Item'),
),
)
),
),
);
}
}
// A placeholder SliverList of 50 items.
class ItemList extends StatelessWidget {
const ItemList({ super.key, this.itemCount = 50 });
final int itemCount;
@override
Widget build(BuildContext context) {
final ColorScheme colorScheme = Theme.of(context).colorScheme;
return SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
return Card(
color: colorScheme.onSecondary,
child: SizedBox(width: 100, child: ListTile(
textColor: colorScheme.secondary,
title: Text('Item $index.$itemCount'),
)),
);
},
childCount: itemCount,
),
);
}
}

View File

@ -0,0 +1,52 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter_api_samples/widgets/scroll_end_notification/scroll_end_notification.1.dart' as example;
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('SliverAutoScroll example', (WidgetTester tester) async {
await tester.pumpWidget(
const example.SliverAutoScrollExampleApp(),
);
final double itemHeight = tester.getSize(find.widgetWithText(Card, 'Item 0.15')).height;
// The scroll view is 600 pixels high and the big orange
// "AlignedItem" is precedeed by 15 regular items. Scroll up enough
// to make it partially visible.
await tester.drag(find.byType(CustomScrollView), Offset(0, 600 - 15.5 * itemHeight));
await tester.pumpAndSettle();
final Finder alignedItem = find.widgetWithText(Card, 'Aligned Item');
// The "AlignedItem" is now bottom aligned.
expect(tester.getRect(alignedItem).bottom, 600);
// Scrolling down a little (less then the big orange item's height) and no
// auto-scroll occurs.
await tester.drag(find.byType(CustomScrollView), Offset(0, itemHeight));
await tester.pumpAndSettle();
expect(tester.getRect(alignedItem).bottom, 600 + itemHeight);
// Scroll up a little and the "AlignedItem" does not auto-scroll, because
// it's fully visible.
await tester.drag(find.byType(CustomScrollView), Offset(0, - 2 * itemHeight));
await tester.pumpAndSettle();
expect(tester.getRect(alignedItem).bottom, 600 - itemHeight);
// Scroll up far enough to make the AlignedItem partially visible and to trigger
// an auto-scroll that aligns it with the top of the viewport.
await tester.drag(find.byType(CustomScrollView), Offset(0, -600 + itemHeight * 1.5));
await tester.pumpAndSettle();
expect(tester.getRect(alignedItem).top, 0);
// Scroll down a little and the "AlignedItem" does not auto-scroll because
// it's fully visible.
await tester.drag(find.byType(CustomScrollView), Offset(0, itemHeight));
await tester.pumpAndSettle();
expect(tester.getRect(alignedItem).top, itemHeight);
});
}

View File

@ -274,6 +274,18 @@ class OverscrollNotification extends ScrollNotification {
/// ** See code in examples/api/lib/widgets/scroll_end_notification/scroll_end_notification.0.dart **
/// {@end-tool}
///
///
/// {@tool dartpad}
/// This example auto-scrolls one special "aligned item" sliver to
/// the top or bottom of the viewport, whenever it's partially visible
/// (because it overlaps the top or bottom of the viewport). This
/// example differs from the previous one in that the layout of an
/// individual sliver is retrieved from its [RenderSliver] via a
/// [GlobalKey]. The example does not rely on all of the list items
/// having the same extent.
///
/// ** See code in examples/api/lib/widgets/scroll_end_notification/scroll_end_notification.1.dart **
/// {@end-tool}
/// See also:
///
/// * [ScrollStartNotification], which indicates that scrolling has started.