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.