From acb41f3174e4bfd00313992c849396eeb87e01d2 Mon Sep 17 00:00:00 2001 From: Hans Muller Date: Tue, 2 Jul 2024 11:46:19 -0700 Subject: [PATCH] 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. --- .../scroll_end_notification.1.dart | 188 ++++++++++++++++++ .../scroll_end_notification.1_test.dart | 52 +++++ .../lib/src/widgets/scroll_notification.dart | 12 ++ 3 files changed, 252 insertions(+) create mode 100644 examples/api/lib/widgets/scroll_end_notification/scroll_end_notification.1.dart create mode 100644 examples/api/test/widgets/scroll_end_notification/scroll_end_notification.1_test.dart diff --git a/examples/api/lib/widgets/scroll_end_notification/scroll_end_notification.1.dart b/examples/api/lib/widgets/scroll_end_notification/scroll_end_notification.1.dart new file mode 100644 index 00000000000..450e062b08d --- /dev/null +++ b/examples/api/lib/widgets/scroll_end_notification/scroll_end_notification.1.dart @@ -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 createState() => _SliverAutoScrollExampleState(); +} + +class _SliverAutoScrollExampleState extends State { + 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(); + 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( + onNotification: handleScrollNotification, + child: Scrollbar( + controller: scrollController, + thumbVisibility: true, + child: CustomScrollView( + controller: scrollController, + slivers: [ + 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, + ), + ); + } +} diff --git a/examples/api/test/widgets/scroll_end_notification/scroll_end_notification.1_test.dart b/examples/api/test/widgets/scroll_end_notification/scroll_end_notification.1_test.dart new file mode 100644 index 00000000000..8f36c8fde5d --- /dev/null +++ b/examples/api/test/widgets/scroll_end_notification/scroll_end_notification.1_test.dart @@ -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); + }); +} diff --git a/packages/flutter/lib/src/widgets/scroll_notification.dart b/packages/flutter/lib/src/widgets/scroll_notification.dart index 40270b6ee77..5970d896c86 100644 --- a/packages/flutter/lib/src/widgets/scroll_notification.dart +++ b/packages/flutter/lib/src/widgets/scroll_notification.dart @@ -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.