scroll_controller.dart 17.3 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4 5
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'package:flutter/animation.dart';
6
import 'package:flutter/foundation.dart';
7

8 9
import 'scroll_context.dart';
import 'scroll_physics.dart';
10
import 'scroll_position.dart';
11
import 'scroll_position_with_single_context.dart';
12

13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
// 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);

30 31 32 33 34 35 36 37 38 39 40 41
/// 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].
///
42
/// {@macro flutter.widgets.scrollPosition.listening}
43
///
44 45 46 47 48 49 50 51 52 53 54 55
/// 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.
56 57
///  * [ScrollNotification] and [NotificationListener], which can be used to
///    listen to scrolling occur without using a [ScrollController].
58
class ScrollController extends ChangeNotifier {
59 60
  /// Creates a controller for a scrollable widget.
  ///
61
  /// The values of `initialScrollOffset` and `keepScrollOffset` must not be null.
62
  ScrollController({
63 64
    double initialScrollOffset = 0.0,
    this.keepScrollOffset = true,
65
    this.debugLabel,
66 67
    this.onAttach,
    this.onDetach,
68 69 70 71 72
  }) : _initialScrollOffset = initialScrollOffset {
    if (kFlutterMemoryAllocationsEnabled) {
      maybeDispatchObjectCreation();
    }
  }
73 74 75

  /// The initial value to use for [offset].
  ///
76
  /// New [ScrollPosition] objects that are created and attached to this
77 78
  /// controller will have their offset initialized to this value
  /// if [keepScrollOffset] is false or a scroll offset hasn't been saved yet.
79 80
  ///
  /// Defaults to 0.0.
81
  double get initialScrollOffset => _initialScrollOffset;
82
  final double _initialScrollOffset;
83

84 85 86 87 88 89 90 91 92 93 94 95 96
  /// 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
97
  ///    scrollable appears in the same route, to distinguish the [PageStorage]
98 99 100
  ///    locations used to save scroll offsets.
  final bool keepScrollOffset;

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
  /// 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;

135 136
  /// A label that is used in the [toString] output. Intended to aid with
  /// identifying scroll controller instances in debug output.
137
  final String? debugLabel;
138

139 140 141 142 143
  /// The currently attached positions.
  ///
  /// This should not be mutated directly. [ScrollPosition] objects can be added
  /// and removed using [attach] and [detach].
  Iterable<ScrollPosition> get positions => _positions;
144 145
  final List<ScrollPosition> _positions = <ScrollPosition>[];

146 147 148 149 150 151 152 153
  /// 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;

154 155 156 157
  /// 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.
158
  ScrollPosition get position {
159 160
    assert(_positions.isNotEmpty, 'ScrollController not attached to any scroll views.');
    assert(_positions.length == 1, 'ScrollController attached to multiple scroll views.');
161
    return _positions.single;
162 163
  }

164 165 166
  /// The current scroll offset of the scrollable widget.
  ///
  /// Requires the controller to be controlling exactly one scrollable widget.
167 168
  double get offset => position.pixels;

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
  /// 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].
194 195 196 197
  ///
  /// When calling [animateTo] in widget tests, `await`ing the returned
  /// [Future] may cause the test to hang and timeout. Instead, use
  /// [WidgetTester.pumpAndSettle].
198 199
  Future<void> animateTo(
    double offset, {
200 201 202
    required Duration duration,
    required Curve curve,
  }) async {
203
    assert(_positions.isNotEmpty, 'ScrollController not attached to any scroll views.');
204 205 206
    await Future.wait<void>(<Future<void>>[
      for (int i = 0; i < _positions.length; i += 1) _positions[i].animateTo(offset, duration: duration, curve: curve),
    ]);
207
  }
208 209 210 211 212 213 214 215 216 217 218 219 220

  /// 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.
221 222
  void jumpTo(double value) {
    assert(_positions.isNotEmpty, 'ScrollController not attached to any scroll views.');
223
    for (final ScrollPosition position in List<ScrollPosition>.of(_positions)) {
224
      position.jumpTo(value);
225
    }
226
  }
227 228 229 230 231 232 233 234

  /// 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);
235
    position.addListener(notifyListeners);
236 237 238
    if (onAttach != null) {
      onAttach!(position);
    }
239 240 241 242 243 244 245 246
  }

  /// 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));
247 248 249
    if (onDetach != null) {
      onDetach!(position);
    }
250
    position.removeListener(notifyListeners);
251 252
    _positions.remove(position);
  }
253

254 255
  @override
  void dispose() {
256
    for (final ScrollPosition position in _positions) {
257
      position.removeListener(notifyListeners);
258
    }
259 260 261
    super.dispose();
  }

262 263 264 265 266 267 268 269 270
  /// 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].
271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287
  ///
  /// 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.
288
  ScrollPosition createScrollPosition(
289 290
    ScrollPhysics physics,
    ScrollContext context,
291
    ScrollPosition? oldPosition,
292
  ) {
293
    return ScrollPositionWithSingleContext(
294
      physics: physics,
295
      context: context,
296
      initialPixels: initialScrollOffset,
297
      keepScrollOffset: keepScrollOffset,
298
      oldPosition: oldPosition,
299
      debugLabel: debugLabel,
300 301
    );
  }
302 303 304

  @override
  String toString() {
305 306
    final List<String> description = <String>[];
    debugFillDescription(description);
307
    return '${describeIdentity(this)}(${description.join(", ")})';
308 309
  }

310 311 312 313 314 315 316
  /// 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.
  ///
317 318
  /// Implementations of this method should start with a call to the inherited
  /// method, as in `super.debugFillDescription(description)`.
319 320
  @mustCallSuper
  void debugFillDescription(List<String> description) {
321
    if (debugLabel != null) {
322
      description.add(debugLabel!);
323 324
    }
    if (initialScrollOffset != 0.0) {
325
      description.add('initialScrollOffset: ${initialScrollOffset.toStringAsFixed(1)}, ');
326
    }
327
    if (_positions.isEmpty) {
328
      description.add('no clients');
329
    } else if (_positions.length == 1) {
330
      // Don't actually list the client itself, since its toString may refer to us.
331
      description.add('one client, offset ${offset.toStringAsFixed(1)}');
332
    } else {
333
      description.add('${_positions.length} clients');
334 335
    }
  }
336
}
337 338

// Examples can assume:
339
// TrackingScrollController? _trackingScrollController;
340

341
/// A [ScrollController] whose [initialScrollOffset] tracks its most recently
342 343 344 345 346 347 348
/// 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`.
///
349
/// {@tool snippet}
350 351
///
/// In this example each [PageView] page contains a [ListView] and all three
352 353 354
/// [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.
355 356
///
/// ```dart
357
/// PageView(
358
///   children: <Widget>[
359
///     ListView(
360
///       controller: _trackingScrollController,
361
///       children: List<Widget>.generate(100, (int i) => Text('page 0 item $i')).toList(),
362
///     ),
363 364 365 366 367
///     ListView(
///       controller: _trackingScrollController,
///       children: List<Widget>.generate(200, (int i) => Text('page 1 item $i')).toList(),
///     ),
///     ListView(
368
///      controller: _trackingScrollController,
369
///      children: List<Widget>.generate(300, (int i) => Text('page 2 item $i')).toList(),
370 371 372 373
///     ),
///   ],
/// )
/// ```
374
/// {@end-tool}
375 376 377 378
///
/// In this example the `_trackingController` would have been created by the
/// stateful widget that built the widget tree.
class TrackingScrollController extends ScrollController {
379 380
  /// Creates a scroll controller that continually updates its
  /// [initialScrollOffset] to match the last scroll notification it received.
381
  TrackingScrollController({
382 383 384 385
    super.initialScrollOffset,
    super.keepScrollOffset,
    super.debugLabel,
  });
386

387
  final Map<ScrollPosition, VoidCallback> _positionToListener = <ScrollPosition, VoidCallback>{};
388 389
  ScrollPosition? _lastUpdated;
  double? _lastUpdatedOffset;
390 391

  /// The last [ScrollPosition] to change. Returns null if there aren't any
392 393
  /// attached scroll positions, or there hasn't been any scrolling yet, or the
  /// last [ScrollPosition] to change has since been removed.
394
  ScrollPosition? get mostRecentlyUpdatedPosition => _lastUpdated;
395

396 397 398 399 400 401
  /// 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.
402
  @override
403
  double get initialScrollOffset => _lastUpdatedOffset ?? super.initialScrollOffset;
404 405 406 407 408

  @override
  void attach(ScrollPosition position) {
    super.attach(position);
    assert(!_positionToListener.containsKey(position));
409 410 411 412
    _positionToListener[position] = () {
      _lastUpdated = position;
      _lastUpdatedOffset = position.pixels;
    };
413
    position.addListener(_positionToListener[position]!);
414 415 416 417 418 419
  }

  @override
  void detach(ScrollPosition position) {
    super.detach(position);
    assert(_positionToListener.containsKey(position));
420
    position.removeListener(_positionToListener[position]!);
421
    _positionToListener.remove(position);
422
    if (_lastUpdated == position) {
423
      _lastUpdated = null;
424 425
    }
    if (_positionToListener.isEmpty) {
426
      _lastUpdatedOffset = null;
427
    }
428 429 430 431
  }

  @override
  void dispose() {
432
    for (final ScrollPosition position in positions) {
433
      assert(_positionToListener.containsKey(position));
434
      position.removeListener(_positionToListener[position]!);
435 436 437 438
    }
    super.dispose();
  }
}