scrollable.dart 30.8 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:newton/newton.dart';
10
import 'package:flutter/gestures.dart';
11
import 'package:flutter/rendering.dart' show HasScrollDirection;
12 13 14 15 16

import 'basic.dart';
import 'framework.dart';
import 'gesture_detector.dart';
import 'mixed_viewport.dart';
17
import 'notification_listener.dart';
Adam Barth's avatar
Adam Barth committed
18
import 'page_storage.dart';
19
import 'scroll_behavior.dart';
20

21
/// The accuracy to which scrolling is computed.
Hans Muller's avatar
Hans Muller committed
22 23 24 25 26
final Tolerance kPixelScrollTolerance = new Tolerance(
  velocity: 1.0 / (0.050 * ui.window.devicePixelRatio),  // logical pixels per second
  distance: 1.0 / ui.window.devicePixelRatio  // logical pixels
);

27
typedef void ScrollListener(double scrollOffset);
28
typedef double SnapOffsetCallback(double scrollOffset, Size containerSize);
29

30 31 32 33 34 35 36
/// 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].
37 38
abstract class Scrollable extends StatefulComponent {
  Scrollable({
39
    Key key,
40
    this.initialScrollOffset,
41
    this.scrollDirection: Axis.vertical,
42
    this.scrollAnchor: ViewportAnchor.start,
43
    this.onScrollStart,
44
    this.onScroll,
45
    this.onScrollEnd,
46
    this.snapOffsetCallback
47
  }) : super(key: key) {
48 49
    assert(scrollDirection == Axis.vertical || scrollDirection == Axis.horizontal);
    assert(scrollAnchor == ViewportAnchor.start || scrollAnchor == ViewportAnchor.end);
50
  }
51

52
  /// The scroll offset this widget should use when first created.
53
  final double initialScrollOffset;
54 55

  /// The axis along which this widget should scroll.
56
  final Axis scrollDirection;
57

58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82
  /// 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).
83 84
  final ViewportAnchor scrollAnchor;

85
  /// Called whenever this widget starts to scroll.
86
  final ScrollListener onScrollStart;
87 88

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

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

94 95 96 97 98 99 100 101 102 103 104 105 106
  /// 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.
107
  final SnapOffsetCallback snapOffsetCallback;
108

109
  /// The state from the closest instance of this class that encloses the given context.
110
  static ScrollableState of(BuildContext context) {
Ian Hickson's avatar
Ian Hickson committed
111
    return context.ancestorStateOfType(const TypeMatcher<ScrollableState>());
112 113 114
  }

  /// Scrolls the closest enclosing scrollable to make the given context visible.
115
  static Future ensureVisible(BuildContext context, { Duration duration, Curve curve: Curves.ease }) {
116 117 118 119 120 121 122 123 124 125 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)
      return new Future.value();

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

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

131 132 133 134 135
    double targetMin;
    double targetMax;
    double scrollableMin;
    double scrollableMax;

136
    switch (scrollable.config.scrollDirection) {
137
      case Axis.vertical:
138 139 140 141
        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;
142
        break;
143
      case Axis.horizontal:
144 145 146 147
        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;
148 149 150
        break;
    }

151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167
    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 {
      return new Future.value();
    }

168 169 170 171 172 173 174 175 176 177
    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);

    return new Future.value();
  }

178
  ScrollableState createState();
179
}
180

181
/// Contains the state for common scrolling widgets.
182
///
183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218
/// 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.
219
abstract class ScrollableState<T extends Scrollable> extends State<T> {
220
  void initState() {
221
    super.initState();
222
    _controller = new AnimationController.unbounded()..addListener(_handleAnimationChanged);
Adam Barth's avatar
Adam Barth committed
223
    _scrollOffset = PageStorage.of(context)?.readState(context) ?? config.initialScrollOffset ?? 0.0;
224 225
  }

226
  AnimationController _controller;
227

228 229 230 231 232
  void dispose() {
    _controller.stop();
    super.dispose();
  }

233 234 235 236 237
  /// 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.
238
  double get scrollOffset => _scrollOffset;
Ian Hickson's avatar
Ian Hickson committed
239
  double _scrollOffset;
240

Hans Muller's avatar
Hans Muller committed
241 242 243 244
  /// 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.
245 246
  ///
  /// This function should be the inverse of [scrollOffsetToPixelOffset].
247 248 249 250 251 252 253 254 255 256
  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
257

258 259 260
  /// Convert a scrollOffset value to the number of pixels to which it corresponds.
  ///
  /// This function should be the inverse of [pixelOffsetToScrollOffset].
261 262 263 264 265 266 267 268 269 270 271
  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.
272 273 274
  ///
  /// A pixel delta is an [Offset] in pixels. Typically this function
  /// is implemented in terms of [pixelOffsetToScrollOffset].
275 276 277 278 279 280 281 282 283 284 285
  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.
286 287
  ///
  /// See the definition of [ScrollableState] for more details.
288 289 290 291 292 293 294
  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
295 296
  }

297 298 299 300 301
  /// 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.
302
  ScrollBehavior get scrollBehavior {
303
    return _scrollBehavior ??= createScrollBehavior();
304
  }
305
  ScrollBehavior _scrollBehavior;
306

307 308 309
  /// Subclasses should override this function to create the [ScrollBehavior]
  /// they desire.
  ScrollBehavior createScrollBehavior();
310

311
  bool _scrollOffsetIsInBounds(double scrollOffset) {
312 313 314
    if (scrollBehavior is! ExtentScrollBehavior)
      return false;
    ExtentScrollBehavior behavior = scrollBehavior;
315
    return scrollOffset >= behavior.minScrollOffset && scrollOffset < behavior.maxScrollOffset;
316 317
  }

318 319 320 321
  void _handleAnimationChanged() {
    _setScrollOffset(_controller.value);
  }

322 323 324 325 326 327
  void _setScrollOffset(double newScrollOffset) {
    if (_scrollOffset == newScrollOffset)
      return;
    setState(() {
      _scrollOffset = newScrollOffset;
    });
Adam Barth's avatar
Adam Barth committed
328
    PageStorage.of(context)?.writeState(context, _scrollOffset);
329
    new ScrollNotification(this, _scrollOffset).dispatch(context);
330
    _startScroll();
331
    dispatchOnScroll();
332
    _endScroll();
333 334
  }

335 336 337 338 339 340 341 342 343
  /// 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.
  Future scrollBy(double scrollDelta, { Duration duration, Curve curve: Curves.ease }) {
    double newScrollOffset = scrollBehavior.applyCurve(_scrollOffset, scrollDelta);
    return scrollTo(newScrollOffset, duration: duration, curve: curve);
  }

344 345 346 347
  /// 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.
348 349 350 351
  ///
  /// 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.
352
  Future scrollTo(double newScrollOffset, { Duration duration, Curve curve: Curves.ease }) {
353
    if (newScrollOffset == _scrollOffset)
354
      return new Future.value();
355

356
    if (duration == null) {
357
      _controller.stop();
358 359
      _setScrollOffset(newScrollOffset);
      return new Future.value();
360 361
    }

362
    assert(duration > Duration.ZERO);
363
    return _animateTo(newScrollOffset, duration, curve);
364 365
  }

366 367 368
  Future _animateTo(double newScrollOffset, Duration duration, Curve curve) {
    _controller.stop();
    _controller.value = scrollOffset;
369 370
    _startScroll();
    return _controller.animateTo(newScrollOffset, duration: duration, curve: curve).then(_endScroll);
371 372
  }

373 374 375 376 377
  /// 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.
378
  Future fling(double scrollVelocity) {
379
    if (scrollVelocity != 0.0 || !_controller.isAnimating)
Hans Muller's avatar
Hans Muller committed
380
      return _startToEndAnimation(scrollVelocity);
381
    return new Future.value();
382 383
  }

384 385 386 387 388
  /// 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.
389
  Future settleScrollOffset() {
390
    return _startToEndAnimation(0.0);
391 392
  }

393 394 395 396 397
  Future _startToEndAnimation(double scrollVelocity) {
    _controller.stop();
    Simulation simulation = _createSnapSimulation(scrollVelocity) ?? _createFlingSimulation(scrollVelocity);
    if (simulation == null)
      return new Future.value();
398 399
    _startScroll();
    return _controller.animateWith(simulation).then(_endScroll);
400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449
  }

  /// 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) {
    final Simulation simulation =  scrollBehavior.createFlingScrollSimulation(scrollOffset, scrollVelocity);
    if (simulation != null) {
      final double endVelocity = pixelOffsetToScrollOffset(kPixelScrollTolerance.velocity).abs() * (scrollVelocity < 0.0 ? -1.0 : 1.0);
      final double endDistance = pixelOffsetToScrollOffset(kPixelScrollTolerance.distance).abs();
      simulation.tolerance = new Tolerance(velocity: endVelocity, distance: endDistance);
    }
    return simulation;
  }

450 451 452 453 454 455 456 457 458
  // 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;
459

460 461 462 463
  /// Calls the onScroll callback.
  ///
  /// Subclasses can override this function to hook the scroll callback.
  void dispatchOnScroll() {
464
    assert(_numberOfInProgressScrolls > 0);
465 466 467 468 469 470 471 472 473
    if (config.onScroll != null)
      config.onScroll(_scrollOffset);
  }

  void _handlePointerDown(_) {
    _controller.stop();
  }

  void _handleDragStart(_) {
474
    _startScroll();
475 476
  }

477 478 479
  void _startScroll() {
    _numberOfInProgressScrolls += 1;
    if (_numberOfInProgressScrolls == 1)
480 481 482
      dispatchOnScrollStart();
  }

483 484 485
  /// Calls the onScrollStart callback.
  ///
  /// Subclasses can override this function to hook the scroll start callback.
486
  void dispatchOnScrollStart() {
487
    assert(_numberOfInProgressScrolls == 1);
488 489 490 491
    if (config.onScrollStart != null)
      config.onScrollStart(_scrollOffset);
  }

492 493 494 495 496 497 498
  void _handleDragUpdate(double delta) {
    scrollBy(pixelOffsetToScrollOffset(delta));
  }

  Future _handleDragEnd(Velocity velocity) {
    double scrollVelocity = pixelDeltaToScrollOffset(velocity.pixelsPerSecond) / Duration.MILLISECONDS_PER_SECOND;
    // The gesture velocity properties are pixels/second, config min,max limits are pixels/ms
499
    return fling(scrollVelocity.clamp(-kMaxFlingVelocity, kMaxFlingVelocity)).then(_endScroll);
500 501
  }

502 503 504
  void _endScroll([_]) {
    _numberOfInProgressScrolls -= 1;
    if (_numberOfInProgressScrolls == 0)
505
      dispatchOnScrollEnd();
506 507
  }

508 509 510
  /// Calls the dispatchOnScrollEnd callback.
  ///
  /// Subclasses can override this function to hook the scroll end callback.
511
  void dispatchOnScrollEnd() {
512
    assert(_numberOfInProgressScrolls == 0);
513 514 515 516
    if (config.onScrollEnd != null)
      config.onScrollEnd(_scrollOffset);
  }

517
  final GlobalKey _gestureDetectorKey = new GlobalKey();
518

519 520 521 522 523 524 525 526 527 528
  Widget build(BuildContext context) {
    return new RawGestureDetector(
      key: _gestureDetectorKey,
      gestures: buildGestureDetectors(),
      behavior: HitTestBehavior.opaque,
      child: new Listener(
        child: buildContent(context),
        onPointerDown: _handlePointerDown
      )
    );
529 530
  }

531 532 533 534 535 536 537 538 539 540
  /// 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
541 542
  }

543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572
  /// 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())
                ..onStart = _handleDragStart
                ..onUpdate = _handleDragUpdate
                ..onEnd = _handleDragEnd;
            }
          };
        case Axis.horizontal:
          return <Type, GestureRecognizerFactory>{
            HorizontalDragGestureRecognizer: (HorizontalDragGestureRecognizer recognizer) {
              return (recognizer ??= new HorizontalDragGestureRecognizer())
                ..onStart = _handleDragStart
                ..onUpdate = _handleDragUpdate
                ..onEnd = _handleDragEnd;
            }
          };
      }
    }
    return const <Type, GestureRecognizerFactory>{};
573
  }
574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592

  /// Subclasses should override this function to build the interior of their
  /// 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
  /// container and the scrolled contents). [Viewport] and
  /// [MixedViewport] provide an [onPaintOffsetUpdateNeeded] callback
  /// for this purpose; [GridViewport], [ListViewport], and
  /// [LazyListViewport] provide an [onExtentsChanged] callback for
  /// this purpose.
  ///
  /// This callback should be used to update the scroll behavior, if
  /// necessary, and then to call [updateGestureDetector] to update
  /// the gesture detectors accordingly.
  Widget buildContent(BuildContext context);
593 594
}

595
/// Indicates that a descendant scrollable has scrolled.
596
class ScrollNotification extends Notification {
597 598 599
  ScrollNotification(this.scrollable, this.scrollOffset);

  /// The scrollable that scrolled.
600
  final ScrollableState scrollable;
601 602 603

  /// The new scroll offset that the scrollable obtained.
  final double scrollOffset;
604 605
}

606 607 608
/// A simple scrollable widget that has a single child. Use this component if
/// you are not worried about offscreen widgets consuming resources.
class ScrollableViewport extends Scrollable {
609 610
  ScrollableViewport({
    Key key,
611
    double initialScrollOffset,
612
    Axis scrollDirection: Axis.vertical,
613
    ViewportAnchor scrollAnchor: ViewportAnchor.start,
614 615
    ScrollListener onScrollStart,
    ScrollListener onScroll,
616 617
    ScrollListener onScrollEnd,
    this.child
618 619 620
  }) : super(
    key: key,
    scrollDirection: scrollDirection,
621
    scrollAnchor: scrollAnchor,
622
    initialScrollOffset: initialScrollOffset,
623 624 625
    onScrollStart: onScrollStart,
    onScroll: onScroll,
    onScrollEnd: onScrollEnd
626
  );
627

628
  final Widget child;
629

630
  ScrollableState createState() => new _ScrollableViewportState();
631
}
632

633
class _ScrollableViewportState extends ScrollableState<ScrollableViewport> {
634 635
  ScrollBehavior createScrollBehavior() => new OverscrollWhenScrollableBehavior();
  OverscrollWhenScrollableBehavior get scrollBehavior => super.scrollBehavior;
636

637 638
  double _viewportSize = 0.0;
  double _childSize = 0.0;
639 640 641 642 643 644 645 646

  Offset _handlePaintOffsetUpdateNeeded(ViewportDimensions dimensions) {
    // 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;
647
    scrollTo(scrollBehavior.updateExtents(
648 649
      contentExtent: _childSize,
      containerExtent: _viewportSize,
Hixie's avatar
Hixie committed
650 651
      scrollOffset: scrollOffset
    ));
652 653
    updateGestureDetector();
    return scrollOffsetToPixelDelta(scrollOffset);
654 655
  }

656
  Widget buildContent(BuildContext context) {
657 658 659 660 661 662
    return new Viewport(
      paintOffset: scrollOffsetToPixelDelta(scrollOffset),
      scrollDirection: config.scrollDirection,
      scrollAnchor: config.scrollAnchor,
      onPaintOffsetUpdateNeeded: _handlePaintOffsetUpdateNeeded,
      child: config.child
663 664 665 666
    );
  }
}

667
/// A mashup of [ScrollableViewport] and [BlockBody]. Useful when you have a small,
668 669
/// 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).
670
class Block extends StatelessComponent {
671
  Block({
672
    Key key,
673
    this.children: const <Widget>[],
Hixie's avatar
Hixie committed
674
    this.padding,
675
    this.initialScrollOffset,
676
    this.scrollDirection: Axis.vertical,
677
    this.scrollAnchor: ViewportAnchor.start,
678 679
    this.onScroll,
    this.scrollableKey
680
  }) : super(key: key) {
681
    assert(children != null);
682 683
    assert(!children.any((Widget child) => child == null));
  }
684 685

  final List<Widget> children;
Hixie's avatar
Hixie committed
686
  final EdgeDims padding;
687
  final double initialScrollOffset;
688
  final Axis scrollDirection;
689
  final ViewportAnchor scrollAnchor;
690
  final ScrollListener onScroll;
691
  final Key scrollableKey;
692

693
  Widget build(BuildContext context) {
694
    Widget contents = new BlockBody(children: children, direction: scrollDirection);
Hixie's avatar
Hixie committed
695 696
    if (padding != null)
      contents = new Padding(padding: padding, child: contents);
697
    return new ScrollableViewport(
698
      key: scrollableKey,
699
      initialScrollOffset: initialScrollOffset,
700
      scrollDirection: scrollDirection,
701
      scrollAnchor: scrollAnchor,
702
      onScroll: onScroll,
Hixie's avatar
Hixie committed
703
      child: contents
704 705 706 707
    );
  }
}

708 709
abstract class ScrollableListPainter extends Painter {
  void attach(RenderObject renderObject) {
710
    assert(renderObject is RenderBox);
711
    assert(renderObject is HasScrollDirection);
712 713 714
    super.attach(renderObject);
  }

715
  RenderBox get renderObject => super.renderObject;
716

717
  Axis get scrollDirection {
718 719
    HasScrollDirection scrollable = renderObject as dynamic;
    return scrollable?.scrollDirection;
720
  }
721

722
  Size get viewportSize => renderObject.size;
723 724 725 726 727 728 729 730 731

  double get contentExtent => _contentExtent;
  double _contentExtent = 0.0;
  void set contentExtent (double value) {
    assert(value != null);
    assert(value >= 0.0);
    if (_contentExtent == value)
      return;
    _contentExtent = value;
732
    renderObject?.markNeedsPaint();
733 734 735 736 737 738 739 740 741
  }

  double get scrollOffset => _scrollOffset;
  double _scrollOffset = 0.0;
  void set scrollOffset (double value) {
    assert(value != null);
    if (_scrollOffset == value)
      return;
    _scrollOffset = value;
742
    renderObject?.markNeedsPaint();
743 744 745 746 747 748 749 750 751 752 753 754 755 756
  }

  /// Called when a scroll starts. Subclasses may override this method to
  /// initialize some state or to play an animation. The returned Future should
  /// complete when the computation triggered by this method has finished.
  Future scrollStarted() => new Future.value();


  /// Similar to scrollStarted(). Called when a scroll ends. For fling scrolls
  /// "ended" means that the scroll animation either stopped of its own accord
  /// or was canceled  by the user.
  Future scrollEnded() => new Future.value();
}

757
/// A general scrollable list for a large number of children that might not all
758
/// have the same height. Prefer [ScrollableWidgetList] when all the children
759 760
/// have the same height because it can use that property to be more efficient.
/// Prefer [ScrollableViewport] with a single child.
761 762
///
/// ScrollableMixedWidgetList only supports vertical scrolling.
763 764
class ScrollableMixedWidgetList extends Scrollable {
  ScrollableMixedWidgetList({
765
    Key key,
766
    double initialScrollOffset,
767 768
    ScrollListener onScroll,
    SnapOffsetCallback snapOffsetCallback,
769 770
    this.builder,
    this.token,
771 772 773 774 775
    this.onInvalidatorAvailable
  }) : super(
    key: key,
    initialScrollOffset: initialScrollOffset,
    onScroll: onScroll,
776
    snapOffsetCallback: snapOffsetCallback
777
  );
778

779 780
  // TODO(ianh): Support horizontal scrolling.

781 782 783
  final IndexedBuilder builder;
  final Object token;
  final InvalidatorAvailableCallback onInvalidatorAvailable;
784

785 786
  ScrollableMixedWidgetListState createState() => new ScrollableMixedWidgetListState();
}
787

788
class ScrollableMixedWidgetListState extends ScrollableState<ScrollableMixedWidgetList> {
789 790
  void initState() {
    super.initState();
791 792 793
    scrollBehavior.updateExtents(
      contentExtent: double.INFINITY
    );
794 795 796 797 798
  }

  ScrollBehavior createScrollBehavior() => new OverscrollBehavior();
  OverscrollBehavior get scrollBehavior => super.scrollBehavior;

799 800 801 802 803 804 805 806 807 808 809 810
  Offset _handlePaintOffsetUpdateNeeded(ViewportDimensions dimensions) {
    // 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.
    scrollTo(scrollBehavior.updateExtents(
      contentExtent: dimensions.contentSize.height,
      containerExtent: dimensions.containerSize.height,
      scrollOffset: scrollOffset
    ));
    updateGestureDetector();
    return scrollOffsetToPixelDelta(scrollOffset);
811 812
  }

813
  Widget buildContent(BuildContext context) {
814 815 816 817 818 819
    return new MixedViewport(
      startOffset: scrollOffset,
      builder: config.builder,
      token: config.token,
      onInvalidatorAvailable: config.onInvalidatorAvailable,
      onPaintOffsetUpdateNeeded: _handlePaintOffsetUpdateNeeded
820 821 822
    );
  }
}