1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
// Copyright 2015 The Chromium 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 'dart:async';
import 'package:flutter/animation.dart';
import 'package:flutter/foundation.dart';
import 'scroll_context.dart';
import 'scroll_physics.dart';
import 'scroll_position.dart';
import 'scroll_position_with_single_context.dart';
/// Controls a scrollable widget.
///
/// Scroll controllers are typically stored as member variables in [State]
/// objects and are reused in each [State.build]. A single scroll controller can
/// be used to control multiple scrollable widgets, but some operations, such
/// as reading the scroll [offset], require the controller to be used with a
/// single scrollable widget.
///
/// A scroll controller creates a [ScrollPosition] to manage the state specific
/// 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.
///
/// Typically used with [ListView], [GridView], [CustomScrollView].
///
/// See also:
///
/// * [ListView], [GridView], [CustomScrollView], which can be controlled by a
/// [ScrollController].
/// * [Scrollable], which is the lower-level widget that creates and associates
/// [ScrollPosition] objects with [ScrollController] objects.
/// * [PageController], which is an analogous object for controlling a
/// [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].
class ScrollController extends ChangeNotifier {
/// Creates a controller for a scrollable widget.
///
/// The values of `initialScrollOffset` and `keepScrollOffset` must not be null.
ScrollController({
double initialScrollOffset = 0.0,
this.keepScrollOffset = true,
this.debugLabel,
}) : assert(initialScrollOffset != null),
assert(keepScrollOffset != null),
_initialScrollOffset = initialScrollOffset;
/// The initial value to use for [offset].
///
/// New [ScrollPosition] objects that are created and attached to this
/// controller will have their offset initialized to this value
/// if [keepScrollOffset] is false or a scroll offset hasn't been saved yet.
///
/// Defaults to 0.0.
double get initialScrollOffset => _initialScrollOffset;
final double _initialScrollOffset;
/// Each time a scroll completes, save the current scroll [offset] with
/// [PageStorage] and restore it if this controller's scrollable is recreated.
///
/// If this property is set to false, the scroll offset is never saved
/// and [initialScrollOffset] is always used to initialize the scroll
/// offset. If true (the default), the initial scroll offset is used the
/// first time the controller's scrollable is created, since there's no
/// scroll offset to restore yet. Subsequently the saved offset is
/// restored and [initialScrollOffset] is ignored.
///
/// See also:
///
/// * [PageStorageKey], which should be used when more than one
/// scrollable appears in the same route, to distinguish the [PageStorage]
/// locations used to save scroll offsets.
final bool keepScrollOffset;
/// A label that is used in the [toString] output. Intended to aid with
/// identifying scroll controller instances in debug output.
final String debugLabel;
/// The currently attached positions.
///
/// This should not be mutated directly. [ScrollPosition] objects can be added
/// and removed using [attach] and [detach].
@protected
Iterable<ScrollPosition> get positions => _positions;
final List<ScrollPosition> _positions = <ScrollPosition>[];
/// Whether any [ScrollPosition] objects have attached themselves to the
/// [ScrollController] using the [attach] method.
///
/// If this is false, then members that interact with the [ScrollPosition],
/// such as [position], [offset], [animateTo], and [jumpTo], must not be
/// called.
bool get hasClients => _positions.isNotEmpty;
/// Returns the attached [ScrollPosition], from which the actual scroll offset
/// of the [ScrollView] can be obtained.
///
/// Calling this is only valid when only a single position is attached.
ScrollPosition get position {
assert(_positions.isNotEmpty, 'ScrollController not attached to any scroll views.');
assert(_positions.length == 1, 'ScrollController attached to multiple scroll views.');
return _positions.single;
}
/// The current scroll offset of the scrollable widget.
///
/// Requires the controller to be controlling exactly one scrollable widget.
double get offset => position.pixels;
/// Animates the position from its current value to the given value.
///
/// Any active animation is canceled. If the user is currently scrolling, that
/// action is canceled.
///
/// The returned [Future] will complete when the animation ends, whether it
/// completed successfully or whether it was interrupted prematurely.
///
/// An animation will be interrupted whenever the user attempts to scroll
/// manually, or whenever another activity is started, or whenever the
/// animation reaches the edge of the viewport and attempts to overscroll. (If
/// the [ScrollPosition] does not overscroll but instead allows scrolling
/// beyond the extents, then going beyond the extents will not interrupt the
/// animation.)
///
/// The animation is indifferent to changes to the viewport or content
/// dimensions.
///
/// Once the animation has completed, the scroll position will attempt to
/// begin a ballistic activity in case its value is not stable (for example,
/// if it is scrolled beyond the extents and in that situation the scroll
/// position would normally bounce back).
///
/// The duration must not be zero. To jump to a particular value without an
/// animation, use [jumpTo].
Future<void> animateTo(double offset, {
@required Duration duration,
@required Curve curve,
}) {
assert(_positions.isNotEmpty, 'ScrollController not attached to any scroll views.');
final List<Future<void>> animations = List<Future<void>>(_positions.length);
for (int i = 0; i < _positions.length; i += 1)
animations[i] = _positions[i].animateTo(offset, duration: duration, curve: curve);
return Future.wait<void>(animations).then<void>((List<void> _) => null);
}
/// Jumps the scroll position from its current value to the given value,
/// without animation, and without checking if the new value is in range.
///
/// Any active animation is canceled. If the user is currently scrolling, that
/// action is canceled.
///
/// If this method changes the scroll position, a sequence of start/update/end
/// scroll notifications will be dispatched. No overscroll notifications can
/// be generated by this method.
///
/// Immediately after the jump, a ballistic activity is started, in case the
/// value was out of range.
void jumpTo(double value) {
assert(_positions.isNotEmpty, 'ScrollController not attached to any scroll views.');
for (ScrollPosition position in List<ScrollPosition>.from(_positions))
position.jumpTo(value);
}
/// Register the given position with this controller.
///
/// After this function returns, the [animateTo] and [jumpTo] methods on this
/// controller will manipulate the given position.
void attach(ScrollPosition position) {
assert(!_positions.contains(position));
_positions.add(position);
position.addListener(notifyListeners);
}
/// Unregister the given position with this controller.
///
/// After this function returns, the [animateTo] and [jumpTo] methods on this
/// controller will not manipulate the given position.
void detach(ScrollPosition position) {
assert(_positions.contains(position));
position.removeListener(notifyListeners);
_positions.remove(position);
}
@override
void dispose() {
for (ScrollPosition position in _positions)
position.removeListener(notifyListeners);
super.dispose();
}
/// Creates a [ScrollPosition] for use by a [Scrollable] widget.
///
/// Subclasses can override this function to customize the [ScrollPosition]
/// used by the scrollable widgets they control. For example, [PageController]
/// overrides this function to return a page-oriented scroll position
/// subclass that keeps the same page visible when the scrollable widget
/// resizes.
///
/// By default, returns a [ScrollPositionWithSingleContext].
///
/// The arguments are generally passed to the [ScrollPosition] being created:
///
/// * `physics`: An instance of [ScrollPhysics] that determines how the
/// [ScrollPosition] should react to user interactions, how it should
/// simulate scrolling when released or flung, etc. The value will not be
/// null. It typically comes from the [ScrollView] or other widget that
/// creates the [Scrollable], or, if none was provided, from the ambient
/// [ScrollConfiguration].
/// * `context`: A [ScrollContext] used for communicating with the object
/// that is to own the [ScrollPosition] (typically, this is the
/// [Scrollable] itself).
/// * `oldPosition`: If this is not the first time a [ScrollPosition] has
/// been created for this [Scrollable], this will be the previous instance.
/// This is used when the environment has changed and the [Scrollable]
/// needs to recreate the [ScrollPosition] object. It is null the first
/// time the [ScrollPosition] is created.
ScrollPosition createScrollPosition(
ScrollPhysics physics,
ScrollContext context,
ScrollPosition oldPosition,
) {
return ScrollPositionWithSingleContext(
physics: physics,
context: context,
initialPixels: initialScrollOffset,
keepScrollOffset: keepScrollOffset,
oldPosition: oldPosition,
debugLabel: debugLabel,
);
}
@override
String toString() {
final List<String> description = <String>[];
debugFillDescription(description);
return '${describeIdentity(this)}(${description.join(", ")})';
}
/// Add additional information to the given description for use by [toString].
///
/// This method makes it easier for subclasses to coordinate to provide a
/// high-quality [toString] implementation. The [toString] implementation on
/// the [ScrollController] base class calls [debugFillDescription] to collect
/// useful information from subclasses to incorporate into its return value.
///
/// If you override this, make sure to start your method with a call to
/// `super.debugFillDescription(description)`.
@mustCallSuper
void debugFillDescription(List<String> description) {
if (debugLabel != null)
description.add(debugLabel);
if (initialScrollOffset != 0.0)
description.add('initialScrollOffset: ${initialScrollOffset.toStringAsFixed(1)}, ');
if (_positions.isEmpty) {
description.add('no clients');
} else if (_positions.length == 1) {
// Don't actually list the client itself, since its toString may refer to us.
description.add('one client, offset ${offset?.toStringAsFixed(1)}');
} else {
description.add('${_positions.length} clients');
}
}
}
// Examples can assume:
// TrackingScrollController _trackingScrollController;
/// A [ScrollController] whose [initialScrollOffset] tracks its most recently
/// updated [ScrollPosition].
///
/// This class can be used to synchronize the scroll offset of two or more
/// lazily created scroll views that share a single [TrackingScrollController].
/// It tracks the most recently updated scroll position and reports it as its
/// `initialScrollOffset`.
///
/// {@tool sample}
///
/// In this example each [PageView] page contains a [ListView] and all three
/// [ListView]'s share a [TrackingScrollController]. The scroll offsets of all
/// three list views will track each other, to the extent that's possible given
/// the different list lengths.
///
/// ```dart
/// PageView(
/// children: <Widget>[
/// ListView(
/// controller: _trackingScrollController,
/// children: List<Widget>.generate(100, (int i) => Text('page 0 item $i')).toList(),
/// ),
/// ListView(
/// controller: _trackingScrollController,
/// children: List<Widget>.generate(200, (int i) => Text('page 1 item $i')).toList(),
/// ),
/// ListView(
/// controller: _trackingScrollController,
/// children: List<Widget>.generate(300, (int i) => Text('page 2 item $i')).toList(),
/// ),
/// ],
/// )
/// ```
/// {@end-tool}
///
/// In this example the `_trackingController` would have been created by the
/// stateful widget that built the widget tree.
class TrackingScrollController extends ScrollController {
/// Creates a scroll controller that continually updates its
/// [initialScrollOffset] to match the last scroll notification it received.
TrackingScrollController({
double initialScrollOffset = 0.0,
bool keepScrollOffset = true,
String debugLabel,
}) : super(initialScrollOffset: initialScrollOffset,
keepScrollOffset: keepScrollOffset,
debugLabel: debugLabel);
final Map<ScrollPosition, VoidCallback> _positionToListener = <ScrollPosition, VoidCallback>{};
ScrollPosition _lastUpdated;
double _lastUpdatedOffset;
/// The last [ScrollPosition] to change. Returns null if there aren't any
/// attached scroll positions, or there hasn't been any scrolling yet, or the
/// last [ScrollPosition] to change has since been removed.
ScrollPosition get mostRecentlyUpdatedPosition => _lastUpdated;
/// Returns the scroll offset of the [mostRecentlyUpdatedPosition] or, if that
/// is null, the initial scroll offset provided to the constructor.
///
/// See also:
///
/// * [ScrollController.initialScrollOffset], which this overrides.
@override
double get initialScrollOffset => _lastUpdatedOffset ?? super.initialScrollOffset;
@override
void attach(ScrollPosition position) {
super.attach(position);
assert(!_positionToListener.containsKey(position));
_positionToListener[position] = () {
_lastUpdated = position;
_lastUpdatedOffset = position.pixels;
};
position.addListener(_positionToListener[position]);
}
@override
void detach(ScrollPosition position) {
super.detach(position);
assert(_positionToListener.containsKey(position));
position.removeListener(_positionToListener[position]);
_positionToListener.remove(position);
if (_lastUpdated == position)
_lastUpdated = null;
if (_positionToListener.isEmpty)
_lastUpdatedOffset = null;
}
@override
void dispose() {
for (ScrollPosition position in positions) {
assert(_positionToListener.containsKey(position));
position.removeListener(_positionToListener[position]);
}
super.dispose();
}
}