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

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

import 'basic.dart';
import 'framework.dart';
import 'gesture_detector.dart';
import 'homogeneous_viewport.dart';
import 'mixed_viewport.dart';
import 'page_storage.dart';

// The gesture velocity properties are pixels/second, config min,max limits are pixels/ms
const double _kMillisecondsPerSecond = 1000.0;
const double _kMinFlingVelocity = -kMaxFlingVelocity * _kMillisecondsPerSecond;
const double _kMaxFlingVelocity = kMaxFlingVelocity * _kMillisecondsPerSecond;

typedef void ScrollListener(double scrollOffset);
typedef double SnapOffsetCallback(double scrollOffset);

/// A base class for scrollable widgets that reacts to user input and generates
/// a scrollOffset.
abstract class Scrollable extends StatefulComponent {
  Scrollable({
    Key key,
    this.initialScrollOffset,
    this.scrollDirection: ScrollDirection.vertical,
    this.onScrollStart,
    this.onScroll,
    this.onScrollEnd,
    this.snapOffsetCallback,
    this.snapAlignmentOffset: 0.0
  }) : super(key: key) {
    assert(scrollDirection == ScrollDirection.vertical ||
           scrollDirection == ScrollDirection.horizontal);
  }

  final double initialScrollOffset;
  final ScrollDirection scrollDirection;
  final ScrollListener onScrollStart;
  final ScrollListener onScroll;
  final ScrollListener onScrollEnd;
  final SnapOffsetCallback snapOffsetCallback;
  final double snapAlignmentOffset;
}

abstract class ScrollableState<T extends Scrollable> extends State<T> {
  void initState() {
    super.initState();
    _animation = new SimulationStepper(_setScrollOffset);
    _scrollOffset = PageStorage.of(context)?.readState(context) ?? config.initialScrollOffset ?? 0.0;
  }

  SimulationStepper _animation;

  double get scrollOffset => _scrollOffset;
  double _scrollOffset;

  Offset get scrollOffsetVector {
    if (config.scrollDirection == ScrollDirection.horizontal)
      return new Offset(scrollOffset, 0.0);
    return new Offset(0.0, scrollOffset);
  }

  ScrollBehavior _scrollBehavior;
  ScrollBehavior createScrollBehavior();
  ScrollBehavior get scrollBehavior {
    if (_scrollBehavior == null)
      _scrollBehavior = createScrollBehavior();
    return _scrollBehavior;
  }

  GestureDragStartCallback _getDragStartHandler(ScrollDirection direction) {
    if (config.scrollDirection != direction || !scrollBehavior.isScrollable)
      return null;
    return _handleDragStart;
  }

  GestureDragUpdateCallback _getDragUpdateHandler(ScrollDirection direction) {
    if (config.scrollDirection != direction || !scrollBehavior.isScrollable)
      return null;
    return _handleDragUpdate;
  }

  GestureDragEndCallback _getDragEndHandler(ScrollDirection direction) {
    if (config.scrollDirection != direction || !scrollBehavior.isScrollable)
      return null;
    return _handleDragEnd;
  }

  Widget build(BuildContext context) {
    return new GestureDetector(
      onVerticalDragStart: _getDragStartHandler(ScrollDirection.vertical),
      onVerticalDragUpdate: _getDragUpdateHandler(ScrollDirection.vertical),
      onVerticalDragEnd: _getDragEndHandler(ScrollDirection.vertical),
      onHorizontalDragStart: _getDragStartHandler(ScrollDirection.horizontal),
      onHorizontalDragUpdate: _getDragUpdateHandler(ScrollDirection.horizontal),
      onHorizontalDragEnd: _getDragEndHandler(ScrollDirection.horizontal),
      child: new Listener(
        child: buildContent(context),
        onPointerDown: _handlePointerDown
      )
    );
  }

  Widget buildContent(BuildContext context);

  Future _animateTo(double newScrollOffset, Duration duration, Curve curve) {
    _animation.stop();
    _animation.value = scrollOffset;
    return _animation.animateTo(newScrollOffset, duration: duration, curve: curve);
  }

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

  Simulation _createFlingSimulation(double velocity) {
    return scrollBehavior.createFlingScrollSimulation(scrollOffset, velocity);
  }

  Simulation _createSnapSimulation(double velocity) {
    if (velocity == null || config.snapOffsetCallback == null || !_scrollOffsetIsInBounds(scrollOffset))
      return null;

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

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

    double snappedScrollOffset = config.snapOffsetCallback(endScrollOffset + config.snapAlignmentOffset);
    double alignedScrollOffset = snappedScrollOffset - config.snapAlignmentOffset;
    if (!_scrollOffsetIsInBounds(alignedScrollOffset))
      return null;

    double snapVelocity = velocity.abs() * (alignedScrollOffset - scrollOffset).sign;
    Simulation toSnapSimulation =
      scrollBehavior.createSnapScrollSimulation(scrollOffset, alignedScrollOffset, snapVelocity);
    if (toSnapSimulation == null)
      return null;

    double offsetMin = math.min(scrollOffset, alignedScrollOffset);
    double offsetMax = math.max(scrollOffset, alignedScrollOffset);
    return new ClampedSimulation(toSnapSimulation, xMin: offsetMin, xMax: offsetMax);
  }

  Future _startToEndAnimation({ double velocity }) {
    _animation.stop();
    Simulation simulation =
      _createSnapSimulation(velocity) ?? _createFlingSimulation(velocity ?? 0.0);
    if (simulation == null)
      return new Future.value();
    return _animation.animateWith(simulation);
  }

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

  void _setScrollOffset(double newScrollOffset) {
    if (_scrollOffset == newScrollOffset)
      return;
    setState(() {
      _scrollOffset = newScrollOffset;
    });
    PageStorage.of(context)?.writeState(context, _scrollOffset);
    dispatchOnScroll();
  }

  Future scrollTo(double newScrollOffset, { Duration duration, Curve curve: Curves.ease }) {
    if (newScrollOffset == _scrollOffset)
      return new Future.value();

    if (duration == null) {
      _animation.stop();
      _setScrollOffset(newScrollOffset);
      return new Future.value();
    }

    return _animateTo(newScrollOffset, duration, curve);
  }

  Future scrollBy(double scrollDelta, { Duration duration, Curve curve }) {
    double newScrollOffset = scrollBehavior.applyCurve(_scrollOffset, scrollDelta);
    return scrollTo(newScrollOffset, duration: duration, curve: curve);
  }

  Future fling(Offset velocity) {
    if (velocity != Offset.zero)
      return _startToEndAnimation(velocity: _scrollVelocity(velocity));
    if (!_animation.isAnimating)
      return settleScrollOffset();
    return new Future.value();
  }

  Future settleScrollOffset() {
    return _startToEndAnimation();
  }

  void dispatchOnScrollStart() {
    if (config.onScrollStart != null)
      config.onScrollStart(_scrollOffset);
  }

  // Derived classes can override this method and call super.dispatchOnScroll()
  void dispatchOnScroll() {
    if (config.onScroll != null)
      config.onScroll(_scrollOffset);
  }

  void dispatchOnScrollEnd() {
    if (config.onScrollEnd != null)
      config.onScrollEnd(_scrollOffset);
  }

  double _scrollVelocity(ui.Offset velocity) {
    double scrollVelocity = config.scrollDirection == ScrollDirection.horizontal
      ? -velocity.dx
      : -velocity.dy;
    return scrollVelocity.clamp(_kMinFlingVelocity, _kMaxFlingVelocity) / _kMillisecondsPerSecond;
  }

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

  void _handleDragStart(_) {
    scheduleMicrotask(dispatchOnScrollStart);
  }

  void _handleDragUpdate(double delta) {
    // 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).
    scrollBy(-delta);
  }

  Future _handleDragEnd(Offset velocity) {
    return fling(velocity).then((_) {
        dispatchOnScrollEnd();
    });
  }
}

ScrollableState findScrollableAncestor(BuildContext context) {
  ScrollableState result;
  context.visitAncestorElements((Element element) {
    if (element is StatefulComponentElement) {
      if (element.state is ScrollableState) {
        result = element.state;
        return false;
      }
    }
    return true;
  });
  return result;
}

Future ensureWidgetIsVisible(BuildContext context, { Duration duration, Curve curve }) {
  assert(context.findRenderObject() is RenderBox);
  // TODO(abarth): This function doesn't handle nested scrollable widgets.

  ScrollableState scrollable = findScrollableAncestor(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;

  double scrollOffsetDelta;
  switch (scrollable.config.scrollDirection) {
    case ScrollDirection.vertical:
      Point targetCenter = targetBox.localToGlobal(new Point(0.0, targetSize.height / 2.0));
      Point scrollableCenter = scrollableBox.localToGlobal(new Point(0.0, scrollableSize.height / 2.0));
      scrollOffsetDelta = targetCenter.y - scrollableCenter.y;
      break;
    case ScrollDirection.horizontal:
      Point targetCenter = targetBox.localToGlobal(new Point(targetSize.width / 2.0, 0.0));
      Point scrollableCenter = scrollableBox.localToGlobal(new Point(scrollableSize.width / 2.0, 0.0));
      scrollOffsetDelta = targetCenter.x - scrollableCenter.x;
      break;
    case ScrollDirection.both:
      assert(false); // See https://github.com/flutter/engine/issues/888
      break;
  }

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

/// 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 {
  ScrollableViewport({
    Key key,
    this.child,
    double initialScrollOffset,
    ScrollDirection scrollDirection: ScrollDirection.vertical,
    ScrollListener onScroll
  }) : super(
    key: key,
    scrollDirection: scrollDirection,
    initialScrollOffset: initialScrollOffset,
    onScroll: onScroll
  );

  final Widget child;

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

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

  double _viewportSize = 0.0;
  double _childSize = 0.0;
  void _handleViewportSizeChanged(Size newSize) {
    _viewportSize = config.scrollDirection == ScrollDirection.vertical ? newSize.height : newSize.width;
    setState(() {
      _updateScrollBehaviour();
    });
  }
  void _handleChildSizeChanged(Size newSize) {
    _childSize = config.scrollDirection == ScrollDirection.vertical ? newSize.height : newSize.width;
    setState(() {
      _updateScrollBehaviour();
    });
  }
  void _updateScrollBehaviour() {
    // if you don't call this from build(), you must call it from setState().
    scrollTo(scrollBehavior.updateExtents(
      contentExtent: _childSize,
      containerExtent: _viewportSize,
      scrollOffset: scrollOffset
    ));
  }

  Widget buildContent(BuildContext context) {
    return new SizeObserver(
      onSizeChanged: _handleViewportSizeChanged,
      child: new Viewport(
        scrollOffset: scrollOffsetVector,
        scrollDirection: config.scrollDirection,
        child: new SizeObserver(
          onSizeChanged: _handleChildSizeChanged,
          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 StatelessComponent {
  Block(this.children, {
    Key key,
    this.padding,
    this.initialScrollOffset,
    this.scrollDirection: ScrollDirection.vertical,
    this.onScroll
  }) : super(key: key) {
    assert(!children.any((Widget child) => child == null));
  }

  final List<Widget> children;
  final EdgeDims padding;
  final double initialScrollOffset;
  final ScrollDirection scrollDirection;
  final ScrollListener onScroll;

  BlockDirection get _direction {
    if (scrollDirection == ScrollDirection.vertical)
      return BlockDirection.vertical;
    return BlockDirection.horizontal;
  }

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

abstract class ScrollableListPainter extends Painter {
  void attach(RenderObject renderObject) {
    assert(renderObject is RenderBlockViewport);
    super.attach(renderObject);
  }

  RenderBlockViewport get renderer => renderObject;

  bool get isVertical => renderer.isVertical;

  Size get viewportSize => renderer.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;
    renderer?.markNeedsPaint();
  }

  double get scrollOffset => _scrollOffset;
  double _scrollOffset = 0.0;
  void set scrollOffset (double value) {
    assert(value != null);
    if (_scrollOffset == value)
      return;
    _scrollOffset = value;
    renderer?.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 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();
}

/// An optimized scrollable widget for a large number of children that are all
/// the same size (extent) in the scrollDirection. For example for
/// ScrollDirection.vertical itemExtent is the height of each item. Use this
/// widget when you have a large number of children or when you are concerned
// about offscreen widgets consuming resources.
abstract class ScrollableWidgetList extends Scrollable {
  ScrollableWidgetList({
    Key key,
    double initialScrollOffset,
    ScrollDirection scrollDirection: ScrollDirection.vertical,
    ScrollListener onScroll,
    SnapOffsetCallback snapOffsetCallback,
    double snapAlignmentOffset: 0.0,
    this.itemsWrap: false,
    this.itemExtent,
    this.padding,
    this.scrollableListPainter
  }) : super(
    key: key,
    initialScrollOffset: initialScrollOffset,
    scrollDirection: scrollDirection,
    onScroll: onScroll,
    snapOffsetCallback: snapOffsetCallback,
    snapAlignmentOffset: snapAlignmentOffset
  ) {
    assert(itemExtent != null);
  }

  final bool itemsWrap;
  final double itemExtent;
  final EdgeDims padding;
  final ScrollableListPainter scrollableListPainter;
}

abstract class ScrollableWidgetListState<T extends ScrollableWidgetList> extends ScrollableState<T> {
  /// Subclasses must implement `get itemCount` to tell ScrollableWidgetList
  /// how many items there are in the list.
  int get itemCount;
  int _previousItemCount;

  Size _containerSize = Size.zero;

  void didUpdateConfig(T oldConfig) {
    super.didUpdateConfig(oldConfig);

    bool scrollBehaviorUpdateNeeded =
      config.padding != oldConfig.padding ||
      config.itemExtent != oldConfig.itemExtent ||
      config.scrollDirection != oldConfig.scrollDirection;

    if (config.itemsWrap != oldConfig.itemsWrap) {
      _scrollBehavior = null;
      scrollBehaviorUpdateNeeded = true;
    }

    if (itemCount != _previousItemCount) {
      _previousItemCount = itemCount;
      scrollBehaviorUpdateNeeded = true;
    }

    if (scrollBehaviorUpdateNeeded)
      _updateScrollBehavior();
  }

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

  double get _containerExtent {
    return config.scrollDirection == ScrollDirection.vertical
      ? _containerSize.height
      : _containerSize.width;
  }

  void _handleSizeChanged(Size newSize) {
    setState(() {
      _containerSize = newSize;
      _updateScrollBehavior();
    });
  }

  double get _leadingPadding {
    EdgeDims padding = config.padding;
    if (config.scrollDirection == ScrollDirection.vertical)
      return padding != null ? padding.top : 0.0;
    return padding != null ? padding.left : -.0;
  }

  double get _trailingPadding {
    EdgeDims padding = config.padding;
    if (config.scrollDirection == ScrollDirection.vertical)
      return padding != null ? padding.bottom : 0.0;
    return padding != null ? padding.right : 0.0;
  }

  EdgeDims get _crossAxisPadding {
    EdgeDims padding = config.padding;
    if (padding == null)
      return null;
    if (config.scrollDirection == ScrollDirection.vertical)
      return new EdgeDims.only(left: padding.left, right: padding.right);
    return new EdgeDims.only(top: padding.top, bottom: padding.bottom);
  }

  double get _contentExtent {
    if (itemCount == null)
      return null;
    double contentExtent = config.itemExtent * itemCount;
    if (config.padding != null)
      contentExtent += _leadingPadding + _trailingPadding;
    return contentExtent;
  }

  void _updateScrollBehavior() {
    // if you don't call this from build(), you must call it from setState().
    if (config.scrollableListPainter != null)
      config.scrollableListPainter.contentExtent = _contentExtent;
    scrollTo(scrollBehavior.updateExtents(
      contentExtent: _contentExtent,
      containerExtent: _containerExtent,
      scrollOffset: scrollOffset
    ));
  }

  void dispatchOnScrollStart() {
    super.dispatchOnScrollStart();
    config.scrollableListPainter?.scrollStarted();
  }

  void dispatchOnScroll() {
    super.dispatchOnScroll();
    if (config.scrollableListPainter != null)
      config.scrollableListPainter.scrollOffset = scrollOffset;
  }

  void dispatchOnScrollEnd() {
    super.dispatchOnScrollEnd();
    config.scrollableListPainter?.scrollEnded();
  }

  Widget buildContent(BuildContext context) {
    if (itemCount != _previousItemCount) {
      _previousItemCount = itemCount;
      _updateScrollBehavior();
    }

    return new SizeObserver(
      onSizeChanged: _handleSizeChanged,
      child: new Container(
        padding: _crossAxisPadding,
        child: new HomogeneousViewport(
          builder: _buildItems,
          itemsWrap: config.itemsWrap,
          itemExtent: config.itemExtent,
          itemCount: itemCount,
          direction: config.scrollDirection,
          startOffset: scrollOffset - _leadingPadding,
          overlayPainter: config.scrollableListPainter
        )
      )
    );
  }

  List<Widget> _buildItems(BuildContext context, int start, int count) {
    List<Widget> result = buildItems(context, start, count);
    assert(result.every((Widget item) => item.key != null));
    return result;
  }

  List<Widget> buildItems(BuildContext context, int start, int count);

}

typedef Widget ItemBuilder<T>(BuildContext context, T item, int index);

/// A wrapper around [ScrollableWidgetList] that helps you translate a list of
/// model objects into a scrollable list of widgets. Assumes all the widgets
/// have the same height.
class ScrollableList<T> extends ScrollableWidgetList {
  ScrollableList({
    Key key,
    double initialScrollOffset,
    ScrollDirection scrollDirection: ScrollDirection.vertical,
    ScrollListener onScroll,
    SnapOffsetCallback snapOffsetCallback,
    double snapAlignmentOffset: 0.0,
    this.items,
    this.itemBuilder,
    itemsWrap: false,
    double itemExtent,
    EdgeDims padding,
    ScrollableListPainter scrollableListPainter
  }) : super(
    key: key,
    initialScrollOffset: initialScrollOffset,
    scrollDirection: scrollDirection,
    onScroll: onScroll,
    snapOffsetCallback: snapOffsetCallback,
    snapAlignmentOffset: snapAlignmentOffset,
    itemsWrap: itemsWrap,
    itemExtent: itemExtent,
    padding: padding,
    scrollableListPainter: scrollableListPainter
  );

  final List<T> items;
  final ItemBuilder<T> itemBuilder;

  ScrollableListState<T, ScrollableList<T>> createState() => new ScrollableListState<T, ScrollableList<T>>();
}

class ScrollableListState<T, Config extends ScrollableList<T>> extends ScrollableWidgetListState<Config> {
  ScrollBehavior createScrollBehavior() {
    return config.itemsWrap ? new UnboundedBehavior() : super.createScrollBehavior();
  }

  int get itemCount => config.items.length;

  List<Widget> buildItems(BuildContext context, int start, int count) {
    List<Widget> result = new List<Widget>();
    int begin = config.itemsWrap ? start : math.max(0, start);
    int end = config.itemsWrap ? begin + count : math.min(begin + count, config.items.length);
    for (int i = begin; i < end; ++i)
      result.add(config.itemBuilder(context, config.items[i % itemCount], i));
    return result;
  }
}

typedef void PageChangedCallback(int newPage);

class PageableList<T> extends ScrollableList<T> {
  PageableList({
    Key key,
    int initialPage,
    ScrollDirection scrollDirection: ScrollDirection.horizontal,
    ScrollListener onScroll,
    List<T> items,
    ItemBuilder<T> itemBuilder,
    bool itemsWrap: false,
    double itemExtent,
    this.onPageChanged,
    EdgeDims padding,
    this.duration: const Duration(milliseconds: 200),
    this.curve: Curves.ease
  }) : super(
    key: key,
    initialScrollOffset: initialPage == null ? null : initialPage * itemExtent,
    scrollDirection: scrollDirection,
    onScroll: onScroll,
    items: items,
    itemBuilder: itemBuilder,
    itemsWrap: itemsWrap,
    itemExtent: itemExtent,
    padding: padding
  );

  final Duration duration;
  final Curve curve;
  final PageChangedCallback onPageChanged;

  PageableListState<T> createState() => new PageableListState<T>();
}

class PageableListState<T> extends ScrollableListState<T, PageableList<T>> {
  double _snapScrollOffset(double newScrollOffset) {
    double scaledScrollOffset = newScrollOffset / config.itemExtent;
    double previousScrollOffset = scaledScrollOffset.floor() * config.itemExtent;
    double nextScrollOffset = scaledScrollOffset.ceil() * config.itemExtent;
    double delta = newScrollOffset - previousScrollOffset;
    return (delta < config.itemExtent / 2.0 ? previousScrollOffset : nextScrollOffset)
      .clamp(scrollBehavior.minScrollOffset, scrollBehavior.maxScrollOffset);
  }

  Future fling(ui.Offset velocity) {
    double scrollVelocity = _scrollVelocity(velocity);
    double newScrollOffset = _snapScrollOffset(scrollOffset + scrollVelocity.sign * config.itemExtent)
      .clamp(_snapScrollOffset(scrollOffset - config.itemExtent / 2.0),
             _snapScrollOffset(scrollOffset + config.itemExtent / 2.0));
    return scrollTo(newScrollOffset, duration: config.duration, curve: config.curve).then(_notifyPageChanged);
  }

  int get currentPage => (scrollOffset / config.itemExtent).floor() % itemCount;

  void _notifyPageChanged(_) {
    if (config.onPageChanged != null)
      config.onPageChanged(currentPage);
  }

  Future settleScrollOffset() {
    return scrollTo(_snapScrollOffset(scrollOffset), duration: config.duration, curve: config.curve).then(_notifyPageChanged);
  }
}

/// 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.
class ScrollableMixedWidgetList extends Scrollable {
  ScrollableMixedWidgetList({
    Key key,
    double initialScrollOffset,
    ScrollListener onScroll,
    SnapOffsetCallback snapOffsetCallback,
    double snapAlignmentOffset: 0.0,
    this.builder,
    this.token,
    this.onInvalidatorAvailable
  }) : super(
    key: key,
    initialScrollOffset: initialScrollOffset,
    onScroll: onScroll,
    snapOffsetCallback: snapOffsetCallback,
    snapAlignmentOffset: snapAlignmentOffset
  );

  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 createScrollBehavior() => new OverscrollBehavior();
  OverscrollBehavior get scrollBehavior => super.scrollBehavior;

  void _handleSizeChanged(Size newSize) {
    setState(() {
      scrollBy(scrollBehavior.updateExtents(
        containerExtent: newSize.height,
        scrollOffset: scrollOffset
      ));
    });
  }

  bool _contentChanged = false;

  void didUpdateConfig(ScrollableMixedWidgetList oldConfig) {
    super.didUpdateConfig(oldConfig);
    if (config.token != oldConfig.token) {
      // When the token changes the scrollable's contents may have changed.
      // Remember as much so that after the new contents have been laid out we
      // can adjust the scrollOffset so that the last page of content is still
      // visible.
      _contentChanged = true;
    }
  }

  void _handleExtentsUpdate(double newExtents) {
    double newScrollOffset;
    setState(() {
      newScrollOffset = scrollBehavior.updateExtents(
        contentExtent: newExtents ?? double.INFINITY,
        scrollOffset: scrollOffset
      );
    });
    if (_contentChanged) {
      _contentChanged = false;
      scrollTo(newScrollOffset);
    }
  }

  Widget buildContent(BuildContext context) {
    return new SizeObserver(
      onSizeChanged: _handleSizeChanged,
      child: new MixedViewport(
        startOffset: scrollOffset,
        builder: config.builder,
        token: config.token,
        onInvalidatorAvailable: config.onInvalidatorAvailable,
        onExtentsUpdate: _handleExtentsUpdate
      )
    );
  }
}
