// Copyright 2015 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:async';
import 'dart:math' as math;
import 'dart:ui' as ui show window;

import 'package:newton/newton.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart' show HasMainAxis;

import 'basic.dart';
import 'framework.dart';
import 'gesture_detector.dart';
import 'mixed_viewport.dart';
import 'notification_listener.dart';
import 'page_storage.dart';
import 'scroll_behavior.dart';

/// The accuracy to which scrolling is computed.
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
);

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

/// 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].
abstract class Scrollable extends StatefulWidget {
  Scrollable({
    Key key,
    this.initialScrollOffset,
    this.scrollDirection: Axis.vertical,
    this.scrollAnchor: ViewportAnchor.start,
    this.onScrollStart,
    this.onScroll,
    this.onScrollEnd,
    this.snapOffsetCallback
  }) : super(key: key) {
    assert(scrollDirection == Axis.vertical || scrollDirection == Axis.horizontal);
    assert(scrollAnchor == ViewportAnchor.start || scrollAnchor == ViewportAnchor.end);
  }

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

  /// 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.
  ///
  /// 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).
  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 state from the closest instance of this class that encloses the given context.
  static ScrollableState of(BuildContext context) {
    return context.ancestorStateOfType(const TypeMatcher<ScrollableState>());
  }

  /// Scrolls the closest enclosing scrollable to make the given context visible.
  static Future<Null> ensureVisible(BuildContext context, { Duration duration, Curve curve: Curves.ease }) {
    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<Null>.value();

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

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

    double targetMin;
    double targetMax;
    double scrollableMin;
    double scrollableMax;

    switch (scrollable.config.scrollDirection) {
      case Axis.vertical:
        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;
        break;
      case Axis.horizontal:
        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;
        break;
    }

    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<Null>.value();
    }

    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<Null>.value();
  }

  ScrollableState createState();
}

/// Contains the state for common scrolling widgets that scroll only
/// along one axis.
///
/// 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.
abstract class ScrollableState<T extends Scrollable> extends State<T> {
  void initState() {
    super.initState();
    _controller = new AnimationController.unbounded()..addListener(_handleAnimationChanged);
    _scrollOffset = PageStorage.of(context)?.readState(context) ?? config.initialScrollOffset ?? 0.0;
  }

  AnimationController _controller;

  void dispose() {
    _controller.stop();
    super.dispose();
  }

  /// 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.
  double get scrollOffset => _scrollOffset;
  double _scrollOffset;

  /// 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.
  ///
  /// This function should be the inverse of [scrollOffsetToPixelOffset].
  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;
    }
  }

  /// Convert a scrollOffset value to the number of pixels to which it corresponds.
  ///
  /// This function should be the inverse of [pixelOffsetToScrollOffset].
  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.
  ///
  /// A pixel delta is an [Offset] in pixels. Typically this function
  /// is implemented in terms of [pixelOffsetToScrollOffset].
  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.
  ///
  /// See the definition of [ScrollableState] for more details.
  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));
    }
  }

  /// 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.
  ScrollBehavior<double, double> get scrollBehavior {
    return _scrollBehavior ??= createScrollBehavior();
  }
  ScrollBehavior<double, double> _scrollBehavior;

  /// Subclasses should override this function to create the [ScrollBehavior]
  /// they desire.
  ScrollBehavior<double, double> createScrollBehavior();

  bool _scrollOffsetIsInBounds(double scrollOffset) {
    if (scrollBehavior is! ExtentScrollBehavior)
      return false;
    ExtentScrollBehavior behavior = scrollBehavior;
    return scrollOffset >= behavior.minScrollOffset && scrollOffset < behavior.maxScrollOffset;
  }

  void _handleAnimationChanged() {
    _setScrollOffset(_controller.value);
  }

  void _setScrollOffset(double newScrollOffset) {
    if (_scrollOffset == newScrollOffset)
      return;
    setState(() {
      _scrollOffset = newScrollOffset;
    });
    PageStorage.of(context)?.writeState(context, _scrollOffset);
    new ScrollNotification(this, _scrollOffset).dispatch(context);
    _startScroll();
    dispatchOnScroll();
    _endScroll();
  }

  /// 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<Null> scrollBy(double scrollDelta, { Duration duration, Curve curve: Curves.ease }) {
    double newScrollOffset = scrollBehavior.applyCurve(_scrollOffset, scrollDelta);
    return scrollTo(newScrollOffset, duration: duration, curve: curve);
  }

  /// 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.
  ///
  /// 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.
  Future<Null> scrollTo(double newScrollOffset, { Duration duration, Curve curve: Curves.ease }) {
    if (newScrollOffset == _scrollOffset)
      return new Future<Null>.value();

    if (duration == null) {
      _controller.stop();
      _setScrollOffset(newScrollOffset);
      return new Future<Null>.value();
    }

    assert(duration > Duration.ZERO);
    return _animateTo(newScrollOffset, duration, curve);
  }

  Future<Null> _animateTo(double newScrollOffset, Duration duration, Curve curve) {
    _controller.stop();
    _controller.value = scrollOffset;
    _startScroll();
    return _controller.animateTo(newScrollOffset, duration: duration, curve: curve).then(_endScroll);
  }

  /// 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.
  Future<Null> fling(double scrollVelocity) {
    if (scrollVelocity != 0.0 || !_controller.isAnimating)
      return _startToEndAnimation(scrollVelocity);
    return new Future<Null>.value();
  }

  /// 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.
      Future<Null> settleScrollOffset() {
    return _startToEndAnimation(0.0);
  }

  Future<Null> _startToEndAnimation(double scrollVelocity) {
    _controller.stop();
    Simulation simulation = _createSnapSimulation(scrollVelocity) ?? _createFlingSimulation(scrollVelocity);
    if (simulation == null)
      return new Future<Null>.value();
    _startScroll();
    return _controller.animateWith(simulation).then(_endScroll);
  }

  /// 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.createScrollSimulation(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;
  }

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

  /// Calls the onScroll callback.
  ///
  /// Subclasses can override this function to hook the scroll callback.
  void dispatchOnScroll() {
    assert(_numberOfInProgressScrolls > 0);
    if (config.onScroll != null)
      config.onScroll(_scrollOffset);
  }

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

  void _handleDragStart(_) {
    _startScroll();
  }

  void _startScroll() {
    _numberOfInProgressScrolls += 1;
    if (_numberOfInProgressScrolls == 1)
      dispatchOnScrollStart();
  }

  /// Calls the onScrollStart callback.
  ///
  /// Subclasses can override this function to hook the scroll start callback.
  void dispatchOnScrollStart() {
    assert(_numberOfInProgressScrolls == 1);
    if (config.onScrollStart != null)
      config.onScrollStart(_scrollOffset);
  }

  void _handleDragUpdate(double delta) {
    scrollBy(pixelOffsetToScrollOffset(delta));
  }

  Future<Null> _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
    return fling(scrollVelocity.clamp(-kMaxFlingVelocity, kMaxFlingVelocity)).then(_endScroll);
  }

  Null _endScroll([Null _]) {
    _numberOfInProgressScrolls -= 1;
    if (_numberOfInProgressScrolls == 0)
      dispatchOnScrollEnd();
    return null;
  }

  /// Calls the dispatchOnScrollEnd callback.
  ///
  /// Subclasses can override this function to hook the scroll end callback.
  void dispatchOnScrollEnd() {
    assert(_numberOfInProgressScrolls == 0);
    if (config.onScrollEnd != null)
      config.onScrollEnd(_scrollOffset);
  }

  final GlobalKey _gestureDetectorKey = new GlobalKey();

  Widget build(BuildContext context) {
    return new RawGestureDetector(
      key: _gestureDetectorKey,
      gestures: buildGestureDetectors(),
      behavior: HitTestBehavior.opaque,
      child: new Listener(
        child: buildContent(context),
        onPointerDown: _handlePointerDown
      )
    );
  }

  /// 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());
  }

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

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

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

  /// The scrollable that scrolled.
  final ScrollableState scrollable;

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

/// A simple scrollable widget that has a single child. Use this widget if
/// you are not worried about offscreen widgets consuming resources.
class ScrollableViewport extends Scrollable {
  ScrollableViewport({
    Key key,
    double initialScrollOffset,
    Axis scrollDirection: Axis.vertical,
    ViewportAnchor scrollAnchor: ViewportAnchor.start,
    ScrollListener onScrollStart,
    ScrollListener onScroll,
    ScrollListener onScrollEnd,
    this.child
  }) : super(
    key: key,
    scrollDirection: scrollDirection,
    scrollAnchor: scrollAnchor,
    initialScrollOffset: initialScrollOffset,
    onScrollStart: onScrollStart,
    onScroll: onScroll,
    onScrollEnd: onScrollEnd
  );

  final Widget child;

  ScrollableState createState() => new _ScrollableViewportState();
}

class _ScrollableViewportState extends ScrollableState<ScrollableViewport> {
  ScrollBehavior<double, double> createScrollBehavior() => new OverscrollWhenScrollableBehavior();
  OverscrollWhenScrollableBehavior get scrollBehavior => super.scrollBehavior;

  double _viewportSize = 0.0;
  double _childSize = 0.0;

  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;
    scrollTo(scrollBehavior.updateExtents(
      contentExtent: _childSize,
      containerExtent: _viewportSize,
      scrollOffset: scrollOffset
    ));
    updateGestureDetector();
    return scrollOffsetToPixelDelta(scrollOffset);
  }

  Widget buildContent(BuildContext context) {
    return new Viewport(
      paintOffset: scrollOffsetToPixelDelta(scrollOffset),
      mainAxis: config.scrollDirection,
      anchor: config.scrollAnchor,
      onPaintOffsetUpdateNeeded: _handlePaintOffsetUpdateNeeded,
      child: config.child
    );
  }
}

/// A mashup of [ScrollableViewport] and [BlockBody]. Useful when you have a small,
/// 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).
class Block extends StatelessWidget {
  Block({
    Key key,
    this.children: const <Widget>[],
    this.padding,
    this.initialScrollOffset,
    this.scrollDirection: Axis.vertical,
    this.scrollAnchor: ViewportAnchor.start,
    this.onScroll,
    this.scrollableKey
  }) : super(key: key) {
    assert(children != null);
    assert(!children.any((Widget child) => child == null));
  }

  final List<Widget> children;
  final EdgeInsets padding;
  final double initialScrollOffset;
  final Axis scrollDirection;
  final ViewportAnchor scrollAnchor;
  final ScrollListener onScroll;
  final Key scrollableKey;

  Widget build(BuildContext context) {
    Widget contents = new BlockBody(children: children, mainAxis: scrollDirection);
    if (padding != null)
      contents = new Padding(padding: padding, child: contents);
    return new ScrollableViewport(
      key: scrollableKey,
      initialScrollOffset: initialScrollOffset,
      scrollDirection: scrollDirection,
      scrollAnchor: scrollAnchor,
      onScroll: onScroll,
      child: contents
    );
  }
}

abstract class ScrollableListPainter extends RenderObjectPainter {
  void attach(RenderObject renderObject) {
    assert(renderObject is RenderBox);
    assert(renderObject is HasMainAxis);
    super.attach(renderObject);
  }

  RenderBox get renderObject => super.renderObject;

  Axis get scrollDirection {
    HasMainAxis scrollable = renderObject as dynamic;
    return scrollable?.mainAxis;
  }

  Size get viewportSize => renderObject.size;

  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;
    renderObject?.markNeedsPaint();
  }

  double get scrollOffset => _scrollOffset;
  double _scrollOffset = 0.0;
  void set scrollOffset (double value) {
    assert(value != null);
    if (_scrollOffset == value)
      return;
    _scrollOffset = value;
    renderObject?.markNeedsPaint();
  }

  /// 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<Null> scrollStarted() => new Future<Null>.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<Null> scrollEnded() => new Future<Null>.value();
}

/// A general scrollable list for a large number of children that might not all
/// have the same height. Prefer [ScrollableWidgetList] when all the children
/// have the same height because it can use that property to be more efficient.
/// Prefer [ScrollableViewport] with a single child.
///
/// ScrollableMixedWidgetList only supports vertical scrolling.
class ScrollableMixedWidgetList extends Scrollable {
  ScrollableMixedWidgetList({
    Key key,
    double initialScrollOffset,
    ScrollListener onScroll,
    SnapOffsetCallback snapOffsetCallback,
    this.builder,
    this.token,
    this.onInvalidatorAvailable
  }) : super(
    key: key,
    initialScrollOffset: initialScrollOffset,
    onScroll: onScroll,
    snapOffsetCallback: snapOffsetCallback
  );

  // TODO(ianh): Support horizontal scrolling.

  final IndexedBuilder builder;
  final Object token;
  final InvalidatorAvailableCallback onInvalidatorAvailable;

  ScrollableMixedWidgetListState createState() => new ScrollableMixedWidgetListState();
}

class ScrollableMixedWidgetListState extends ScrollableState<ScrollableMixedWidgetList> {
  void initState() {
    super.initState();
    scrollBehavior.updateExtents(
      contentExtent: double.INFINITY
    );
  }

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

  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);
  }

  Widget buildContent(BuildContext context) {
    return new MixedViewport(
      startOffset: scrollOffset,
      builder: config.builder,
      token: config.token,
      onInvalidatorAvailable: config.onInvalidatorAvailable,
      onPaintOffsetUpdateNeeded: _handlePaintOffsetUpdateNeeded
    );
  }
}