Unverified Commit 27caa7fe authored by Kate Lovett's avatar Kate Lovett Committed by GitHub

Add ScrollController.onAttach & onDetach, samples/docs on listening/getting...

Add ScrollController.onAttach & onDetach, samples/docs on listening/getting scrolling info (#124823)

This PR does a couple of things!

https://user-images.githubusercontent.com/16964204/231897483-416287f9-50ce-468d-a714-2a4bc0f2e011.mov

![Screenshot 2023-04-13 at 3 24 28 PM](https://user-images.githubusercontent.com/16964204/231897497-f5bee17d-43ed-46e5-acd7-e1bd64768274.png)

Fixes #20819 
Fixes #41910 
Fixes #121419

### Adds ScrollController.onAttach and ScrollController.onDetach

This resolves a long held pain point for developers. When using a scroll controller, there is not scroll position until the scrollable widget is built, and almost all methods of notification are only triggered when scrolling happens. Adding these two methods will help developers gain access to the scroll position when it is created. A common workaround for this was using a post frame callback to access controller.position after the first frame, but this is ripe for issues such as having multiple positions attached to the controller, or the scrollable no longer existing after that post frame callback. I think this can also be helpful for folks to debug cases when the scroll controller has multiple positions attached.

In particular, this also resolves this commented case: https://github.com/flutter/flutter/issues/20819#issuecomment-417784218
The isScrollingNotifier is hard for developers to access.

### Docs & samples

I was surprised we did not have samples on scroll notification or scroll controller, so I overhauled it and added a lot of docs on all the different ways to access scrolling information, when it is available and how they differ.
parent dda7d28d
// 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';
/// Flutter code sample for [ScrollController] & [ScrollNotification].
void main() => runApp(const ScrollNotificationDemo());
class ScrollNotificationDemo extends StatefulWidget {
const ScrollNotificationDemo({super.key});
@override
State<ScrollNotificationDemo> createState() => _ScrollNotificationDemoState();
}
class _ScrollNotificationDemoState extends State<ScrollNotificationDemo> {
ScrollNotification? _lastNotification;
late final ScrollController _controller;
bool _useController = true;
// This method handles the notification from the ScrollController.
void _handleControllerNotification() {
print('Notified through the scroll controller.');
// Access the position directly through the controller for details on the
// scroll position.
}
// This method handles the notification from the NotificationListener.
bool _handleScrollNotification(ScrollNotification notification) {
print('Notified through scroll notification.');
// The position can still be accessed through the scroll controller, but
// the notification object provides more details about the activity that is
// occurring.
if (_lastNotification.runtimeType != notification.runtimeType) {
setState(() {
// Call set state to respond to a change in the scroll notification.
_lastNotification = notification;
});
}
// Returning false allows the notification to continue bubbling up to
// ancestor listeners. If we wanted the notification to stop bubbling,
// return true.
return false;
}
@override
void initState() {
_controller = ScrollController();
if (_useController) {
// When listening to scrolling via the ScrollController, call
// `addListener` on the controller.
_controller.addListener(_handleControllerNotification);
}
super.initState();
}
@override
Widget build(BuildContext context) {
// ListView.separated works very similarly to this example with
// CustomScrollView & SliverList.
Widget body = CustomScrollView(
// Provide the scroll controller to the scroll view.
controller: _controller,
slivers: <Widget>[
SliverList.separated(
itemCount: 50,
itemBuilder: (_,int index) {
return Padding(
padding: const EdgeInsets.symmetric(
vertical: 8.0,
horizontal: 20.0,
),
child: Text('Item $index'),
);
},
separatorBuilder: (_, __) => const Divider(
indent: 20,
endIndent: 20,
thickness: 2,
),
),
],
);
if (!_useController) {
// If we are not using a ScrollController to listen to scrolling,
// let's use a NotificationListener. Similar, but with a different
// handler that provides information on what scrolling is occurring.
body = NotificationListener<ScrollNotification>(
onNotification: _handleScrollNotification,
child: body,
);
}
return MaterialApp(
theme: ThemeData.from(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blueGrey),
),
home: Scaffold(
appBar: AppBar(
title: const Text('Listening to a ScrollPosition'),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(70),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: <Widget>[
if (!_useController) Text('Last notification: ${_lastNotification.runtimeType}'),
if (!_useController) const SizedBox.square(dimension: 10),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text('with:'),
Radio<bool>(
value: true,
groupValue: _useController,
onChanged: _handleRadioChange,
),
const Text('ScrollController'),
Radio<bool>(
value: false,
groupValue: _useController,
onChanged: _handleRadioChange,
),
const Text('NotificationListener'),
],
),
],
),
),
),
body: body,
),
);
}
void _handleRadioChange(bool? value) {
if (value == null) {
return;
}
if (value != _useController) {
setState(() {
// Respond to a change in selected radio button, and add/remove the
// listener to the scroll controller.
_useController = value;
if (_useController) {
_controller.addListener(_handleControllerNotification);
} else {
_controller.removeListener(_handleControllerNotification);
}
});
}
}
@override
void dispose() {
_controller.removeListener(_handleControllerNotification);
super.dispose();
}
}
// 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';
/// Flutter code sample for [ScrollController].
void main() => runApp(const ScrollControllerDemo());
class ScrollControllerDemo extends StatefulWidget {
const ScrollControllerDemo({super.key});
@override
State<ScrollControllerDemo> createState() => _ScrollControllerDemoState();
}
class _ScrollControllerDemoState extends State<ScrollControllerDemo> {
late final ScrollController _controller;
bool isScrolling = false;
void _handleScrollChange() {
if (isScrolling != _controller.position.isScrollingNotifier.value) {
setState((){
isScrolling = _controller.position.isScrollingNotifier.value;
});
}
}
void _handlePositionAttach(ScrollPosition position) {
// From here, add a listener to the given ScrollPosition.
// Here the isScrollingNotifier will be used to inform when scrolling starts
// and stops and change the AppBar's color in response.
position.isScrollingNotifier.addListener(_handleScrollChange);
}
void _handlePositionDetach(ScrollPosition position) {
// From here, add a listener to the given ScrollPosition.
// Here the isScrollingNotifier will be used to inform when scrolling starts
// and stops and change the AppBar's color in response.
position.isScrollingNotifier.removeListener(_handleScrollChange);
}
@override
void initState() {
_controller = ScrollController(
// These methods will be called in response to a scroll position
// being attached to or detached from this ScrollController. This happens
// when the Scrollable is built.
onAttach: _handlePositionAttach,
onDetach: _handlePositionDetach,
);
super.initState();
}
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: Text(isScrolling ? 'Scrolling' : 'Not Scrolling'),
backgroundColor: isScrolling
? Colors.green[800]!.withOpacity(.85)
: Colors.redAccent[700]!.withOpacity(.85),
),
// ListView.builder works very similarly to this example with
// CustomScrollView & SliverList.
body: CustomScrollView(
// Provide the scroll controller to the scroll view.
controller: _controller,
slivers: <Widget>[
SliverList.builder(
itemCount: 50,
itemBuilder: (_, int index) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Center(
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.blueGrey[50],
boxShadow: const <BoxShadow>[
BoxShadow(
color: Colors.black12,
offset: Offset(5, 5),
blurRadius: 5,
),
],
borderRadius: const BorderRadius.all(Radius.circular(10))
),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 12.0,
horizontal: 20.0,
),
child: Text('Item $index'),
),
),
),
);
},
),
],
),
),
);
}
}
// 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_position/scroll_controller_notification.0.dart'
as example;
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('Can toggle between scroll notification types', (WidgetTester tester) async {
await tester.pumpWidget(
const example.ScrollNotificationDemo(),
);
expect(find.byType(CustomScrollView), findsOneWidget);
expect(find.text('Last notification: Null'), findsNothing);
// Toggle to use NotificationListener
await tester.tap(
find.byWidgetPredicate((Widget widget) {
return widget is Radio<bool> && !widget.value;
})
);
await tester.pumpAndSettle();
expect(find.text('Last notification: Null'), findsOneWidget);
await tester.drag(find.byType(CustomScrollView), const Offset(20.0, 20.0));
await tester.pumpAndSettle();
expect(find.text('Last notification: UserScrollNotification'), findsOneWidget);
});
}
// 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_position/scroll_controller_on_attach.0.dart'
as example;
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('Can toggle between scroll notification types', (WidgetTester tester) async {
await tester.pumpWidget(
const example.ScrollControllerDemo(),
);
expect(find.byType(CustomScrollView), findsOneWidget);
expect(find.text('Not Scrolling'), findsOneWidget);
Material appBarMaterial = tester.widget<Material>(
find.descendant(
of: find.byType(AppBar),
matching: find.byType(Material),
),
);
expect(appBarMaterial.color, Colors.redAccent[700]!.withOpacity(.85));
final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(CustomScrollView)));
await gesture.moveBy(const Offset(10.0, 10.0));
await tester.pump();
expect(find.text('Scrolling'), findsOneWidget);
appBarMaterial = tester.widget<Material>(
find.descendant(
of: find.byType(AppBar),
matching: find.byType(Material),
),
);
expect(appBarMaterial.color, Colors.green[800]!.withOpacity(.85));
await gesture.up();
await tester.pumpAndSettle();
expect(find.text('Not Scrolling'), findsOneWidget);
appBarMaterial = tester.widget<Material>(
find.descendant(
of: find.byType(AppBar),
matching: find.byType(Material),
),
);
});
}
......@@ -10,6 +10,23 @@ import 'scroll_physics.dart';
import 'scroll_position.dart';
import 'scroll_position_with_single_context.dart';
// Examples can assume:
// TrackingScrollController _trackingScrollController = TrackingScrollController();
/// Signature for when a [ScrollController] has added or removed a
/// [ScrollPosition].
///
/// Since a [ScrollPosition] is not created and attached to a controller until
/// the [Scrollable] is built, this can be used to respond to the position being
/// attached to a controller.
///
/// By having access to the position directly, additional listeners can be
/// applied to aspects of the scroll position, like
/// [ScrollPosition.isScrollingNotifier].
///
/// Used by [ScrollController.onAttach] and [ScrollController.onDetach].
typedef ScrollControllerCallback = void Function(ScrollPosition position);
/// Controls a scrollable widget.
///
/// Scroll controllers are typically stored as member variables in [State]
......@@ -22,10 +39,7 @@ import 'scroll_position_with_single_context.dart';
/// to an individual [Scrollable] widget. To use a custom [ScrollPosition],
/// subclass [ScrollController] and override [createScrollPosition].
///
/// A [ScrollController] is a [Listenable]. It notifies its listeners whenever
/// any of the attached [ScrollPosition]s notify _their_ listeners (i.e.
/// whenever any of them scroll). It does not notify its listeners when the list
/// of attached [ScrollPosition]s changes.
/// {@macro flutter.widgets.scrollPosition.listening}
///
/// Typically used with [ListView], [GridView], [CustomScrollView].
///
......@@ -39,8 +53,8 @@ import 'scroll_position_with_single_context.dart';
/// [PageView].
/// * [ScrollPosition], which manages the scroll offset for an individual
/// scrolling widget.
/// * [ScrollNotification] and [NotificationListener], which can be used to watch
/// the scroll position without using a [ScrollController].
/// * [ScrollNotification] and [NotificationListener], which can be used to
/// listen to scrolling occur without using a [ScrollController].
class ScrollController extends ChangeNotifier {
/// Creates a controller for a scrollable widget.
///
......@@ -49,6 +63,8 @@ class ScrollController extends ChangeNotifier {
double initialScrollOffset = 0.0,
this.keepScrollOffset = true,
this.debugLabel,
this.onAttach,
this.onDetach,
}) : _initialScrollOffset = initialScrollOffset;
/// The initial value to use for [offset].
......@@ -78,6 +94,40 @@ class ScrollController extends ChangeNotifier {
/// locations used to save scroll offsets.
final bool keepScrollOffset;
/// Called when a [ScrollPosition] is attached to the scroll controller.
///
/// Since a scroll position is not attached until a [Scrollable] is actually
/// built, this can be used to respond to a new position being attached.
///
/// At the time that a scroll position is attached, the [ScrollMetrics], such as
/// the [ScrollMetrics.maxScrollExtent], are not yet available. These are not
/// determined until the [Scrollable] has finished laying out its contents and
/// computing things like the full extent of that content.
/// [ScrollPosition.hasContentDimensions] can be used to know when the
/// metrics are available, or a [ScrollMetricsNotification] can be used,
/// discussed further below.
///
/// {@tool dartpad}
/// This sample shows how to apply a listener to the
/// [ScrollPosition.isScrollingNotifier] using [ScrollController.onAttach].
/// This is used to change the [AppBar]'s color when scrolling is occurring.
///
/// ** See code in examples/api/lib/widgets/scroll_position/scroll_controller_on_attach.0.dart **
/// {@end-tool}
final ScrollControllerCallback? onAttach;
/// Called when a [ScrollPosition] is detached from the scroll controller.
///
/// {@tool dartpad}
/// This sample shows how to apply a listener to the
/// [ScrollPosition.isScrollingNotifier] using [ScrollController.onAttach]
/// & [ScrollController.onDetach].
/// This is used to change the [AppBar]'s color when scrolling is occurring.
///
/// ** See code in examples/api/lib/widgets/scroll_position/scroll_controller_on_attach.0.dart **
/// {@end-tool}
final ScrollControllerCallback? onDetach;
/// A label that is used in the [toString] output. Intended to aid with
/// identifying scroll controller instances in debug output.
final String? debugLabel;
......@@ -179,6 +229,9 @@ class ScrollController extends ChangeNotifier {
assert(!_positions.contains(position));
_positions.add(position);
position.addListener(notifyListeners);
if (onAttach != null) {
onAttach!(position);
}
}
/// Unregister the given position with this controller.
......@@ -187,6 +240,9 @@ class ScrollController extends ChangeNotifier {
/// controller will not manipulate the given position.
void detach(ScrollPosition position) {
assert(_positions.contains(position));
if (onDetach != null) {
onDetach!(position);
}
position.removeListener(notifyListeners);
_positions.remove(position);
}
......
......@@ -27,6 +27,14 @@ import 'package:flutter/rendering.dart';
/// [extentInside], and [extentAfter], which may be more useful for use cases
/// such as scroll bars; for example, see [Scrollbar].
///
/// {@tool dartpad}
/// This sample shows how a [ScrollMetricsNotification] is dispatched when
/// the [ScrollMetrics] changed as a result of resizing the [Viewport].
/// Press the floating action button to increase the scrollable window's size.
///
/// ** See code in examples/api/lib/widgets/scroll_position/scroll_metrics_notification.0.dart **
/// {@end-tool}
///
/// See also:
///
/// * [FixedScrollMetrics], which is an immutable object that implements this
......@@ -135,6 +143,14 @@ mixin ScrollMetrics {
/// An immutable snapshot of values associated with a [Scrollable] viewport.
///
/// For details, see [ScrollMetrics], which defines this object's interfaces.
///
/// {@tool dartpad}
/// This sample shows how a [ScrollMetricsNotification] is dispatched when
/// the [ScrollMetrics] changed as a result of resizing the [Viewport].
/// Press the floating action button to increase the scrollable window's size.
///
/// ** See code in examples/api/lib/widgets/scroll_position/scroll_metrics_notification.0.dart **
/// {@end-tool}
class FixedScrollMetrics with ScrollMetrics {
/// Creates an immutable snapshot of values associated with a [Scrollable] viewport.
FixedScrollMetrics({
......
......@@ -83,8 +83,37 @@ mixin ViewportElementMixin on NotifiableElementMixin {
/// happens after layout). The [GlowingOverscrollIndicator] and [Scrollbar]
/// widgets are examples of paint effects that use scroll notifications.
///
/// {@tool dartpad}
/// This sample shows the difference between using a [ScrollController] or a
/// [NotificationListener] of type [ScrollNotification] to listen to scrolling
/// activities. Toggling the [Radio] button switches between the two.
/// Using a [ScrollNotification] will provide details about the scrolling
/// activity, along with the metrics of the [ScrollPosition], but not the scroll
/// position object itself. By listening with a [ScrollController], the position
/// object is directly accessible.
/// Both of these types of notifications are only triggered by scrolling.
///
/// ** See code in examples/api/lib/widgets/scroll_position/scroll_controller_notification.0.dart **
/// {@end-tool}
///
/// To drive layout based on the scroll position, consider listening to the
/// [ScrollPosition] directly (or indirectly via a [ScrollController]).
/// [ScrollPosition] directly (or indirectly via a [ScrollController]). This
/// will not notify when the [ScrollMetrics] of a given scroll position changes,
/// such as when the window is resized, changing the dimensions of the
/// [Viewport]. In order to listen to changes in scroll metrics, use a
/// [NotificationListener] of type [ScrollMetricsNotification].
/// This type of notification differs from [ScrollNotification], as it is not
/// associated with the activity of scrolling, but rather the dimensions of
/// the scrollable area.
///
/// {@tool dartpad}
/// This sample shows how a [ScrollMetricsNotification] is dispatched when
/// the `windowSize` is changed. Press the floating action button to increase
/// the scrollable window's size.
///
/// ** See code in examples/api/lib/widgets/scroll_position/scroll_metrics_notification.0.dart **
/// {@end-tool}
///
abstract class ScrollNotification extends LayoutChangedNotification with ViewportNotificationMixin {
/// Initializes fields for subclasses.
ScrollNotification({
......
......@@ -66,6 +66,94 @@ enum ScrollPositionAlignmentPolicy {
/// This object is a [Listenable] that notifies its listeners when [pixels]
/// changes.
///
/// {@template flutter.widgets.scrollPosition.listening}
/// ### Accessing Scrolling Information
///
/// There are several ways to acquire information about scrolling and
/// scrollable widgets, but each provides different types of information about
/// the scrolling activity, the position, and the dimensions of the [Viewport].
///
/// A [ScrollController] is a [Listenable]. It notifies its listeners whenever
/// any of the attached [ScrollPosition]s notify _their_ listeners, such as when
/// scrolling occurs. This is very similar to using a [NotificationListener] of
/// type [ScrollNotification] to listen to changes in the scroll position, with
/// the difference being that a notification listener will provide information
/// about the scrolling activity. A notification listener can further listen to
/// specific subclasses of [ScrollNotification], like [UserScrollNotification].
///
/// {@tool dartpad}
/// This sample shows the difference between using a [ScrollController] or a
/// [NotificationListener] of type [ScrollNotification] to listen to scrolling
/// activities. Toggling the [Radio] button switches between the two.
/// Using a [ScrollNotification] will provide details about the scrolling
/// activity, along with the metrics of the [ScrollPosition], but not the scroll
/// position object itself. By listening with a [ScrollController], the position
/// object is directly accessible.
/// Both of these types of notifications are only triggered by scrolling.
///
/// ** See code in examples/api/lib/widgets/scroll_position/scroll_controller_notification.0.dart **
/// {@end-tool}
///
/// [ScrollController] does not notify its listeners when the list of
/// [ScrollPosition]s attached to the scroll controller changes. To listen to
/// the attaching and detaching of scroll positions to the controller, use the
/// [ScrollController.onAttach] and [ScrollController.onDetach] methods. This is
/// also useful for adding a listener to the
/// [ScrollPosition.isScrollingNotifier] when the position is created during the
/// build method of the [Scrollable].
///
/// At the time that a scroll position is attached, the [ScrollMetrics], such as
/// the [ScrollMetrics.maxScrollExtent], are not yet available. These are not
/// determined until the [Scrollable] has finished laying out its contents and
/// computing things like the full extent of that content.
/// [ScrollPosition.hasContentDimensions] can be used to know when the
/// metrics are available, or a [ScrollMetricsNotification] can be used,
/// discussed further below.
///
/// {@tool dartpad}
/// This sample shows how to apply a listener to the
/// [ScrollPosition.isScrollingNotifier] using [ScrollController.onAttach].
/// This is used to change the [AppBar]'s color when scrolling is occurring.
///
/// ** See code in examples/api/lib/widgets/scroll_position/scroll_controller_on_attach.0.dart **
/// {@end-tool}
///
/// #### From a different context
///
/// When needing to access scrolling information from a context that is within
/// the scrolling widget itself, use [Scrollable.of] to access the
/// [ScrollableState] and the [ScrollableState.position]. This would be the same
/// [ScrollPosition] attached to a [ScrollController].
///
/// When needing to access scrolling information from a context that is not an
/// ancestor of the scrolling widget, use [ScrollNotificationObserver]. This is
/// used by [AppBar] to create the scrolled under effect. Since [Scaffold.appBar]
/// is a separate subtree from the [Scaffold.body], scroll notifications would
/// not bubble up to the app bar. Use
/// [ScrollNotificationObserverState.addListener] to listen to scroll
/// notifications happening outside of the current context.
///
/// #### Dimension changes
///
/// Lastly, listening to a [ScrollController] or a [ScrollPosition] will
/// _not_ notify when the [ScrollMetrics] of a given scroll position changes,
/// such as when the window is resized, changing the dimensions of the
/// [Viewport] and the previously mentioned extents of the scrollable. In order
/// to listen to changes in scroll metrics, use a [NotificationListener] of type
/// [ScrollMetricsNotification]. This type of notification differs from
/// [ScrollNotification], as it is not associated with the activity of
/// scrolling, but rather the dimensions of the scrollable area, such as the
/// window size.
///
/// {@tool dartpad}
/// This sample shows how a [ScrollMetricsNotification] is dispatched when
/// the `windowSize` is changed. Press the floating action button to increase
/// the scrollable window's size.
///
/// ** See code in examples/api/lib/widgets/scroll_position/scroll_metrics_notification.0.dart **
/// {@end-tool}
/// {@endtemplate}
///
/// ## Subclassing ScrollPosition
///
/// Over time, a [Scrollable] might have many different [ScrollPosition]
......
......@@ -24,6 +24,8 @@ import 'scroll_position.dart';
/// manages [ScrollActivity] instances, which change what content is visible in
/// the [Scrollable]'s [Viewport].
///
/// {@macro flutter.widgets.scrollPosition.listening}
///
/// See also:
///
/// * [ScrollPosition], which defines the underlying model for a position
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment