Skip to content
This repository was archived by the owner on Oct 13, 2025. It is now read-only.

Commit 3bad43f

Browse files
authored
[scollable_positioned_list] Add ability to listen to scroll position changes (#472)
* Scroll diff attempt * Add docs for scrollOffsetListener * Add an exculsion for recording programmatic scroll offset changes * Move ScrollOffsetNotifier to own file * Fix formatting
1 parent b832a8b commit 3bad43f

5 files changed

Lines changed: 271 additions & 0 deletions

File tree

packages/scrollable_positioned_list/lib/scrollable_positioned_list.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@
44

55
export 'src/item_positions_listener.dart';
66
export 'src/scrollable_positioned_list.dart';
7+
export 'src/scroll_offset_listener.dart';
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import 'dart:async';
2+
3+
import 'scroll_offset_notifier.dart';
4+
5+
/// Provides an affordance for listening to scroll offset changes.
6+
abstract class ScrollOffsetListener {
7+
/// Stream of scroll offset deltas.
8+
Stream<double> get changes;
9+
10+
/// Construct a ScrollOffsetListener.
11+
///
12+
/// Set [recordProgrammaticScrolls] to false to prevent reporting of
13+
/// programmatic scrolls.
14+
factory ScrollOffsetListener.create(
15+
{bool recordProgrammaticScrolls = true}) =>
16+
ScrollOffsetNotifier(
17+
recordProgrammaticScrolls: recordProgrammaticScrolls);
18+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import 'dart:async';
2+
3+
import 'scroll_offset_listener.dart';
4+
5+
class ScrollOffsetNotifier implements ScrollOffsetListener {
6+
final bool recordProgrammaticScrolls;
7+
8+
ScrollOffsetNotifier({this.recordProgrammaticScrolls = true});
9+
10+
final _streamController = StreamController<double>();
11+
12+
@override
13+
Stream<double> get changes => _streamController.stream;
14+
15+
StreamController get changeController => _streamController;
16+
17+
void dispose() {
18+
_streamController.close();
19+
}
20+
}

packages/scrollable_positioned_list/lib/src/scrollable_positioned_list.dart

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import 'item_positions_listener.dart';
1313
import 'item_positions_notifier.dart';
1414
import 'positioned_list.dart';
1515
import 'post_mount_callback.dart';
16+
import 'scroll_offset_listener.dart';
17+
import 'scroll_offset_notifier.dart';
1618

1719
/// Number of screens to scroll when scrolling a long distance.
1820
const int _screenScrollCount = 2;
@@ -29,6 +31,9 @@ const int _screenScrollCount = 2;
2931
/// in the list. The [itemPositionsNotifier] can be used to get a list of items
3032
/// currently laid out by the list.
3133
///
34+
/// The [scrollOffsetListener] can be used to get updates about scroll position
35+
/// changes.
36+
///
3237
/// All other parameters are the same as specified in [ListView].
3338
class ScrollablePositionedList extends StatefulWidget {
3439
/// Create a [ScrollablePositionedList] whose items are provided by
@@ -40,6 +45,7 @@ class ScrollablePositionedList extends StatefulWidget {
4045
this.itemScrollController,
4146
this.shrinkWrap = false,
4247
ItemPositionsListener? itemPositionsListener,
48+
ScrollOffsetListener? scrollOffsetListener,
4349
this.initialScrollIndex = 0,
4450
this.initialAlignment = 0,
4551
this.scrollDirection = Axis.vertical,
@@ -54,6 +60,7 @@ class ScrollablePositionedList extends StatefulWidget {
5460
}) : assert(itemCount != null),
5561
assert(itemBuilder != null),
5662
itemPositionsNotifier = itemPositionsListener as ItemPositionsNotifier?,
63+
scrollOffsetNotifier = scrollOffsetListener as ScrollOffsetNotifier?,
5764
separatorBuilder = null,
5865
super(key: key);
5966

@@ -67,6 +74,7 @@ class ScrollablePositionedList extends StatefulWidget {
6774
this.shrinkWrap = false,
6875
this.itemScrollController,
6976
ItemPositionsListener? itemPositionsListener,
77+
ScrollOffsetListener? scrollOffsetListener,
7078
this.initialScrollIndex = 0,
7179
this.initialAlignment = 0,
7280
this.scrollDirection = Axis.vertical,
@@ -82,6 +90,7 @@ class ScrollablePositionedList extends StatefulWidget {
8290
assert(itemBuilder != null),
8391
assert(separatorBuilder != null),
8492
itemPositionsNotifier = itemPositionsListener as ItemPositionsNotifier?,
93+
scrollOffsetNotifier = scrollOffsetListener as ScrollOffsetNotifier?,
8594
super(key: key);
8695

8796
/// Number of items the [itemBuilder] can produce.
@@ -101,6 +110,9 @@ class ScrollablePositionedList extends StatefulWidget {
101110
/// Notifier that reports the items laid out in the list after each frame.
102111
final ItemPositionsNotifier? itemPositionsNotifier;
103112

113+
/// Notifier that reports the changes to the scroll offset.
114+
final ScrollOffsetNotifier? scrollOffsetNotifier;
115+
104116
/// Index of an item to initially align within the viewport.
105117
final int initialScrollIndex;
106118

@@ -272,6 +284,8 @@ class _ScrollablePositionedListState extends State<ScrollablePositionedList>
272284

273285
var _animationController;
274286

287+
double previousOffset = 0;
288+
275289
@override
276290
void initState() {
277291
super.initState();
@@ -285,6 +299,15 @@ class _ScrollablePositionedListState extends State<ScrollablePositionedList>
285299
widget.itemScrollController?._attach(this);
286300
primary.itemPositionsNotifier.itemPositions.addListener(_updatePositions);
287301
secondary.itemPositionsNotifier.itemPositions.addListener(_updatePositions);
302+
primary.scrollController.addListener(() {
303+
final currentOffset = primary.scrollController.offset;
304+
final offsetChange = currentOffset - previousOffset;
305+
previousOffset = currentOffset;
306+
if (!_isTransitioning |
307+
(widget.scrollOffsetNotifier?.recordProgrammaticScrolls ?? false)) {
308+
widget.scrollOffsetNotifier?.changeController.add(offsetChange);
309+
}
310+
});
288311
}
289312

290313
@override
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
// Copyright 2023 The Fuchsia 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 'dart:async';
6+
7+
import 'package:flutter/material.dart';
8+
import 'package:flutter_test/flutter_test.dart';
9+
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
10+
11+
const screenHeight = 400.0;
12+
const screenWidth = 400.0;
13+
const itemHeight = screenHeight / 10.0;
14+
const defaultItemCount = 500;
15+
const scrollDuration = Duration(seconds: 1);
16+
const scrollDurationTolerance = Duration(milliseconds: 1);
17+
const tolerance = 1e-3;
18+
19+
void main() {
20+
Future<void> setUpWidgetTest(
21+
WidgetTester tester, {
22+
Key? key,
23+
ItemScrollController? itemScrollController,
24+
ItemPositionsListener? itemPositionsListener,
25+
ScrollOffsetListener? scrollOffsetListener,
26+
Axis? scrollDirection,
27+
int initialIndex = 0,
28+
double initialAlignment = 0.0,
29+
int itemCount = defaultItemCount,
30+
ScrollPhysics? physics,
31+
bool addSemanticIndexes = true,
32+
int? semanticChildCount,
33+
EdgeInsets? padding,
34+
bool addRepaintBoundaries = true,
35+
bool addAutomaticKeepAlives = true,
36+
double? minCacheExtent,
37+
bool variableHeight = false,
38+
}) async {
39+
tester.binding.window.devicePixelRatioTestValue = 1.0;
40+
tester.binding.window.physicalSizeTestValue =
41+
const Size(screenWidth, screenHeight);
42+
43+
await tester.pumpWidget(
44+
MaterialApp(
45+
home: ScrollablePositionedList.builder(
46+
key: key,
47+
itemCount: itemCount,
48+
itemScrollController: itemScrollController,
49+
scrollOffsetListener: scrollOffsetListener,
50+
scrollDirection: scrollDirection ?? Axis.vertical,
51+
itemBuilder: (context, index) {
52+
assert(index >= 0 && index <= itemCount - 1);
53+
return SizedBox(
54+
height:
55+
variableHeight ? (itemHeight + (index % 13) * 5) : itemHeight,
56+
child: Text('Item $index'),
57+
);
58+
},
59+
itemPositionsListener: itemPositionsListener,
60+
initialScrollIndex: initialIndex,
61+
initialAlignment: initialAlignment,
62+
physics: physics,
63+
addSemanticIndexes: addSemanticIndexes,
64+
semanticChildCount: semanticChildCount,
65+
padding: padding,
66+
addAutomaticKeepAlives: addAutomaticKeepAlives,
67+
addRepaintBoundaries: addRepaintBoundaries,
68+
minCacheExtent: minCacheExtent,
69+
),
70+
),
71+
);
72+
}
73+
74+
testWidgets('Manual scroll up 10 pixels', (WidgetTester tester) async {
75+
final scrollDistance = 50.0;
76+
77+
final itemScrollController = ItemScrollController();
78+
final itemPositionsListener = ItemPositionsListener.create();
79+
final ScrollSum scrollSummer = ScrollSum();
80+
81+
await setUpWidgetTest(tester,
82+
itemScrollController: itemScrollController,
83+
itemPositionsListener: itemPositionsListener,
84+
scrollOffsetListener: scrollSummer.scrollOffsetListener,
85+
initialIndex: 5);
86+
87+
expect(
88+
itemPositionsListener.itemPositions.value
89+
.firstWhere((position) => position.index == 5)
90+
.itemLeadingEdge,
91+
0);
92+
93+
await tester.drag(
94+
find.byType(ScrollablePositionedList), Offset(0, -scrollDistance));
95+
await tester.pumpAndSettle();
96+
97+
expect(scrollSummer.totalScroll, scrollDistance);
98+
});
99+
100+
testWidgets('Manual scroll left 10 pixels', (WidgetTester tester) async {
101+
final scrollDistance = 50.0;
102+
103+
final itemScrollController = ItemScrollController();
104+
final itemPositionsListener = ItemPositionsListener.create();
105+
final ScrollSum scrollSummer = ScrollSum();
106+
107+
await setUpWidgetTest(tester,
108+
itemScrollController: itemScrollController,
109+
itemPositionsListener: itemPositionsListener,
110+
scrollOffsetListener: scrollSummer.scrollOffsetListener,
111+
scrollDirection: Axis.horizontal,
112+
initialIndex: 5);
113+
114+
expect(
115+
itemPositionsListener.itemPositions.value
116+
.firstWhere((position) => position.index == 5)
117+
.itemLeadingEdge,
118+
0);
119+
120+
await tester.drag(
121+
find.byType(ScrollablePositionedList), Offset(-scrollDistance, 0));
122+
await tester.pumpAndSettle();
123+
124+
expect(scrollSummer.totalScroll, scrollDistance);
125+
});
126+
127+
testWidgets('Programmatic scroll to item 100 with programmatic recording on',
128+
(WidgetTester tester) async {
129+
final itemScrollController = ItemScrollController();
130+
final itemPositionsListener = ItemPositionsListener.create();
131+
final ScrollSum scrollSummer = ScrollSum();
132+
133+
await setUpWidgetTest(tester,
134+
itemScrollController: itemScrollController,
135+
itemPositionsListener: itemPositionsListener,
136+
scrollOffsetListener: scrollSummer.scrollOffsetListener,
137+
scrollDirection: Axis.horizontal,
138+
initialIndex: 5);
139+
140+
unawaited(
141+
itemScrollController.scrollTo(index: 100, duration: scrollDuration));
142+
143+
await tester.pumpAndSettle();
144+
145+
expect(scrollSummer.totalScroll, 2 * screenHeight);
146+
});
147+
148+
testWidgets('Programmatic scroll to item 100 with programmatic recording off',
149+
(WidgetTester tester) async {
150+
final itemScrollController = ItemScrollController();
151+
final itemPositionsListener = ItemPositionsListener.create();
152+
final ScrollSum scrollSummer = ScrollSum(recordProgrammaticScrolls: false);
153+
154+
await setUpWidgetTest(tester,
155+
itemScrollController: itemScrollController,
156+
itemPositionsListener: itemPositionsListener,
157+
scrollOffsetListener: scrollSummer.scrollOffsetListener,
158+
scrollDirection: Axis.horizontal,
159+
initialIndex: 5);
160+
161+
unawaited(
162+
itemScrollController.scrollTo(index: 100, duration: scrollDuration));
163+
164+
await tester.pumpAndSettle();
165+
166+
expect(scrollSummer.totalScroll, 0);
167+
});
168+
169+
testWidgets('Manual scroll up 10 pixels with programmatic recording off',
170+
(WidgetTester tester) async {
171+
final scrollDistance = 50.0;
172+
173+
final itemScrollController = ItemScrollController();
174+
final itemPositionsListener = ItemPositionsListener.create();
175+
final ScrollSum scrollSummer = ScrollSum(recordProgrammaticScrolls: false);
176+
177+
await setUpWidgetTest(tester,
178+
itemScrollController: itemScrollController,
179+
itemPositionsListener: itemPositionsListener,
180+
scrollOffsetListener: scrollSummer.scrollOffsetListener,
181+
initialIndex: 5);
182+
183+
expect(
184+
itemPositionsListener.itemPositions.value
185+
.firstWhere((position) => position.index == 5)
186+
.itemLeadingEdge,
187+
0);
188+
189+
await tester.drag(
190+
find.byType(ScrollablePositionedList), Offset(0, -scrollDistance));
191+
await tester.pumpAndSettle();
192+
193+
expect(scrollSummer.totalScroll, scrollDistance);
194+
});
195+
}
196+
197+
class ScrollSum {
198+
final bool recordProgrammaticScrolls;
199+
double totalScroll = 0.0;
200+
final scrollOffsetListener;
201+
202+
ScrollSum({this.recordProgrammaticScrolls = true})
203+
: scrollOffsetListener = ScrollOffsetListener.create(
204+
recordProgrammaticScrolls: recordProgrammaticScrolls) {
205+
scrollOffsetListener.changes.listen((event) {
206+
totalScroll += event;
207+
});
208+
}
209+
}

0 commit comments

Comments
 (0)