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

5
import 'dart:async';
6
import 'dart:math' as math;
7
import 'dart:ui';
8

9
import 'package:flutter/gestures.dart';
10
import 'package:flutter/rendering.dart';
11
import 'package:flutter/services.dart';
12

13
import 'actions.dart';
14
import 'basic.dart';
15
import 'focus_manager.dart';
16 17
import 'framework.dart';
import 'gesture_detector.dart';
18
import 'notification_listener.dart';
19
import 'primary_scroll_controller.dart';
20 21
import 'restoration.dart';
import 'restoration_properties.dart';
22
import 'scroll_configuration.dart';
23
import 'scroll_context.dart';
24
import 'scroll_controller.dart';
25
import 'scroll_metrics.dart';
26
import 'scroll_physics.dart';
27
import 'scroll_position.dart';
28
import 'ticker_provider.dart';
29 30 31 32
import 'viewport.dart';

export 'package:flutter/physics.dart' show Tolerance;

33 34
/// Signature used by [Scrollable] to build the viewport through which the
/// scrollable content is displayed.
35
typedef ViewportBuilder = Widget Function(BuildContext context, ViewportOffset position);
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
/// A widget that scrolls.
///
/// [Scrollable] implements the interaction model for a scrollable widget,
/// including gesture recognition, but does not have an opinion about how the
/// viewport, which actually displays the children, is constructed.
///
/// It's rare to construct a [Scrollable] directly. Instead, consider [ListView]
/// or [GridView], which combine scrolling, viewporting, and a layout model. To
/// combine layout models (or to use a custom layout mode), consider using
/// [CustomScrollView].
///
/// The static [Scrollable.of] and [Scrollable.ensureVisible] functions are
/// often used to interact with the [Scrollable] widget inside a [ListView] or
/// a [GridView].
///
/// To further customize scrolling behavior with a [Scrollable]:
///
/// 1. You can provide a [viewportBuilder] to customize the child model. For
///    example, [SingleChildScrollView] uses a viewport that displays a single
///    box child whereas [CustomScrollView] uses a [Viewport] or a
///    [ShrinkWrappingViewport], both of which display a list of slivers.
///
/// 2. You can provide a custom [ScrollController] that creates a custom
///    [ScrollPosition] subclass. For example, [PageView] uses a
///    [PageController], which creates a page-oriented scroll position subclass
///    that keeps the same page visible when the [Scrollable] resizes.
///
/// See also:
///
///  * [ListView], which is a commonly used [ScrollView] that displays a
///    scrolling, linear list of child widgets.
///  * [PageView], which is a scrolling list of child widgets that are each the
///    size of the viewport.
///  * [GridView], which is a [ScrollView] that displays a scrolling, 2D array
///    of child widgets.
///  * [CustomScrollView], which is a [ScrollView] that creates custom scroll
///    effects using slivers.
///  * [SingleChildScrollView], which is a scrollable widget that has a single
///    child.
76
///  * [ScrollNotification] and [NotificationListener], which can be used to watch
77
///    the scroll position without using a [ScrollController].
Adam Barth's avatar
Adam Barth committed
78
class Scrollable extends StatefulWidget {
79 80 81
  /// Creates a widget that scrolls.
  ///
  /// The [axisDirection] and [viewportBuilder] arguments must not be null.
82
  const Scrollable({
83
    Key? key,
84
    this.axisDirection = AxisDirection.down,
85
    this.controller,
Adam Barth's avatar
Adam Barth committed
86
    this.physics,
87
    required this.viewportBuilder,
88
    this.incrementCalculator,
89
    this.excludeFromSemantics = false,
90
    this.semanticChildCount,
91
    this.dragStartBehavior = DragStartBehavior.start,
92
    this.restorationId,
93
    this.scrollBehavior,
94
  }) : assert(axisDirection != null),
95
       assert(dragStartBehavior != null),
96
       assert(viewportBuilder != null),
97
       assert(excludeFromSemantics != null),
98
       assert(semanticChildCount == null || semanticChildCount >= 0),
99
       super (key: key);
100

101 102 103 104 105 106 107 108 109 110
  /// The direction in which this widget scrolls.
  ///
  /// For example, if the [axisDirection] is [AxisDirection.down], increasing
  /// the scroll position will cause content below the bottom of the viewport to
  /// become visible through the viewport. Similarly, if [axisDirection] is
  /// [AxisDirection.right], increasing the scroll position will cause content
  /// beyond the right edge of the viewport to become visible through the
  /// viewport.
  ///
  /// Defaults to [AxisDirection.down].
111 112
  final AxisDirection axisDirection;

113 114 115
  /// An object that can be used to control the position to which this widget is
  /// scrolled.
  ///
116 117 118 119 120 121 122 123
  /// A [ScrollController] serves several purposes. It can be used to control
  /// the initial scroll position (see [ScrollController.initialScrollOffset]).
  /// It can be used to control whether the scroll view should automatically
  /// save and restore its scroll position in the [PageStorage] (see
  /// [ScrollController.keepScrollOffset]). It can be used to read the current
  /// scroll position (see [ScrollController.offset]), or change it (see
  /// [ScrollController.animateTo]).
  ///
124 125 126 127
  /// See also:
  ///
  ///  * [ensureVisible], which animates the scroll position to reveal a given
  ///    [BuildContext].
128
  final ScrollController? controller;
129

130 131 132 133 134
  /// How the widgets should respond to user input.
  ///
  /// For example, determines how the widget continues to animate after the
  /// user stops dragging the scroll view.
  ///
135 136 137
  /// Defaults to matching platform conventions via the physics provided from
  /// the ambient [ScrollConfiguration].
  ///
138 139 140 141
  /// If an explicit [ScrollBehavior] is provided to [scrollBehavior], the
  /// [ScrollPhysics] provided by that behavior will take precedence after
  /// [physics].
  ///
142 143 144 145 146 147 148 149 150 151 152 153 154
  /// The physics can be changed dynamically, but new physics will only take
  /// effect if the _class_ of the provided object changes. Merely constructing
  /// a new instance with a different configuration is insufficient to cause the
  /// physics to be reapplied. (This is because the final object used is
  /// generated dynamically, which can be relatively expensive, and it would be
  /// inefficient to speculatively create this object each frame to see if the
  /// physics should be updated.)
  ///
  /// See also:
  ///
  ///  * [AlwaysScrollableScrollPhysics], which can be used to indicate that the
  ///    scrollable should react to scroll requests (and possible overscroll)
  ///    even if the scrollable's contents fit without scrolling being necessary.
155
  final ScrollPhysics? physics;
Adam Barth's avatar
Adam Barth committed
156

157 158 159 160 161 162 163 164 165 166
  /// Builds the viewport through which the scrollable content is displayed.
  ///
  /// A typical viewport uses the given [ViewportOffset] to determine which part
  /// of its content is actually visible through the viewport.
  ///
  /// See also:
  ///
  ///  * [Viewport], which is a viewport that displays a list of slivers.
  ///  * [ShrinkWrappingViewport], which is a viewport that displays a list of
  ///    slivers and sizes itself based on the size of the slivers.
167
  final ViewportBuilder viewportBuilder;
168

169 170 171 172 173 174 175 176 177 178 179
  /// An optional function that will be called to calculate the distance to
  /// scroll when the scrollable is asked to scroll via the keyboard using a
  /// [ScrollAction].
  ///
  /// If not supplied, the [Scrollable] will scroll a default amount when a
  /// keyboard navigation key is pressed (e.g. pageUp/pageDown, control-upArrow,
  /// etc.), or otherwise invoked by a [ScrollAction].
  ///
  /// If [incrementCalculator] is null, the default for
  /// [ScrollIncrementType.page] is 80% of the size of the scroll window, and
  /// for [ScrollIncrementType.line], 50 logical pixels.
180
  final ScrollIncrementCalculator? incrementCalculator;
181

182 183 184 185 186 187 188 189 190 191 192 193 194
  /// Whether the scroll actions introduced by this [Scrollable] are exposed
  /// in the semantics tree.
  ///
  /// Text fields with an overflow are usually scrollable to make sure that the
  /// user can get to the beginning/end of the entered text. However, these
  /// scrolling actions are generally not exposed to the semantics layer.
  ///
  /// See also:
  ///
  ///  * [GestureDetector.excludeFromSemantics], which is used to accomplish the
  ///    exclusion.
  final bool excludeFromSemantics;

195 196 197 198 199 200 201 202 203 204 205 206 207 208 209
  /// The number of children that will contribute semantic information.
  ///
  /// The value will be null if the number of children is unknown or unbounded.
  ///
  /// Some subtypes of [ScrollView] can infer this value automatically. For
  /// example [ListView] will use the number of widgets in the child list,
  /// while the [new ListView.separated] constructor will use half that amount.
  ///
  /// For [CustomScrollView] and other types which do not receive a builder
  /// or list of widgets, the child count must be explicitly provided.
  ///
  /// See also:
  ///
  ///  * [CustomScrollView], for an explanation of scroll semantics.
  ///  * [SemanticsConfiguration.scrollChildCount], the corresponding semantics property.
210
  final int? semanticChildCount;
211

212
  // TODO(jslavitz): Set the DragStartBehavior default to be start across all widgets.
213 214 215 216
  /// {@template flutter.widgets.scrollable.dragStartBehavior}
  /// Determines the way that drag start behavior is handled.
  ///
  /// If set to [DragStartBehavior.start], scrolling drag behavior will
217 218 219
  /// begin at the position where the drag gesture won the arena. If set to
  /// [DragStartBehavior.down] it will begin at the position where a down
  /// event is first detected.
220 221 222 223 224
  ///
  /// In general, setting this to [DragStartBehavior.start] will make drag
  /// animation smoother and setting it to [DragStartBehavior.down] will make
  /// drag behavior feel slightly more reactive.
  ///
225
  /// By default, the drag start behavior is [DragStartBehavior.start].
226 227 228
  ///
  /// See also:
  ///
229 230 231
  ///  * [DragGestureRecognizer.dragStartBehavior], which gives an example for
  ///    the different behaviors.
  ///
232 233 234
  /// {@endtemplate}
  final DragStartBehavior dragStartBehavior;

235 236 237 238 239 240 241 242 243 244 245 246 247 248
  /// {@template flutter.widgets.scrollable.restorationId}
  /// Restoration ID to save and restore the scroll offset of the scrollable.
  ///
  /// If a restoration id is provided, the scrollable will persist its current
  /// scroll offset and restore it during state restoration.
  ///
  /// The scroll offset is persisted in a [RestorationBucket] claimed from
  /// the surrounding [RestorationScope] using the provided restoration ID.
  ///
  /// See also:
  ///
  ///  * [RestorationManager], which explains how state restoration works in
  ///    Flutter.
  /// {@endtemplate}
249
  final String? restorationId;
250

251 252 253 254 255 256 257 258
  /// {@macro flutter.widgets.shadow.scrollBehavior}
  ///
  /// [ScrollBehavior]s also provide [ScrollPhysics]. If an explicit
  /// [ScrollPhysics] is provided in [physics], it will take precedence,
  /// followed by [scrollBehavior], and then the inherited ancestor
  /// [ScrollBehavior].
  final ScrollBehavior? scrollBehavior;

259 260 261
  /// The axis along which the scroll view scrolls.
  ///
  /// Determined by the [axisDirection].
262 263 264
  Axis get axis => axisDirectionToAxis(axisDirection);

  @override
265
  ScrollableState createState() => ScrollableState();
266 267

  @override
268 269
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
270 271
    properties.add(EnumProperty<AxisDirection>('axisDirection', axisDirection));
    properties.add(DiagnosticsProperty<ScrollPhysics>('physics', physics));
272
    properties.add(StringProperty('restorationId', restorationId));
273
  }
274 275 276 277 278 279

  /// The state from the closest instance of this class that encloses the given context.
  ///
  /// Typical usage is as follows:
  ///
  /// ```dart
Adam Barth's avatar
Adam Barth committed
280
  /// ScrollableState scrollable = Scrollable.of(context);
281
  /// ```
282 283 284
  ///
  /// Calling this method will create a dependency on the closest [Scrollable]
  /// in the [context], if there is one.
285 286
  static ScrollableState? of(BuildContext context) {
    final _ScrollableScope? widget = context.dependOnInheritedWidgetOfExactType<_ScrollableScope>();
287
    return widget?.scrollable;
288 289
  }

290 291 292 293 294 295 296 297
  /// Provides a heuristic to determine if expensive frame-bound tasks should be
  /// deferred for the [context] at a specific point in time.
  ///
  /// Calling this method does _not_ create a dependency on any other widget.
  /// This also means that the value returned is only good for the point in time
  /// when it is called, and callers will not get updated if the value changes.
  ///
  /// The heuristic used is determined by the [physics] of this [Scrollable]
298 299
  /// via [ScrollPhysics.recommendDeferredLoading]. That method is called with
  /// the current [ScrollPosition.activity]'s [ScrollActivity.velocity].
300 301 302 303
  ///
  /// If there is no [Scrollable] in the widget tree above the [context], this
  /// method returns false.
  static bool recommendDeferredLoadingForContext(BuildContext context) {
304
    final _ScrollableScope? widget = context.getElementForInheritedWidgetOfExactType<_ScrollableScope>()?.widget as _ScrollableScope?;
305 306 307 308 309 310
    if (widget == null) {
      return false;
    }
    return widget.position.recommendDeferredLoading(context);
  }

311 312
  /// Scrolls the scrollables that enclose the given context so as to make the
  /// given context visible.
313 314
  static Future<void> ensureVisible(
    BuildContext context, {
315 316 317
    double alignment = 0.0,
    Duration duration = Duration.zero,
    Curve curve = Curves.ease,
318
    ScrollPositionAlignmentPolicy alignmentPolicy = ScrollPositionAlignmentPolicy.explicit,
319
  }) {
320
    final List<Future<void>> futures = <Future<void>>[];
321

322 323 324 325 326 327 328
    // The `targetRenderObject` is used to record the first target renderObject.
    // If there are multiple scrollable widgets nested, we should let
    // the `targetRenderObject` as visible as possible to improve the user experience.
    // Otherwise, let the outer renderObject as visible as possible maybe cause
    // the `targetRenderObject` invisible.
    // Also see https://github.com/flutter/flutter/issues/65100
    RenderObject? targetRenderObject;
329
    ScrollableState? scrollable = Scrollable.of(context);
330
    while (scrollable != null) {
331
      futures.add(scrollable.position.ensureVisible(
332
        context.findRenderObject()!,
333 334 335
        alignment: alignment,
        duration: duration,
        curve: curve,
336
        alignmentPolicy: alignmentPolicy,
337
        targetRenderObject: targetRenderObject,
338
      ));
339 340

      targetRenderObject = targetRenderObject ?? context.findRenderObject();
341
      context = scrollable.context;
Adam Barth's avatar
Adam Barth committed
342
      scrollable = Scrollable.of(context);
343 344
    }

345
    if (futures.isEmpty || duration == Duration.zero)
346
      return Future<void>.value();
347
    if (futures.length == 1)
348
      return futures.single;
349
    return Future.wait<void>(futures).then<void>((List<void> _) => null);
350
  }
351 352
}

353 354 355
// Enable Scrollable.of() to work as if ScrollableState was an inherited widget.
// ScrollableState.build() always rebuilds its _ScrollableScope.
class _ScrollableScope extends InheritedWidget {
356
  const _ScrollableScope({
357 358 359 360
    Key? key,
    required this.scrollable,
    required this.position,
    required Widget child,
361 362 363
  }) : assert(scrollable != null),
       assert(child != null),
       super(key: key, child: child);
364 365 366 367 368 369 370 371 372 373

  final ScrollableState scrollable;
  final ScrollPosition position;

  @override
  bool updateShouldNotify(_ScrollableScope old) {
    return position != old.position;
  }
}

Adam Barth's avatar
Adam Barth committed
374
/// State object for a [Scrollable] widget.
375
///
Adam Barth's avatar
Adam Barth committed
376
/// To manipulate a [Scrollable] widget's scroll position, use the object
377 378
/// obtained from the [position] property.
///
Adam Barth's avatar
Adam Barth committed
379 380
/// To be informed of when a [Scrollable] widget is scrolling, use a
/// [NotificationListener] to listen for [ScrollNotification] notifications.
381 382
///
/// This class is not intended to be subclassed. To specialize the behavior of a
Adam Barth's avatar
Adam Barth committed
383
/// [Scrollable], provide it with a [ScrollPhysics].
384
class ScrollableState extends State<Scrollable> with TickerProviderStateMixin, RestorationMixin
385 386
    implements ScrollContext {
  /// The manager for this [Scrollable] widget's viewport position.
387
  ///
Adam Barth's avatar
Adam Barth committed
388
  /// To control what kind of [ScrollPosition] is created for a [Scrollable],
389 390
  /// provide it with custom [ScrollController] that creates the appropriate
  /// [ScrollPosition] in its [ScrollController.createScrollPosition] method.
391 392
  ScrollPosition get position => _position!;
  ScrollPosition? _position;
393

394 395
  final _RestorableScrollOffset _persistedScrollOffset = _RestorableScrollOffset();

396 397 398
  @override
  AxisDirection get axisDirection => widget.axisDirection;

399 400
  late ScrollBehavior _configuration;
  ScrollPhysics? _physics;
401 402 403
  ScrollController? _fallbackScrollController;

  ScrollController get _effectiveScrollController => widget.controller ?? _fallbackScrollController!;
404

405
  // Only call this from places that will definitely trigger a rebuild.
406
  void _updatePosition() {
407
    _configuration = widget.scrollBehavior ?? ScrollConfiguration.of(context);
408
    _physics = _configuration.getScrollPhysics(context);
409
    if (widget.physics != null) {
410
      _physics = widget.physics!.applyTo(_physics);
411 412 413
    } else if (widget.scrollBehavior != null) {
      _physics = widget.scrollBehavior!.getScrollPhysics(context).applyTo(_physics);
    }
414
    final ScrollPosition? oldPosition = _position;
415
    if (oldPosition != null) {
416
      _effectiveScrollController.detach(oldPosition);
417 418 419
      // It's important that we not dispose the old position until after the
      // viewport has had a chance to unregister its listeners from the old
      // position. So, schedule a microtask to do it.
420 421
      scheduleMicrotask(oldPosition.dispose);
    }
422

423
    _position = _effectiveScrollController.createScrollPosition(_physics!, this, oldPosition);
424
    assert(_position != null);
425
    _effectiveScrollController.attach(position);
426 427
  }

428
  @override
429
  void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
430
    registerForRestoration(_persistedScrollOffset, 'offset');
431
    assert(_position != null);
432
    if (_persistedScrollOffset.value != null) {
433
      position.restoreOffset(_persistedScrollOffset.value!, initialRestore: initialRestore);
434 435 436 437 438 439 440 441 442
    }
  }

  @override
  void saveOffset(double offset) {
    assert(debugIsSerializableForRestoration(offset));
    _persistedScrollOffset.value = offset;
    // [saveOffset] is called after a scrolling ends and it is usually not
    // followed by a frame. Therefore, manually flush restoration data.
443
    ServicesBinding.instance!.restorationManager.flushData();
444 445
  }

446 447 448 449 450 451 452
  @override
  void initState() {
    if (widget.controller == null)
      _fallbackScrollController = ScrollController();
    super.initState();
  }

453
  @override
454
  void didChangeDependencies() {
455
    _updatePosition();
456
    super.didChangeDependencies();
457 458
  }

459
  bool _shouldUpdatePosition(Scrollable oldWidget) {
460 461
    ScrollPhysics? newPhysics = widget.physics ?? widget.scrollBehavior?.getScrollPhysics(context);
    ScrollPhysics? oldPhysics = oldWidget.physics ?? oldWidget.scrollBehavior?.getScrollPhysics(context);
462 463 464 465 466 467 468 469
    do {
      if (newPhysics?.runtimeType != oldPhysics?.runtimeType)
        return true;
      newPhysics = newPhysics?.parent;
      oldPhysics = oldPhysics?.parent;
    } while (newPhysics != null || oldPhysics != null);

    return widget.controller?.runtimeType != oldWidget.controller?.runtimeType;
470 471
  }

472
  @override
473 474
  void didUpdateWidget(Scrollable oldWidget) {
    super.didUpdateWidget(oldWidget);
475

476
    if (widget.controller != oldWidget.controller) {
477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495
      if (oldWidget.controller == null) {
        // The old controller was null, meaning the fallback cannot be null.
        // Dispose of the fallback.
        assert(_fallbackScrollController !=  null);
        assert(widget.controller != null);
        _fallbackScrollController!.detach(position);
        _fallbackScrollController!.dispose();
        _fallbackScrollController = null;
      } else {
        // The old controller was not null, detach.
        oldWidget.controller?.detach(position);
        if (widget.controller == null) {
          // If the new controller is null, we need to set up the fallback
          // ScrollController.
          _fallbackScrollController = ScrollController();
        }
      }
      // Attach the updated effective scroll controller.
      _effectiveScrollController.attach(position);
496 497
    }

498
    if (_shouldUpdatePosition(oldWidget))
499 500 501 502 503
      _updatePosition();
  }

  @override
  void dispose() {
504 505 506 507 508 509 510
    if (widget.controller != null) {
      widget.controller!.detach(position);
    } else {
      _fallbackScrollController?.detach(position);
      _fallbackScrollController?.dispose();
    }

511
    position.dispose();
512
    _persistedScrollOffset.dispose();
513 514 515 516
    super.dispose();
  }


517 518
  // SEMANTICS

519
  final GlobalKey _scrollSemanticsKey = GlobalKey();
520 521 522 523 524

  @override
  @protected
  void setSemanticsActions(Set<SemanticsAction> actions) {
    if (_gestureDetectorKey.currentState != null)
525
      _gestureDetectorKey.currentState!.replaceSemanticsActions(actions);
526 527
  }

528 529
  // GESTURE RECOGNITION AND POINTER IGNORING

530 531
  final GlobalKey<RawGestureDetectorState> _gestureDetectorKey = GlobalKey<RawGestureDetectorState>();
  final GlobalKey _ignorePointerKey = GlobalKey();
532 533 534 535 536

  // This field is set during layout, and then reused until the next time it is set.
  Map<Type, GestureRecognizerFactory> _gestureRecognizers = const <Type, GestureRecognizerFactory>{};
  bool _shouldIgnorePointer = false;

537 538
  bool? _lastCanDrag;
  Axis? _lastAxisDirection;
539

540 541
  @override
  @protected
542 543
  void setCanDrag(bool value) {
    if (value == _lastCanDrag && (!value || widget.axis == _lastAxisDirection))
544
      return;
545
    if (!value) {
546
      _gestureRecognizers = const <Type, GestureRecognizerFactory>{};
547 548 549 550
      // Cancel the active hold/drag (if any) because the gesture recognizers
      // will soon be disposed by our RawGestureDetector, and we won't be
      // receiving pointer up events to cancel the hold/drag.
      _handleDragCancel();
551
    } else {
552
      switch (widget.axis) {
553 554
        case Axis.vertical:
          _gestureRecognizers = <Type, GestureRecognizerFactory>{
555
            VerticalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<VerticalDragGestureRecognizer>(
556
              () => VerticalDragGestureRecognizer(supportedDevices: _configuration.dragDevices),
557 558 559 560 561 562 563 564 565
              (VerticalDragGestureRecognizer instance) {
                instance
                  ..onDown = _handleDragDown
                  ..onStart = _handleDragStart
                  ..onUpdate = _handleDragUpdate
                  ..onEnd = _handleDragEnd
                  ..onCancel = _handleDragCancel
                  ..minFlingDistance = _physics?.minFlingDistance
                  ..minFlingVelocity = _physics?.minFlingVelocity
566
                  ..maxFlingVelocity = _physics?.maxFlingVelocity
567
                  ..velocityTrackerBuilder = _configuration.velocityTrackerBuilder(context)
568
                  ..dragStartBehavior = widget.dragStartBehavior;
569 570
              },
            ),
571 572 573 574
          };
          break;
        case Axis.horizontal:
          _gestureRecognizers = <Type, GestureRecognizerFactory>{
575
            HorizontalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<HorizontalDragGestureRecognizer>(
576
              () => HorizontalDragGestureRecognizer(supportedDevices: _configuration.dragDevices),
577 578 579 580 581 582 583 584 585
              (HorizontalDragGestureRecognizer instance) {
                instance
                  ..onDown = _handleDragDown
                  ..onStart = _handleDragStart
                  ..onUpdate = _handleDragUpdate
                  ..onEnd = _handleDragEnd
                  ..onCancel = _handleDragCancel
                  ..minFlingDistance = _physics?.minFlingDistance
                  ..minFlingVelocity = _physics?.minFlingVelocity
586
                  ..maxFlingVelocity = _physics?.maxFlingVelocity
587
                  ..velocityTrackerBuilder = _configuration.velocityTrackerBuilder(context)
588
                  ..dragStartBehavior = widget.dragStartBehavior;
589 590
              },
            ),
591 592 593 594
          };
          break;
      }
    }
595
    _lastCanDrag = value;
596
    _lastAxisDirection = widget.axis;
597
    if (_gestureDetectorKey.currentState != null)
598
      _gestureDetectorKey.currentState!.replaceGestureRecognizers(_gestureRecognizers);
599 600
  }

601 602 603 604 605 606
  @override
  TickerProvider get vsync => this;

  @override
  @protected
  void setIgnorePointer(bool value) {
607 608 609 610
    if (_shouldIgnorePointer == value)
      return;
    _shouldIgnorePointer = value;
    if (_ignorePointerKey.currentContext != null) {
611
      final RenderIgnorePointer renderBox = _ignorePointerKey.currentContext!.findRenderObject()! as RenderIgnorePointer;
612 613 614 615
      renderBox.ignoring = _shouldIgnorePointer;
    }
  }

616
  @override
617
  BuildContext? get notificationContext => _gestureDetectorKey.currentContext;
618

619 620 621
  @override
  BuildContext get storageContext => context;

622 623
  // TOUCH HANDLERS

624 625
  Drag? _drag;
  ScrollHoldController? _hold;
626 627 628

  void _handleDragDown(DragDownDetails details) {
    assert(_drag == null);
629 630
    assert(_hold == null);
    _hold = position.hold(_disposeHold);
631 632 633
  }

  void _handleDragStart(DragStartDetails details) {
Ian Hickson's avatar
Ian Hickson committed
634 635 636
    // It's possible for _hold to become null between _handleDragDown and
    // _handleDragStart, for example if some user code calls jumpTo or otherwise
    // triggers a new activity to begin.
637
    assert(_drag == null);
638
    _drag = position.drag(details, _disposeDrag);
639
    assert(_drag != null);
640
    assert(_hold == null);
641 642 643
  }

  void _handleDragUpdate(DragUpdateDetails details) {
644
    // _drag might be null if the drag activity ended and called _disposeDrag.
645
    assert(_hold == null || _drag == null);
646
    _drag?.update(details);
647 648 649
  }

  void _handleDragEnd(DragEndDetails details) {
650
    // _drag might be null if the drag activity ended and called _disposeDrag.
651
    assert(_hold == null || _drag == null);
652
    _drag?.end(details);
653 654 655
    assert(_drag == null);
  }

656
  void _handleDragCancel() {
657
    // _hold might be null if the drag started.
658
    // _drag might be null if the drag activity ended and called _disposeDrag.
659 660
    assert(_hold == null || _drag == null);
    _hold?.cancel();
661
    _drag?.cancel();
662
    assert(_hold == null);
663 664 665
    assert(_drag == null);
  }

666 667 668 669
  void _disposeHold() {
    _hold = null;
  }

670
  void _disposeDrag() {
671 672 673
    _drag = null;
  }

674 675
  // SCROLL WHEEL

676 677
  // Returns the offset that should result from applying [event] to the current
  // position, taking min/max scroll extent into account.
678
  double _targetScrollOffsetForPointerScroll(double delta) {
679 680 681 682
    return math.min(
      math.max(position.pixels + delta, position.minScrollExtent),
      position.maxScrollExtent,
    );
683 684
  }

685 686
  // Returns the delta that should result from applying [event] with axis and
  // direction taken into account.
687
  double _pointerSignalEventDelta(PointerScrollEvent event) {
688
    double delta = widget.axis == Axis.horizontal
689 690
      ? event.scrollDelta.dx
      : event.scrollDelta.dy;
691 692 693 694

    if (axisDirectionIsReversed(widget.axisDirection)) {
      delta *= -1;
    }
695
    return delta;
696 697 698
  }

  void _receivedPointerSignal(PointerSignalEvent event) {
699
    if (event is PointerScrollEvent && _position != null) {
700 701 702
      if (_physics != null && !_physics!.shouldAcceptUserOffset(position)) {
        return;
      }
703 704
      final double delta = _pointerSignalEventDelta(event);
      final double targetScrollOffset = _targetScrollOffsetForPointerScroll(delta);
705
      // Only express interest in the event if it would actually result in a scroll.
706
      if (delta != 0.0 && targetScrollOffset != position.pixels) {
707
        GestureBinding.instance!.pointerSignalResolver.register(event, _handlePointerScroll);
708 709 710 711 712 713
      }
    }
  }

  void _handlePointerScroll(PointerEvent event) {
    assert(event is PointerScrollEvent);
714 715 716 717
    final double delta = _pointerSignalEventDelta(event as PointerScrollEvent);
    final double targetScrollOffset = _targetScrollOffsetForPointerScroll(delta);
    if (delta != 0.0 && targetScrollOffset != position.pixels) {
      position.pointerScroll(delta);
718 719
    }
  }
720

721 722 723 724 725 726 727 728 729
  bool _handleScrollMetricsNotification(ScrollMetricsNotification notification) {
    if (notification.depth == 0) {
      final RenderObject? scrollSemanticsRenderObject = _scrollSemanticsKey.currentContext?.findRenderObject();
      if (scrollSemanticsRenderObject != null)
        scrollSemanticsRenderObject.markNeedsSemanticsUpdate();
    }
    return false;
  }

730 731 732 733
  // DESCRIPTION

  @override
  Widget build(BuildContext context) {
734
    assert(_position != null);
735 736 737 738 739 740 741 742 743 744 745 746
    // _ScrollableScope must be placed above the BuildContext returned by notificationContext
    // so that we can get this ScrollableState by doing the following:
    //
    // ScrollNotification notification;
    // Scrollable.of(notification.context)
    //
    // Since notificationContext is pointing to _gestureDetectorKey.context, _ScrollableScope
    // must be placed above the widget using it: RawGestureDetector
    Widget result = _ScrollableScope(
      scrollable: this,
      position: position,
      // TODO(ianh): Having all these global keys is sad.
747 748 749 750 751 752 753 754 755 756 757 758 759 760 761
      child: Listener(
        onPointerSignal: _receivedPointerSignal,
        child: RawGestureDetector(
          key: _gestureDetectorKey,
          gestures: _gestureRecognizers,
          behavior: HitTestBehavior.opaque,
          excludeFromSemantics: widget.excludeFromSemantics,
          child: Semantics(
            explicitChildNodes: !widget.excludeFromSemantics,
            child: IgnorePointer(
              key: _ignorePointerKey,
              ignoring: _shouldIgnorePointer,
              ignoringSemantics: false,
              child: widget.viewportBuilder(context, position),
            ),
762
          ),
763
        ),
764 765
      ),
    );
766 767

    if (!widget.excludeFromSemantics) {
768 769 770 771 772 773 774 775 776
      result = NotificationListener<ScrollMetricsNotification>(
        onNotification: _handleScrollMetricsNotification,
        child: _ScrollSemantics(
          key: _scrollSemanticsKey,
          position: position,
          allowImplicitScrolling: _physics!.allowImplicitScrolling,
          semanticChildCount: widget.semanticChildCount,
          child: result,
        )
777 778 779
      );
    }

780 781 782 783 784 785 786 787 788 789
    final ScrollableDetails details = ScrollableDetails(
      direction: widget.axisDirection,
      controller: _effectiveScrollController,
    );

    return _configuration.buildScrollbar(
      context,
      _configuration.buildOverscrollIndicator(context, result, details),
      details,
    );
790 791 792
  }

  @override
793 794
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
795
    properties.add(DiagnosticsProperty<ScrollPosition>('position', position));
796
    properties.add(DiagnosticsProperty<ScrollPhysics>('effective physics', _physics));
797
  }
798 799

  @override
800
  String? get restorationId => widget.restorationId;
801
}
802

803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829
/// Describes the aspects of a Scrollable widget to inform inherited widgets
/// like [ScrollBehavior] for decorating.
///
/// Decorations like [GlowingOverscrollIndicator]s and [Scrollbar]s require
/// information about the Scrollable in order to be initialized.
@immutable
class ScrollableDetails {
  /// Creates a set of details describing the [Scrollable]. The [direction]
  /// cannot be null.
  const ScrollableDetails({
    required this.direction,
    required this.controller,
  });

  /// The direction in which this widget scrolls.
  ///
  /// Cannot be null.
  final AxisDirection direction;

  /// A [ScrollController] that can be used to control the position of the
  /// [Scrollable] widget.
  ///
  /// This can be used by [ScrollBehavior] to apply a [Scrollbar] to the associated
  /// [Scrollable].
  final ScrollController controller;
}

Ian Hickson's avatar
Ian Hickson committed
830
/// With [_ScrollSemantics] certain child [SemanticsNode]s can be
831 832 833 834 835 836 837 838 839 840 841 842 843
/// excluded from the scrollable area for semantics purposes.
///
/// Nodes, that are to be excluded, have to be tagged with
/// [RenderViewport.excludeFromScrolling] and the [RenderAbstractViewport] in
/// use has to add the [RenderViewport.useTwoPaneSemantics] tag to its
/// [SemanticsConfiguration] by overriding
/// [RenderObject.describeSemanticsConfiguration].
///
/// If the tag [RenderViewport.useTwoPaneSemantics] is present on the viewport,
/// two semantics nodes will be used to represent the [Scrollable]: The outer
/// node will contain all children, that are excluded from scrolling. The inner
/// node, which is annotated with the scrolling actions, will house the
/// scrollable children.
Ian Hickson's avatar
Ian Hickson committed
844 845
class _ScrollSemantics extends SingleChildRenderObjectWidget {
  const _ScrollSemantics({
846 847 848 849 850
    Key? key,
    required this.position,
    required this.allowImplicitScrolling,
    required this.semanticChildCount,
    Widget? child,
851
  }) : assert(position != null),
852
       assert(semanticChildCount == null || semanticChildCount >= 0),
853
       super(key: key, child: child);
854 855

  final ScrollPosition position;
856
  final bool allowImplicitScrolling;
857
  final int? semanticChildCount;
858 859

  @override
Ian Hickson's avatar
Ian Hickson committed
860
  _RenderScrollSemantics createRenderObject(BuildContext context) {
861
    return _RenderScrollSemantics(
862 863
      position: position,
      allowImplicitScrolling: allowImplicitScrolling,
864
      semanticChildCount: semanticChildCount,
865 866 867
    );
  }

868
  @override
Ian Hickson's avatar
Ian Hickson committed
869
  void updateRenderObject(BuildContext context, _RenderScrollSemantics renderObject) {
870 871
    renderObject
      ..allowImplicitScrolling = allowImplicitScrolling
872 873
      ..position = position
      ..semanticChildCount = semanticChildCount;
874
  }
875 876
}

Ian Hickson's avatar
Ian Hickson committed
877 878
class _RenderScrollSemantics extends RenderProxyBox {
  _RenderScrollSemantics({
879 880 881 882
    required ScrollPosition position,
    required bool allowImplicitScrolling,
    required int? semanticChildCount,
    RenderBox? child,
883 884
  }) : _position = position,
       _allowImplicitScrolling = allowImplicitScrolling,
885
       _semanticChildCount = semanticChildCount,
886 887
       assert(position != null),
       super(child) {
888 889 890 891 892 893 894 895 896 897 898 899 900 901 902
    position.addListener(markNeedsSemanticsUpdate);
  }

  /// Whether this render object is excluded from the semantic tree.
  ScrollPosition get position => _position;
  ScrollPosition _position;
  set position(ScrollPosition value) {
    assert(value != null);
    if (value == _position)
      return;
    _position.removeListener(markNeedsSemanticsUpdate);
    _position = value;
    _position.addListener(markNeedsSemanticsUpdate);
    markNeedsSemanticsUpdate();
  }
903

904 905 906 907 908 909 910 911 912 913
  /// Whether this node can be scrolled implicitly.
  bool get allowImplicitScrolling => _allowImplicitScrolling;
  bool _allowImplicitScrolling;
  set allowImplicitScrolling(bool value) {
    if (value == _allowImplicitScrolling)
      return;
    _allowImplicitScrolling = value;
    markNeedsSemanticsUpdate();
  }

914 915 916
  int? get semanticChildCount => _semanticChildCount;
  int? _semanticChildCount;
  set semanticChildCount(int? value) {
917 918 919 920 921 922
    if (value == semanticChildCount)
      return;
    _semanticChildCount = value;
    markNeedsSemanticsUpdate();
  }

923 924 925 926
  @override
  void describeSemanticsConfiguration(SemanticsConfiguration config) {
    super.describeSemanticsConfiguration(config);
    config.isSemanticBoundary = true;
927 928
    if (position.haveDimensions) {
      config
929
          ..hasImplicitScrolling = allowImplicitScrolling
930 931
          ..scrollPosition = _position.pixels
          ..scrollExtentMax = _position.maxScrollExtent
932 933
          ..scrollExtentMin = _position.minScrollExtent
          ..scrollChildCount = semanticChildCount;
934
    }
935 936
  }

937
  SemanticsNode? _innerNode;
938 939 940 941 942 943 944 945

  @override
  void assembleSemanticsNode(SemanticsNode node, SemanticsConfiguration config, Iterable<SemanticsNode> children) {
    if (children.isEmpty || !children.first.isTagged(RenderViewport.useTwoPaneSemantics)) {
      super.assembleSemanticsNode(node, config, children);
      return;
    }

946
    _innerNode ??= SemanticsNode(showOnScreen: showOnScreen);
947
    _innerNode!
948
      ..isMergedIntoParent = node.isPartOfNodeMerging
949
      ..rect = node.rect;
950

951 952
    int? firstVisibleIndex;
    final List<SemanticsNode> excluded = <SemanticsNode>[_innerNode!];
953
    final List<SemanticsNode> included = <SemanticsNode>[];
954
    for (final SemanticsNode child in children) {
955
      assert(child.isTagged(RenderViewport.useTwoPaneSemantics));
956
      if (child.isTagged(RenderViewport.excludeFromScrolling)) {
957
        excluded.add(child);
958
      } else {
959 960
        if (!child.hasFlag(SemanticsFlag.isHidden))
          firstVisibleIndex ??= child.indexInParent;
961
        included.add(child);
962
      }
963
    }
964
    config.scrollIndex = firstVisibleIndex;
965
    node.updateWith(config: null, childrenInInversePaintOrder: excluded);
966
    _innerNode!.updateWith(config: config, childrenInInversePaintOrder: included);
967 968
  }

969 970 971 972
  @override
  void clearSemantics() {
    super.clearSemantics();
    _innerNode = null;
973 974
  }
}
975 976 977 978 979 980 981 982 983 984 985 986 987 988

/// A typedef for a function that can calculate the offset for a type of scroll
/// increment given a [ScrollIncrementDetails].
///
/// This function is used as the type for [Scrollable.incrementCalculator],
/// which is called from a [ScrollAction].
typedef ScrollIncrementCalculator = double Function(ScrollIncrementDetails details);

/// Describes the type of scroll increment that will be performed by a
/// [ScrollAction] on a [Scrollable].
///
/// This is used to configure a [ScrollIncrementDetails] object to pass to a
/// [ScrollIncrementCalculator] function on a [Scrollable].
///
989
/// {@template flutter.widgets.ScrollIncrementType.intent}
990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025
/// This indicates the *intent* of the scroll, not necessarily the size. Not all
/// scrollable areas will have the concept of a "line" or "page", but they can
/// respond to the different standard key bindings that cause scrolling, which
/// are bound to keys that people use to indicate a "line" scroll (e.g.
/// control-arrowDown keys) or a "page" scroll (e.g. pageDown key). It is
/// recommended that at least the relative magnitudes of the scrolls match
/// expectations.
/// {@endtemplate}
enum ScrollIncrementType {
  /// Indicates that the [ScrollIncrementCalculator] should return the scroll
  /// distance it should move when the user requests to scroll by a "line".
  ///
  /// The distance a "line" scrolls refers to what should happen when the key
  /// binding for "scroll down/up by a line" is triggered. It's up to the
  /// [ScrollIncrementCalculator] function to decide what that means for a
  /// particular scrollable.
  line,

  /// Indicates that the [ScrollIncrementCalculator] should return the scroll
  /// distance it should move when the user requests to scroll by a "page".
  ///
  /// The distance a "page" scrolls refers to what should happen when the key
  /// binding for "scroll down/up by a page" is triggered. It's up to the
  /// [ScrollIncrementCalculator] function to decide what that means for a
  /// particular scrollable.
  page,
}

/// A details object that describes the type of scroll increment being requested
/// of a [ScrollIncrementCalculator] function, as well as the current metrics
/// for the scrollable.
class ScrollIncrementDetails {
  /// A const constructor for a [ScrollIncrementDetails].
  ///
  /// All of the arguments must not be null, and are required.
  const ScrollIncrementDetails({
1026 1027
    required this.type,
    required this.metrics,
1028 1029 1030 1031 1032
  })  : assert(type != null),
        assert(metrics != null);

  /// The type of scroll this is (e.g. line, page, etc.).
  ///
1033
  /// {@macro flutter.widgets.ScrollIncrementType.intent}
1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049
  final ScrollIncrementType type;

  /// The current metrics of the scrollable that is being scrolled.
  final ScrollMetrics metrics;
}

/// An [Intent] that represents scrolling the nearest scrollable by an amount
/// appropriate for the [type] specified.
///
/// The actual amount of the scroll is determined by the
/// [Scrollable.incrementCalculator], or by its defaults if that is not
/// specified.
class ScrollIntent extends Intent {
  /// Creates a const [ScrollIntent] that requests scrolling in the given
  /// [direction], with the given [type].
  const ScrollIntent({
1050
    required this.direction,
1051 1052
    this.type = ScrollIncrementType.line,
  })  : assert(direction != null),
1053
        assert(type != null);
1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065

  /// The direction in which to scroll the scrollable containing the focused
  /// widget.
  final AxisDirection direction;

  /// The type of scrolling that is intended.
  final ScrollIncrementType type;
}

/// An [Action] that scrolls the [Scrollable] that encloses the current
/// [primaryFocus] by the amount configured in the [ScrollIntent] given to it.
///
1066 1067 1068 1069
/// If a Scrollable cannot be found above the current [primaryFocus], the
/// [PrimaryScrollController] will be considered for default handling of
/// [ScrollAction]s.
///
1070 1071 1072 1073
/// If [Scrollable.incrementCalculator] is null for the scrollable, the default
/// for a [ScrollIntent.type] set to [ScrollIncrementType.page] is 80% of the
/// size of the scroll window, and for [ScrollIncrementType.line], 50 logical
/// pixels.
1074 1075
class ScrollAction extends Action<ScrollIntent> {
  @override
1076
  bool isEnabled(ScrollIntent intent) {
1077
    final FocusNode? focus = primaryFocus;
1078 1079 1080
    final bool contextIsValid = focus != null && focus.context != null;
    if (contextIsValid) {
      // Check for primary scrollable within the current context
1081
      if (Scrollable.of(focus.context!) != null)
1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092
        return true;
      // Check for fallback scrollable with context from PrimaryScrollController
      if (PrimaryScrollController.of(focus.context!) != null) {
        final ScrollController? primaryScrollController = PrimaryScrollController.of(focus.context!);
        return primaryScrollController != null
          && primaryScrollController.hasClients
          && primaryScrollController.position.context.notificationContext != null
          && Scrollable.of(primaryScrollController.position.context.notificationContext!) != null;
      }
    }
    return false;
1093 1094
  }

1095 1096 1097 1098 1099 1100 1101 1102 1103 1104
  // Returns the scroll increment for a single scroll request, for use when
  // scrolling using a hardware keyboard.
  //
  // Must not be called when the position is null, or when any of the position
  // metrics (pixels, viewportDimension, maxScrollExtent, minScrollExtent) are
  // null. The type and state arguments must not be null, and the widget must
  // have already been laid out so that the position fields are valid.
  double _calculateScrollIncrement(ScrollableState state, { ScrollIncrementType type = ScrollIncrementType.line }) {
    assert(type != null);
    assert(state.position != null);
1105
    assert(state.position.hasPixels);
1106 1107 1108
    assert(state.position.viewportDimension != null);
    assert(state.position.maxScrollExtent != null);
    assert(state.position.minScrollExtent != null);
1109
    assert(state._physics == null || state._physics!.shouldAcceptUserOffset(state.position));
1110
    if (state.widget.incrementCalculator != null) {
1111
      return state.widget.incrementCalculator!(
1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174
        ScrollIncrementDetails(
          type: type,
          metrics: state.position,
        ),
      );
    }
    switch (type) {
      case ScrollIncrementType.line:
        return 50.0;
      case ScrollIncrementType.page:
        return 0.8 * state.position.viewportDimension;
    }
  }

  // Find out how much of an increment to move by, taking the different
  // directions into account.
  double _getIncrement(ScrollableState state, ScrollIntent intent) {
    final double increment = _calculateScrollIncrement(state, type: intent.type);
    switch (intent.direction) {
      case AxisDirection.down:
        switch (state.axisDirection) {
          case AxisDirection.up:
            return -increment;
          case AxisDirection.down:
            return increment;
          case AxisDirection.right:
          case AxisDirection.left:
            return 0.0;
        }
      case AxisDirection.up:
        switch (state.axisDirection) {
          case AxisDirection.up:
            return increment;
          case AxisDirection.down:
            return -increment;
          case AxisDirection.right:
          case AxisDirection.left:
            return 0.0;
        }
      case AxisDirection.left:
        switch (state.axisDirection) {
          case AxisDirection.right:
            return -increment;
          case AxisDirection.left:
            return increment;
          case AxisDirection.up:
          case AxisDirection.down:
            return 0.0;
        }
      case AxisDirection.right:
        switch (state.axisDirection) {
          case AxisDirection.right:
            return increment;
          case AxisDirection.left:
            return -increment;
          case AxisDirection.up:
          case AxisDirection.down:
            return 0.0;
        }
    }
  }

  @override
1175
  void invoke(ScrollIntent intent) {
1176 1177 1178 1179 1180
    ScrollableState? state = Scrollable.of(primaryFocus!.context!);
    if (state == null) {
      final ScrollController? primaryScrollController = PrimaryScrollController.of(primaryFocus!.context!);
      state = Scrollable.of(primaryScrollController!.position.context.notificationContext!);
    }
1181
    assert(state != null, '$ScrollAction was invoked on a context that has no scrollable parent');
1182 1183 1184 1185
    assert(state!.position.hasPixels, 'Scrollable must be laid out before it can be scrolled via a ScrollAction');
    assert(state!.position.viewportDimension != null);
    assert(state!.position.maxScrollExtent != null);
    assert(state!.position.minScrollExtent != null);
1186 1187

    // Don't do anything if the user isn't allowed to scroll.
1188
    if (state!._physics != null && !state._physics!.shouldAcceptUserOffset(state.position)) {
1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201
      return;
    }
    final double increment = _getIncrement(state, intent);
    if (increment == 0.0) {
      return;
    }
    state.position.moveTo(
      state.position.pixels + increment,
      duration: const Duration(milliseconds: 100),
      curve: Curves.easeInOut,
    );
  }
}
1202 1203 1204

// Not using a RestorableDouble because we want to allow null values and override
// [enabled].
1205
class _RestorableScrollOffset extends RestorableValue<double?> {
1206
  @override
1207
  double? createDefaultValue() => null;
1208 1209

  @override
1210
  void didUpdateValue(double? oldValue) {
1211 1212 1213 1214
    notifyListeners();
  }

  @override
1215 1216
  double fromPrimitives(Object? data) {
    return data! as double;
1217 1218 1219
  }

  @override
1220
  Object? toPrimitives() {
1221 1222 1223 1224 1225 1226
    return value;
  }

  @override
  bool get enabled => value != null;
}