blob: ae400f68b712d8da6904a4d1207300dc5aa55f36 [file]
// 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:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/physics.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'basic.dart';
import 'clamp_overscrolls.dart';
import 'framework.dart';
import 'gesture_detector.dart';
import 'notification_listener.dart';
import 'page_storage.dart';
import 'scroll_absolute.dart' show ViewportScrollBehavior;
import 'scroll_behavior.dart';
import 'scroll_configuration.dart';
import 'scroll_notification.dart';
import 'ticker_provider.dart';
import 'viewport.dart';
export 'package:flutter/physics.dart' show Tolerance;
// This file defines an unopinionated scrolling mechanism.
// See scroll_absolute.dart for variants that do things by pixels.
abstract class ScrollPosition extends ViewportOffset {
/// Create a new [ScrollPosition].
///
/// The first argument is the [Scrollable2State] object with which this scroll
/// position is associated. The second provides the tolerances for activities
/// that use simulations and need to decide when to end them. The final
/// argument is the previous instance of [ScrollPosition] that was being used
/// by the same [Scrollable2State], if any.
ScrollPosition(this.state, this.scrollTolerances, ScrollPosition oldPosition) {
assert(state is TickerProvider);
assert(scrollTolerances != null);
if (oldPosition != null)
absorb(oldPosition);
if (activity == null)
beginIdleActivity();
assert(activity != null);
assert(activity.position == this);
}
@protected
final Scrollable2State state;
final Tolerance scrollTolerances;
@protected
TickerProvider get vsync => state;
@protected
ScrollActivity get activity => _activity;
ScrollActivity _activity;
/// Take any current applicable state from the given [ScrollPosition].
///
/// This method is called by the constructor, instead of calling
/// [beginIdleActivity], if it is given an `oldPosition`. It adopts the old
/// position's current [activity] as its own.
///
/// This method is destructive to the other [ScrollPosition]. The other
/// object must be disposed immediately after this call (in the same call
/// stack, before microtask resolution, by whomever called this object's
/// constructor).
///
/// If the old [ScrollPosition] object is a different [runtimeType] than this
/// one, the [ScrollActivity.resetActivity] method is invoked on the newly
/// adopted [ScrollActivity].
///
/// When overriding this method, call `super.absorb` after setting any
/// metrics-related or activity-related state, since this method may restart
/// the activity and scroll activities tend to use those metrics when being
/// restarted.
@protected
@mustCallSuper
void absorb(ScrollPosition other) {
assert(activity == null);
assert(other != this);
assert(other.state == state);
assert(other.activity != null);
final bool oldIgnorePointer = shouldIgnorePointer;
_userScrollDirection = other._userScrollDirection;
other.activity._position = this;
_activity = other.activity;
other._activity = null;
if (oldIgnorePointer != shouldIgnorePointer)
state._updateIgnorePointer(shouldIgnorePointer);
if (other.runtimeType != runtimeType)
activity.resetActivity();
}
/// Change the current [activity], disposing of the old one and
/// sending scroll notifications as necessary.
///
/// If the argument is null, this method has no effect. This is convenient for
/// cases where the new activity is obtained from another method, and that
/// method might return null, since it means the caller does not have to
/// explictly null-check the argument.
void beginActivity(ScrollActivity newActivity) {
if (newActivity == null)
return;
assert(newActivity.position == this);
final bool oldIgnorePointer = shouldIgnorePointer;
bool wasScrolling;
if (activity != null) {
wasScrolling = activity.isScrolling;
if (wasScrolling && !newActivity.isScrolling)
dispatchNotification(activity.createScrollEndNotification(state));
activity.dispose();
} else {
wasScrolling = false;
}
_activity = newActivity;
if (oldIgnorePointer != shouldIgnorePointer)
state._updateIgnorePointer(shouldIgnorePointer);
if (!activity.isScrolling)
updateUserScrollDirection(ScrollDirection.idle);
if (!wasScrolling && activity.isScrolling)
dispatchNotification(activity.createScrollStartNotification(state));
}
@protected
void dispatchNotification(Notification notification) {
assert(state.mounted);
notification.dispatch(state._viewportKey.currentContext);
}
@override
void dispose() {
activity?.dispose(); // it will be null if it got absorbed by another ScrollPosition
_activity = null;
super.dispose();
}
void touched() {
_activity.touched();
}
@override
@mustCallSuper
void applyViewportDimension(double viewportDimension) {
state._updateGestureDetectors(canDrag);
}
@override
@mustCallSuper
bool applyContentDimensions(double minScrollExtent, double maxScrollExtent) {
state._updateGestureDetectors(canDrag);
return true;
}
/// The direction that the user most recently began scrolling in.
@override
ScrollDirection get userScrollDirection => _userScrollDirection;
ScrollDirection _userScrollDirection = ScrollDirection.idle;
/// Set [userScrollDirection] to the given value.
///
/// If this changes the value, then a [UserScrollNotification] is dispatched.
///
/// This should only be set from the current [ScrollActivity] (see [activity]).
void updateUserScrollDirection(ScrollDirection value) {
assert(value != null);
if (userScrollDirection == value)
return;
_userScrollDirection = value;
dispatchNotification(new UserScrollNotification(scrollable: state, direction: value));
}
@override
void debugFillDescription(List<String> description) {
super.debugFillDescription(description);
description.add('$activity');
description.add('$userScrollDirection');
}
bool get canDrag => false;
bool get shouldIgnorePointer => false;
@mustCallSuper
void postFrameCleanup() { }
void beginIdleActivity() {
beginActivity(new IdleScrollActivity(this));
}
DragScrollActivity beginDragActivity(DragStartDetails details) {
if (canDrag) {
throw new FlutterError(
'$runtimeType does not implement beginDragActivity but canDrag is true.\n'
'If a ScrollPosition class ever returns true from canDrag, then it must '
'implement the beginDragActivity method to handle drags.\n'
'The beginDragActivity method should call beginActivity, passing it a new '
'instance of a DragScrollActivity subclass that has been initialized with '
'this ScrollPosition object as its position.'
);
}
assert(false);
return null;
}
/// Used by [AbsoluteDragScrollActivity] and other user-driven activities to
/// convert an offset in logical pixels as provided by the [DragUpdateDetails]
/// into a delta to apply using [setPixels].
///
/// This is used by some [ScrollPosition] subclasses to apply friction during
/// overscroll situations.
double applyPhysicsToUserOffset(double offset) => offset;
// ///
// /// The velocity should be in logical pixels per second.
void beginBallisticActivity(double velocity) {
beginIdleActivity();
}
// ABSTRACT METHODS
/// Update the scroll position ([pixels]) to a given pixel value.
///
/// This should only be called by the current [ScrollActivity], either during
/// the transient callback phase or in response to user input.
///
/// Returns the overscroll, if any. If the return value is 0.0, that means
/// that [pixels] now returns the given `value`. If the return value is
/// positive, then [pixels] is less than the requested `value` by the given
/// amount (overscroll past the max extent), and if it is negative, it is
/// greater than the requested `value` by the given amount (underscroll past
/// the min extent).
///
/// Implementations of this method must dispatch scroll update notifications
/// (using [dispatchNotification] and
/// [ScrollActivity.createScrollUpdateNotification]) after applying the new
/// value (so after [pixels] changes). If the entire change is not applied,
/// the overscroll should be reported by subsequently also dispatching an
/// overscroll notification using
/// [ScrollActivity.createOverscrollNotification].
double setPixels(double value);
/// Returns a description of the [Scrollable].
///
/// Accurately describing the metrics typicaly requires using information
/// provided by the viewport to the [applyViewportDimension] and
/// [applyContentDimensions] methods.
///
/// The metrics do not need to be in absolute (pixel) units, but they must be
/// in consistent units (so that they can be compared over time or used to
/// drive diagrammatic user interfaces such as scrollbars).
ScrollableMetrics getMetrics();
// Subclasses must also implement the [pixels] getter and [correctBy].
}
/// Base class for scrolling activities like dragging, and flinging.
abstract class ScrollActivity {
ScrollActivity(ScrollPosition position) {
_position = position;
}
@protected
ScrollPosition get position => _position;
ScrollPosition _position;
/// Called by the [ScrollPosition] when it has changed type (for example, when
/// changing from an Android-style scroll position to an iOS-style scroll
/// position). If this activity can differ between the two modes, then it
/// should tell the position to restart that activity appropriately.
///
/// For example, [BallisticScrollActivity]'s implementation calls
/// [ScrollPosition.beginBallisticActivity].
void resetActivity() { }
Notification createScrollStartNotification(Scrollable2State scrollable) {
return new ScrollStartNotification(scrollable: scrollable);
}
Notification createScrollUpdateNotification(Scrollable2State scrollable, double scrollDelta) {
return new ScrollUpdateNotification(scrollable: scrollable, scrollDelta: scrollDelta);
}
Notification createOverscrollNotification(Scrollable2State scrollable, double overscroll) {
return new OverscrollNotification(scrollable: scrollable, overscroll: overscroll);
}
Notification createScrollEndNotification(Scrollable2State scrollable) {
return new ScrollEndNotification(scrollable: scrollable);
}
void touched() { }
void applyNewDimensions() { }
bool get shouldIgnorePointer;
bool get isScrolling;
@mustCallSuper
void dispose() { }
@override
String toString() => '$runtimeType';
}
class IdleScrollActivity extends ScrollActivity {
IdleScrollActivity(ScrollPosition position) : super(position);
@override
void applyNewDimensions() {
position.beginBallisticActivity(0.0);
}
@override
bool get shouldIgnorePointer => false;
@override
bool get isScrolling => false;
}
abstract class DragScrollActivity extends ScrollActivity {
DragScrollActivity(ScrollPosition position) : super(position);
void update(DragUpdateDetails details, { bool reverse });
void end(DragEndDetails details, { bool reverse });
@override
void touched() {
assert(false);
}
@override
void dispose() {
position.state._drag = null;
super.dispose();
}
}
/// Base class for delegates that instantiate [ScrollPosition] objects.
abstract class ScrollBehavior2 {
const ScrollBehavior2();
Widget wrap(BuildContext context, Widget child, AxisDirection axisDirection);
/// Returns a new instance of the ScrollPosition class that this
/// ScrollBehavior2 subclass creates.
///
/// A given ScrollBehavior2 object must always return the same kind of
/// ScrollPosition, with the same configuration.
///
/// The `oldPosition` argument should be passed to the `ScrollPosition`
/// constructor so that the new position can take over the old position's
/// offset and (if it's the same type) activity.
///
/// When calling [createScrollPosition] with a non-null `oldPosition`, that
/// object must be disposed (via [ScrollPosition.oldPosition]) in the same
/// call stack. Passing a non-null `oldPosition` is a destructive operation
/// for that [ScrollPosition].
ScrollPosition createScrollPosition(BuildContext context, Scrollable2State state, ScrollPosition oldPosition);
/// Whether this delegate is different than the old delegate, or would now
/// return meaningfully different widgets from [wrap] or a meaningfully
/// different [ScrollPosition] from [createScrollPosition].
///
/// It is not necessary to return true if the return values for [wrap] and
/// [createScrollPosition] would only be different because of depending on the
/// [BuildContext] argument they are provided, as dependency checking is
/// handled separately.
bool shouldNotify(@checked ScrollBehavior2 oldDelegate);
@override
String toString() => '$runtimeType';
}
class ScrollConfiguration2 extends InheritedWidget {
const ScrollConfiguration2({
Key key,
@required this.delegate,
@required Widget child,
}) : super(key: key, child: child);
final ScrollBehavior2 delegate;
static ScrollBehavior2 of(BuildContext context) {
ScrollConfiguration2 configuration = context.inheritFromWidgetOfExactType(ScrollConfiguration2);
return configuration?.delegate;
}
@override
bool updateShouldNotify(ScrollConfiguration2 old) {
assert(delegate != null);
return delegate.runtimeType != old.delegate.runtimeType
|| delegate.shouldNotify(old.delegate);
}
}
class Scrollable2 extends StatefulWidget {
Scrollable2({
Key key,
this.axisDirection: AxisDirection.down,
this.anchor: 0.0,
this.initialScrollOffset: 0.0,
this.scrollBehavior,
this.center,
this.children,
}) : super (key: key) {
assert(axisDirection != null);
assert(anchor != null);
assert(initialScrollOffset != null);
}
final AxisDirection axisDirection;
final double anchor;
final double initialScrollOffset;
/// The delegate that creates the [ScrollPosition] and wraps the viewport
/// in extra widgets (e.g. for overscroll effects).
///
/// If no scroll behavior delegate is explicitly supplied, the scroll behavior
/// from the nearest [ScrollConfiguration2] is used. If there is no
/// [ScrollConfiguration2] in scope, a new [ViewportScrollBehavior] is used.
final ScrollBehavior2 scrollBehavior;
final Key center;
final List<Widget> children;
Axis get axis => axisDirectionToAxis(axisDirection);
@override
Scrollable2State createState() => new Scrollable2State();
ScrollBehavior2 getScrollBehavior(BuildContext context) {
return scrollBehavior
?? ScrollConfiguration2.of(context)
?? new ViewportScrollBehavior();
}
/// Whether, when this widget has been replaced by another, the scroll
/// behavior and scroll position need to be updated as well.
bool shouldUpdateScrollPosition(Scrollable2 oldWidget) {
return scrollBehavior.runtimeType != oldWidget.scrollBehavior.runtimeType
|| (scrollBehavior != null && scrollBehavior.shouldNotify(oldWidget.scrollBehavior));
}
@override
void debugFillDescription(List<String> description) {
super.debugFillDescription(description);
description.add('$axisDirection');
if (anchor != 0.0)
description.add('anchor: ${anchor.toStringAsFixed(1)}');
if (initialScrollOffset != 0.0)
description.add('initialScrollOffset: ${initialScrollOffset.toStringAsFixed(1)}');
if (scrollBehavior != null) {
description.add('scrollBehavior: $scrollBehavior');
} else {
description.add('scrollBehavior: use inherited ScrollBehavior2');
}
if (center != null)
description.add('center: $center');
}
}
/// State object for a [Scrollable2] widget.
///
/// To manipulate a [Scrollable2] widget's scroll position, use the object
/// obtained from the [position] property.
///
/// To be informed of when a [Scrollable2] widget is scrolling, use a
/// [NotificationListener] to listen for [ScrollNotification2] notifications.
///
/// This class is not intended to be subclassed. To specialize the behavior of a
/// [Scrollable2], provide it with a custom [ScrollBehavior2] delegate.
class Scrollable2State extends State<Scrollable2> with TickerProviderStateMixin {
/// The controller for this [Scrollable2] widget's viewport position.
///
/// To control what kind of [ScrollPosition] is created for a [Scrollable2],
/// provide it with a custom [ScrollBehavior2] delegate that creates the
/// appropriate [ScrollPosition] controller in its
/// [ScrollBehavior2.createScrollPosition] method.
ScrollPosition get position => _position;
ScrollPosition _position;
ScrollBehavior2 _scrollBehavior;
// only call this from places that will definitely trigger a rebuild
void _updatePosition() {
_scrollBehavior = config.getScrollBehavior(context);
final ScrollPosition oldPosition = position;
_position = _scrollBehavior.createScrollPosition(context, this, oldPosition);
assert(position != null);
if (oldPosition != null) {
// It's important that we not do this until after the RenderViewport2
// object has had a chance to unregister its listeners from the old
// position. So, schedule a microtask to do it.
scheduleMicrotask(oldPosition.dispose);
}
}
@override
void dependenciesChanged() {
super.dependenciesChanged();
_updatePosition();
}
@override
void didUpdateConfig(Scrollable2 oldConfig) {
super.didUpdateConfig(oldConfig);
if (config.shouldUpdateScrollPosition(oldConfig))
_updatePosition();
}
@override
void dispose() {
position.dispose();
super.dispose();
}
// GESTURE RECOGNITION AND POINTER IGNORING
final GlobalKey<RawGestureDetectorState> _gestureDetectorKey = new GlobalKey<RawGestureDetectorState>();
final GlobalKey _ignorePointerKey = new GlobalKey();
final GlobalKey _viewportKey = new GlobalKey();
// This field is set during layout, and then reused until the next time it is set.
Map<Type, GestureRecognizerFactory> _gestureRecognizers = const <Type, GestureRecognizerFactory>{};
bool _shouldIgnorePointer = false;
bool _lastCanDrag;
Axis _lastAxisDirection;
void _updateGestureDetectors(bool canDrag) {
if (canDrag == _lastCanDrag && (!canDrag || config.axis == _lastAxisDirection))
return;
if (!canDrag) {
_gestureRecognizers = const <Type, GestureRecognizerFactory>{};
} else {
switch (config.axis) {
case Axis.vertical:
_gestureRecognizers = <Type, GestureRecognizerFactory>{
VerticalDragGestureRecognizer: (VerticalDragGestureRecognizer recognizer) { // ignore: map_value_type_not_assignable, https://github.com/flutter/flutter/issues/7173
return (recognizer ??= new VerticalDragGestureRecognizer())
..onDown = _handleDragDown
..onStart = _handleDragStart
..onUpdate = _handleDragUpdate
..onEnd = _handleDragEnd;
}
};
break;
case Axis.horizontal:
_gestureRecognizers = <Type, GestureRecognizerFactory>{
HorizontalDragGestureRecognizer: (HorizontalDragGestureRecognizer recognizer) { // ignore: map_value_type_not_assignable, https://github.com/flutter/flutter/issues/7173
return (recognizer ??= new HorizontalDragGestureRecognizer())
..onDown = _handleDragDown
..onStart = _handleDragStart
..onUpdate = _handleDragUpdate
..onEnd = _handleDragEnd;
}
};
break;
}
}
_lastCanDrag = canDrag;
_lastAxisDirection = config.axis;
if (_gestureDetectorKey.currentState != null)
_gestureDetectorKey.currentState.replaceGestureRecognizers(_gestureRecognizers);
}
void _updateIgnorePointer(bool value) {
if (_shouldIgnorePointer == value)
return;
_shouldIgnorePointer = value;
if (_ignorePointerKey.currentContext != null) {
RenderIgnorePointer renderBox = _ignorePointerKey.currentContext.findRenderObject();
renderBox.ignoring = _shouldIgnorePointer;
}
}
// TOUCH HANDLERS
DragScrollActivity _drag;
bool get _reverseDirection {
assert(config.axisDirection != null);
switch (config.axisDirection) {
case AxisDirection.up:
case AxisDirection.left:
return true;
case AxisDirection.down:
case AxisDirection.right:
return false;
}
return null;
}
void _handleDragDown(DragDownDetails details) {
assert(_drag == null);
position.touched();
}
void _handleDragStart(DragStartDetails details) {
assert(_drag == null);
_drag = position.beginDragActivity(details);
}
void _handleDragUpdate(DragUpdateDetails details) {
assert(_drag != null);
_drag.update(details, reverse: _reverseDirection);
}
void _handleDragEnd(DragEndDetails details) {
assert(_drag != null);
_drag.end(details, reverse: _reverseDirection);
assert(_drag == null);
}
// DESCRIPTION
@override
Widget build(BuildContext context) {
assert(position != null);
// TODO(ianh): Having all these global keys is sad.
Widget result = new RawGestureDetector(
key: _gestureDetectorKey,
gestures: _gestureRecognizers,
behavior: HitTestBehavior.opaque,
child: new IgnorePointer(
key: _ignorePointerKey,
ignoring: _shouldIgnorePointer,
child: new Viewport2(
key: _viewportKey,
axisDirection: config.axisDirection,
anchor: config.anchor,
offset: position,
center: config.center,
children: config.children,
),
),
);
return _scrollBehavior.wrap(context, result, config.axisDirection);
}
@override
void debugFillDescription(List<String> description) {
super.debugFillDescription(description);
description.add('position: $position');
}
}
// DELETE EVERYTHING BELOW THIS LINE WHEN REMOVING LEGACY SCROLLING CODE
/// Identifies one or both limits of a [Scrollable] in terms of its scrollDirection.
enum ScrollableEdge {
/// The top and bottom of the scrollable if its scrollDirection is vertical
/// or the left and right if its scrollDirection is horizontal.
both,
/// Only the top of the scrollable if its scrollDirection is vertical,
/// or only the left if its scrollDirection is horizontal.
leading,
/// Only the bottom of the scrollable if its scroll-direction is vertical,
/// or only the right if its scrollDirection is horizontal.
trailing,
/// The overscroll indicator should not appear at all.
none,
}
/// The accuracy to which scrolling is computed.
final Tolerance kPixelScrollTolerance = new Tolerance(
// TODO(ianh): Handle the case of the device pixel ratio changing.
velocity: 1.0 / (0.050 * ui.window.devicePixelRatio), // logical pixels per second
distance: 1.0 / ui.window.devicePixelRatio // logical pixels
);
/// Signature for building a widget based on [ScrollableState].
///
/// Used by [Scrollable.builder].
typedef Widget ScrollBuilder(BuildContext context, ScrollableState state);
/// Signature for callbacks that receive a scroll offset.
///
/// Used by [Scrollable.onScrollStart], [Scrollable.onScroll], and [Scrollable.onScrollEnd].
typedef void ScrollListener(double scrollOffset);
/// Signature for determining the offset at which scrolling should snap.
///
/// Used by [Scrollable.snapOffsetCallback].
typedef double SnapOffsetCallback(double scrollOffset, Size containerSize);
/// A base class for scrollable widgets.
///
/// If you have a list of widgets and want them to be able to scroll if there is
/// insufficient room, consider using [Block].
///
/// Commonly used classes that are based on Scrollable include [ScrollableList],
/// [ScrollableGrid], and [ScrollableViewport].
///
/// Widgets that subclass [Scrollable] typically use state objects that subclass
/// [ScrollableState].
class Scrollable extends StatefulWidget {
/// Initializes fields for subclasses.
///
/// The [scrollDirection] and [scrollAnchor] arguments must not be null.
Scrollable({
Key key,
this.initialScrollOffset,
this.scrollDirection: Axis.vertical,
this.scrollAnchor: ViewportAnchor.start,
this.onScrollStart,
this.onScroll,
this.onScrollEnd,
this.snapOffsetCallback,
this.builder
}) : super(key: key) {
assert(scrollDirection == Axis.vertical || scrollDirection == Axis.horizontal);
assert(scrollAnchor == ViewportAnchor.start || scrollAnchor == ViewportAnchor.end);
}
// Warning: keep the dartdoc comments that follow in sync with the copies in
// ScrollableViewport, LazyBlock, ScrollableLazyList, ScrollableList, and
// ScrollableGrid. And see: https://github.com/dart-lang/dartdoc/issues/1161.
/// 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;
/// Using to build the content of this widget.
///
/// See [buildContent] for details.
final ScrollBuilder builder;
/// The state from the closest instance of this class that encloses the given context.
///
/// Typical usage is as follows:
///
/// ```dart
/// ScrollableState scrollable = Scrollable.of(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 too 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();
}
@override
ScrollableState createState() => new ScrollableState<Scrollable>();
}
/// 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.
@optionalTypeArgs
class ScrollableState<T extends Scrollable> extends State<T> with SingleTickerProviderStateMixin {
@override
void initState() {
super.initState();
_controller = new AnimationController.unbounded(vsync: this)
..addListener(_handleAnimationChanged)
..addStatusListener(_handleAnimationStatusChanged);
_scrollOffset = PageStorage.of(context)?.readState(context) ?? config.initialScrollOffset ?? 0.0;
_virtualScrollOffset = _scrollOffset;
}
Simulation _simulation; // if we're flinging, then this is the animation with which we're doing it
AnimationController _controller;
double _contentExtent;
double _containerExtent;
bool _scrollUnderway = false;
@override
void dispose() {
_controller.dispose();
_simulation = null;
super.dispose();
}
@override
void dependenciesChanged() {
_scrollBehavior = createScrollBehavior();
didUpdateScrollBehavior(_scrollBehavior.updateExtents(
contentExtent: _contentExtent,
containerExtent: _containerExtent,
scrollOffset: scrollOffset
));
super.dependenciesChanged();
}
/// 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.
///
/// The scroll offset's value may be above or below the limits defined
/// by the [scrollBehavior]. This is called "overscrolling" and it can be
/// prevented with the [ClampOverscrolls] widget.
///
/// See also:
///
/// * [virtualScrollOffset]
/// * [initialScrollOffset]
/// * [onScrollStart]
/// * [onScroll]
/// * [onScrollEnd]
/// * [ScrollNotification]
double get scrollOffset => _scrollOffset;
double _scrollOffset;
/// The current scroll offset, irrespective of the constraints defined
/// by any [ClampOverscrolls] widget ancestors.
///
/// See also:
///
/// * [scrollOffset]
double get virtualScrollOffset => _virtualScrollOffset;
double _virtualScrollOffset;
/// 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;
}
assert(config.scrollAnchor != null);
return null;
}
/// 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;
}
assert(config.scrollAnchor != null);
return null;
}
/// 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);
}
assert(config.scrollDirection != null);
return null;
}
/// 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));
}
assert(config.scrollDirection != null);
return null;
}
/// 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.
ExtentScrollBehavior get scrollBehavior => _scrollBehavior;
ExtentScrollBehavior _scrollBehavior;
/// Use the value returned by [ScrollConfiguration.createScrollBehavior].
/// If this widget doesn't have a ScrollConfiguration ancestor,
/// or its createScrollBehavior callback is null, then return a new instance
/// of [OverscrollWhenScrollableBehavior].
@protected
ExtentScrollBehavior createScrollBehavior() {
return ScrollConfiguration.of(context)?.createScrollBehavior();
}
bool _scrollOffsetIsInBounds(double scrollOffset) {
if (scrollBehavior is! ExtentScrollBehavior)
return false;
final ExtentScrollBehavior behavior = scrollBehavior;
return scrollOffset >= behavior.minScrollOffset && scrollOffset < behavior.maxScrollOffset;
}
void _handleAnimationChanged() {
_setScrollOffset(_controller.value);
}
void _handleAnimationStatusChanged(AnimationStatus status) {
// this is not called when stop() is called on the controller
setState(() {
if (!_controller.isAnimating) {
_simulation = null;
_scrollUnderway = false;
}
});
}
void _setScrollOffset(double newScrollOffset, { DragUpdateDetails details }) {
if (_scrollOffset == newScrollOffset)
return;
final ClampOverscrolls clampOverscrolls = ClampOverscrolls.of(context);
final double clampedScrollOffset = clampOverscrolls?.clampScrollOffset(this, newScrollOffset) ?? newScrollOffset;
_setStateMaybeDuringBuild(() {
_virtualScrollOffset = newScrollOffset;
_scrollUnderway = _scrollOffset != clampedScrollOffset;
_scrollOffset = clampedScrollOffset;
});
PageStorage.of(context)?.writeState(context, _scrollOffset);
_startScroll();
dispatchOnScroll();
new ScrollNotification(
scrollable: this,
kind: ScrollNotificationKind.updated,
details: details
).dispatch(context);
_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,
DragUpdateDetails details
}) {
double newScrollOffset = scrollBehavior.applyCurve(virtualScrollOffset, scrollDelta);
return scrollTo(newScrollOffset, duration: duration, curve: curve, details: details);
}
/// 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.
///
/// The returned [Future] completes when the scrolling animation is complete.
Future<Null> scrollTo(double newScrollOffset, {
Duration duration,
Curve curve: Curves.ease,
DragUpdateDetails details
}) {
if (newScrollOffset == _scrollOffset)
return new Future<Null>.value();
if (duration == null) {
_stop();
_setScrollOffset(newScrollOffset, details: details);
return new Future<Null>.value();
}
assert(duration > Duration.ZERO);
return _animateTo(newScrollOffset, duration, curve);
}
Future<Null> _animateTo(double newScrollOffset, Duration duration, Curve curve) {
_stop();
_controller.value = virtualScrollOffset;
_startScroll();
return _controller.animateTo(newScrollOffset, duration: duration, curve: curve).then((Null _) {
_endScroll();
});
}
/// Update any in-progress scrolling physics to account for new scroll behavior.
///
/// The scrolling physics depends on the scroll behavior. When changing the
/// scrolling behavior, call this function to update any in-progress scrolling
/// physics to account for the new scroll behavior. This function preserves
/// the current velocity when updating the physics.
///
/// If there are no in-progress scrolling physics, this function scrolls to
/// the given offset instead.
void didUpdateScrollBehavior(double newScrollOffset) {
_setStateMaybeDuringBuild(() {
_contentExtent = scrollBehavior.contentExtent;
_containerExtent = scrollBehavior.containerExtent;
});
// This does not call setState, because if anything below actually
// changes our build, it will itself independently trigger a frame.
assert(_controller.isAnimating || _simulation == null);
if (_numberOfInProgressScrolls > 0) {
if (_simulation != null) {
double dx = _simulation.dx(_controller.lastElapsedDuration.inMicroseconds / Duration.MICROSECONDS_PER_SECOND);
_startToEndAnimation(dx); // dx - logical pixels / second
}
return;
}
scrollTo(newScrollOffset);
}
/// Updates the scroll behavior for the new content and container extent.
///
/// For convenience, this function combines three common operations:
///
/// 1. Updating the scroll behavior extents with
/// [ExtentScrollBehavior.updateExtents].
/// 2. Notifying this object that the scroll behavior was updated with
/// [didUpdateScrollBehavior].
/// 3. Updating this object's gesture detector with [updateGestureDetector].
void handleExtentsChanged(double contentExtent, double containerExtent) {
didUpdateScrollBehavior(scrollBehavior.updateExtents(
contentExtent: contentExtent,
containerExtent: containerExtent,
scrollOffset: scrollOffset
));
updateGestureDetector();
}
/// If [scrollVelocity] is greater than [PixelScrollTolerance.velocity] then
/// fling the scroll offset with the given velocity in logical pixels/second.
/// Otherwise, if this scrollable is overscrolled or a [snapOffsetCallback]
/// was given, animate the scroll offset to its final value with [settleScrollOffset].
///
/// Calling this function starts a physics-based animation of the scroll
/// offset with the given value as the initial velocity. The physics
/// simulation is determined by the scroll behavior.
///
/// The returned [Future] completes when the scrolling animation is complete.
Future<Null> fling(double scrollVelocity) {
if (scrollVelocity.abs() > kPixelScrollTolerance.velocity)
return _startToEndAnimation(scrollVelocity);
// If a scroll animation isn't underway already and we're overscrolled or we're
// going to have to snap the scroll offset, then animate the scroll offset to its
// final value.
if (!_controller.isAnimating &&
(shouldSnapScrollOffset || !_scrollOffsetIsInBounds(scrollOffset)))
return settleScrollOffset();
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) {
_stop();
_simulation = _createSnapSimulation(scrollVelocity) ?? _createFlingSimulation(scrollVelocity);
if (_simulation == null)
return new Future<Null>.value();
_startScroll();
return _controller.animateWith(_simulation).then((Null _) {
_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) {
return config.snapOffsetCallback == null ? scrollOffset : config.snapOffsetCallback(scrollOffset, context.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);
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(
virtualScrollOffset, 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(virtualScrollOffset, scrollVelocity);
if (simulation != null) {
final double endVelocity = pixelOffsetToScrollOffset(kPixelScrollTolerance.velocity).abs();
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 method to hook the scroll callback.
void dispatchOnScroll() {
assert(_numberOfInProgressScrolls > 0);
if (config.onScroll != null)
config.onScroll(_scrollOffset);
}
void _handleDragDown(DragDownDetails details) {
setState(() {
_stop();
});
}
void _stop() {
assert(mounted);
assert(_controller.isAnimating || _simulation == null);
_controller.stop(); // this does not trigger a status notification
_simulation = null;
}
void _handleDragStart(DragStartDetails details) {
_startScroll(details: details);
}
void _startScroll({ DragStartDetails details }) {
_numberOfInProgressScrolls += 1;
if (_numberOfInProgressScrolls == 1) {
dispatchOnScrollStart();
new ScrollNotification(
scrollable: this,
kind: ScrollNotificationKind.started,
details: details
).dispatch(context);
}
}
/// Calls the onScrollStart callback.
///
/// Subclasses can override this method to hook the scroll start callback.
void dispatchOnScrollStart() {
assert(_numberOfInProgressScrolls == 1);
if (config.onScrollStart != null)
config.onScrollStart(_scrollOffset);
}
void _handleDragUpdate(DragUpdateDetails details) {
scrollBy(pixelOffsetToScrollOffset(details.primaryDelta), details: details);
}
void _handleDragEnd(DragEndDetails details) {
final double scrollVelocity = pixelDeltaToScrollOffset(details.velocity.pixelsPerSecond);
fling(scrollVelocity).then<Null>((Null value) {
_endScroll(details: details);
});
}
// Used for state changes that sometimes occur during a build phase. If so,
// we skip calling setState, as the changes will apply to the next build.
// TODO(ianh): This is ugly and hopefully temporary. Ideally this won't be
// needed after Scrollable is rewritten.
void _setStateMaybeDuringBuild(VoidCallback fn) {
if (SchedulerBinding.instance.schedulerPhase == SchedulerPhase.persistentCallbacks) {
fn();
} else {
setState(fn);
}
}
void _endScroll({ DragEndDetails details }) {
_numberOfInProgressScrolls -= 1;
if (_numberOfInProgressScrolls == 0) {
_simulation = null;
if (_scrollUnderway && mounted) {
// If the scroll hasn't already stopped because we've hit a clamped
// edge or the controller stopped animating, then rebuild the Scrollable
// with the IgnorePointer widget turned off.
_setStateMaybeDuringBuild(() {
_scrollUnderway = false;
});
}
dispatchOnScrollEnd();
if (mounted) {
new ScrollNotification(
scrollable: this,
kind: ScrollNotificationKind.ended,
details: details
).dispatch(context);
}
}
}
/// Calls the dispatchOnScrollEnd callback.
///
/// Subclasses can override this method to hook the scroll end callback.
void dispatchOnScrollEnd() {
assert(_numberOfInProgressScrolls == 0);
if (config.onScrollEnd != null)
config.onScrollEnd(_scrollOffset);
}
final GlobalKey<RawGestureDetectorState> _gestureDetectorKey = new GlobalKey<RawGestureDetectorState>();
@override
Widget build(BuildContext context) {
return new RawGestureDetector(
key: _gestureDetectorKey,
gestures: buildGestureDetectors(),
behavior: HitTestBehavior.opaque,
child: new IgnorePointer(
ignoring: _scrollUnderway,
child: buildContent(context)
)
);
}
/// 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) { // ignore: map_value_type_not_assignable, https://github.com/flutter/flutter/issues/5771
return (recognizer ??= new VerticalDragGestureRecognizer())
..onDown = _handleDragDown
..onStart = _handleDragStart
..onUpdate = _handleDragUpdate
..onEnd = _handleDragEnd;
}
};
case Axis.horizontal:
return <Type, GestureRecognizerFactory>{
HorizontalDragGestureRecognizer: (HorizontalDragGestureRecognizer recognizer) { // ignore: map_value_type_not_assignable, https://github.com/flutter/flutter/issues/5771
return (recognizer ??= new HorizontalDragGestureRecognizer())
..onDown = _handleDragDown
..onStart = _handleDragStart
..onUpdate = _handleDragUpdate
..onEnd = _handleDragEnd;
}
};
}
}
return const <Type, GestureRecognizerFactory>{};
}
/// Calls the widget's [builder] by default.
///
/// Subclasses can override this method 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] provides an
/// [onPaintOffsetUpdateNeeded] callback for this purpose; [GridViewport],
/// [ListViewport], [LazyListViewport], and [LazyBlockViewport] 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) {
assert(config.builder != null);
return config.builder(context, this);
}
}
/// Indicates if a [ScrollNotification] indicates the start, end or the
/// middle of a scroll.
enum ScrollNotificationKind {
/// The [ScrollNotification] indicates that the scrollOffset has been changed
/// and no existing scroll is underway.
started,
/// The [ScrollNotification] indicates that the scrollOffset has been changed.
updated,
/// The [ScrollNotification] indicates that the scrollOffset has stopped changing.
/// This may be because the fling animation that follows a drag gesture has
/// completed or simply because the scrollOffset was reset.
ended
}
/// Indicates that a scrollable descendant is scrolling.
///
/// See also:
///
/// * [NotificationListener].
class ScrollNotification extends Notification {
/// Creates a notification about scrolling.
ScrollNotification({ this.scrollable, this.kind, dynamic details }) : _details = details {
assert(scrollable != null);
assert(kind != null);
assert(details == null
|| (kind == ScrollNotificationKind.started && details is DragStartDetails)
|| (kind == ScrollNotificationKind.updated && details is DragUpdateDetails)
|| (kind == ScrollNotificationKind.ended && details is DragEndDetails));
}
/// Indicates if we're at the start, middle, or end of a scroll.
final ScrollNotificationKind kind;
/// The scrollable that scrolled.
final ScrollableState scrollable;
/// The details from the underlying [DragGestureRecognizer] gesture, if the
/// notification ultimately came from a [DragGestureRecognizer.onStart]
/// handler; otherwise null.
DragStartDetails get dragStartDetails => kind == ScrollNotificationKind.started ? _details : null;
/// The details from the underlying [DragGestureRecognizer] gesture, if the
/// notification ultimately came from a [DragGestureRecognizer.onUpdate]
/// handler; otherwise null.
DragUpdateDetails get dragUpdateDetails => kind == ScrollNotificationKind.updated ? _details : null;
/// The details from the underlying [DragGestureRecognizer] gesture, if the
/// notification ultimately came from a [DragGestureRecognizer.onEnd]
/// handler; otherwise null.
DragEndDetails get dragEndDetails => kind == ScrollNotificationKind.ended ? _details : null;
final dynamic _details;
/// The number of scrollable widgets that have already received this
/// notification. Typically listeners only respond to notifications
/// with depth = 0.
int get depth => _depth;
int _depth = 0;
@override
bool visitAncestor(Element element) {
if (element is StatefulElement && element.state is ScrollableState)
_depth += 1;
return super.visitAncestor(element);
}
}
/// A simple scrolling widget that has a single child.
///
/// Use this widget if you are not worried about offscreen widgets consuming
/// resources.
///
/// See also:
///
/// * [Block], if your single child is a [Column].
/// * [ScrollableList], if you have many identically-sized children.
/// * [PageableList], if you have children that each take the entire screen.
/// * [ScrollableGrid], if your children are in a grid pattern.
/// * [LazyBlock], if you have many children of varying sizes.
class ScrollableViewport extends StatelessWidget {
/// Creates a simple scrolling widget that has a single child.
///
/// The [scrollDirection] and [scrollAnchor] arguments must not be null.
ScrollableViewport({
Key key,
this.initialScrollOffset,
this.scrollDirection: Axis.vertical,
this.scrollAnchor: ViewportAnchor.start,
this.onScrollStart,
this.onScroll,
this.onScrollEnd,
this.snapOffsetCallback,
this.scrollableKey,
this.child
}) : super(key: key) {
assert(scrollDirection != null);
assert(scrollAnchor != null);
}
// Warning: keep the dartdoc comments that follow in sync with the copies in
// Scrollable, LazyBlock, ScrollableLazyList, ScrollableList, and
// ScrollableGrid. And see: https://github.com/dart-lang/dartdoc/issues/1161.
/// 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.
final ViewportAnchor scrollAnchor;
/// Called whenever this widget starts to scroll.
final ScrollListener onScrollStart;
/// Called whenever this widget's scroll offset changes.
final ScrollListener onScroll;
/// Called whenever this widget stops scrolling.
final ScrollListener onScrollEnd;
/// Called to determine the offset to which scrolling should snap,
/// when handling a fling.
///
/// This callback, if set, will be called with the offset that the
/// Scrollable would have scrolled to in the absence of this
/// callback, and a Size describing the size of the Scrollable
/// itself.
///
/// The callback's return value is used as the new scroll offset to
/// aim for.
///
/// If the callback simply returns its first argument (the offset),
/// then it is as if the callback was null.
final SnapOffsetCallback snapOffsetCallback;
/// The key for the Scrollable created by this widget.
final Key scrollableKey;
/// The widget that will be scrolled. It will become the child of a Scrollable.
final Widget child;
Widget _buildViewport(BuildContext context, ScrollableState state) {
return new Viewport(
paintOffset: state.scrollOffsetToPixelDelta(state.scrollOffset),
mainAxis: scrollDirection,
anchor: scrollAnchor,
onPaintOffsetUpdateNeeded: (ViewportDimensions dimensions) {
final double contentExtent = scrollDirection == Axis.vertical ? dimensions.contentSize.height : dimensions.contentSize.width;
final double containerExtent = scrollDirection == Axis.vertical ? dimensions.containerSize.height : dimensions.containerSize.width;
state.handleExtentsChanged(contentExtent, containerExtent);
return state.scrollOffsetToPixelDelta(state.scrollOffset);
},
child: child
);
}
@override
Widget build(BuildContext context) {
final Widget result = new Scrollable(
key: scrollableKey,
initialScrollOffset: initialScrollOffset,
scrollDirection: scrollDirection,
scrollAnchor: scrollAnchor,
onScrollStart: onScrollStart,
onScroll: onScroll,
onScrollEnd: onScrollEnd,
snapOffsetCallback: snapOffsetCallback,
builder: _buildViewport
);
return ScrollConfiguration.wrap(context, result);
}
}
/// A scrolling list of variably-sized children.
///
/// 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).
///
/// If you have a large number of children, or if you always expect this to need
/// to scroll, consider using [LazyBlock] (if the children have variable height)
/// or [ScrollableList] (if the children all have the same fixed height), as
/// they avoid doing work for children that are not visible.
///
/// This widget is implemented using [ScrollableViewport] and [BlockBody]. If
/// you have a single child, consider using [ScrollableViewport] directly.
///
/// See also:
///
/// * [LazyBlock], if you have many children with varying heights.
/// * [ScrollableList], if all your children are the same height.
/// * [ScrollableViewport], if you only have one child.
class Block extends StatelessWidget {
/// Creates a scrollable array of children.
Block({
Key key,
this.children: const <Widget>[],
this.padding,
this.initialScrollOffset,
this.scrollDirection: Axis.vertical,
this.scrollAnchor: ViewportAnchor.start,
this.onScrollStart,
this.onScroll,
this.onScrollEnd,
this.scrollableKey
}) : super(key: key) {
assert(children != null);
assert(!children.any((Widget child) => child == null));
}
/// The children, all of which are materialized.
final List<Widget> children;
/// The amount of space by which to inset the children inside the viewport.
final EdgeInsets padding;
/// 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.
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;
/// The key to use for the underlying scrollable widget.
final Key scrollableKey;
@override
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(
scrollableKey: scrollableKey,
initialScrollOffset: initialScrollOffset,
scrollDirection: scrollDirection,
scrollAnchor: scrollAnchor,
onScrollStart: onScrollStart,
onScroll: onScroll,
onScrollEnd: onScrollEnd,
child: contents
);
}
}