scrollable.dart 34 KB
Newer Older
1 2 3 4
// 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.

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

9
import 'package:flutter/physics.dart';
10
import 'package:flutter/gestures.dart';
pq's avatar
pq committed
11
import 'package:meta/meta.dart';
12 13

import 'basic.dart';
14
import 'clamp_overscrolls.dart';
15 16
import 'framework.dart';
import 'gesture_detector.dart';
17
import 'notification_listener.dart';
Adam Barth's avatar
Adam Barth committed
18
import 'page_storage.dart';
19
import 'scroll_behavior.dart';
20
import 'scroll_configuration.dart';
21

22
/// The accuracy to which scrolling is computed.
Hans Muller's avatar
Hans Muller committed
23
final Tolerance kPixelScrollTolerance = new Tolerance(
Ian Hickson's avatar
Ian Hickson committed
24 25 26
  // TODO(ianh): Handle the case of the device pixel ratio changing.
  velocity: 1.0 / (0.050 * ui.window.devicePixelRatio), // logical pixels per second
  distance: 1.0 / ui.window.devicePixelRatio // logical pixels
Hans Muller's avatar
Hans Muller committed
27 28
);

29
typedef Widget ScrollBuilder(BuildContext context, ScrollableState state);
30
typedef void ScrollListener(double scrollOffset);
31
typedef double SnapOffsetCallback(double scrollOffset, Size containerSize);
32

33 34 35 36 37 38 39
/// A base class for scrollable widgets.
///
/// Commonly used subclasses include [ScrollableList], [ScrollableGrid], and
/// [ScrollableViewport].
///
/// Widgets that subclass [Scrollable] typically use state objects that subclass
/// [ScrollableState].
40
class Scrollable extends StatefulWidget {
41
  Scrollable({
42
    Key key,
43
    this.initialScrollOffset,
44
    this.scrollDirection: Axis.vertical,
45
    this.scrollAnchor: ViewportAnchor.start,
46
    this.onScrollStart,
47
    this.onScroll,
48
    this.onScrollEnd,
49 50
    this.snapOffsetCallback,
    this.builder
51
  }) : super(key: key) {
52 53
    assert(scrollDirection == Axis.vertical || scrollDirection == Axis.horizontal);
    assert(scrollAnchor == ViewportAnchor.start || scrollAnchor == ViewportAnchor.end);
54
  }
55

Hans Muller's avatar
Hans Muller committed
56 57 58 59
  // Warning: keep the dartdoc comments that follow in sync with the copies in
  // ScrollableViewport, LazyBlock, ScrollableLazyList, ScrollableList, and
  // ScrollableGrid. And see: https://github.com/dart-lang/dartdoc/issues/1161.

60
  /// The scroll offset this widget should use when first created.
61
  final double initialScrollOffset;
62 63

  /// The axis along which this widget should scroll.
64
  final Axis scrollDirection;
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
  /// Whether to place first child at the start of the container or
  /// the last child at the end of the container, when the scrollable
  /// has not been scrolled and has no initial scroll offset.
  ///
  /// For example, if the [scrollDirection] is [Axis.vertical] and
  /// there are enough items to overflow the container, then
  /// [ViewportAnchor.start] means that the top of the first item
  /// should be aligned with the top of the scrollable with the last
  /// item below the bottom, and [ViewportAnchor.end] means the bottom
  /// of the last item should be aligned with the bottom of the
  /// scrollable, with the first item above the top.
  ///
  /// This also affects whether, when an item is added or removed, the
  /// displacement will be towards the first item or the last item.
  /// Continuing the earlier example, if a new item is inserted in the
  /// middle of the list, in the [ViewportAnchor.start] case the items
  /// after it (with greater indices, down to the item with the
  /// highest index) will be pushed down, while in the
  /// [ViewportAnchor.end] case the items before it (with lower
  /// indices, up to the item with the index 0) will be pushed up.
  ///
  /// Subclasses may ignore this value if, for instance, they do not
  /// have a concept of an anchor, or have more complicated behavior
  /// (e.g. they would by default put the middle item in the middle of
  /// the container).
91 92
  final ViewportAnchor scrollAnchor;

93
  /// Called whenever this widget starts to scroll.
94
  final ScrollListener onScrollStart;
95 96

  /// Called whenever this widget's scroll offset changes.
97
  final ScrollListener onScroll;
98 99

  /// Called whenever this widget stops scrolling.
100
  final ScrollListener onScrollEnd;
101

102 103 104 105 106 107 108 109 110 111 112 113 114
  /// Called to determine the offset to which scrolling should snap,
  /// when handling a fling.
  ///
  /// This callback, if set, will be called with the offset that the
  /// Scrollable would have scrolled to in the absence of this
  /// callback, and a Size describing the size of the Scrollable
  /// itself.
  ///
  /// The callback's return value is used as the new scroll offset to
  /// aim for.
  ///
  /// If the callback simply returns its first argument (the offset),
  /// then it is as if the callback was null.
115
  final SnapOffsetCallback snapOffsetCallback;
116

117 118
  final ScrollBuilder builder;

119
  /// The state from the closest instance of this class that encloses the given context.
120
  static ScrollableState of(BuildContext context) {
Ian Hickson's avatar
Ian Hickson committed
121
    return context.ancestorStateOfType(const TypeMatcher<ScrollableState>());
122 123 124
  }

  /// Scrolls the closest enclosing scrollable to make the given context visible.
125
  static Future<Null> ensureVisible(BuildContext context, { Duration duration, Curve curve: Curves.ease }) {
126 127 128 129 130
    assert(context.findRenderObject() is RenderBox);
    // TODO(abarth): This function doesn't handle nested scrollable widgets.

    ScrollableState scrollable = Scrollable.of(context);
    if (scrollable == null)
131
      return new Future<Null>.value();
132 133 134 135 136 137 138 139 140

    RenderBox targetBox = context.findRenderObject();
    assert(targetBox.attached);
    Size targetSize = targetBox.size;

    RenderBox scrollableBox = scrollable.context.findRenderObject();
    assert(scrollableBox.attached);
    Size scrollableSize = scrollableBox.size;

141 142 143 144 145
    double targetMin;
    double targetMax;
    double scrollableMin;
    double scrollableMax;

146
    switch (scrollable.config.scrollDirection) {
147
      case Axis.vertical:
148 149 150 151
        targetMin = targetBox.localToGlobal(Point.origin).y;
        targetMax = targetBox.localToGlobal(new Point(0.0, targetSize.height)).y;
        scrollableMin = scrollableBox.localToGlobal(Point.origin).y;
        scrollableMax = scrollableBox.localToGlobal(new Point(0.0, scrollableSize.height)).y;
152
        break;
153
      case Axis.horizontal:
154 155 156 157
        targetMin = targetBox.localToGlobal(Point.origin).x;
        targetMax = targetBox.localToGlobal(new Point(targetSize.width, 0.0)).x;
        scrollableMin = scrollableBox.localToGlobal(Point.origin).x;
        scrollableMax = scrollableBox.localToGlobal(new Point(scrollableSize.width, 0.0)).x;
158 159 160
        break;
    }

161 162 163 164 165 166 167 168 169 170 171 172 173 174
    double scrollOffsetDelta;
    if (targetMin < scrollableMin) {
      if (targetMax > scrollableMax) {
        // The target is to big to fit inside the scrollable. The best we can do
        // is to center the target.
        double targetCenter = (targetMin + targetMax) / 2.0;
        double scrollableCenter = (scrollableMin + scrollableMax) / 2.0;
        scrollOffsetDelta = targetCenter - scrollableCenter;
      } else {
        scrollOffsetDelta = targetMin - scrollableMin;
      }
    } else if (targetMax > scrollableMax) {
      scrollOffsetDelta = targetMax - scrollableMax;
    } else {
175
      return new Future<Null>.value();
176 177
    }

178 179 180 181 182 183 184
    ExtentScrollBehavior scrollBehavior = scrollable.scrollBehavior;
    double scrollOffset = (scrollable.scrollOffset + scrollOffsetDelta)
      .clamp(scrollBehavior.minScrollOffset, scrollBehavior.maxScrollOffset);

    if (scrollOffset != scrollable.scrollOffset)
      return scrollable.scrollTo(scrollOffset, duration: duration, curve: curve);

185
    return new Future<Null>.value();
186 187
  }

188
  @override
189
  ScrollableState createState() => new ScrollableState<Scrollable>();
190
}
191

192 193
/// Contains the state for common scrolling widgets that scroll only
/// along one axis.
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
/// Widgets that subclass [Scrollable] typically use state objects
/// that subclass [ScrollableState].
///
/// The main state of a ScrollableState is the "scroll offset", which
/// is the the logical description of the current scroll position and
/// is stored in [scrollOffset] as a double. The units of the scroll
/// offset are defined by the specific subclass. By default, the units
/// are logical pixels.
///
/// A "pixel offset" is a distance in logical pixels (or a velocity in
/// logical pixels per second). The pixel offset corresponding to the
/// current scroll position is typically used as the paint offset
/// argument to the underlying [Viewport] class (or equivalent); see
/// the [buildContent] method.
///
/// A "pixel delta" is an [Offset] that describes a two-dimensional
/// distance as reported by input events. If the scrolling convention
/// is axis-aligned (as in a vertical scrolling list or a horizontal
/// scrolling list), then the pixel delta will consist of a pixel
/// offset in the scroll axis, and a value in the other axis that is
/// either ignored (when converting to a scroll offset) or set to zero
/// (when converting a scroll offset to a pixel delta).
///
/// If the units of the scroll offset are not logical pixels, then a
/// mapping must be made from logical pixels (as used by incoming
/// input events) and the scroll offset (as stored internally). To
/// provide this mapping, override the [pixelOffsetToScrollOffset] and
/// [scrollOffsetToPixelOffset] methods.
///
/// If the scrollable is not providing axis-aligned scrolling, then,
/// to convert pixel deltas to scroll offsets and vice versa, override
/// the [pixelDeltaToScrollOffset] and [scrollOffsetToPixelOffset]
/// methods. By default, these assume an axis-aligned scroll behavior
/// along the [config.scrollDirection] axis and are implemented in
/// terms of the [pixelOffsetToScrollOffset] and
/// [scrollOffsetToPixelOffset] methods.
pq's avatar
pq committed
231
@optionalTypeArgs
232
class ScrollableState<T extends Scrollable> extends State<T> {
233
  @override
234
  void initState() {
235
    super.initState();
236 237
    _controller = new AnimationController.unbounded()
      ..addListener(_handleAnimationChanged);
Adam Barth's avatar
Adam Barth committed
238
    _scrollOffset = PageStorage.of(context)?.readState(context) ?? config.initialScrollOffset ?? 0.0;
239 240
  }

241
  Simulation _simulation; // if we're flinging, then this is the animation with which we're doing it
242
  AnimationController _controller;
243

244
  @override
245
  void dispose() {
246
    _stop();
247 248 249
    super.dispose();
  }

250 251 252 253 254
  /// The current scroll offset.
  ///
  /// The scroll offset is applied to the child widget along the scroll
  /// direction before painting. A positive scroll offset indicates that
  /// more content in the preferred reading direction is visible.
255
  double get scrollOffset => _scrollOffset;
Ian Hickson's avatar
Ian Hickson committed
256
  double _scrollOffset;
257

Hans Muller's avatar
Hans Muller committed
258 259 260 261
  /// Convert a position or velocity measured in terms of pixels to a scrollOffset.
  /// Scrollable gesture handlers convert their incoming values with this method.
  /// Subclasses that define scrollOffset in units other than pixels must
  /// override this method.
262 263
  ///
  /// This function should be the inverse of [scrollOffsetToPixelOffset].
264 265 266 267 268 269 270 271 272 273
  double pixelOffsetToScrollOffset(double pixelOffset) {
    switch (config.scrollAnchor) {
      case ViewportAnchor.start:
        // We negate the delta here because a positive scroll offset moves the
        // the content up (or to the left) rather than down (or the right).
        return -pixelOffset;
      case ViewportAnchor.end:
        return pixelOffset;
    }
  }
Hans Muller's avatar
Hans Muller committed
274

275 276 277
  /// Convert a scrollOffset value to the number of pixels to which it corresponds.
  ///
  /// This function should be the inverse of [pixelOffsetToScrollOffset].
278 279 280 281 282 283 284 285 286 287 288
  double scrollOffsetToPixelOffset(double scrollOffset) {
    switch (config.scrollAnchor) {
      case ViewportAnchor.start:
        return -scrollOffset;
      case ViewportAnchor.end:
        return scrollOffset;
    }
  }

  /// Returns the scroll offset component of the given pixel delta, accounting
  /// for the scroll direction and scroll anchor.
289 290 291
  ///
  /// A pixel delta is an [Offset] in pixels. Typically this function
  /// is implemented in terms of [pixelOffsetToScrollOffset].
292 293 294 295 296 297 298 299 300 301 302
  double pixelDeltaToScrollOffset(Offset pixelDelta) {
    switch (config.scrollDirection) {
      case Axis.horizontal:
        return pixelOffsetToScrollOffset(pixelDelta.dx);
      case Axis.vertical:
        return pixelOffsetToScrollOffset(pixelDelta.dy);
    }
  }

  /// Returns a two-dimensional representation of the scroll offset, accounting
  /// for the scroll direction and scroll anchor.
303 304
  ///
  /// See the definition of [ScrollableState] for more details.
305 306 307 308 309 310 311
  Offset scrollOffsetToPixelDelta(double scrollOffset) {
    switch (config.scrollDirection) {
      case Axis.horizontal:
        return new Offset(scrollOffsetToPixelOffset(scrollOffset), 0.0);
      case Axis.vertical:
        return new Offset(0.0, scrollOffsetToPixelOffset(scrollOffset));
    }
Hans Muller's avatar
Hans Muller committed
312 313
  }

314 315 316 317 318
  /// The current scroll behavior of this widget.
  ///
  /// Scroll behaviors control where the boundaries of the scrollable are placed
  /// and how the scrolling physics should behave near those boundaries and
  /// after the user stops directly manipulating the scrollable.
319
  ExtentScrollBehavior get scrollBehavior {
320
    return _scrollBehavior ??= createScrollBehavior();
321
  }
322
  ExtentScrollBehavior _scrollBehavior;
323

324 325 326 327 328 329 330 331 332
  /// Use the value returned by [ScrollConfiguration.createScrollBehavior].
  /// If this widget doesn't have a ScrollConfiguration ancestor,
  /// or its createScrollBehavior callback is null, then return a new instance
  /// of [OverscrollWhenScrollableBehavior].
  ExtentScrollBehavior createScrollBehavior() {
    // TODO(hansmuller): this will not be called when the ScrollConfiguration changes.
    // An override of dependenciesChanged() is probably needed.
    return ScrollConfiguration.of(context)?.createScrollBehavior();
  }
333

334
  bool _scrollOffsetIsInBounds(double scrollOffset) {
335 336 337
    if (scrollBehavior is! ExtentScrollBehavior)
      return false;
    ExtentScrollBehavior behavior = scrollBehavior;
338
    return scrollOffset >= behavior.minScrollOffset && scrollOffset < behavior.maxScrollOffset;
339 340
  }

341 342 343 344
  void _handleAnimationChanged() {
    _setScrollOffset(_controller.value);
  }

345 346 347 348 349 350
  void _setScrollOffset(double newScrollOffset) {
    if (_scrollOffset == newScrollOffset)
      return;
    setState(() {
      _scrollOffset = newScrollOffset;
    });
Adam Barth's avatar
Adam Barth committed
351
    PageStorage.of(context)?.writeState(context, _scrollOffset);
352
    _startScroll();
353
    dispatchOnScroll();
354
    _endScroll();
355 356
  }

357 358 359 360
  /// Scroll this widget by the given scroll delta.
  ///
  /// If a non-null [duration] is provided, the widget will animate to the new
  /// scroll offset over the given duration with the given curve.
361
  Future<Null> scrollBy(double scrollDelta, { Duration duration, Curve curve: Curves.ease }) {
362 363 364 365
    double newScrollOffset = scrollBehavior.applyCurve(_scrollOffset, scrollDelta);
    return scrollTo(newScrollOffset, duration: duration, curve: curve);
  }

366 367 368 369
  /// Scroll this widget to the given scroll offset.
  ///
  /// If a non-null [duration] is provided, the widget will animate to the new
  /// scroll offset over the given duration with the given curve.
370 371 372 373
  ///
  /// This function does not accept a zero duration. To jump-scroll to
  /// the new offset, do not provide a duration, rather than providing
  /// a zero duration.
374
  Future<Null> scrollTo(double newScrollOffset, { Duration duration, Curve curve: Curves.ease }) {
375
    if (newScrollOffset == _scrollOffset)
376
      return new Future<Null>.value();
377

378
    if (duration == null) {
379
      _stop();
380
      _setScrollOffset(newScrollOffset);
381
      return new Future<Null>.value();
382 383
    }

384
    assert(duration > Duration.ZERO);
385
    return _animateTo(newScrollOffset, duration, curve);
386 387
  }

388
  Future<Null> _animateTo(double newScrollOffset, Duration duration, Curve curve) {
389
    _stop();
390
    _controller.value = scrollOffset;
391 392
    _startScroll();
    return _controller.animateTo(newScrollOffset, duration: duration, curve: curve).then(_endScroll);
393 394
  }

395 396 397 398 399 400 401 402 403
  /// Update any in-progress scrolling physics to account for new scroll behavior.
  ///
  /// The scrolling physics depends on the scroll behavior. When changing the
  /// scrolling behavior, call this function to update any in-progress scrolling
  /// physics to account for the new scroll behavior. This function preserves
  /// the current velocity when updating the physics.
  ///
  /// If there are no in-progress scrolling physics, this function scrolls to
  /// the given offset instead.
404
  void didUpdateScrollBehavior(double newScrollOffset) {
405
    assert(_controller.isAnimating || _simulation == null);
406 407 408 409 410 411 412 413 414 415 416
    if (_numberOfInProgressScrolls > 0) {
      if (_simulation != null) {
        double dx = _simulation.dx(_controller.lastElapsedDuration.inMicroseconds / Duration.MICROSECONDS_PER_SECOND);
        // TODO(abarth): We should be consistent about the units we use for velocity (i.e., per second).
        _startToEndAnimation(dx / Duration.MILLISECONDS_PER_SECOND);
      }
      return;
    }
    scrollTo(newScrollOffset);
  }

417 418 419 420 421
  /// Fling the scroll offset with the given velocity.
  ///
  /// Calling this function starts a physics-based animation of the scroll
  /// offset with the given value as the initial velocity. The physics
  /// simulation used is determined by the scroll behavior.
422
  Future<Null> fling(double scrollVelocity) {
423
    if (scrollVelocity != 0.0 || !_controller.isAnimating)
Hans Muller's avatar
Hans Muller committed
424
      return _startToEndAnimation(scrollVelocity);
425
    return new Future<Null>.value();
426 427
  }

428 429 430 431 432
  /// Animate the scroll offset to a value with a local minima of energy.
  ///
  /// Calling this function starts a physics-based animation of the scroll
  /// offset either to a snap point or to within the scrolling bounds. The
  /// physics simulation used is determined by the scroll behavior.
433
  Future<Null> settleScrollOffset() {
434
    return _startToEndAnimation(0.0);
435 436
  }

437
  Future<Null> _startToEndAnimation(double scrollVelocity) {
438
    _stop();
439 440
    _simulation = _createSnapSimulation(scrollVelocity) ?? _createFlingSimulation(scrollVelocity);
    if (_simulation == null)
441
      return new Future<Null>.value();
442
    _startScroll();
443
    return _controller.animateWith(_simulation).then(_endScroll);
444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484
  }

  /// Whether this scrollable should attempt to snap scroll offsets.
  bool get shouldSnapScrollOffset => config.snapOffsetCallback != null;

  /// Returns the snapped offset closest to the given scroll offset.
  double snapScrollOffset(double scrollOffset) {
    RenderBox box = context.findRenderObject();
    return config.snapOffsetCallback == null ? scrollOffset : config.snapOffsetCallback(scrollOffset, box.size);
  }

  Simulation _createSnapSimulation(double scrollVelocity) {
    if (!shouldSnapScrollOffset || scrollVelocity == 0.0 || !_scrollOffsetIsInBounds(scrollOffset))
      return null;

    Simulation simulation = _createFlingSimulation(scrollVelocity);
    if (simulation == null)
        return null;

    final double endScrollOffset = simulation.x(double.INFINITY);
    if (endScrollOffset.isNaN)
      return null;

    final double snappedScrollOffset = snapScrollOffset(endScrollOffset); // invokes the config.snapOffsetCallback callback
    if (!_scrollOffsetIsInBounds(snappedScrollOffset))
      return null;

    final double snapVelocity = scrollVelocity.abs() * (snappedScrollOffset - scrollOffset).sign;
    final double endVelocity = pixelOffsetToScrollOffset(kPixelScrollTolerance.velocity).abs() * (scrollVelocity < 0.0 ? -1.0 : 1.0);
    Simulation toSnapSimulation = scrollBehavior.createSnapScrollSimulation(
      scrollOffset, snappedScrollOffset, snapVelocity, endVelocity
    );
    if (toSnapSimulation == null)
      return null;

    final double scrollOffsetMin = math.min(scrollOffset, snappedScrollOffset);
    final double scrollOffsetMax = math.max(scrollOffset, snappedScrollOffset);
    return new ClampedSimulation(toSnapSimulation, xMin: scrollOffsetMin, xMax: scrollOffsetMax);
  }

  Simulation _createFlingSimulation(double scrollVelocity) {
485
    final Simulation simulation =  scrollBehavior.createScrollSimulation(scrollOffset, scrollVelocity);
486
    if (simulation != null) {
487
      final double endVelocity = pixelOffsetToScrollOffset(kPixelScrollTolerance.velocity).abs();
488 489 490 491 492 493
      final double endDistance = pixelOffsetToScrollOffset(kPixelScrollTolerance.distance).abs();
      simulation.tolerance = new Tolerance(velocity: endVelocity, distance: endDistance);
    }
    return simulation;
  }

494 495 496 497 498 499 500 501 502
  // When we start an scroll animation, we stop any previous scroll animation.
  // However, the code that would deliver the onScrollEnd callback is watching
  // for animations to end using a Future that resolves at the end of the
  // microtask. That causes animations to "overlap" between the time we start a
  // new animation and the end of the microtask. By the time the microtask is
  // over and we check whether to deliver an onScrollEnd callback, we will have
  // started the new animation (having skipped the onScrollStart) and therefore
  // we won't deliver the onScrollEnd until the second animation is finished.
  int _numberOfInProgressScrolls = 0;
503

504 505 506 507
  /// Calls the onScroll callback.
  ///
  /// Subclasses can override this function to hook the scroll callback.
  void dispatchOnScroll() {
508
    assert(_numberOfInProgressScrolls > 0);
509 510
    if (config.onScroll != null)
      config.onScroll(_scrollOffset);
511
    new ScrollNotification(this, ScrollNotificationKind.updated).dispatch(context);
512 513
  }

514
  void _handleDragDown(_) {
515 516 517 518 519
    _stop();
  }

  void _stop() {
    assert(_controller.isAnimating || _simulation == null);
520
    _controller.stop();
521
    _simulation = null;
522 523 524
  }

  void _handleDragStart(_) {
525
    _startScroll();
526 527
  }

528 529 530
  void _startScroll() {
    _numberOfInProgressScrolls += 1;
    if (_numberOfInProgressScrolls == 1)
531 532 533
      dispatchOnScrollStart();
  }

534 535 536
  /// Calls the onScrollStart callback.
  ///
  /// Subclasses can override this function to hook the scroll start callback.
537
  void dispatchOnScrollStart() {
538
    assert(_numberOfInProgressScrolls == 1);
539 540
    if (config.onScrollStart != null)
      config.onScrollStart(_scrollOffset);
541
    new ScrollNotification(this, ScrollNotificationKind.started).dispatch(context);
542 543
  }

544 545 546 547
  void _handleDragUpdate(double delta) {
    scrollBy(pixelOffsetToScrollOffset(delta));
  }

548
  Future<Null> _handleDragEnd(Velocity velocity) {
549
    double scrollVelocity = pixelDeltaToScrollOffset(velocity.pixelsPerSecond) / Duration.MILLISECONDS_PER_SECOND;
Hans Muller's avatar
Hans Muller committed
550
    return fling(scrollVelocity).then(_endScroll);
551 552
  }

Ian Hickson's avatar
Ian Hickson committed
553
  Null _endScroll([Null _]) {
554
    _numberOfInProgressScrolls -= 1;
555 556
    if (_numberOfInProgressScrolls == 0) {
      _simulation = null;
557
      dispatchOnScrollEnd();
558
    }
Ian Hickson's avatar
Ian Hickson committed
559
    return null;
560 561
  }

562 563 564
  /// Calls the dispatchOnScrollEnd callback.
  ///
  /// Subclasses can override this function to hook the scroll end callback.
565
  void dispatchOnScrollEnd() {
566
    assert(_numberOfInProgressScrolls == 0);
567 568
    if (config.onScrollEnd != null)
      config.onScrollEnd(_scrollOffset);
569 570
    if (mounted)
      new ScrollNotification(this, ScrollNotificationKind.ended).dispatch(context);
571 572
  }

Ian Hickson's avatar
Ian Hickson committed
573
  final GlobalKey<RawGestureDetectorState> _gestureDetectorKey = new GlobalKey<RawGestureDetectorState>();
574

575
  @override
576 577 578 579 580
  Widget build(BuildContext context) {
    return new RawGestureDetector(
      key: _gestureDetectorKey,
      gestures: buildGestureDetectors(),
      behavior: HitTestBehavior.opaque,
581
      child: buildContent(context)
582
    );
583 584
  }

585 586 587 588 589 590 591 592 593 594
  /// Fixes up the gesture detector to listen to the appropriate
  /// gestures based on the current information about the layout.
  ///
  /// This method should be called from the
  /// [onPaintOffsetUpdateNeeded] or [onExtentsChanged] handler given
  /// to the [Viewport] or equivalent used by the subclass's
  /// [buildContent] method. See the [buildContent] method's
  /// description for details.
  void updateGestureDetector() {
    _gestureDetectorKey.currentState.replaceGestureRecognizers(buildGestureDetectors());
Hans Muller's avatar
Hans Muller committed
595 596
  }

597 598 599 600 601 602 603 604 605 606 607 608 609
  /// Return the gesture detectors, in the form expected by
  /// [RawGestureDetector.gestures] and
  /// [RawGestureDetectorState.replaceGestureRecognizers], that are
  /// applicable to this [Scrollable] in its current state.
  ///
  /// This is called by [build] and [updateGestureDetector].
  Map<Type, GestureRecognizerFactory> buildGestureDetectors() {
    if (scrollBehavior.isScrollable) {
      switch (config.scrollDirection) {
        case Axis.vertical:
          return <Type, GestureRecognizerFactory>{
            VerticalDragGestureRecognizer: (VerticalDragGestureRecognizer recognizer) {
              return (recognizer ??= new VerticalDragGestureRecognizer())
610
                ..onDown = _handleDragDown
611 612 613 614 615 616 617 618 619
                ..onStart = _handleDragStart
                ..onUpdate = _handleDragUpdate
                ..onEnd = _handleDragEnd;
            }
          };
        case Axis.horizontal:
          return <Type, GestureRecognizerFactory>{
            HorizontalDragGestureRecognizer: (HorizontalDragGestureRecognizer recognizer) {
              return (recognizer ??= new HorizontalDragGestureRecognizer())
620
                ..onDown = _handleDragDown
621 622 623 624 625 626 627 628
                ..onStart = _handleDragStart
                ..onUpdate = _handleDragUpdate
                ..onEnd = _handleDragEnd;
            }
          };
      }
    }
    return const <Type, GestureRecognizerFactory>{};
629
  }
630

Hans Muller's avatar
Hans Muller committed
631 632
  /// Calls the widget's [builder] by default.
  ///
633
  /// Subclasses can override this function to build the interior of their
634 635 636 637 638 639 640
  /// scrollable widget. Scrollable wraps the returned widget in a
  /// [GestureDetector] to observe the user's interaction with this widget and
  /// to adjust the scroll offset accordingly.
  ///
  /// The widgets used by this method should be widgets that provide a
  /// layout-time callback that reports the sizes that are relevant to
  /// the scroll offset (typically the size of the scrollable
Adam Barth's avatar
Adam Barth committed
641 642 643 644
  /// container and the scrolled contents). [Viewport] provides an
  /// [onPaintOffsetUpdateNeeded] callback for this purpose; [GridViewport],
  /// [ListViewport], [LazyListViewport], and [LazyBlockViewport] provide an
  /// [onExtentsChanged] callback for this purpose.
645 646 647 648
  ///
  /// This callback should be used to update the scroll behavior, if
  /// necessary, and then to call [updateGestureDetector] to update
  /// the gesture detectors accordingly.
649
  Widget buildContent(BuildContext context) {
Hans Muller's avatar
Hans Muller committed
650
    assert(config.builder != null);
651 652
    return config.builder(context, this);
  }
653 654
}

655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670
/// Indicates if a [ScrollNotification] indicates the start, end or the
/// middle of a scroll.
enum ScrollNotificationKind {
  /// The [ScrollNotification] indicates that the scrollOffset has been changed
  /// and no existing scroll is underway.
  started,

  /// The [ScrollNotification] indicates that the scrollOffset has been changed.
  updated,

  /// The [ScrollNotification] indicates that the scrollOffset has stopped changing.
  /// This may be because the fling animation that follows a drag gesture has
  /// completed or simply because the scrollOffset was reset.
  ended
}

671
/// Indicates that a descendant scrollable has scrolled.
672
class ScrollNotification extends Notification {
673 674 675 676
  ScrollNotification(this.scrollable, this.kind);

  // Indicates if we're at the start, end or the middle of a scroll.
  final ScrollNotificationKind kind;
677 678

  /// The scrollable that scrolled.
679 680 681
  final ScrollableState scrollable;
}

682
/// A simple scrolling widget that has a single child. Use this widget if
683
/// you are not worried about offscreen widgets consuming resources.
684
class ScrollableViewport extends StatefulWidget {
685 686
  ScrollableViewport({
    Key key,
687 688 689 690 691 692 693 694
    this.initialScrollOffset,
    this.scrollDirection: Axis.vertical,
    this.scrollAnchor: ViewportAnchor.start,
    this.onScrollStart,
    this.onScroll,
    this.onScrollEnd,
    this.snapOffsetCallback,
    this.scrollableKey,
695
    this.child
696
  }) : super(key: key);
697

Hans Muller's avatar
Hans Muller committed
698 699 700 701
  // Warning: keep the dartdoc comments that follow in sync with the copies in
  // Scrollable, LazyBlock, ScrollableLazyList, ScrollableList, and
  // ScrollableGrid. And see: https://github.com/dart-lang/dartdoc/issues/1161.

702 703
  /// The scroll offset this widget should use when first created.
  final double initialScrollOffset;
704

705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758
  /// The axis along which this widget should scroll.
  final Axis scrollDirection;

  /// Whether to place first child at the start of the container or
  /// the last child at the end of the container, when the scrollable
  /// has not been scrolled and has no initial scroll offset.
  ///
  /// For example, if the [scrollDirection] is [Axis.vertical] and
  /// there are enough items to overflow the container, then
  /// [ViewportAnchor.start] means that the top of the first item
  /// should be aligned with the top of the scrollable with the last
  /// item below the bottom, and [ViewportAnchor.end] means the bottom
  /// of the last item should be aligned with the bottom of the
  /// scrollable, with the first item above the top.
  ///
  /// This also affects whether, when an item is added or removed, the
  /// displacement will be towards the first item or the last item.
  /// Continuing the earlier example, if a new item is inserted in the
  /// middle of the list, in the [ViewportAnchor.start] case the items
  /// after it (with greater indices, down to the item with the
  /// highest index) will be pushed down, while in the
  /// [ViewportAnchor.end] case the items before it (with lower
  /// indices, up to the item with the index 0) will be pushed up.
  final ViewportAnchor scrollAnchor;

  /// Called whenever this widget starts to scroll.
  final ScrollListener onScrollStart;

  /// Called whenever this widget's scroll offset changes.
  final ScrollListener onScroll;

  /// Called whenever this widget stops scrolling.
  final ScrollListener onScrollEnd;

  /// Called to determine the offset to which scrolling should snap,
  /// when handling a fling.
  ///
  /// This callback, if set, will be called with the offset that the
  /// Scrollable would have scrolled to in the absence of this
  /// callback, and a Size describing the size of the Scrollable
  /// itself.
  ///
  /// The callback's return value is used as the new scroll offset to
  /// aim for.
  ///
  /// If the callback simply returns its first argument (the offset),
  /// then it is as if the callback was null.
  final SnapOffsetCallback snapOffsetCallback;

  /// The key for the Scrollable created by this widget.
  final Key scrollableKey;

  /// The widget that will be scrolled. It will become the child of a Scrollable.
  final Widget child;
759 760

  @override
761 762
  _ScrollableViewportState createState() => new _ScrollableViewportState();
}
763

764
class _ScrollableViewportState extends State<ScrollableViewport> {
765 766
  double _viewportSize = 0.0;
  double _childSize = 0.0;
767

768
  Offset _handlePaintOffsetUpdateNeeded(ScrollableState state, ViewportDimensions dimensions) {
769 770 771 772 773 774
    // We make various state changes here but don't have to do so in a
    // setState() callback because we are called during layout and all
    // we're updating is the new offset, which we are providing to the
    // render object via our return value.
    _viewportSize = config.scrollDirection == Axis.vertical ? dimensions.containerSize.height : dimensions.containerSize.width;
    _childSize = config.scrollDirection == Axis.vertical ? dimensions.contentSize.height : dimensions.contentSize.width;
775
    state.didUpdateScrollBehavior(state.scrollBehavior.updateExtents(
776 777
      contentExtent: _childSize,
      containerExtent: _viewportSize,
778
      scrollOffset: state.scrollOffset
Hixie's avatar
Hixie committed
779
    ));
780 781
    state.updateGestureDetector();
    return state.scrollOffsetToPixelDelta(state.scrollOffset);
782 783
  }

784
  Widget _buildViewport(BuildContext context, ScrollableState state, double scrollOffset) {
785
    return new Viewport(
786
      paintOffset: state.scrollOffsetToPixelDelta(scrollOffset),
787
      mainAxis: config.scrollDirection,
788
      anchor: config.scrollAnchor,
789 790 791
      onPaintOffsetUpdateNeeded: (ViewportDimensions dimensions) {
        return _handlePaintOffsetUpdateNeeded(state, dimensions);
      },
792
      child: config.child
793 794
    );
  }
795

796 797 798 799
  Widget _buildContent(BuildContext context, ScrollableState state) {
    return ClampOverscrolls.buildViewport(context, state, _buildViewport);
  }

800 801
  @override
  Widget build(BuildContext context) {
802
    final Widget result = new Scrollable(
803 804 805 806 807 808 809 810 811 812
      key: config.scrollableKey,
      initialScrollOffset: config.initialScrollOffset,
      scrollDirection: config.scrollDirection,
      scrollAnchor: config.scrollAnchor,
      onScrollStart: config.onScrollStart,
      onScroll: config.onScroll,
      onScrollEnd: config.onScrollEnd,
      snapOffsetCallback: config.snapOffsetCallback,
      builder: _buildContent
    );
813
    return ScrollConfiguration.wrap(context, result);
814
  }
815 816
}

817
/// A mashup of [ScrollableViewport] and [BlockBody]. Useful when you have a small,
818 819
/// fixed number of children that you wish to arrange in a block layout and that
/// might exceed the height of its container (and therefore need to scroll).
Adam Barth's avatar
Adam Barth committed
820 821 822 823
///
/// If you have a large number of children, consider using [LazyBlock] (if the
/// children have variable height) or [ScrollableList] (if the children all have
/// the same fixed height).
824
class Block extends StatelessWidget {
825
  Block({
826
    Key key,
827
    this.children: const <Widget>[],
Hixie's avatar
Hixie committed
828
    this.padding,
829
    this.initialScrollOffset,
830
    this.scrollDirection: Axis.vertical,
831
    this.scrollAnchor: ViewportAnchor.start,
832
    this.onScrollStart,
833
    this.onScroll,
834
    this.onScrollEnd,
835
    this.scrollableKey
836
  }) : super(key: key) {
837
    assert(children != null);
838 839
    assert(!children.any((Widget child) => child == null));
  }
840 841

  final List<Widget> children;
842 843

  /// The amount of space by which to inset the children inside the viewport.
844
  final EdgeInsets padding;
845

846
  /// The scroll offset this widget should use when first created.
847
  final double initialScrollOffset;
848

849
  final Axis scrollDirection;
850
  final ViewportAnchor scrollAnchor;
851 852 853 854 855

  /// Called whenever this widget starts to scroll.
  final ScrollListener onScrollStart;

  /// Called whenever this widget's scroll offset changes.
856
  final ScrollListener onScroll;
857 858 859 860 861

  /// Called whenever this widget stops scrolling.
  final ScrollListener onScrollEnd;

  /// The key to use for the underlying scrollable widget.
862
  final Key scrollableKey;
863

864
  @override
865
  Widget build(BuildContext context) {
866
    Widget contents = new BlockBody(children: children, mainAxis: scrollDirection);
Hixie's avatar
Hixie committed
867 868
    if (padding != null)
      contents = new Padding(padding: padding, child: contents);
869
    return new ScrollableViewport(
870
      scrollableKey: scrollableKey,
871
      initialScrollOffset: initialScrollOffset,
872
      scrollDirection: scrollDirection,
873
      scrollAnchor: scrollAnchor,
874
      onScrollStart: onScrollStart,
875
      onScroll: onScroll,
876
      onScrollEnd: onScrollEnd,
Hixie's avatar
Hixie committed
877
      child: contents
878 879 880
    );
  }
}