// Copyright 2016 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 '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 'framework.dart'; import 'primary_scroll_controller.dart'; import 'scroll_activity.dart'; import 'scroll_context.dart'; import 'scroll_controller.dart'; import 'scroll_metrics.dart'; import 'scroll_physics.dart'; import 'scroll_position.dart'; import 'scroll_view.dart'; import 'sliver.dart'; import 'ticker_provider.dart'; /// Signature used by [NestedScrollView] for building its header. /// /// The `innerBoxIsScrolled` argument is typically used to control the /// [SliverAppBar.forceElevated] property to ensure that the app bar shows a /// shadow, since it would otherwise not necessarily be aware that it had /// content ostensibly below it. typedef List NestedScrollViewHeaderSliversBuilder(BuildContext context, bool innerBoxIsScrolled); class NestedScrollView extends StatefulWidget { /// Creates a nested scroll view. /// /// The [reverse], [headerSliverBuilder], and [body] arguments must not be null. const NestedScrollView({ Key key, this.controller, this.scrollDirection: Axis.vertical, this.reverse: false, this.physics, @required this.headerSliverBuilder, @required this.body, }) : assert(scrollDirection != null), assert(reverse != null), assert(headerSliverBuilder != null), assert(body != null), super(key: key); /// An object that can be used to control the position to which the outer /// scroll view is scrolled. final ScrollController controller; /// The axis along which the scroll view scrolls. /// /// Defaults to [Axis.vertical]. final Axis scrollDirection; /// Whether the scroll view scrolls in the reading direction. /// /// For example, if the reading direction is left-to-right and /// [scrollDirection] is [Axis.horizontal], then the scroll view scrolls from /// left to right when [reverse] is false and from right to left when /// [reverse] is true. /// /// Similarly, if [scrollDirection] is [Axis.vertical], then the scroll view /// scrolls from top to bottom when [reverse] is false and from bottom to top /// when [reverse] is true. /// /// Defaults to false. final bool reverse; /// How the scroll view should respond to user input. /// /// For example, determines how the scroll view continues to animate after the /// user stops dragging the scroll view (providing a custom implementation of /// [ScrollPhysics.createBallisticSimulation] allows this particular aspect of /// the physics to be overridden). /// /// Defaults to matching platform conventions. /// /// The [ScrollPhysics.applyBoundaryConditions] implementation of the provided /// object should not allow scrolling outside the scroll extent range /// described by the [ScrollMetrics.minScrollExtent] and /// [ScrollMetrics.maxScrollExtent] properties passed to that method. If that /// invariant is not maintained, the nested scroll view may respond to user /// scrolling erratically. final ScrollPhysics physics; /// A builder for any widgets that are to precede the inner scroll views (as /// given by [body]). /// /// Typically this is used to create a [SliverAppBar] with a [TabBar]. final NestedScrollViewHeaderSliversBuilder headerSliverBuilder; /// The widget to show inside the [NestedScrollView]. /// /// Typically this will be [TabBarView]. /// /// The [body] is built in a context that provides a [PrimaryScrollController] /// that interacts with the [NestedScrollView]'s scroll controller. final Widget body; List _buildSlivers(BuildContext context, ScrollController innerController, bool bodyIsScrolled) { final List slivers = []; slivers.addAll(headerSliverBuilder(context, bodyIsScrolled)); slivers.add(new SliverFillRemaining( child: new PrimaryScrollController( controller: innerController, child: body, ), )); return slivers; } @override _NestedScrollViewState createState() => new _NestedScrollViewState(); } class _NestedScrollViewState extends State { _NestedScrollCoordinator _coordinator; @override void initState() { super.initState(); _coordinator = new _NestedScrollCoordinator(context, widget.controller); } @override void didChangeDependencies() { super.didChangeDependencies(); _coordinator.updateParent(); } @override void dispose() { _coordinator.dispose(); _coordinator = null; super.dispose(); } @override Widget build(BuildContext context) { return new CustomScrollView( scrollDirection: widget.scrollDirection, reverse: widget.reverse, physics: widget.physics != null ? widget.physics.applyTo(const ClampingScrollPhysics()) : const ClampingScrollPhysics(), controller: _coordinator._outerController, slivers: widget._buildSlivers(context, _coordinator._innerController, _coordinator.hasScrolledBody), ); } } class _NestedScrollMetrics extends FixedScrollMetrics { _NestedScrollMetrics({ @required double minScrollExtent, @required double maxScrollExtent, @required double pixels, @required double viewportDimension, @required AxisDirection axisDirection, @required this.minRange, @required this.maxRange, @required this.correctionOffset, }) : super( minScrollExtent: minScrollExtent, maxScrollExtent: maxScrollExtent, pixels: pixels, viewportDimension: viewportDimension, axisDirection: axisDirection, ); final double minRange; final double maxRange; final double correctionOffset; } typedef ScrollActivity _NestedScrollActivityGetter(_NestedScrollPosition position); class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldController { _NestedScrollCoordinator(this._context, this._parent) { final double initialScrollOffset = _parent?.initialScrollOffset ?? 0.0; _outerController = new _NestedScrollController(this, initialScrollOffset: initialScrollOffset, debugLabel: 'outer'); _innerController = new _NestedScrollController(this, initialScrollOffset: 0.0, debugLabel: 'inner'); } final BuildContext _context; final ScrollController _parent; _NestedScrollController _outerController; _NestedScrollController _innerController; _NestedScrollPosition get _outerPosition { if (!_outerController.hasClients) return null; return _outerController.nestedPositions.single; } Iterable<_NestedScrollPosition> get _innerPositions { return _innerController.nestedPositions; } bool get hasScrolledBody { for (_NestedScrollPosition position in _innerPositions) { if (position.pixels > position.minScrollExtent) return true; } return false; } ScrollDirection get userScrollDirection => _userScrollDirection; ScrollDirection _userScrollDirection = ScrollDirection.idle; void updateUserScrollDirection(ScrollDirection value) { assert(value != null); if (userScrollDirection == value) return; _userScrollDirection = value; _outerPosition.didUpdateScrollDirection(value); for (_NestedScrollPosition position in _innerPositions) position.didUpdateScrollDirection(value); } ScrollDragController _currentDrag; void beginActivity(ScrollActivity newOuterActivity, _NestedScrollActivityGetter innerActivityGetter) { _outerPosition.beginActivity(newOuterActivity); bool scrolling = newOuterActivity.isScrolling; for (_NestedScrollPosition position in _innerPositions) { final ScrollActivity newInnerActivity = innerActivityGetter(position); position.beginActivity(newInnerActivity); scrolling = scrolling && newInnerActivity.isScrolling; } _currentDrag?.dispose(); _currentDrag = null; if (!scrolling) updateUserScrollDirection(ScrollDirection.idle); } @override AxisDirection get axisDirection => _outerPosition.axisDirection; static IdleScrollActivity _createIdleScrollActivity(_NestedScrollPosition position) { return new IdleScrollActivity(position); } @override void goIdle() { beginActivity(_createIdleScrollActivity(_outerPosition), _createIdleScrollActivity); } @override void goBallistic(double velocity) { beginActivity( createOuterBallisticScrollActivity(velocity), (_NestedScrollPosition position) => createInnerBallisticScrollActivity(position, velocity), ); } ScrollActivity createOuterBallisticScrollActivity(double velocity) { // TODO(ianh): Refactor so this doesn't need to poke at the internals of the // other classes here (e.g. calling through _outerPosition.physics) // This function creates a ballistic scroll for the outer scrollable. // // It assumes that the outer scrollable can't be overscrolled, and sets up a // ballistic scroll over the combined space of the innerPositions and the // outerPosition. // First we must pick a representative inner position that we will care // about. This is somewhat arbitrary. Ideally we'd pick the one that is "in // the center" but there isn't currently a good way to do that so we // arbitrarily pick the one that is the furthest away from the infinity we // are heading towards. _NestedScrollPosition innerPosition; if (velocity != 0.0) { for (_NestedScrollPosition position in _innerPositions) { if (innerPosition != null) { if (velocity > 0.0) { if (innerPosition.pixels < position.pixels) continue; } else { assert(velocity < 0.0); if (innerPosition.pixels > position.pixels) continue; } } innerPosition = position; } } if (innerPosition == null) { // It's either just us or a velocity=0 situation. return _outerPosition.createBallisticScrollActivity( _outerPosition.physics.createBallisticSimulation(_outerPosition, velocity), mode: _NestedBallisticScrollActivityMode.independent, ); } final _NestedScrollMetrics metrics = _getMetrics(innerPosition, velocity); return _outerPosition.createBallisticScrollActivity( _outerPosition.physics.createBallisticSimulation(metrics, velocity), mode: _NestedBallisticScrollActivityMode.outer, metrics: metrics, ); } @protected ScrollActivity createInnerBallisticScrollActivity(_NestedScrollPosition position, double velocity) { return position.createBallisticScrollActivity( position.physics.createBallisticSimulation( velocity == 0 ? position : _getMetrics(position, velocity), velocity, ), mode: _NestedBallisticScrollActivityMode.inner, ); } _NestedScrollMetrics _getMetrics(_NestedScrollPosition innerPosition, double velocity) { assert(innerPosition != null); double pixels, minRange, maxRange, correctionOffset, extra; if (innerPosition.pixels == innerPosition.minScrollExtent) { pixels = _outerPosition.pixels.clamp(_outerPosition.minScrollExtent, _outerPosition.maxScrollExtent); // TODO(ianh): gracefully handle out-of-range outer positions minRange = _outerPosition.minScrollExtent; maxRange = _outerPosition.maxScrollExtent; assert(minRange <= maxRange); correctionOffset = 0.0; extra = 0.0; } else { assert(innerPosition.pixels != innerPosition.minScrollExtent); if (innerPosition.pixels < innerPosition.minScrollExtent) { pixels = innerPosition.pixels - innerPosition.minScrollExtent + _outerPosition.minScrollExtent; } else { assert(innerPosition.pixels > innerPosition.minScrollExtent); pixels = innerPosition.pixels - innerPosition.minScrollExtent + _outerPosition.maxScrollExtent; } if ((velocity > 0.0) && (innerPosition.pixels > innerPosition.minScrollExtent)) { // This handles going forward (fling up) and inner list is scrolled past // zero. We want to grab the extra pixels immediately to shrink. extra = _outerPosition.maxScrollExtent - _outerPosition.pixels; assert(extra >= 0.0); minRange = pixels; maxRange = pixels + extra; assert(minRange <= maxRange); correctionOffset = _outerPosition.pixels - pixels; } else if ((velocity < 0.0) && (innerPosition.pixels < innerPosition.minScrollExtent)) { // This handles going backward (fling down) and inner list is // underscrolled. We want to grab the extra pixels immediately to grow. extra = _outerPosition.pixels - _outerPosition.minScrollExtent; assert(extra >= 0.0); minRange = pixels - extra; maxRange = pixels; assert(minRange <= maxRange); correctionOffset = _outerPosition.pixels - pixels; } else { // This handles going forward (fling up) and inner list is // underscrolled, OR, going backward (fling down) and inner list is // scrolled past zero. We want to skip the pixels we don't need to grow // or shrink over. if (velocity > 0.0) { // shrinking extra = _outerPosition.minScrollExtent - _outerPosition.pixels; } else { assert(velocity < 0.0); // growing extra = _outerPosition.pixels - (_outerPosition.maxScrollExtent - _outerPosition.minScrollExtent); } assert(extra <= 0.0); minRange = _outerPosition.minScrollExtent; maxRange = _outerPosition.maxScrollExtent + extra; assert(minRange <= maxRange); correctionOffset = 0.0; } } return new _NestedScrollMetrics( minScrollExtent: _outerPosition.minScrollExtent, maxScrollExtent: _outerPosition.maxScrollExtent + innerPosition.maxScrollExtent - innerPosition.minScrollExtent + extra, pixels: pixels, viewportDimension: _outerPosition.viewportDimension, axisDirection: _outerPosition.axisDirection, minRange: minRange, maxRange: maxRange, correctionOffset: correctionOffset, ); } double unnestOffset(double value, _NestedScrollPosition source) { if (source == _outerPosition) return value.clamp(_outerPosition.minScrollExtent, _outerPosition.maxScrollExtent); if (value < source.minScrollExtent) return value - source.minScrollExtent + _outerPosition.minScrollExtent; return value - source.minScrollExtent + _outerPosition.maxScrollExtent; } double nestOffset(double value, _NestedScrollPosition target) { if (target == _outerPosition) return value.clamp(_outerPosition.minScrollExtent, _outerPosition.maxScrollExtent); if (value < _outerPosition.minScrollExtent) return value - _outerPosition.minScrollExtent + target.minScrollExtent; if (value > _outerPosition.maxScrollExtent) return value - _outerPosition.maxScrollExtent + target.minScrollExtent; return target.minScrollExtent; } void updateCanDrag() { if (!_outerPosition.haveDimensions) return; double maxInnerExtent = 0.0; for (_NestedScrollPosition position in _innerPositions) { if (!position.haveDimensions) return; maxInnerExtent = math.max(maxInnerExtent, position.maxScrollExtent - position.minScrollExtent); } _outerPosition.updateCanDrag(maxInnerExtent); } Future animateTo(double to, { @required Duration duration, @required Curve curve, }) async { final DrivenScrollActivity outerActivity = _outerPosition.createDrivenScrollActivity( nestOffset(to, _outerPosition), duration, curve, ); final List> resultFutures = >[outerActivity.done]; beginActivity( outerActivity, (_NestedScrollPosition position) { final DrivenScrollActivity innerActivity = position.createDrivenScrollActivity( nestOffset(to, position), duration, curve, ); resultFutures.add(innerActivity.done); return innerActivity; }, ); await Future.wait(resultFutures); } void jumpTo(double to) { goIdle(); _outerPosition.localJumpTo(nestOffset(to, _outerPosition)); for (_NestedScrollPosition position in _innerPositions) position.localJumpTo(nestOffset(to, position)); goBallistic(0.0); } @override double setPixels(double newPixels) { assert(false); return 0.0; } ScrollHoldController hold(VoidCallback holdCancelCallback) { beginActivity( new HoldScrollActivity(delegate: _outerPosition, onHoldCanceled: holdCancelCallback), (_NestedScrollPosition position) => new HoldScrollActivity(delegate: position), ); return this; } @override void cancel() { goBallistic(0.0); } Drag drag(DragStartDetails details, VoidCallback dragCancelCallback) { final ScrollDragController drag = new ScrollDragController( delegate: this, details: details, onDragCanceled: dragCancelCallback, ); beginActivity( new DragScrollActivity(_outerPosition, drag), (_NestedScrollPosition position) => new DragScrollActivity(position, drag), ); assert(_currentDrag == null); _currentDrag = drag; return drag; } @override void applyUserOffset(double delta) { updateUserScrollDirection(delta > 0.0 ? ScrollDirection.forward : ScrollDirection.reverse); assert(delta != 0.0); if (_innerPositions.isEmpty) { _outerPosition.applyFullDragUpdate(delta); } else if (delta < 0.0) { // dragging "up" // TODO(ianh): prioritize first getting rid of overscroll, and then the // outer view, so that the app bar will scroll out of the way asap. // Right now we ignore overscroll. This works fine on Android but looks // weird on iOS if you fling down then up. The problem is it's not at all // clear what this should do when you have multiple inner positions at // different levels of overscroll. final double innerDelta = _outerPosition.applyClampedDragUpdate(delta); if (innerDelta != 0.0) { for (_NestedScrollPosition position in _innerPositions) position.applyFullDragUpdate(innerDelta); } } else { // dragging "down" - delta is positive // prioritize the inner views, so that the inner content will move before the app bar grows double outerDelta = 0.0; // it will go positive if it changes final List overscrolls = []; final List<_NestedScrollPosition> innerPositions = _innerPositions.toList(); for (_NestedScrollPosition position in innerPositions) { final double overscroll = position.applyClampedDragUpdate(delta); outerDelta = math.max(outerDelta, overscroll); overscrolls.add(overscroll); } if (outerDelta != 0.0) outerDelta -= _outerPosition.applyClampedDragUpdate(outerDelta); // now deal with any overscroll for (int i = 0; i < innerPositions.length; ++i) { final double remainingDelta = overscrolls[i] - outerDelta; if (remainingDelta > 0.0) innerPositions[i].applyFullDragUpdate(remainingDelta); } } } void updateParent() { _outerPosition?.setParent(_parent ?? PrimaryScrollController.of(_context)); } @mustCallSuper void dispose() { _currentDrag?.dispose(); _currentDrag = null; _outerController.dispose(); _innerController.dispose(); } } class _NestedScrollController extends ScrollController { _NestedScrollController(this.coordinator, { double initialScrollOffset: 0.0, String debugLabel, }) : super(initialScrollOffset: initialScrollOffset, debugLabel: debugLabel); final _NestedScrollCoordinator coordinator; @override ScrollPosition createScrollPosition( ScrollPhysics physics, ScrollContext context, ScrollPosition oldPosition, ) { return new _NestedScrollPosition( coordinator: coordinator, physics: physics, context: context, initialPixels: initialScrollOffset, oldPosition: oldPosition, debugLabel: debugLabel, ); } @override void attach(ScrollPosition position) { assert(position is _NestedScrollPosition); super.attach(position); coordinator.updateParent(); coordinator.updateCanDrag(); } Iterable<_NestedScrollPosition> get nestedPositions sync* { yield* positions; } } class _NestedScrollPosition extends ScrollPosition implements ScrollActivityDelegate { _NestedScrollPosition({ @required ScrollPhysics physics, @required ScrollContext context, double initialPixels: 0.0, ScrollPosition oldPosition, String debugLabel, @required this.coordinator, }) : super( physics: physics, context: context, oldPosition: oldPosition, debugLabel: debugLabel, ) { if (pixels == null && initialPixels != null) correctPixels(initialPixels); if (activity == null) goIdle(); assert(activity != null); } final _NestedScrollCoordinator coordinator; TickerProvider get vsync => context.vsync; ScrollController _parent; void setParent(ScrollController value) { _parent?.detach(this); _parent = value; _parent?.attach(this); } @override AxisDirection get axisDirection => context.axisDirection; @override void absorb(ScrollPosition other) { super.absorb(other); activity.updateDelegate(this); } // Returns the amount of delta that was not used. double applyClampedDragUpdate(double delta) { assert(delta != 0.0); final double min = delta < 0.0 ? -double.INFINITY : minScrollExtent; final double max = delta > 0.0 ? double.INFINITY : maxScrollExtent; final double oldPixels = pixels; final double newPixels = (pixels - delta).clamp(min, max); final double clampedDelta = newPixels - pixels; if (clampedDelta == 0.0) return delta; final double overscroll = physics.applyBoundaryConditions(this, newPixels); final double actualNewPixels = newPixels - overscroll; final double offset = actualNewPixels - oldPixels; if (offset != 0.0) { forcePixels(actualNewPixels); didUpdateScrollPositionBy(offset); } return delta + offset; } // Returns the overscroll. double applyFullDragUpdate(double delta) { assert(delta != 0.0); final double oldPixels = pixels; final double newPixels = pixels - physics.applyPhysicsToUserOffset(this, delta); if (oldPixels == newPixels) return 0.0; // delta must have been so small we dropped it during floating point addition final double overscroll = physics.applyBoundaryConditions(this, newPixels); final double actualNewPixels = newPixels - overscroll; if (actualNewPixels != oldPixels) { forcePixels(actualNewPixels); didUpdateScrollPositionBy(actualNewPixels - oldPixels); } if (overscroll != 0.0) { didOverscrollBy(overscroll); return overscroll; } return 0.0; } @override ScrollDirection get userScrollDirection => coordinator.userScrollDirection; DrivenScrollActivity createDrivenScrollActivity(double to, Duration duration, Curve curve) { return new DrivenScrollActivity( this, from: pixels, to: to, duration: duration, curve: curve, vsync: vsync, ); } @override double applyUserOffset(double delta) { assert(false); return 0.0; } // This is called by activities when they finish their work. @override void goIdle() { beginActivity(new IdleScrollActivity(this)); } // This is called by activities when they finish their work and want to go ballistic. @override void goBallistic(double velocity) { Simulation simulation; if (velocity != 0.0 || outOfRange) simulation = physics.createBallisticSimulation(this, velocity); beginActivity(createBallisticScrollActivity( simulation, mode: _NestedBallisticScrollActivityMode.independent, )); } ScrollActivity createBallisticScrollActivity(Simulation simulation, { @required _NestedBallisticScrollActivityMode mode, _NestedScrollMetrics metrics, }) { if (simulation == null) return new IdleScrollActivity(this); assert(mode != null); switch (mode) { case _NestedBallisticScrollActivityMode.outer: assert(metrics != null); if (metrics.minRange == metrics.maxRange) return new IdleScrollActivity(this); return new _NestedOuterBallisticScrollActivity(coordinator, this, metrics, simulation, context.vsync); case _NestedBallisticScrollActivityMode.inner: return new _NestedInnerBallisticScrollActivity(coordinator, this, simulation, context.vsync); case _NestedBallisticScrollActivityMode.independent: return new BallisticScrollActivity(this, simulation, context.vsync); } return null; } @override Future animateTo(double to, { @required Duration duration, @required Curve curve, }) { return coordinator.animateTo(coordinator.unnestOffset(to, this), duration: duration, curve: curve); } @override void jumpTo(double value) { return coordinator.jumpTo(coordinator.unnestOffset(value, this)); } @override void jumpToWithoutSettling(double value) { assert(false); } void localJumpTo(double value) { if (pixels != value) { final double oldPixels = pixels; forcePixels(value); didStartScroll(); didUpdateScrollPositionBy(pixels - oldPixels); didEndScroll(); } } @override void applyNewDimensions() { super.applyNewDimensions(); coordinator.updateCanDrag(); } void updateCanDrag(double totalExtent) { context.setCanDrag(totalExtent > (viewportDimension - maxScrollExtent) || minScrollExtent != maxScrollExtent); } @override ScrollHoldController hold(VoidCallback holdCancelCallback) { return coordinator.hold(holdCancelCallback); } @override Drag drag(DragStartDetails details, VoidCallback dragCancelCallback) { return coordinator.drag(details, dragCancelCallback); } @override void dispose() { _parent?.detach(this); super.dispose(); } } enum _NestedBallisticScrollActivityMode { outer, inner, independent } class _NestedInnerBallisticScrollActivity extends BallisticScrollActivity { _NestedInnerBallisticScrollActivity( this.coordinator, _NestedScrollPosition position, Simulation simulation, TickerProvider vsync, ) : super(position, simulation, vsync); final _NestedScrollCoordinator coordinator; @override _NestedScrollPosition get delegate => super.delegate; @override void resetActivity() { delegate.beginActivity(coordinator.createInnerBallisticScrollActivity(delegate, velocity)); } @override void applyNewDimensions() { delegate.beginActivity(coordinator.createInnerBallisticScrollActivity(delegate, velocity)); } @override bool applyMoveTo(double value) { return super.applyMoveTo(coordinator.nestOffset(value, delegate)); } } class _NestedOuterBallisticScrollActivity extends BallisticScrollActivity { _NestedOuterBallisticScrollActivity( this.coordinator, _NestedScrollPosition position, this.metrics, Simulation simulation, TickerProvider vsync, ) : assert(metrics.minRange != metrics.maxRange), assert(metrics.maxRange > metrics.minRange), super(position, simulation, vsync); final _NestedScrollCoordinator coordinator; final _NestedScrollMetrics metrics; @override _NestedScrollPosition get delegate => super.delegate; @override void resetActivity() { delegate.beginActivity(coordinator.createOuterBallisticScrollActivity(velocity)); } @override void applyNewDimensions() { delegate.beginActivity(coordinator.createOuterBallisticScrollActivity(velocity)); } @override bool applyMoveTo(double value) { bool done = false; if (velocity > 0.0) { if (value < metrics.minRange) return true; if (value > metrics.maxRange) { value = metrics.maxRange; done = true; } } else if (velocity < 0.0) { if (value > metrics.maxRange) return true; if (value < metrics.minRange) { value = metrics.minRange; done = true; } } else { value = value.clamp(metrics.minRange, metrics.maxRange); done = true; } final bool result = super.applyMoveTo(value + metrics.correctionOffset); assert(result); // since we tried to pass an in-range value, it shouldn't ever overflow return !done; } @override String toString() { return '$runtimeType(${metrics.minRange} .. ${metrics.maxRange}; correcting by ${metrics.correctionOffset})'; } }