Skip to content

Commit 27caa7f

Browse files
authored
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: flutter/flutter#20819 (comment) 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.
1 parent dda7d28 commit 27caa7f

9 files changed

+547
-7
lines changed
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
// Copyright 2014 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter/material.dart';
6+
7+
/// Flutter code sample for [ScrollController] & [ScrollNotification].
8+
9+
void main() => runApp(const ScrollNotificationDemo());
10+
11+
class ScrollNotificationDemo extends StatefulWidget {
12+
const ScrollNotificationDemo({super.key});
13+
14+
@override
15+
State<ScrollNotificationDemo> createState() => _ScrollNotificationDemoState();
16+
}
17+
18+
class _ScrollNotificationDemoState extends State<ScrollNotificationDemo> {
19+
ScrollNotification? _lastNotification;
20+
late final ScrollController _controller;
21+
bool _useController = true;
22+
23+
// This method handles the notification from the ScrollController.
24+
void _handleControllerNotification() {
25+
print('Notified through the scroll controller.');
26+
// Access the position directly through the controller for details on the
27+
// scroll position.
28+
}
29+
30+
// This method handles the notification from the NotificationListener.
31+
bool _handleScrollNotification(ScrollNotification notification) {
32+
print('Notified through scroll notification.');
33+
// The position can still be accessed through the scroll controller, but
34+
// the notification object provides more details about the activity that is
35+
// occurring.
36+
if (_lastNotification.runtimeType != notification.runtimeType) {
37+
setState(() {
38+
// Call set state to respond to a change in the scroll notification.
39+
_lastNotification = notification;
40+
});
41+
}
42+
43+
// Returning false allows the notification to continue bubbling up to
44+
// ancestor listeners. If we wanted the notification to stop bubbling,
45+
// return true.
46+
return false;
47+
}
48+
49+
@override
50+
void initState() {
51+
_controller = ScrollController();
52+
if (_useController) {
53+
// When listening to scrolling via the ScrollController, call
54+
// `addListener` on the controller.
55+
_controller.addListener(_handleControllerNotification);
56+
}
57+
super.initState();
58+
}
59+
60+
@override
61+
Widget build(BuildContext context) {
62+
// ListView.separated works very similarly to this example with
63+
// CustomScrollView & SliverList.
64+
Widget body = CustomScrollView(
65+
// Provide the scroll controller to the scroll view.
66+
controller: _controller,
67+
slivers: <Widget>[
68+
SliverList.separated(
69+
itemCount: 50,
70+
itemBuilder: (_,int index) {
71+
return Padding(
72+
padding: const EdgeInsets.symmetric(
73+
vertical: 8.0,
74+
horizontal: 20.0,
75+
),
76+
child: Text('Item $index'),
77+
);
78+
},
79+
separatorBuilder: (_, __) => const Divider(
80+
indent: 20,
81+
endIndent: 20,
82+
thickness: 2,
83+
),
84+
),
85+
],
86+
);
87+
88+
if (!_useController) {
89+
// If we are not using a ScrollController to listen to scrolling,
90+
// let's use a NotificationListener. Similar, but with a different
91+
// handler that provides information on what scrolling is occurring.
92+
body = NotificationListener<ScrollNotification>(
93+
onNotification: _handleScrollNotification,
94+
child: body,
95+
);
96+
}
97+
98+
return MaterialApp(
99+
theme: ThemeData.from(
100+
useMaterial3: true,
101+
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blueGrey),
102+
),
103+
home: Scaffold(
104+
appBar: AppBar(
105+
title: const Text('Listening to a ScrollPosition'),
106+
bottom: PreferredSize(
107+
preferredSize: const Size.fromHeight(70),
108+
child: Column(
109+
mainAxisAlignment: MainAxisAlignment.spaceAround,
110+
children: <Widget>[
111+
if (!_useController) Text('Last notification: ${_lastNotification.runtimeType}'),
112+
if (!_useController) const SizedBox.square(dimension: 10),
113+
Row(
114+
mainAxisAlignment: MainAxisAlignment.center,
115+
children: <Widget>[
116+
const Text('with:'),
117+
Radio<bool>(
118+
value: true,
119+
groupValue: _useController,
120+
onChanged: _handleRadioChange,
121+
),
122+
const Text('ScrollController'),
123+
Radio<bool>(
124+
value: false,
125+
groupValue: _useController,
126+
onChanged: _handleRadioChange,
127+
),
128+
const Text('NotificationListener'),
129+
],
130+
),
131+
],
132+
),
133+
),
134+
),
135+
body: body,
136+
),
137+
);
138+
}
139+
140+
void _handleRadioChange(bool? value) {
141+
if (value == null) {
142+
return;
143+
}
144+
if (value != _useController) {
145+
setState(() {
146+
// Respond to a change in selected radio button, and add/remove the
147+
// listener to the scroll controller.
148+
_useController = value;
149+
if (_useController) {
150+
_controller.addListener(_handleControllerNotification);
151+
} else {
152+
_controller.removeListener(_handleControllerNotification);
153+
}
154+
});
155+
}
156+
}
157+
158+
@override
159+
void dispose() {
160+
_controller.removeListener(_handleControllerNotification);
161+
super.dispose();
162+
}
163+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// Copyright 2014 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter/material.dart';
6+
7+
/// Flutter code sample for [ScrollController].
8+
9+
void main() => runApp(const ScrollControllerDemo());
10+
11+
class ScrollControllerDemo extends StatefulWidget {
12+
const ScrollControllerDemo({super.key});
13+
14+
@override
15+
State<ScrollControllerDemo> createState() => _ScrollControllerDemoState();
16+
}
17+
18+
class _ScrollControllerDemoState extends State<ScrollControllerDemo> {
19+
late final ScrollController _controller;
20+
bool isScrolling = false;
21+
22+
void _handleScrollChange() {
23+
if (isScrolling != _controller.position.isScrollingNotifier.value) {
24+
setState((){
25+
isScrolling = _controller.position.isScrollingNotifier.value;
26+
});
27+
}
28+
}
29+
30+
void _handlePositionAttach(ScrollPosition position) {
31+
// From here, add a listener to the given ScrollPosition.
32+
// Here the isScrollingNotifier will be used to inform when scrolling starts
33+
// and stops and change the AppBar's color in response.
34+
position.isScrollingNotifier.addListener(_handleScrollChange);
35+
}
36+
37+
void _handlePositionDetach(ScrollPosition position) {
38+
// From here, add a listener to the given ScrollPosition.
39+
// Here the isScrollingNotifier will be used to inform when scrolling starts
40+
// and stops and change the AppBar's color in response.
41+
position.isScrollingNotifier.removeListener(_handleScrollChange);
42+
}
43+
44+
@override
45+
void initState() {
46+
_controller = ScrollController(
47+
// These methods will be called in response to a scroll position
48+
// being attached to or detached from this ScrollController. This happens
49+
// when the Scrollable is built.
50+
onAttach: _handlePositionAttach,
51+
onDetach: _handlePositionDetach,
52+
);
53+
super.initState();
54+
}
55+
56+
@override
57+
Widget build(BuildContext context) {
58+
return MaterialApp(
59+
home: Scaffold(
60+
appBar: AppBar(
61+
title: Text(isScrolling ? 'Scrolling' : 'Not Scrolling'),
62+
backgroundColor: isScrolling
63+
? Colors.green[800]!.withOpacity(.85)
64+
: Colors.redAccent[700]!.withOpacity(.85),
65+
),
66+
// ListView.builder works very similarly to this example with
67+
// CustomScrollView & SliverList.
68+
body: CustomScrollView(
69+
// Provide the scroll controller to the scroll view.
70+
controller: _controller,
71+
slivers: <Widget>[
72+
SliverList.builder(
73+
itemCount: 50,
74+
itemBuilder: (_, int index) {
75+
return Padding(
76+
padding: const EdgeInsets.all(8.0),
77+
child: Center(
78+
child: DecoratedBox(
79+
decoration: BoxDecoration(
80+
color: Colors.blueGrey[50],
81+
boxShadow: const <BoxShadow>[
82+
BoxShadow(
83+
color: Colors.black12,
84+
offset: Offset(5, 5),
85+
blurRadius: 5,
86+
),
87+
],
88+
borderRadius: const BorderRadius.all(Radius.circular(10))
89+
),
90+
child: Padding(
91+
padding: const EdgeInsets.symmetric(
92+
vertical: 12.0,
93+
horizontal: 20.0,
94+
),
95+
child: Text('Item $index'),
96+
),
97+
),
98+
),
99+
);
100+
},
101+
),
102+
],
103+
),
104+
),
105+
);
106+
}
107+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright 2014 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter/material.dart';
6+
import 'package:flutter_api_samples/widgets/scroll_position/scroll_controller_notification.0.dart'
7+
as example;
8+
import 'package:flutter_test/flutter_test.dart';
9+
10+
void main() {
11+
testWidgets('Can toggle between scroll notification types', (WidgetTester tester) async {
12+
await tester.pumpWidget(
13+
const example.ScrollNotificationDemo(),
14+
);
15+
16+
expect(find.byType(CustomScrollView), findsOneWidget);
17+
expect(find.text('Last notification: Null'), findsNothing);
18+
19+
// Toggle to use NotificationListener
20+
await tester.tap(
21+
find.byWidgetPredicate((Widget widget) {
22+
return widget is Radio<bool> && !widget.value;
23+
})
24+
);
25+
await tester.pumpAndSettle();
26+
27+
expect(find.text('Last notification: Null'), findsOneWidget);
28+
await tester.drag(find.byType(CustomScrollView), const Offset(20.0, 20.0));
29+
await tester.pumpAndSettle();
30+
expect(find.text('Last notification: UserScrollNotification'), findsOneWidget);
31+
});
32+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// Copyright 2014 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter/material.dart';
6+
import 'package:flutter_api_samples/widgets/scroll_position/scroll_controller_on_attach.0.dart'
7+
as example;
8+
import 'package:flutter_test/flutter_test.dart';
9+
10+
void main() {
11+
testWidgets('Can toggle between scroll notification types', (WidgetTester tester) async {
12+
await tester.pumpWidget(
13+
const example.ScrollControllerDemo(),
14+
);
15+
16+
expect(find.byType(CustomScrollView), findsOneWidget);
17+
expect(find.text('Not Scrolling'), findsOneWidget);
18+
Material appBarMaterial = tester.widget<Material>(
19+
find.descendant(
20+
of: find.byType(AppBar),
21+
matching: find.byType(Material),
22+
),
23+
);
24+
expect(appBarMaterial.color, Colors.redAccent[700]!.withOpacity(.85));
25+
final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(CustomScrollView)));
26+
await gesture.moveBy(const Offset(10.0, 10.0));
27+
await tester.pump();
28+
expect(find.text('Scrolling'), findsOneWidget);
29+
appBarMaterial = tester.widget<Material>(
30+
find.descendant(
31+
of: find.byType(AppBar),
32+
matching: find.byType(Material),
33+
),
34+
);
35+
expect(appBarMaterial.color, Colors.green[800]!.withOpacity(.85));
36+
await gesture.up();
37+
await tester.pumpAndSettle();
38+
39+
expect(find.text('Not Scrolling'), findsOneWidget);
40+
appBarMaterial = tester.widget<Material>(
41+
find.descendant(
42+
of: find.byType(AppBar),
43+
matching: find.byType(Material),
44+
),
45+
);
46+
});
47+
}

0 commit comments

Comments
 (0)