Commit 63160b3d authored by Ian Hickson's avatar Ian Hickson Committed by GitHub

Scrolling Refactor (#7420)

parent e52bda2c
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
// found in the LICENSE file. // found in the LICENSE file.
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'arc.dart'; import 'arc.dart';
...@@ -212,6 +213,18 @@ class _ScrollLikeMountainViewDelegate extends ScrollConfigurationDelegate { ...@@ -212,6 +213,18 @@ class _ScrollLikeMountainViewDelegate extends ScrollConfigurationDelegate {
bool updateShouldNotify(ScrollConfigurationDelegate old) => false; bool updateShouldNotify(ScrollConfigurationDelegate old) => false;
} }
class _MaterialScrollBehavior extends ViewportScrollBehavior {
@override
TargetPlatform getPlatform(BuildContext context) {
return Theme.of(context).platform;
}
@override
Color getGlowColor(BuildContext context) {
return Theme.of(context).accentColor;
}
}
class _MaterialAppState extends State<MaterialApp> { class _MaterialAppState extends State<MaterialApp> {
HeroController _heroController; HeroController _heroController;
...@@ -288,9 +301,14 @@ class _MaterialAppState extends State<MaterialApp> { ...@@ -288,9 +301,14 @@ class _MaterialAppState extends State<MaterialApp> {
return true; return true;
}); });
return new ScrollConfiguration( result = new ScrollConfiguration(
delegate: _getScrollDelegate(theme.platform), delegate: _getScrollDelegate(theme.platform),
child: result child: result
); );
return new ScrollConfiguration2(
delegate: new _MaterialScrollBehavior(),
child: result
);
} }
} }
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
// DELETE THIS FILE WHEN REMOVING LEGACY SCROLLING CODE
import 'dart:async' show Timer; import 'dart:async' show Timer;
import 'dart:math' as math; import 'dart:math' as math;
......
...@@ -547,5 +547,5 @@ class ThemeData { ...@@ -547,5 +547,5 @@ class ThemeData {
} }
@override @override
String toString() => '$runtimeType($brightness $primaryColor etc...)'; String toString() => '$runtimeType(${ platform != defaultTargetPlatform ? "$platform " : ''}$brightness $primaryColor etc...)';
} }
...@@ -97,6 +97,19 @@ enum AxisDirection { ...@@ -97,6 +97,19 @@ enum AxisDirection {
left, left,
} }
Axis axisDirectionToAxis(AxisDirection axisDirection) {
assert(axisDirection != null);
switch (axisDirection) {
case AxisDirection.up:
case AxisDirection.down:
return Axis.vertical;
case AxisDirection.left:
case AxisDirection.right:
return Axis.horizontal;
}
return null;
}
AxisDirection applyGrowthDirectionToAxisDirection(AxisDirection axisDirection, GrowthDirection growthDirection) { AxisDirection applyGrowthDirectionToAxisDirection(AxisDirection axisDirection, GrowthDirection growthDirection) {
assert(axisDirection != null); assert(axisDirection != null);
assert(growthDirection != null); assert(growthDirection != null);
...@@ -241,18 +254,7 @@ class SliverConstraints extends Constraints { ...@@ -241,18 +254,7 @@ class SliverConstraints extends Constraints {
return null; return null;
} }
Axis get axis { Axis get axis => axisDirectionToAxis(axisDirection);
assert(axisDirection != null);
switch (axisDirection) {
case AxisDirection.up:
case AxisDirection.down:
return Axis.vertical;
case AxisDirection.left:
case AxisDirection.right:
return Axis.horizontal;
}
return null;
}
/// Return what the [growthDirection] would be if the [axisDirection] was /// Return what the [growthDirection] would be if the [axisDirection] was
/// either [AxisDirection.down] or [AxisDirection.right]. /// either [AxisDirection.down] or [AxisDirection.right].
...@@ -352,7 +354,6 @@ class SliverConstraints extends Constraints { ...@@ -352,7 +354,6 @@ class SliverConstraints extends Constraints {
@override @override
int get hashCode { int get hashCode {
assert(debugAssertIsValid());
return hashValues(axis, growthDirection, scrollOffset, overlap, remainingPaintExtent, crossAxisExtent); return hashValues(axis, growthDirection, scrollOffset, overlap, remainingPaintExtent, crossAxisExtent);
} }
...@@ -413,7 +414,7 @@ class SliverGeometry { ...@@ -413,7 +414,7 @@ class SliverGeometry {
/// The (estimated) total paint extent that this sliver would be able to /// The (estimated) total paint extent that this sliver would be able to
/// provide if the [SliverConstraints.remainingPaintExtent] was infinite. /// provide if the [SliverConstraints.remainingPaintExtent] was infinite.
/// ///
/// This is used for shrink-wrapping (see [RenderViewport2.shrinkWrap]). /// This is used by viewports that implement shrink-wrapping.
/// ///
/// By definition, this cannot be less than [paintExtent]. /// By definition, this cannot be less than [paintExtent].
final double maxPaintExtent; final double maxPaintExtent;
...@@ -1067,15 +1068,6 @@ abstract class ViewportOffset extends ChangeNotifier { ...@@ -1067,15 +1068,6 @@ abstract class ViewportOffset extends ChangeNotifier {
/// typically be 0.0 and the maximum scroll extent will typically be 20.0, /// typically be 0.0 and the maximum scroll extent will typically be 20.0,
/// because there's only 20.0 pixels of actual scroll slack. /// because there's only 20.0 pixels of actual scroll slack.
/// ///
/// The scroll extents also have any fixed scroll extents added to them. For
/// example, if there's 100.0 pixels of scrollable content, 20.0 pixels of
/// fixed content (e.g. a pinned heading), and the viewport is 80.0 pixels
/// high, then the minimum scroll extent will typically be 0.0 and the maximum
/// scroll extent will typically be 40.0. The fixed content essentially
/// shrinks the viewport to 60.0 pixels, which means there's 40.0 pixels of
/// content off screen when it is scroll to the top, and thus there's 40.0
/// pixels of content that can be scrolled into view.
///
/// If applying the content dimensions changes the scroll offset, return /// If applying the content dimensions changes the scroll offset, return
/// false. Otherwise, return true. If you return false, the [RenderViewport2] /// false. Otherwise, return true. If you return false, the [RenderViewport2]
/// will be laid out again with the new scroll offset. This is expensive. /// will be laid out again with the new scroll offset. This is expensive.
...@@ -1155,13 +1147,11 @@ class RenderViewport2 extends RenderBox with ContainerRenderObjectMixin<RenderSl ...@@ -1155,13 +1147,11 @@ class RenderViewport2 extends RenderBox with ContainerRenderObjectMixin<RenderSl
/// list, if any, is used. /// list, if any, is used.
RenderViewport2({ RenderViewport2({
AxisDirection axisDirection: AxisDirection.down, AxisDirection axisDirection: AxisDirection.down,
bool shrinkWrap: false,
double anchor: 0.0, double anchor: 0.0,
ViewportOffset offset, ViewportOffset offset,
List<RenderSliver> children, List<RenderSliver> children,
RenderSliver center, RenderSliver center,
}) : _axisDirection = axisDirection, }) : _axisDirection = axisDirection,
_shrinkWrap = shrinkWrap,
_anchor = anchor, _anchor = anchor,
_offset = offset ?? new ViewportOffset.zero(), _offset = offset ?? new ViewportOffset.zero(),
_center = center { _center = center {
...@@ -1169,7 +1159,6 @@ class RenderViewport2 extends RenderBox with ContainerRenderObjectMixin<RenderSl ...@@ -1169,7 +1159,6 @@ class RenderViewport2 extends RenderBox with ContainerRenderObjectMixin<RenderSl
if (center == null && firstChild != null) if (center == null && firstChild != null)
_center = firstChild; _center = firstChild;
assert(axisDirection != null); assert(axisDirection != null);
assert(shrinkWrap != null);
assert(anchor != null); assert(anchor != null);
assert(anchor >= 0.0 && anchor <= 1.0); assert(anchor >= 0.0 && anchor <= 1.0);
} }
...@@ -1184,28 +1173,10 @@ class RenderViewport2 extends RenderBox with ContainerRenderObjectMixin<RenderSl ...@@ -1184,28 +1173,10 @@ class RenderViewport2 extends RenderBox with ContainerRenderObjectMixin<RenderSl
markNeedsLayout(); markNeedsLayout();
} }
Axis get axis { Axis get axis => axisDirectionToAxis(axisDirection);
assert(axisDirection != null);
switch (axisDirection) {
case AxisDirection.up:
case AxisDirection.down:
return Axis.vertical;
case AxisDirection.left:
case AxisDirection.right:
return Axis.horizontal;
}
return null;
}
bool get shrinkWrap => _shrinkWrap; // TODO(ianh): Extract the shrink-wrap logic into a separate viewport class.
bool _shrinkWrap; bool _shrinkWrap = false;
set shrinkWrap(bool value) {
assert(value != null);
if (value == _shrinkWrap)
return;
_shrinkWrap = value;
markNeedsLayout();
}
double get anchor => _anchor; double get anchor => _anchor;
double _anchor; double _anchor;
...@@ -1221,7 +1192,7 @@ class RenderViewport2 extends RenderBox with ContainerRenderObjectMixin<RenderSl ...@@ -1221,7 +1192,7 @@ class RenderViewport2 extends RenderBox with ContainerRenderObjectMixin<RenderSl
ViewportOffset get offset => _offset; ViewportOffset get offset => _offset;
ViewportOffset _offset; ViewportOffset _offset;
set offset(ViewportOffset value) { set offset(ViewportOffset value) {
value ??= new ViewportOffset.zero(); assert(value != null);
if (value == _offset) if (value == _offset)
return; return;
if (attached) if (attached)
...@@ -1231,6 +1202,31 @@ class RenderViewport2 extends RenderBox with ContainerRenderObjectMixin<RenderSl ...@@ -1231,6 +1202,31 @@ class RenderViewport2 extends RenderBox with ContainerRenderObjectMixin<RenderSl
_offset = value; _offset = value;
if (attached) if (attached)
_offset.addListener(markNeedsLayout); _offset.addListener(markNeedsLayout);
if (hasSize) {
assert(_minScrollExtent != null);
assert(_maxScrollExtent != null);
assert(anchor != null);
// If we already have a size, then we should re-report the dimensions
// to the new ViewportOffset. If we don't then we'll report them when
// we establish the dimensions later, so don't worry about it now.
double effectiveExtent;
switch (axis) {
case Axis.vertical:
effectiveExtent = size.height;
break;
case Axis.horizontal:
effectiveExtent = size.width;
break;
}
assert(effectiveExtent != null);
offset.applyViewportDimension(effectiveExtent);
if (offset.applyContentDimensions(
// when updating this, also update similar code in performLayout()
math.min(0.0, _minScrollExtent + effectiveExtent * anchor),
math.max(0.0, _maxScrollExtent - effectiveExtent * (1.0 - anchor)),
))
markNeedsLayout();
}
} }
RenderSliver get center => _center; RenderSliver get center => _center;
...@@ -1256,19 +1252,19 @@ class RenderViewport2 extends RenderBox with ContainerRenderObjectMixin<RenderSl ...@@ -1256,19 +1252,19 @@ class RenderViewport2 extends RenderBox with ContainerRenderObjectMixin<RenderSl
@override @override
void detach() { void detach() {
super.detach();
_offset.removeListener(markNeedsLayout); _offset.removeListener(markNeedsLayout);
super.detach();
} }
@override @override
bool get isRepaintBoundary => true; bool get isRepaintBoundary => true;
@override @override
bool get sizedByParent => !shrinkWrap; bool get sizedByParent => !_shrinkWrap;
@override @override
void performResize() { void performResize() {
assert(!shrinkWrap); assert(!_shrinkWrap);
assert(constraints.hasBoundedHeight && constraints.hasBoundedWidth); assert(constraints.hasBoundedHeight && constraints.hasBoundedWidth);
size = constraints.biggest; size = constraints.biggest;
switch (axis) { switch (axis) {
...@@ -1288,9 +1284,10 @@ class RenderViewport2 extends RenderBox with ContainerRenderObjectMixin<RenderSl ...@@ -1288,9 +1284,10 @@ class RenderViewport2 extends RenderBox with ContainerRenderObjectMixin<RenderSl
@override @override
void performLayout() { void performLayout() {
assert(!_shrinkWrap || anchor == 0.0);
if (center == null) { if (center == null) {
assert(firstChild == null); assert(firstChild == null);
if (shrinkWrap) { if (_shrinkWrap) {
switch (axis) { switch (axis) {
case Axis.vertical: case Axis.vertical:
size = new Size(constraints.maxWidth, 0.0); size = new Size(constraints.maxWidth, 0.0);
...@@ -1301,6 +1298,9 @@ class RenderViewport2 extends RenderBox with ContainerRenderObjectMixin<RenderSl ...@@ -1301,6 +1298,9 @@ class RenderViewport2 extends RenderBox with ContainerRenderObjectMixin<RenderSl
} }
offset.applyViewportDimension(0.0); offset.applyViewportDimension(0.0);
} }
_minScrollExtent = 0.0;
_maxScrollExtent = 0.0;
_shrinkWrapExtent = 0.0;
offset.applyContentDimensions(0.0, 0.0); offset.applyContentDimensions(0.0, 0.0);
return; return;
} }
...@@ -1308,19 +1308,21 @@ class RenderViewport2 extends RenderBox with ContainerRenderObjectMixin<RenderSl ...@@ -1308,19 +1308,21 @@ class RenderViewport2 extends RenderBox with ContainerRenderObjectMixin<RenderSl
double extent; double extent;
double crossExtent; double crossExtent;
if (shrinkWrap) { if (_shrinkWrap) {
assert(constraints.hasBoundedHeight && constraints.hasBoundedWidth);
switch (axis) { switch (axis) {
case Axis.vertical: case Axis.vertical:
assert(constraints.hasBoundedWidth);
extent = constraints.maxHeight; extent = constraints.maxHeight;
crossExtent = constraints.maxWidth; crossExtent = constraints.maxWidth;
break; break;
case Axis.horizontal: case Axis.horizontal:
assert(constraints.hasBoundedHeight);
extent = constraints.maxWidth; extent = constraints.maxWidth;
crossExtent = constraints.maxHeight; crossExtent = constraints.maxHeight;
break; break;
} }
} else { } else {
assert(constraints.hasBoundedHeight && constraints.hasBoundedWidth);
switch (axis) { switch (axis) {
case Axis.vertical: case Axis.vertical:
extent = size.height; extent = size.height;
...@@ -1343,7 +1345,7 @@ class RenderViewport2 extends RenderBox with ContainerRenderObjectMixin<RenderSl ...@@ -1343,7 +1345,7 @@ class RenderViewport2 extends RenderBox with ContainerRenderObjectMixin<RenderSl
if (correction != 0.0) { if (correction != 0.0) {
offset.correctBy(correction); offset.correctBy(correction);
} else { } else {
if (shrinkWrap) { if (_shrinkWrap) {
switch (axis) { switch (axis) {
case Axis.vertical: case Axis.vertical:
effectiveExtent = constraints.constrainHeight(_shrinkWrapExtent); effectiveExtent = constraints.constrainHeight(_shrinkWrapExtent);
...@@ -1363,16 +1365,17 @@ class RenderViewport2 extends RenderBox with ContainerRenderObjectMixin<RenderSl ...@@ -1363,16 +1365,17 @@ class RenderViewport2 extends RenderBox with ContainerRenderObjectMixin<RenderSl
break; break;
} }
} }
// when updating this, also update similar code in offset setter
if (offset.applyContentDimensions( if (offset.applyContentDimensions(
math.min(0.0, _minScrollExtent + effectiveExtent * anchor), math.min(0.0, _minScrollExtent + effectiveExtent * anchor),
math.max(0.0, _maxScrollExtent - effectiveExtent * (1.0 - anchor) math.max(0.0, _maxScrollExtent - effectiveExtent * (1.0 - anchor))
))) ))
break; break;
} }
} while (true); } while (true);
assert(shrinkWrap != sizedByParent); assert(_shrinkWrap != sizedByParent);
if (shrinkWrap) { if (_shrinkWrap) {
switch (axis) { switch (axis) {
case Axis.vertical: case Axis.vertical:
size = new Size(crossExtent, effectiveExtent); size = new Size(crossExtent, effectiveExtent);
...@@ -1385,6 +1388,11 @@ class RenderViewport2 extends RenderBox with ContainerRenderObjectMixin<RenderSl ...@@ -1385,6 +1388,11 @@ class RenderViewport2 extends RenderBox with ContainerRenderObjectMixin<RenderSl
} }
double _attemptLayout(double extent, double crossExtent, double correctedOffset) { double _attemptLayout(double extent, double crossExtent, double correctedOffset) {
assert(!extent.isNaN);
assert(extent >= 0.0);
assert(crossExtent.isFinite);
assert(crossExtent >= 0.0);
assert(correctedOffset.isFinite);
_minScrollExtent = 0.0; _minScrollExtent = 0.0;
_maxScrollExtent = 0.0; _maxScrollExtent = 0.0;
_shrinkWrapExtent = 0.0; _shrinkWrapExtent = 0.0;
...@@ -1431,6 +1439,8 @@ class RenderViewport2 extends RenderBox with ContainerRenderObjectMixin<RenderSl ...@@ -1431,6 +1439,8 @@ class RenderViewport2 extends RenderBox with ContainerRenderObjectMixin<RenderSl
GrowthDirection growthDirection, GrowthDirection growthDirection,
_Advancer advance, _Advancer advance,
) { ) {
assert(scrollOffset.isFinite);
assert(scrollOffset >= 0.0);
ScrollDirection adjustedUserScrollDirection; ScrollDirection adjustedUserScrollDirection;
switch (growthDirection) { switch (growthDirection) {
case GrowthDirection.forward: case GrowthDirection.forward:
...@@ -1454,6 +1464,7 @@ class RenderViewport2 extends RenderBox with ContainerRenderObjectMixin<RenderSl ...@@ -1454,6 +1464,7 @@ class RenderViewport2 extends RenderBox with ContainerRenderObjectMixin<RenderSl
double maxPaintOffset = layoutOffset; double maxPaintOffset = layoutOffset;
double initialLayoutOffset = layoutOffset; double initialLayoutOffset = layoutOffset;
while (child != null) { while (child != null) {
assert(scrollOffset >= 0.0);
child.layout(new SliverConstraints( child.layout(new SliverConstraints(
axisDirection: axisDirection, axisDirection: axisDirection,
growthDirection: growthDirection, growthDirection: growthDirection,
...@@ -1654,7 +1665,7 @@ class RenderViewport2 extends RenderBox with ContainerRenderObjectMixin<RenderSl ...@@ -1654,7 +1665,7 @@ class RenderViewport2 extends RenderBox with ContainerRenderObjectMixin<RenderSl
void debugFillDescription(List<String> description) { void debugFillDescription(List<String> description) {
super.debugFillDescription(description); super.debugFillDescription(description);
description.add('$axisDirection'); description.add('$axisDirection');
if (shrinkWrap) if (_shrinkWrap)
description.add('shrink-wrap enabled'); description.add('shrink-wrap enabled');
description.add('anchor: $anchor'); description.add('anchor: $anchor');
description.add('offset: $offset'); description.add('offset: $offset');
...@@ -1714,7 +1725,7 @@ class RenderViewport2 extends RenderBox with ContainerRenderObjectMixin<RenderSl ...@@ -1714,7 +1725,7 @@ class RenderViewport2 extends RenderBox with ContainerRenderObjectMixin<RenderSl
class RenderSliverToBoxAdapter extends RenderSliver with RenderObjectWithChildMixin<RenderBox>, RenderSliverHelpers { class RenderSliverToBoxAdapter extends RenderSliver with RenderObjectWithChildMixin<RenderBox>, RenderSliverHelpers {
/// Creates a [RenderSliver] that wraps a [RenderBox]. /// Creates a [RenderSliver] that wraps a [RenderBox].
RenderSliverToBoxAdapter({ RenderSliverToBoxAdapter({
RenderBox child RenderBox child,
}) { }) {
this.child = child; this.child = child;
} }
......
...@@ -2703,6 +2703,16 @@ class WidgetToRenderBoxAdapter extends LeafRenderObjectWidget { ...@@ -2703,6 +2703,16 @@ class WidgetToRenderBoxAdapter extends LeafRenderObjectWidget {
} }
} }
class SliverToBoxAdapter extends SingleChildRenderObjectWidget {
SliverToBoxAdapter({
Key key,
Widget child,
}) : super(key: key, child: child);
@override
RenderSliverToBoxAdapter createRenderObject(BuildContext context) => new RenderSliverToBoxAdapter();
}
// EVENT HANDLING // EVENT HANDLING
......
...@@ -12,9 +12,9 @@ import 'package:flutter/rendering.dart'; ...@@ -12,9 +12,9 @@ import 'package:flutter/rendering.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
export 'dart:ui' show hashValues, hashList; export 'dart:ui' show hashValues, hashList;
export 'package:flutter/foundation.dart' show FlutterError; export 'package:flutter/foundation.dart' show FlutterError, debugPrint, debugPrintStack;
export 'package:flutter/foundation.dart' show VoidCallback, ValueChanged, ValueGetter, ValueSetter; export 'package:flutter/foundation.dart' show VoidCallback, ValueChanged, ValueGetter, ValueSetter;
export 'package:flutter/rendering.dart' show RenderObject, RenderBox, debugPrint; export 'package:flutter/rendering.dart' show RenderObject, RenderBox, debugDumpRenderTree;
// KEYS // KEYS
...@@ -3864,6 +3864,13 @@ class MultiChildRenderObjectElement extends RenderObjectElement { ...@@ -3864,6 +3864,13 @@ class MultiChildRenderObjectElement extends RenderObjectElement {
@override @override
MultiChildRenderObjectWidget get widget => super.widget; MultiChildRenderObjectWidget get widget => super.widget;
/// The current list of children of this element.
///
/// This list is filtered to hide elements that have been forgotten (using
/// [forgetChild]).
@protected
Iterable<Element> get children => _children.where((Element child) => !_forgottenChildren.contains(child));
List<Element> _children; List<Element> _children;
// We keep a set of forgotten children to avoid O(n^2) work walking _children // We keep a set of forgotten children to avoid O(n^2) work walking _children
// repeatedly to remove children. // repeatedly to remove children.
......
...@@ -122,7 +122,7 @@ class NotificationListener<T extends Notification> extends StatelessWidget { ...@@ -122,7 +122,7 @@ class NotificationListener<T extends Notification> extends StatelessWidget {
/// Useful if, for instance, you're trying to align multiple descendants. /// Useful if, for instance, you're trying to align multiple descendants.
/// ///
/// In the widgets library, only the [SizeChangedLayoutNotifier] class and /// In the widgets library, only the [SizeChangedLayoutNotifier] class and
/// [Scrollable] classes dispatch this notification (specifically, they dispatch /// [Scrollable2] classes dispatch this notification (specifically, they dispatch
/// [SizeChangedLayoutNotification]s and [ScrollNotification]s respectively). /// [SizeChangedLayoutNotification]s and [ScrollNotification]s respectively).
/// Transitions, in particular, do not. Changing one's layout in one's build /// Transitions, in particular, do not. Changing one's layout in one's build
/// function does not cause this notification to be dispatched automatically. If /// function does not cause this notification to be dispatched automatically. If
......
// 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' show Timer;
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/physics.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/widgets.dart';
class GlowingOverscrollIndicator extends StatefulWidget {
GlowingOverscrollIndicator({
Key key,
this.showLeading: true,
this.showTrailing: true,
@required this.axisDirection,
@required this.color,
this.child,
}) : super(key: key) {
assert(showLeading != null);
assert(showTrailing != null);
assert(axisDirection != null);
assert(color != null);
}
/// Whether to show the overscroll glow on the side with negative scroll
/// offsets.
///
/// For a vertical downwards viewport, this is the top side.
///
/// Defaults to true.
///
/// See [showTrailing] for the corresponding control on the other side of the
/// viewport.
final bool showLeading;
/// Whether to show the overscroll glow on the side with positive scroll
/// offsets.
///
/// For a vertical downwards viewport, this is the bottom side.
///
/// Defaults to true.
///
/// See [showLeading] for the corresponding control on the other side of the
/// viewport.
final bool showTrailing;
/// The direction of positive scroll offsets in the viewport of the
/// [Scrollable2] whose overscrolls are to be visualized.
final AxisDirection axisDirection;
Axis get axis => axisDirectionToAxis(axisDirection);
/// The color of the glow. The alpha channel is ignored.
final Color color;
/// The subtree to place inside the overscroll indicator. This should include
/// a source of [ScrollNotification2] notifications, typically a [Scrollable2]
/// widget.
///
/// Typically a [GlowingOverscrollIndicator] is created by a
/// [ScrollBehavior2.wrap] method, in which case the child is usually the one
/// provided as an argument to that method.
final Widget child;
@override
_GlowingOverscrollIndicatorState createState() => new _GlowingOverscrollIndicatorState();
@override
void debugFillDescription(List<String> description) {
super.debugFillDescription(description);
description.add('$axisDirection');
if (showLeading && showTrailing) {
description.add('show: both sides');
} else if (showLeading) {
description.add('show: leading side only');
} else if (showTrailing) {
description.add('show: trailing side only');
} else {
description.add('show: neither side (!)');
}
description.add('$color');
}
}
class _GlowingOverscrollIndicatorState extends State<GlowingOverscrollIndicator> with TickerProviderStateMixin {
_GlowController _leadingController;
_GlowController _trailingController;
@override
void initState() {
super.initState();
_leadingController = new _GlowController(vsync: this, color: config.color, axis: config.axis);
_trailingController = new _GlowController(vsync: this, color: config.color, axis: config.axis);
}
@override
void didUpdateConfig(GlowingOverscrollIndicator oldConfig) {
if (oldConfig.color != config.color || oldConfig.axis != config.axis) {
_leadingController.color = config.color;
_leadingController.axis = config.axis;
_trailingController.color = config.color;
_trailingController.axis = config.axis;
}
}
bool _handleScrollNotification(ScrollNotification2 notification) {
if (notification is OverscrollNotification) {
_GlowController controller;
if (notification.overscroll < 0.0) {
controller = _leadingController;
} else if (notification.overscroll > 0.0) {
controller = _trailingController;
} else {
assert(false);
}
assert(controller != null);
assert(notification.axis == config.axis);
if (notification.velocity != 0.0) {
assert(notification.dragDetails == null);
controller.absorbImpact(notification.velocity.abs());
} else {
assert(notification.overscroll != 0.0);
if (notification.dragDetails != null) {
assert(notification.dragDetails.globalPosition != null);
final RenderBox renderer = notification.context.findRenderObject();
assert(renderer != null);
assert(renderer.hasSize);
final Size size = renderer.size;
final Point position = renderer.globalToLocal(notification.dragDetails.globalPosition);
switch (notification.axis) {
case Axis.horizontal:
controller.pull(notification.overscroll.abs(), size.width, position.y.clamp(0.0, size.height), size.height);
break;
case Axis.vertical:
controller.pull(notification.overscroll.abs(), size.height, position.x.clamp(0.0, size.width), size.width);
break;
}
}
}
} else if (notification is ScrollEndNotification || notification is ScrollUpdateNotification) {
if (notification.dragDetails != null) { // ignore: undefined_getter
_leadingController.scrollEnd();
_trailingController.scrollEnd();
}
}
return false;
}
@override
void dispose() {
_leadingController.dispose();
_trailingController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return new NotificationListener<ScrollNotification2>(
onNotification: _handleScrollNotification,
child: new RepaintBoundary(
child: new CustomPaint(
foregroundPainter: new _GlowingOverscrollIndicatorPainter(
leadingController: config.showLeading ? _leadingController : null,
trailingController: config.showTrailing ? _trailingController : null,
axisDirection: config.axisDirection,
),
child: new RepaintBoundary(
child: config.child,
),
),
),
);
}
}
// The Glow logic is a port of the logic in the following file:
// https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/widget/EdgeEffect.java
// as of December 2016.
enum _GlowState { idle, absorb, pull, recede }
class _GlowController extends ChangeNotifier {
_GlowController({
TickerProvider vsync,
@required Color color,
@required Axis axis,
}) : _color = color,
_axis = axis {
assert(vsync != null);
assert(color != null);
assert(axis != null);
_glowController = new AnimationController(vsync: vsync)
..addStatusListener(_changePhase);
Animation<double> decelerator = new CurvedAnimation(
parent: _glowController,
curve: Curves.decelerate,
)..addListener(notifyListeners);
_glowOpacity = _glowOpacityTween.animate(decelerator);
_glowSize = _glowSizeTween.animate(decelerator);
_displacementTicker = vsync.createTicker(_tickDisplacement);
}
// animation of the main axis direction
_GlowState _state = _GlowState.idle;
AnimationController _glowController;
Timer _pullRecedeTimer;
// animation values
final Tween<double> _glowOpacityTween = new Tween<double>(begin: 0.0, end: 0.0);
Animation<double> _glowOpacity;
final Tween<double> _glowSizeTween = new Tween<double>(begin: 0.0, end: 0.0);
Animation<double> _glowSize;
// animation of the cross axis position
Ticker _displacementTicker;
Duration _displacementTickerLastElapsed;
double _displacementTarget = 0.5;
double _displacement = 0.5;
// tracking the pull distance
double _pullDistance = 0.0;
Color get color => _color;
Color _color;
set color(Color value) {
assert(color != null);
if (color == value)
return;
_color = value;
notifyListeners();
}
Axis get axis => _axis;
Axis _axis;
set axis(Axis value) {
assert(axis != null);
if (axis == value)
return;
_axis = value;
notifyListeners();
}
static const Duration _recedeTime = const Duration(milliseconds: 600);
static const Duration _pullTime = const Duration(milliseconds: 167);
static const Duration _pullHoldTime = const Duration(milliseconds: 167);
static const Duration _pullDecayTime = const Duration(milliseconds: 2000);
static final Duration _crossAxisHalfTime = new Duration(microseconds: (Duration.MICROSECONDS_PER_SECOND / 60.0).round());
static const double _maxOpacity = 0.5;
static const double _pullOpacityGlowFactor = 0.8;
static const double _velocityGlowFactor = 0.00006;
static const double _SQRT3 = 1.73205080757; // const math.sqrt(3)
static const double _kWidthToHeightFactor = (3.0 / 4.0) * (2.0 - _SQRT3);
// absorbed velocities are clamped to the range _minVelocity.._maxVelocity
static const double _minVelocity = 100.0; // logical pixels per second
static const double _maxVelocity = 10000.0; // logical pixels per second
@override
void dispose() {
_glowController.dispose();
_displacementTicker.dispose();
_pullRecedeTimer?.cancel();
super.dispose();
}
/// Handle a scroll slamming into the edge at a particular velocity.
///
/// The velocity must be positive.
void absorbImpact(double velocity) {
assert(velocity >= 0.0);
_pullRecedeTimer?.cancel();
_pullRecedeTimer = null;
velocity = velocity.clamp(_minVelocity, _maxVelocity);
_glowOpacityTween.begin = _state == _GlowState.idle ? 0.3 : _glowOpacity.value;
_glowOpacityTween.end = (velocity * _velocityGlowFactor).clamp(_glowOpacityTween.begin, _maxOpacity);
_glowSizeTween.begin = _glowSize.value;
_glowSizeTween.end = math.min(0.025 + 7.5e-7 * velocity * velocity, 1.0);
_glowController.duration = new Duration(milliseconds: (0.15 + velocity * 0.02).round());
_glowController.forward(from: 0.0);
_displacement = 0.5;
_state = _GlowState.absorb;
}
/// Handle a user-driven overscroll.
///
/// The `overscroll` argument should be the scroll distance in logical pixels,
/// the `extent` argument should be the total dimension of the viewport in the
/// main axis in logical pixels, the `crossAxisOffset` argument should be the
/// distance from the leading (left or top) edge of the cross axis of the
/// viewport, and the `crossExtent` should be the size of the cross axis. For
/// example, a pull of 50 pixels up the middle of a 200 pixel high and 100
/// pixel wide vertical viewport should result in a call of `pull(50.0, 200.0,
/// 50.0, 100.0)`. The `overscroll` value should be positive regardless of the
/// direction.
void pull(double overscroll, double extent, double crossAxisOffset, double crossExtent) {
_pullRecedeTimer?.cancel();
_pullDistance += overscroll / 200.0; // This factor is magic. Not clear why we need it to match Android.
_glowOpacityTween.begin = _glowOpacity.value;
_glowOpacityTween.end = math.min(_glowOpacity.value + overscroll / extent * _pullOpacityGlowFactor, _maxOpacity);
final double height = math.min(extent, crossExtent * _kWidthToHeightFactor);
_glowSizeTween.begin = _glowSize.value;
_glowSizeTween.end = math.max((1.0 - 1.0 / (0.7 * math.sqrt(_pullDistance * height))), _glowSize.value);
_displacementTarget = crossAxisOffset / crossExtent;
if (_displacementTarget != _displacement) {
if (!_displacementTicker.isTicking) {
assert(_displacementTickerLastElapsed == null);
_displacementTicker.start();
}
} else {
_displacementTicker.stop();
_displacementTickerLastElapsed = null;
}
_glowController.duration = _pullTime;
if (_state != _GlowState.pull) {
_glowController.forward(from: 0.0);
_state = _GlowState.pull;
} else {
if (!_glowController.isAnimating) {
assert(_glowController.value == 1.0);
notifyListeners();
}
}
_pullRecedeTimer = new Timer(_pullHoldTime, () => _recede(_pullDecayTime));
}
void scrollEnd() {
if (_state == _GlowState.pull)
_recede(_recedeTime);
}
void _changePhase(AnimationStatus status) {
if (status != AnimationStatus.completed)
return;
switch (_state) {
case _GlowState.absorb:
_recede(_recedeTime);
break;
case _GlowState.recede:
_state = _GlowState.idle;
_pullDistance = 0.0;
break;
case _GlowState.pull:
case _GlowState.idle:
break;
}
}
void _recede(Duration duration) {
if (_state == _GlowState.recede || _state == _GlowState.idle)
return;
_pullRecedeTimer?.cancel();
_pullRecedeTimer = null;
_glowOpacityTween.begin = _glowOpacity.value;
_glowOpacityTween.end = 0.0;
_glowSizeTween.begin = _glowSize.value;
_glowSizeTween.end = 0.0;
_glowController.duration = duration;
_glowController.forward(from: 0.0);
_state = _GlowState.recede;
}
void _tickDisplacement(Duration elapsed) {
if (_displacementTickerLastElapsed != null) {
double t = (elapsed.inMicroseconds - _displacementTickerLastElapsed.inMicroseconds).toDouble();
_displacement = _displacementTarget - (_displacementTarget - _displacement) * math.pow(2.0, -t / _crossAxisHalfTime.inMicroseconds);
notifyListeners();
}
if (nearEqual(_displacementTarget, _displacement, Tolerance.defaultTolerance.distance)) {
_displacementTicker.stop();
_displacementTickerLastElapsed = null;
} else {
_displacementTickerLastElapsed = elapsed;
}
}
void paint(Canvas canvas, Size size) {
if (_glowOpacity.value == 0.0)
return;
final double baseGlowScale = size.width > size.height ? size.height / size.width : 1.0;
final double radius = size.width * 3.0 / 2.0;
final double height = math.min(size.height, size.width * _kWidthToHeightFactor);
final double scaleY = _glowSize.value * baseGlowScale;
final Rect rect = new Rect.fromLTWH(0.0, 0.0, size.width, height);
final Point center = new Point((size.width / 2.0) * (0.5 + _displacement), height - radius);
final Paint paint = new Paint()..color = color.withOpacity(_glowOpacity.value);
canvas.save();
canvas.scale(1.0, scaleY);
canvas.clipRect(rect);
canvas.drawCircle(center, radius, paint);
canvas.restore();
}
}
class _GlowingOverscrollIndicatorPainter extends CustomPainter {
_GlowingOverscrollIndicatorPainter({
this.leadingController,
this.trailingController,
this.axisDirection,
}) : super(
repaint: new Listenable.merge(<Listenable>[leadingController, trailingController])
);
/// The controller for the overscroll glow on the side with negative scroll offsets.
///
/// For a vertical downwards viewport, this is the top side.
final _GlowController leadingController;
/// The controller for the overscroll glow on the side with positive scroll offsets.
///
/// For a vertical downwards viewport, this is the bottom side.
final _GlowController trailingController;
/// The direction of the viewport.
final AxisDirection axisDirection;
static const double piOver2 = math.PI / 2.0;
void _paintSide(Canvas canvas, Size size, _GlowController controller, AxisDirection axisDirection, GrowthDirection growthDirection) {
if (controller == null)
return;
switch (applyGrowthDirectionToAxisDirection(axisDirection, growthDirection)) {
case AxisDirection.up:
controller.paint(canvas, size);
break;
case AxisDirection.down:
canvas.save();
canvas.translate(0.0, size.height);
canvas.scale(1.0, -1.0);
controller.paint(canvas, size);
canvas.restore();
break;
case AxisDirection.left:
canvas.save();
canvas.translate(0.0, size.height);
canvas.rotate(-piOver2);
controller.paint(canvas, new Size(size.height, size.width));
canvas.restore();
break;
case AxisDirection.right:
canvas.save();
canvas.translate(size.width, size.height);
canvas.rotate(-piOver2);
canvas.scale(1.0, -1.0);
controller.paint(canvas, new Size(size.height, size.width));
canvas.restore();
break;
}
}
@override
void paint(Canvas canvas, Size size) {
_paintSide(canvas, size, leadingController, axisDirection, GrowthDirection.reverse);
_paintSide(canvas, size, trailingController, axisDirection, GrowthDirection.forward);
}
@override
bool shouldRepaint(_GlowingOverscrollIndicatorPainter oldDelegate) {
return oldDelegate.leadingController != leadingController
|| oldDelegate.trailingController != trailingController;
}
}
// 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 'framework.dart';
import 'gesture_detector.dart';
import 'overscroll_indicator.dart';
import 'scroll_simulation.dart';
import 'notification_listener.dart';
import 'scroll_notification.dart';
import 'scrollable.dart';
/// Scrolling logic delegate for lists and other unremarkable scrollable
/// viewports.
///
/// See also:
///
/// * [BouncingAbsoluteScrollPositionMixIn], which is used by this class to
/// implement the scroll behavior for iOS.
/// * [ClampingAbsoluteScrollPositionMixIn] and [GlowingOverscrollIndicator],
/// which are used by this class to implement the scroll behavior for Android.
class ViewportScrollBehavior extends ScrollBehavior2 {
ViewportScrollBehavior({
Tolerance scrollTolerances,
}) : scrollTolerances = scrollTolerances ?? defaultScrollTolerances;
/// The accuracy to which scrolling is computed.
///
/// Defaults to [defaultScrollTolerances].
final Tolerance scrollTolerances;
/// The accuracy to which scrolling is computed by default.
///
/// This is the default value for [scrollTolerances].
static final Tolerance defaultScrollTolerances = new Tolerance(
// TODO(ianh): Handle the case of the device pixel ratio changing.
// TODO(ianh): Get this from the local MediaQuery not dart:ui's window object.
velocity: 1.0 / (0.050 * ui.window.devicePixelRatio), // logical pixels per second
distance: 1.0 / ui.window.devicePixelRatio // logical pixels
);
/// The platform whose scroll physics should be implemented.
///
/// Defaults to the current platform.
TargetPlatform getPlatform(BuildContext context) => defaultTargetPlatform;
/// The color to use for the glow effect when [platform] indicates a platform
/// that uses a [GlowingOverscrollIndicator].
///
/// Defaults to white.
Color getGlowColor(BuildContext context) => const Color(0xFFFFFFFF);
@override
Widget wrap(BuildContext context, Widget child, AxisDirection axisDirection) {
switch (getPlatform(context)) {
case TargetPlatform.iOS:
return child;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
return new GlowingOverscrollIndicator(
child: child,
axisDirection: axisDirection,
color: getGlowColor(context),
);
}
return null;
}
@override
ScrollPosition createScrollPosition(BuildContext context, Scrollable2State state, ScrollPosition oldPosition) {
switch (getPlatform(context)) {
case TargetPlatform.iOS:
return new _CupertinoViewportScrollPosition(
state,
scrollTolerances,
oldPosition,
);
case TargetPlatform.android:
case TargetPlatform.fuchsia:
return new _MountainViewViewportScrollPosition(
state,
scrollTolerances,
oldPosition,
);
}
return null;
}
@override
bool shouldNotify(ViewportScrollBehavior oldDelegate) {
return scrollTolerances != oldDelegate.scrollTolerances;
}
}
abstract class AbsoluteScrollPosition extends ScrollPosition {
AbsoluteScrollPosition(
Scrollable2State state,
Tolerance scrollTolerances,
ScrollPosition oldPosition,
) : super(state, scrollTolerances, oldPosition);
@override
double get pixels => _pixels;
double _pixels = 0.0;
@override
double setPixels(double value) {
assert(SchedulerBinding.instance.schedulerPhase.index <= SchedulerPhase.transientCallbacks.index);
assert(activity.isScrolling);
if (value != pixels) {
final double overScroll = computeOverscroll(value);
assert(() {
double delta = value - pixels;
if (overScroll.abs() > delta.abs()) {
throw new FlutterError(
'$runtimeType.computeOverscroll returned invalid overscroll value.\n'
'setPixels() was called to change the scroll offset from $pixels to $value.\n'
'That is a delta of $delta units.\n'
'computeOverscroll() reported an overscroll of $overScroll units.\n'
'The scroll extents are $minScrollExtent .. $maxScrollExtent, and the '
'viewport dimension is $viewportDimension.'
);
}
return true;
});
double oldPixels = _pixels;
_pixels = value - overScroll;
if (_pixels != oldPixels) {
notifyListeners();
dispatchNotification(activity.createScrollUpdateNotification(state, _pixels - oldPixels));
}
if (overScroll != 0.0) {
reportOverscroll(overScroll);
return overScroll;
}
}
return 0.0;
}
/// Called by [setPixels] just before the [pixels] value is updated, to
/// determine how much of the offset is to be clamped off and sent to
/// [reportOverscroll].
///
/// The `value` argument is guaranteed to not equal [pixels] when this is
/// called.
@protected
double computeOverscroll(double value) => 0.0;
@protected
void reportOverscroll(double value) {
assert(activity.isScrolling);
dispatchNotification(activity.createOverscrollNotification(state, value));
}
double get viewportDimension => _viewportDimension;
double _viewportDimension;
double get minScrollExtent => _minScrollExtent;
double _minScrollExtent;
double get maxScrollExtent => _maxScrollExtent;
double _maxScrollExtent;
bool get outOfRange => pixels < minScrollExtent || pixels > maxScrollExtent;
bool get atEdge => pixels == minScrollExtent || pixels == maxScrollExtent;
bool _didChangeViewportDimension = true;
@override
void applyViewportDimension(double viewportDimension) {
if (_viewportDimension != viewportDimension) {
_viewportDimension = viewportDimension;
_didChangeViewportDimension = true;
// If this is called, you can rely on applyContentDimensions being called
// soon afterwards in the same layout phase. So we put all the logic that
// relies on both values being computed into applyContentDimensions.
}
super.applyViewportDimension(viewportDimension);
}
@override
bool applyContentDimensions(double minScrollExtent, double maxScrollExtent) {
if (_minScrollExtent != minScrollExtent ||
_maxScrollExtent != maxScrollExtent ||
_didChangeViewportDimension) {
_minScrollExtent = minScrollExtent;
_maxScrollExtent = maxScrollExtent;
activity.applyNewDimensions();
_didChangeViewportDimension = false;
}
return super.applyContentDimensions(minScrollExtent, maxScrollExtent);
}
@override
ScrollableMetrics getMetrics() {
return new ScrollableMetrics(
extentBefore: math.max(pixels - minScrollExtent, 0.0),
extentInside: math.min(pixels, maxScrollExtent) - math.max(pixels, minScrollExtent) + math.min(viewportDimension, maxScrollExtent - minScrollExtent),
extentAfter: math.max(maxScrollExtent - pixels, 0.0),
);
}
@override
bool get canDrag => true;
@override
bool get shouldIgnorePointer => activity?.shouldIgnorePointer;
@override
void correctBy(double correction) {
_pixels += correction;
}
@override
void absorb(ScrollPosition other) {
if (other is AbsoluteScrollPosition) {
final AbsoluteScrollPosition typedOther = other;
_pixels = typedOther._pixels;
_viewportDimension = typedOther.viewportDimension;
_minScrollExtent = typedOther.minScrollExtent;
_maxScrollExtent = typedOther.maxScrollExtent;
}
super.absorb(other);
}
@override
DragScrollActivity beginDragActivity(DragStartDetails details) {
beginActivity(new AbsoluteDragScrollActivity(this, details, scrollTolerances));
return activity;
}
@override
void beginBallisticActivity(double velocity) {
final Simulation simulation = createBallisticSimulation(velocity);
if (simulation != null) {
simulation.tolerance = scrollTolerances;
beginActivity(new AbsoluteBallisticScrollActivity(this, simulation, vsync));
} else {
beginIdleActivity();
}
}
/// Animates the position from its current value to the given value `to`.
///
/// Any active animation is canceled. If the user is currently scrolling, that
/// action is canceled.
///
/// The returned [Future] will complete when the animation ends, whether it
/// completed successfully or whether it was interrupted prematurely.
///
/// An animation will be interrupted whenever the user attempts to scroll
/// manually, or whenever another activity is started, or whenever the
/// animation reaches the edge of the viewport and attempts to overscroll. (If
/// the [ScrollPosition] does not overscroll but instead allows scrolling
/// beyond the extents, then going beyond the extents will not interrupt the
/// animation.)
///
/// The animation is indifferent to changes to the viewport or content
/// dimensions.
///
/// Once the animation has completed, the scroll position will attempt to
/// begin a ballistic activity in case its value is not stable (for example,
/// if it is scrolled beyond the extents and in that situation the scroll
/// position would normally bounce back).
///
/// The duration must not be zero. To jump to a particular value without an
/// animation, use [setPixels].
///
/// The animation is handled by an [AbsoluteDrivenScrollActivity].
Future<Null> animate({
@required double to,
@required Duration duration,
@required Curve curve,
}) {
final AbsoluteDrivenScrollActivity activity = new AbsoluteDrivenScrollActivity(
this,
from: pixels,
to: to,
duration: duration,
curve: curve,
vsync: vsync,
);
beginActivity(activity);
return activity.done;
}
/// Jumps the scroll position from its current value to the given value,
/// without animation, and without checking if the new value is in range.
///
/// Any active animation is canceled. If the user is currently scrolling, that
/// action is canceled.
///
/// If this method changes the scroll position, a sequence of start/update/end
/// scroll notifications will be dispatched. No overscroll notifications can
/// be generated by this method.
///
/// Immediately after the jump, a ballistic activity is started, in case the
/// value was out of range.
void jumpTo(double value) {
beginIdleActivity();
if (_pixels != value) {
final double oldPixels = _pixels;
_pixels = value;
notifyListeners();
dispatchNotification(activity.createScrollStartNotification(state));
dispatchNotification(activity.createScrollUpdateNotification(state, _pixels - oldPixels));
dispatchNotification(activity.createScrollEndNotification(state));
}
beginBallisticActivity(0.0);
}
@protected
Simulation createBallisticSimulation(double velocity);
@override
void debugFillDescription(List<String> description) {
super.debugFillDescription(description);
description.add('range: ${minScrollExtent?.toStringAsFixed(1)}..${maxScrollExtent?.toStringAsFixed(1)}');
description.add('viewport: ${viewportDimension?.toStringAsFixed(1)}');
}
}
/// Scroll position mixin for environments that allow the scroll offset to go
/// beyond the bounds of the content, but then bounce the content back to the
/// edge of those bounds.
///
/// This is the behavior typically seen on iOS.
///
/// Mixing this class into an [AbsoluteScrollPosition] subclass, and then
/// overriding [computeOverscroll] to provide some overscroll at one edge, will
/// allow that edge to clamp while the other edge still has the bounce effect.
///
/// See also:
///
/// * [ViewportScrollBehavior], which uses this to provide the iOS component of
/// its scroll behavior.
/// * [ClampingAbsoluteScrollPositionMixIn], which is the equivalent mixin for
/// Android's clamping behavior.
abstract class BouncingAbsoluteScrollPositionMixIn implements AbsoluteScrollPosition {
/// The multiple applied to overscroll to make it appear that scrolling past
/// the edge of the scrollable contents is harder than scrolling the list.
///
/// By default this is 0.5, meaning that overscroll is twice as hard as normal
/// scroll.
double get frictionFactor => 0.5;
@override
double applyPhysicsToUserOffset(double offset) {
assert(offset != 0.0);
assert(minScrollExtent <= maxScrollExtent);
if (offset > 0.0)
return _applyFriction(pixels, minScrollExtent, maxScrollExtent, offset, frictionFactor);
return -_applyFriction(-pixels, -maxScrollExtent, -minScrollExtent, -offset, frictionFactor);
}
static double _applyFriction(double start, double lowLimit, double highLimit, double delta, double gamma) {
assert(lowLimit <= highLimit);
assert(delta > 0.0);
double total = 0.0;
if (start < lowLimit) {
double distanceToLimit = lowLimit - start;
double deltaToLimit = distanceToLimit / gamma;
if (delta < deltaToLimit)
return total + delta * gamma;
total += distanceToLimit;
delta -= deltaToLimit;
}
return total + delta;
}
@override
Simulation createBallisticSimulation(double velocity) {
if (velocity.abs() >= scrollTolerances.velocity || outOfRange) {
return new BouncingScrollSimulation(
position: pixels,
velocity: velocity,
leadingExtent: minScrollExtent,
trailingExtent: maxScrollExtent,
);
}
return null;
}
}
class _CupertinoViewportScrollPosition extends AbsoluteScrollPosition
with BouncingAbsoluteScrollPositionMixIn {
_CupertinoViewportScrollPosition(
Scrollable2State state,
Tolerance scrollTolerances,
ScrollPosition oldPosition,
) : super(state, scrollTolerances, oldPosition);
}
/// Scroll position mixin for environments that prevent the scroll offset from
/// reaching beyond the bounds of the content.
///
/// This is the behavior typically seen on Android.
///
/// See also:
///
/// * [ViewportScrollBehavior], which uses this to provide the Android component
/// of its scroll behavior.
/// * [BouncingAbsoluteScrollPositionMixIn], which is the equivalent mixin for
/// iOS' bouncing behavior.
/// * [GlowingOverscrollIndicator], which is used by [ViewportScrollBehavior] to
/// provide the glowing effect that is usually found with this clamping effect
/// on Android.
abstract class ClampingAbsoluteScrollPositionMixIn implements AbsoluteScrollPosition {
@override
double computeOverscroll(double value) {
assert(value != pixels);
if (value < pixels && pixels <= minScrollExtent) // underscroll
return value - pixels;
if (maxScrollExtent <= pixels && pixels < value) // overscroll
return value - pixels;
if (value < minScrollExtent && minScrollExtent < pixels) // hit top edge
return value - minScrollExtent;
if (pixels < maxScrollExtent && maxScrollExtent < value) // hit bottom edge
return value - maxScrollExtent;
return 0.0;
}
static final SpringDescription _defaultScrollSpring = new SpringDescription.withDampingRatio(
mass: 0.5,
springConstant: 100.0,
ratio: 1.1,
);
@override
Simulation createBallisticSimulation(double velocity) {
if (outOfRange) {
if (pixels > maxScrollExtent)
return new ScrollSpringSimulation(_defaultScrollSpring, pixels, maxScrollExtent, velocity);
if (pixels < minScrollExtent)
return new ScrollSpringSimulation(_defaultScrollSpring, pixels, minScrollExtent, velocity);
assert(false);
}
if (!atEdge && velocity.abs() >= scrollTolerances.velocity) {
return new ClampingScrollSimulation(
position: pixels,
velocity: velocity,
);
}
return null;
}
}
class _MountainViewViewportScrollPosition extends AbsoluteScrollPosition
with ClampingAbsoluteScrollPositionMixIn {
_MountainViewViewportScrollPosition(
Scrollable2State state,
Tolerance scrollTolerances,
ScrollPosition oldPosition,
) : super(state, scrollTolerances, oldPosition);
}
class AbsoluteDragScrollActivity extends DragScrollActivity {
AbsoluteDragScrollActivity(
ScrollPosition position,
DragStartDetails details,
this.scrollTolerances,
) : _lastDetails = details, super(position);
final Tolerance scrollTolerances;
@override
void update(DragUpdateDetails details, { bool reverse }) {
assert(details.primaryDelta != null);
_lastDetails = details;
double offset = details.primaryDelta;
if (offset == 0.0)
return;
if (reverse) // e.g. an AxisDirection.up scrollable
offset = -offset;
position.updateUserScrollDirection(offset > 0.0 ? ScrollDirection.forward : ScrollDirection.reverse);
position.setPixels(position.pixels - position.applyPhysicsToUserOffset(offset));
// We ignore any reported overscroll returned by setPixels,
// because it gets reported via the reportOverscroll path.
}
@override
void end(DragEndDetails details, { bool reverse }) {
assert(details.primaryVelocity != null);
double velocity = details.primaryVelocity;
if (reverse) // e.g. an AxisDirection.up scrollable
velocity = -velocity;
_lastDetails = details;
// We negate the velocity here because if the touch is moving downwards,
// the scroll has to move upwards. It's the same reason that update()
// above negates the delta before applying it to the scroll offset.
position.beginBallisticActivity(-velocity);
}
@override
void dispose() {
_lastDetails = null;
super.dispose();
}
dynamic _lastDetails;
@override
Notification createScrollStartNotification(Scrollable2State scrollable) {
assert(_lastDetails is DragStartDetails);
return new ScrollStartNotification(scrollable: scrollable, dragDetails: _lastDetails);
}
@override
Notification createScrollUpdateNotification(Scrollable2State scrollable, double scrollDelta) {
assert(_lastDetails is DragUpdateDetails);
return new ScrollUpdateNotification(scrollable: scrollable, scrollDelta: scrollDelta, dragDetails: _lastDetails);
}
@override
Notification createOverscrollNotification(Scrollable2State scrollable, double overscroll) {
assert(_lastDetails is DragUpdateDetails);
return new OverscrollNotification(scrollable: scrollable, overscroll: overscroll, dragDetails: _lastDetails);
}
@override
Notification createScrollEndNotification(Scrollable2State scrollable) {
assert(_lastDetails is DragEndDetails);
return new ScrollEndNotification(scrollable: scrollable, dragDetails: _lastDetails);
}
@override
bool get shouldIgnorePointer => true;
@override
bool get isScrolling => true;
}
class AbsoluteBallisticScrollActivity extends ScrollActivity {
///
/// The velocity should be in logical pixels per second.
AbsoluteBallisticScrollActivity(
AbsoluteScrollPosition position,
Simulation simulation,
TickerProvider vsync,
) : super(position) {
_controller = new AnimationController.unbounded(
value: position.pixels,
debugLabel: '$runtimeType',
vsync: vsync,
)
..addListener(_tick)
..animateWith(simulation)
.then(_end);
}
@override
AbsoluteScrollPosition get position => super.position;
double get velocity => _controller.velocity;
AnimationController _controller;
@override
void resetActivity() {
position.beginBallisticActivity(velocity);
}
@override
void touched() {
position.beginIdleActivity();
}
@override
void applyNewDimensions() {
position.beginBallisticActivity(velocity);
}
void _tick() {
if (position.setPixels(_controller.value) != 0.0)
position.beginIdleActivity();
}
void _end(Null value) {
position.beginIdleActivity();
}
@override
Notification createOverscrollNotification(Scrollable2State scrollable, double overscroll) {
return new OverscrollNotification(scrollable: scrollable, overscroll: overscroll, velocity: velocity);
}
@override
bool get shouldIgnorePointer => true;
@override
bool get isScrolling => true;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
String toString() {
return '$runtimeType($_controller)';
}
}
class AbsoluteDrivenScrollActivity extends ScrollActivity {
AbsoluteDrivenScrollActivity(
ScrollPosition position, {
@required double from,
@required double to,
@required Duration duration,
@required Curve curve,
@required TickerProvider vsync,
}) : super(position) {
assert(from != null);
assert(to != null);
assert(duration != null);
assert(duration > Duration.ZERO);
assert(curve != null);
_completer = new Completer<Null>();
_controller = new AnimationController.unbounded(
value: from,
debugLabel: '$runtimeType',
vsync: vsync,
)
..addListener(_tick)
..animateTo(to, duration: duration, curve: curve)
.then(_end);
}
@override
AbsoluteScrollPosition get position => super.position;
Completer<Null> _completer;
AnimationController _controller;
Future<Null> get done => _completer.future;
double get velocity => _controller.velocity;
@override
void touched() {
position.beginIdleActivity();
}
void _tick() {
if (position.setPixels(_controller.value) != 0.0)
position.beginIdleActivity();
}
void _end(Null value) {
position.beginBallisticActivity(velocity);
}
@override
Notification createOverscrollNotification(Scrollable2State scrollable, double overscroll) {
return new OverscrollNotification(scrollable: scrollable, overscroll: overscroll, velocity: velocity);
}
@override
bool get shouldIgnorePointer => true;
@override
bool get isScrolling => true;
@override
void dispose() {
_completer.complete();
_controller.dispose();
super.dispose();
}
@override
String toString() {
return '$runtimeType($_controller)';
}
}
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
// DELETE THIS FILE WHEN REMOVING LEGACY SCROLLING CODE
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
......
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
// DELETE THIS FILE WHEN REMOVING LEGACY SCROLLING CODE
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'framework.dart'; import 'framework.dart';
......
// 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 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'framework.dart';
import 'basic.dart';
import 'notification_listener.dart';
import 'scrollable.dart' show Scrollable2, Scrollable2State;
/// A description of a [Scrollable2]'s contents, useful for modelling the state
/// of the viewport, for example by a [Scrollbar].
///
/// The units used by the [extentBefore], [extentInside], and [extentAfter] are
/// not defined, but must be consistent. For example, they could be in pixels,
/// or in percentages, or in units of the [extentInside] (in the latter case,
/// [extentInside] would always be 1.0).
class ScrollableMetrics {
/// Create a description of the metrics of a [Scrollable2]'s contents.
///
/// The three arguments must be present, non-null, finite, and non-negative.
const ScrollableMetrics({
@required this.extentBefore,
@required this.extentInside,
@required this.extentAfter,
});
/// The quantity of content conceptually "above" the currently visible content
/// of the viewport in the scrollable. This is the content above the content
/// described by [extentInside].
///
/// The units are in general arbitrary, and decided by the [ScrollPosition]
/// that generated the [ScrollableMetrics]. They will be the same units as for
/// [extentInside] and [extentAfter].
final double extentBefore;
/// The quantity of visible content. If [extentBefore] and [extentAfter] are
/// non-zero, then this is typically the height of the viewport. It could be
/// less if there is less content visible than the size of the viewport.
///
/// The units are in general arbitrary, and decided by the [ScrollPosition]
/// that generated the [ScrollableMetrics]. They will be the same units as for
/// [extentBefore] and [extentAfter].
final double extentInside;
/// The quantity of content conceptually "below" the currently visible content
/// of the viewport in the scrollable. This is the content below the content
/// described by [extentInside].
///
/// The units are in general arbitrary, and decided by the [ScrollPosition]
/// that generated the [ScrollableMetrics]. They will be the same units as for
/// [extentBefore] and [extentInside].
final double extentAfter;
@override
String toString() {
return '$runtimeType(${extentBefore.toStringAsFixed(1)}..[${extentInside.toStringAsFixed(1)}]..${extentAfter.toStringAsFixed(1)}})';
}
}
abstract class ScrollNotification2 extends LayoutChangedNotification {
/// Creates a notification about scrolling.
ScrollNotification2({
@required Scrollable2State scrollable,
}) : axisDirection = scrollable.config.axisDirection,
metrics = scrollable.position.getMetrics(),
context = scrollable.context;
/// The direction that positive scroll offsets indicate.
final AxisDirection axisDirection;
Axis get axis => axisDirectionToAxis(axisDirection);
final ScrollableMetrics metrics;
/// The build context of the [Scrollable2] that fired this notification.
///
/// This can be used to find the scrollable's render objects to determine the
/// size of the viewport, for instance.
// TODO(ianh): Maybe just fold those into the ScrollableMetrics?
final BuildContext context;
/// The number of [Scrollable2] widgets that this notification has bubbled
/// through. Typically listeners only respond to notifications with a [depth]
/// of zero.
int get depth => _depth;
int _depth = 0;
@override
bool visitAncestor(Element element) {
if (element.widget is Scrollable2)
_depth += 1;
return super.visitAncestor(element);
}
@override
void debugFillDescription(List<String> description) {
super.debugFillDescription(description);
description.add('$axisDirection');
description.add('metrics: $metrics');
description.add('depth: $depth');
}
}
class ScrollStartNotification extends ScrollNotification2 {
ScrollStartNotification({
@required Scrollable2State scrollable,
this.dragDetails,
}) : super(scrollable: scrollable);
final DragStartDetails dragDetails;
@override
void debugFillDescription(List<String> description) {
super.debugFillDescription(description);
if (dragDetails != null)
description.add('$dragDetails');
}
}
class ScrollUpdateNotification extends ScrollNotification2 {
ScrollUpdateNotification({
@required Scrollable2State scrollable,
this.dragDetails,
this.scrollDelta,
}) : super(scrollable: scrollable);
final DragUpdateDetails dragDetails;
/// The distance by which the [Scrollable2] was scrolled, in logical pixels.
final double scrollDelta;
@override
void debugFillDescription(List<String> description) {
super.debugFillDescription(description);
description.add('scrollDelta: $scrollDelta');
if (dragDetails != null)
description.add('$dragDetails');
}
}
class OverscrollNotification extends ScrollNotification2 {
OverscrollNotification({
@required Scrollable2State scrollable,
this.dragDetails,
@required this.overscroll,
this.velocity: 0.0,
}) : super(scrollable: scrollable) {
assert(overscroll != null);
assert(overscroll.isFinite);
assert(overscroll != 0.0);
assert(velocity != null);
}
final DragUpdateDetails dragDetails;
/// The number of logical pixels that the [Scrollable2] avoided scrolling.
///
/// This will be negative for overscroll on the "start" side and positive for
/// overscroll on the "end" side.
final double overscroll;
/// The velocity at which the [ScrollPosition] was changing when this
/// overscroll happened.
///
/// This will typically be 0.0 for touch-driven overscrolls, and positive
/// for overscrolls that happened from a [BallisticScrollActivity] or
/// [DrivenScrollActivity].
final double velocity;
@override
void debugFillDescription(List<String> description) {
super.debugFillDescription(description);
description.add('overscroll: ${overscroll.toStringAsFixed(1)}');
description.add('velocity: ${velocity.toStringAsFixed(1)}');
if (dragDetails != null)
description.add('$dragDetails');
}
}
class ScrollEndNotification extends ScrollNotification2 {
ScrollEndNotification({
@required Scrollable2State scrollable,
this.dragDetails,
}) : super(scrollable: scrollable);
final DragEndDetails dragDetails;
@override
void debugFillDescription(List<String> description) {
super.debugFillDescription(description);
if (dragDetails != null)
description.add('$dragDetails');
}
}
class UserScrollNotification extends ScrollNotification2 {
UserScrollNotification({
@required Scrollable2State scrollable,
this.direction,
}) : super(scrollable: scrollable);
final ScrollDirection direction;
@override
void debugFillDescription(List<String> description) {
super.debugFillDescription(description);
description.add('direction: $direction');
}
}
...@@ -7,17 +7,120 @@ import 'dart:math' as math; ...@@ -7,17 +7,120 @@ import 'dart:math' as math;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/physics.dart'; import 'package:flutter/physics.dart';
final SpringDescription _kScrollSpring = new SpringDescription.withDampingRatio(mass: 0.5, springConstant: 100.0, ratio: 1.1); /// An implementation of scroll physics that matches iOS.
final double _kDrag = 0.025; ///
/// See also:
///
/// * [ClampingScrollSimulation], which implements Android scroll physics.
class BouncingScrollSimulation extends SimulationGroup {
/// Creates a simulation group for scrolling on iOS, with the given
/// parameters.
///
/// The position and velocity arguments must use the same units as will be
/// expected from the [x] and [dx] methods respectively (typically logical
/// pixels and logical pixels per second respectively).
///
/// The leading and trailing extents must use the unit of length, the same
/// unit as used for the position argument and as expected from the [x]
/// method (typically logical pixels).
///
/// The units used with the provided [SpringDescription] must similarly be
/// consistent with the other arguments. A default set of constants is used
/// for the `spring` description if it is omitted; these defaults assume
/// that the unit of length is the logical pixel.
BouncingScrollSimulation({
@required double position,
@required double velocity,
@required double leadingExtent,
@required double trailingExtent,
SpringDescription spring,
}) : _leadingExtent = leadingExtent,
_trailingExtent = trailingExtent,
_spring = spring ?? _defaultScrollSpring {
assert(position != null);
assert(velocity != null);
assert(_leadingExtent != null);
assert(_trailingExtent != null);
assert(_leadingExtent <= _trailingExtent);
assert(_spring != null);
_chooseSimulation(position, velocity, 0.0);
}
// This class is based on Scroller.java from final double _leadingExtent;
// https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/widget final double _trailingExtent;
// The "See" comments refer to Scroller methods and values. Some simplifications final SpringDescription _spring;
// have been made.
class _MountainViewSimulation extends Simulation { static final SpringDescription _defaultScrollSpring = new SpringDescription.withDampingRatio(
_MountainViewSimulation({ mass: 0.5,
this.position, springConstant: 100.0,
this.velocity, ratio: 1.1,
);
bool _isSpringing = false;
Simulation _currentSimulation;
double _offset = 0.0;
// This simulation can only step forward.
@override
bool step(double time) => _chooseSimulation(
_currentSimulation.x(time - _offset),
_currentSimulation.dx(time - _offset),
time,
);
@override
Simulation get currentSimulation => _currentSimulation;
@override
double get currentIntervalOffset => _offset;
bool _chooseSimulation(double position, double velocity, double intervalOffset) {
if (!_isSpringing) {
if (position > _trailingExtent) {
_isSpringing = true;
_offset = intervalOffset;
_currentSimulation = new ScrollSpringSimulation(_spring, position, _trailingExtent, velocity);
return true;
} else if (position < _leadingExtent) {
_isSpringing = true;
_offset = intervalOffset;
_currentSimulation = new ScrollSpringSimulation(_spring, position, _leadingExtent, velocity);
return true;
} else if (_currentSimulation == null) {
_currentSimulation = new FrictionSimulation(0.135, position, velocity * 0.91);
return true;
}
}
return false;
}
@override
String toString() {
return '$runtimeType(leadingExtent: $_leadingExtent, trailingExtent: $_trailingExtent)';
}
}
/// An implementation of scroll physics that matches Android.
///
/// See also:
///
/// * [BouncingScrollSimulation], which implements iOS scroll physics.
//
// This class is based on Scroller.java from Android:
// https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/widget
//
// The "See..." comments below refer to Scroller methods and values. Some
// simplifications have been made.
class ClampingScrollSimulation extends Simulation {
/// Creates a scroll physics simulation that matches Android scrolling.
//
// TODO(ianh): The incoming `velocity` is used to determine the starting speed
// and duration, but does not represent the exact velocity of the simulation
// at t=0 as it should. This causes crazy scrolling irregularities when the
// scroll dimensions change during a fling.
ClampingScrollSimulation({
@required this.position,
@required this.velocity,
this.friction: 0.015, this.friction: 0.015,
}) { }) {
_scaledFriction = friction * _decelerationForFriction(0.84); // See mPhysicalCoeff _scaledFriction = friction * _decelerationForFriction(0.84); // See mPhysicalCoeff
...@@ -33,7 +136,7 @@ class _MountainViewSimulation extends Simulation { ...@@ -33,7 +136,7 @@ class _MountainViewSimulation extends Simulation {
double _duration; double _duration;
double _distance; double _distance;
// See DECELERATION_RATE // See DECELERATION_RATE.
static final double _decelerationRate = math.log(0.78) / math.log(0.9); static final double _decelerationRate = math.log(0.78) / math.log(0.9);
// See computeDeceleration(). // See computeDeceleration().
...@@ -41,24 +144,26 @@ class _MountainViewSimulation extends Simulation { ...@@ -41,24 +144,26 @@ class _MountainViewSimulation extends Simulation {
return friction * 61774.04968; return friction * 61774.04968;
} }
// See getSplineDeceleration() // See getSplineDeceleration().
double _flingDeceleration(double velocity) { double _flingDeceleration(double velocity) {
return math.log(0.35 * velocity.abs() / _scaledFriction); return math.log(0.35 * velocity.abs() / _scaledFriction);
} }
// See getSplineFlingDuration(). Returns a value in seconds. // See getSplineFlingDuration(). Returns a value in seconds.
double _flingDuration(double velocity) { double _flingDuration(double velocity) {
return math.exp(_flingDeceleration(velocity) / (_decelerationRate - 1.0)); return math.exp(_flingDeceleration(velocity) / (_decelerationRate - 1.0));
} }
// See getSplineFlingDistance() // See getSplineFlingDistance().
double _flingDistance(double velocity) { double _flingDistance(double velocity) {
final double rate = _decelerationRate / (_decelerationRate - 1.0) * _flingDeceleration(velocity); final double rate = _decelerationRate / (_decelerationRate - 1.0) * _flingDeceleration(velocity);
return _scaledFriction * math.exp(rate); return _scaledFriction * math.exp(rate);
} }
// Based on a cubic curve fit to the computeScrollOffset() values produced // Based on a cubic curve fit to the Scroller.computeScrollOffset() values
// for an initial velocity of 4000. The value of scroller.getDuration() // produced for an initial velocity of 4000. The value of Scroller.getDuration()
// and scroller.getFinalY() were 686ms and 961 pixels respectively. // and Scroller.getFinalY() were 686ms and 961 pixels respectively.
//
// Algebra courtesy of Wolfram Alpha. // Algebra courtesy of Wolfram Alpha.
// //
// f(x) = scrollOffset, x is time in millseconds // f(x) = scrollOffset, x is time in millseconds
...@@ -74,7 +179,7 @@ class _MountainViewSimulation extends Simulation { ...@@ -74,7 +179,7 @@ class _MountainViewSimulation extends Simulation {
return (1.2 * t * t * t) - (3.27 * t * t) + (3.065 * t); return (1.2 * t * t * t) - (3.27 * t * t) + (3.065 * t);
} }
// The deriviate of the _flingPenetration() function. // The derivative of the _flingDistancePenetration() function.
double _flingVelocityPenetration(double t) { double _flingVelocityPenetration(double t) {
return (3.63693 * t * t) - (6.5424 * t) + 3.06542; return (3.63693 * t * t) - (6.5424 * t) + 3.06542;
} }
...@@ -88,7 +193,7 @@ class _MountainViewSimulation extends Simulation { ...@@ -88,7 +193,7 @@ class _MountainViewSimulation extends Simulation {
@override @override
double dx(double time) { double dx(double time) {
final double t = (time / _duration).clamp(0.0, 1.0); final double t = (time / _duration).clamp(0.0, 1.0);
return velocity * _flingVelocityPenetration(t); return _distance * _flingVelocityPenetration(t) * velocity.sign;
} }
@override @override
...@@ -97,12 +202,29 @@ class _MountainViewSimulation extends Simulation { ...@@ -97,12 +202,29 @@ class _MountainViewSimulation extends Simulation {
} }
} }
// DELETE EVERYTHING BELOW THIS LINE WHEN REMOVING LEGACY SCROLLING CODE
final SpringDescription _kScrollSpring = new SpringDescription.withDampingRatio(mass: 0.5, springConstant: 100.0, ratio: 1.1);
final double _kDrag = 0.025;
class _CupertinoSimulation extends FrictionSimulation { class _CupertinoSimulation extends FrictionSimulation {
static const double drag = 0.135; static const double drag = 0.135;
_CupertinoSimulation({ double position, double velocity }) _CupertinoSimulation({ double position, double velocity })
: super(drag, position, velocity * 0.91); : super(drag, position, velocity * 0.91);
} }
class _MountainViewSimulation extends ClampingScrollSimulation {
_MountainViewSimulation({
double position,
double velocity,
double friction: 0.015,
}) : super(
position: position,
velocity: velocity,
friction: friction,
);
}
/// Composite simulation for scrollable interfaces. /// Composite simulation for scrollable interfaces.
/// ///
/// Simulates kinetic scrolling behavior between a leading and trailing /// Simulates kinetic scrolling behavior between a leading and trailing
...@@ -123,10 +245,10 @@ class ScrollSimulation extends SimulationGroup { ...@@ -123,10 +245,10 @@ class ScrollSimulation extends SimulationGroup {
/// ///
/// The final argument is the coefficient of friction, which is unitless. /// The final argument is the coefficient of friction, which is unitless.
ScrollSimulation({ ScrollSimulation({
double position, @required double position,
double velocity, @required double velocity,
double leadingExtent, @required double leadingExtent,
double trailingExtent, @required double trailingExtent,
SpringDescription spring, SpringDescription spring,
double drag, double drag,
TargetPlatform platform, TargetPlatform platform,
...@@ -135,6 +257,8 @@ class ScrollSimulation extends SimulationGroup { ...@@ -135,6 +257,8 @@ class ScrollSimulation extends SimulationGroup {
_spring = spring ?? _kScrollSpring, _spring = spring ?? _kScrollSpring,
_drag = drag ?? _kDrag, _drag = drag ?? _kDrag,
_platform = platform { _platform = platform {
assert(position != null);
assert(velocity != null);
assert(_leadingExtent != null); assert(_leadingExtent != null);
assert(_trailingExtent != null); assert(_trailingExtent != null);
assert(_spring != null); assert(_spring != null);
......
...@@ -9,6 +9,7 @@ import 'dart:ui' as ui show window; ...@@ -9,6 +9,7 @@ import 'dart:ui' as ui show window;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/physics.dart'; import 'package:flutter/physics.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'basic.dart'; import 'basic.dart';
...@@ -17,9 +18,650 @@ import 'framework.dart'; ...@@ -17,9 +18,650 @@ import 'framework.dart';
import 'gesture_detector.dart'; import 'gesture_detector.dart';
import 'notification_listener.dart'; import 'notification_listener.dart';
import 'page_storage.dart'; import 'page_storage.dart';
import 'scroll_absolute.dart' show ViewportScrollBehavior;
import 'scroll_behavior.dart'; import 'scroll_behavior.dart';
import 'scroll_configuration.dart'; import 'scroll_configuration.dart';
import 'scroll_notification.dart';
import 'ticker_provider.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. /// Identifies one or both limits of a [Scrollable] in terms of its scrollDirection.
enum ScrollableEdge { enum ScrollableEdge {
...@@ -207,7 +849,7 @@ class Scrollable extends StatefulWidget { ...@@ -207,7 +849,7 @@ class Scrollable extends StatefulWidget {
double scrollOffsetDelta; double scrollOffsetDelta;
if (targetMin < scrollableMin) { if (targetMin < scrollableMin) {
if (targetMax > scrollableMax) { if (targetMax > scrollableMax) {
// The target is to big to fit inside the scrollable. The best we can do // The target is too big to fit inside the scrollable. The best we can do
// is to center the target. // is to center the target.
double targetCenter = (targetMin + targetMax) / 2.0; double targetCenter = (targetMin + targetMax) / 2.0;
double scrollableCenter = (scrollableMin + scrollableMax) / 2.0; double scrollableCenter = (scrollableMin + scrollableMax) / 2.0;
......
// 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 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
import 'framework.dart';
export 'package:flutter/rendering.dart' show
AxisDirection,
GrowthDirection;
class Viewport2 extends MultiChildRenderObjectWidget {
Viewport2({
Key key,
this.axisDirection: AxisDirection.down,
this.anchor: 0.0,
this.offset,
this.center,
List<Widget> children: const <Widget>[],
}) : super(key: key, children: children) {
assert(center == null || children.where((Widget child) => child.key == center).length == 1);
}
final AxisDirection axisDirection;
final double anchor;
final ViewportOffset offset;
final Key center;
@override
RenderViewport2 createRenderObject(BuildContext context) {
return new RenderViewport2(
axisDirection: axisDirection,
anchor: anchor,
offset: offset,
);
}
@override
void updateRenderObject(BuildContext context, RenderViewport2 renderObject) {
renderObject.axisDirection = axisDirection;
renderObject.anchor = anchor;
renderObject.offset = offset;
}
@override
Viewport2Element createElement() => new Viewport2Element(this);
@override
void debugFillDescription(List<String> description) {
super.debugFillDescription(description);
description.add('$axisDirection');
description.add('anchor: $anchor');
description.add('offset: $offset');
if (center != null) {
description.add('center: $center');
} else if (children.isNotEmpty && children.first.key != null) {
description.add('center: ${children.first.key} (implicit)');
}
}
}
class Viewport2Element extends MultiChildRenderObjectElement {
/// Creates an element that uses the given widget as its configuration.
Viewport2Element(Viewport2 widget) : super(widget);
@override
Viewport2 get widget => super.widget;
@override
RenderViewport2 get renderObject => super.renderObject;
@override
void mount(Element parent, dynamic newSlot) {
super.mount(parent, newSlot);
updateCenter();
}
@override
void update(MultiChildRenderObjectWidget newWidget) {
super.update(newWidget);
updateCenter();
}
@protected
void updateCenter() {
// TODO(ianh): cache the keys to make this faster
if (widget.center != null) {
renderObject.center = children.singleWhere(
(Element element) => element.widget.key == widget.center
).renderObject;
} else if (children.isNotEmpty) {
renderObject.center = children.first.renderObject;
} else {
renderObject.center = null;
}
}
}
...@@ -36,6 +36,7 @@ export 'src/widgets/navigator.dart'; ...@@ -36,6 +36,7 @@ export 'src/widgets/navigator.dart';
export 'src/widgets/notification_listener.dart'; export 'src/widgets/notification_listener.dart';
export 'src/widgets/orientation_builder.dart'; export 'src/widgets/orientation_builder.dart';
export 'src/widgets/overlay.dart'; export 'src/widgets/overlay.dart';
export 'src/widgets/overscroll_indicator.dart';
export 'src/widgets/page_storage.dart'; export 'src/widgets/page_storage.dart';
export 'src/widgets/pageable_list.dart'; export 'src/widgets/pageable_list.dart';
export 'src/widgets/pages.dart'; export 'src/widgets/pages.dart';
...@@ -43,8 +44,10 @@ export 'src/widgets/performance_overlay.dart'; ...@@ -43,8 +44,10 @@ export 'src/widgets/performance_overlay.dart';
export 'src/widgets/placeholder.dart'; export 'src/widgets/placeholder.dart';
export 'src/widgets/raw_keyboard_listener.dart'; export 'src/widgets/raw_keyboard_listener.dart';
export 'src/widgets/routes.dart'; export 'src/widgets/routes.dart';
export 'src/widgets/scroll_absolute.dart';
export 'src/widgets/scroll_behavior.dart'; export 'src/widgets/scroll_behavior.dart';
export 'src/widgets/scroll_configuration.dart'; export 'src/widgets/scroll_configuration.dart';
export 'src/widgets/scroll_notification.dart';
export 'src/widgets/scroll_simulation.dart'; export 'src/widgets/scroll_simulation.dart';
export 'src/widgets/scrollable.dart'; export 'src/widgets/scrollable.dart';
export 'src/widgets/scrollable_grid.dart'; export 'src/widgets/scrollable_grid.dart';
...@@ -59,6 +62,7 @@ export 'src/widgets/ticker_provider.dart'; ...@@ -59,6 +62,7 @@ export 'src/widgets/ticker_provider.dart';
export 'src/widgets/title.dart'; export 'src/widgets/title.dart';
export 'src/widgets/transitions.dart'; export 'src/widgets/transitions.dart';
export 'src/widgets/unique_widget.dart'; export 'src/widgets/unique_widget.dart';
export 'src/widgets/viewport.dart';
export 'src/widgets/virtual_viewport.dart'; export 'src/widgets/virtual_viewport.dart';
export 'package:vector_math/vector_math_64.dart' show Matrix4; export 'package:vector_math/vector_math_64.dart' show Matrix4;
...@@ -265,14 +265,14 @@ class _TestRecordingCanvas implements Canvas { ...@@ -265,14 +265,14 @@ class _TestRecordingCanvas implements Canvas {
@override @override
void save() { void save() {
_saveCount += 1; _saveCount += 1;
super.save(); // ends up in noSuchMethod _invocations.add(new _MethodCall(#save));
} }
@override @override
void restore() { void restore() {
_saveCount -= 1; _saveCount -= 1;
assert(_saveCount >= 0); assert(_saveCount >= 0);
super.restore(); // ends up in noSuchMethod _invocations.add(new _MethodCall(#restore));
} }
@override @override
...@@ -281,6 +281,25 @@ class _TestRecordingCanvas implements Canvas { ...@@ -281,6 +281,25 @@ class _TestRecordingCanvas implements Canvas {
} }
} }
class _MethodCall implements Invocation {
_MethodCall(this._name);
final Symbol _name;
@override
bool get isAccessor => false;
@override
bool get isGetter => false;
@override
bool get isMethod => true;
@override
bool get isSetter => false;
@override
Symbol get memberName => _name;
@override
Map<Symbol, dynamic> get namedArguments => <Symbol, dynamic>{};
@override
List<dynamic> get positionalArguments => <dynamic>[];
}
class _TestRecordingPaintingContext implements PaintingContext { class _TestRecordingPaintingContext implements PaintingContext {
_TestRecordingPaintingContext(this.canvas); _TestRecordingPaintingContext(this.canvas);
......
...@@ -8,6 +8,13 @@ import 'package:test/test.dart'; ...@@ -8,6 +8,13 @@ import 'package:test/test.dart';
import 'rendering_tester.dart'; import 'rendering_tester.dart';
void main() { void main() {
test('RenderViewport2 basic test - no children', () {
RenderViewport2 root = new RenderViewport2();
layout(root);
root.offset = new ViewportOffset.fixed(900.0);
pumpFrame();
});
test('RenderViewport2 basic test - down', () { test('RenderViewport2 basic test - down', () {
RenderBox a, b, c, d, e; RenderBox a, b, c, d, e;
RenderViewport2 root = new RenderViewport2( RenderViewport2 root = new RenderViewport2(
...@@ -199,8 +206,6 @@ void main() { ...@@ -199,8 +206,6 @@ void main() {
expect(e.localToGlobal(const Point(0.0, 0.0)), const Point(-300.0, 0.0)); expect(e.localToGlobal(const Point(0.0, 0.0)), const Point(-300.0, 0.0));
}); });
// TODO(ianh): test positioning when the children are too big to fit in the main axis
// TODO(ianh): test shrinkWrap
// TODO(ianh): test anchor // TODO(ianh): test anchor
// TODO(ianh): test offset // TODO(ianh): test offset
// TODO(ianh): test center // TODO(ianh): test center
......
...@@ -98,6 +98,7 @@ void main() { ...@@ -98,6 +98,7 @@ void main() {
expect(didReceiveCallback, isTrue); expect(didReceiveCallback, isTrue);
}); });
testWidgets('Defunct setState throws exception', (WidgetTester tester) async { testWidgets('Defunct setState throws exception', (WidgetTester tester) async {
StateSetter setState; StateSetter setState;
...@@ -143,4 +144,23 @@ void main() { ...@@ -143,4 +144,23 @@ void main() {
expect(log[0], matches('Deactivated')); expect(log[0], matches('Deactivated'));
expect(log[1], matches('Discarding .+ from inactive elements list.')); expect(log[1], matches('Discarding .+ from inactive elements list.'));
}); });
testWidgets('MultiChildRenderObjectElement.children', (WidgetTester tester) async {
GlobalKey key0, key1, key2;
await tester.pumpWidget(new Column(
key: key0 = new GlobalKey(),
children: <Widget>[
new Container(),
new Container(key: key1 = new GlobalKey()),
new Container(child: new Container()),
new Container(key: key2 = new GlobalKey()),
new Container(),
],
));
MultiChildRenderObjectElement element = key0.currentContext;
expect(
element.children.map((Element element) => element.widget.key), // ignore: INVALID_USE_OF_PROTECTED_MEMBER
<Key>[null, key1, null, key2, null],
);
});
} }
// Copyright 2017 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:math' as math;
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter/rendering.dart';
import '../rendering/mock_canvas.dart';
final Matcher doesNotOverscroll = isNot(paints..circle());
Future<Null> slowDrag(WidgetTester tester, Point start, Offset offset) async {
TestGesture gesture = await tester.startGesture(start);
for (int index = 0; index < 10; index += 1) {
await gesture.moveBy(offset);
await tester.pump(const Duration(milliseconds: 20));
}
await gesture.up();
}
void main() {
testWidgets('Overscroll indicator color', (WidgetTester tester) async {
await tester.pumpWidget(
new Scrollable2(
children: <Widget>[
new SliverToBoxAdapter(child: new SizedBox(height: 2000.0)),
],
),
);
RenderObject painter = tester.renderObject(find.byType(CustomPaint));
expect(painter, doesNotOverscroll);
// the scroll gesture from tester.scroll happens in zero time, so nothing should appear:
await tester.scroll(find.byType(Scrollable2), const Offset(0.0, 100.0));
expect(painter, doesNotOverscroll);
await tester.pump(); // allow the ticker to register itself
expect(painter, doesNotOverscroll);
await tester.pump(const Duration(milliseconds: 100)); // animate
expect(painter, doesNotOverscroll);
TestGesture gesture = await tester.startGesture(const Point(200.0, 200.0));
await tester.pump(const Duration(milliseconds: 100)); // animate
expect(painter, doesNotOverscroll);
await gesture.up();
expect(painter, doesNotOverscroll);
await slowDrag(tester, const Point(200.0, 200.0), const Offset(0.0, 5.0));
expect(painter, paints..circle(color: const Color(0x0DFFFFFF)));
await tester.pumpUntilNoTransientCallbacks(const Duration(seconds: 1));
expect(painter, doesNotOverscroll);
});
testWidgets('Overscroll indicator changes side when you drag on the other side', (WidgetTester tester) async {
await tester.pumpWidget(
new Scrollable2(
children: <Widget>[
new SliverToBoxAdapter(child: new SizedBox(height: 2000.0)),
],
),
);
RenderObject painter = tester.renderObject(find.byType(CustomPaint));
await slowDrag(tester, const Point(400.0, 200.0), const Offset(0.0, 10.0));
expect(painter, paints..circle(x: 400.0));
await slowDrag(tester, const Point(100.0, 200.0), const Offset(0.0, 10.0));
expect(painter, paints..something((Symbol method, List<dynamic> arguments) {
if (method != #drawCircle)
return false;
final Point center = arguments[0];
if (center.x < 400.0)
return true;
throw 'Dragging on left hand side did not overscroll on left hand side.';
}));
await slowDrag(tester, const Point(700.0, 200.0), const Offset(0.0, 10.0));
expect(painter, paints..something((Symbol method, List<dynamic> arguments) {
if (method != #drawCircle)
return false;
final Point center = arguments[0];
if (center.x > 400.0)
return true;
throw 'Dragging on right hand side did not overscroll on right hand side.';
}));
await tester.pumpUntilNoTransientCallbacks(const Duration(seconds: 1));
expect(painter, doesNotOverscroll);
});
testWidgets('Overscroll indicator changes side when you shift sides', (WidgetTester tester) async {
await tester.pumpWidget(
new Scrollable2(
children: <Widget>[
new SliverToBoxAdapter(child: new SizedBox(height: 2000.0)),
],
),
);
RenderObject painter = tester.renderObject(find.byType(CustomPaint));
TestGesture gesture = await tester.startGesture(const Point(300.0, 200.0));
await gesture.moveBy(const Offset(0.0, 10.0));
await tester.pump(const Duration(milliseconds: 20));
double oldX = 0.0;
for (int index = 0; index < 10; index += 1) {
await gesture.moveBy(const Offset(50.0, 50.0));
await tester.pump(const Duration(milliseconds: 20));
expect(painter, paints..something((Symbol method, List<dynamic> arguments) {
if (method != #drawCircle)
return false;
final Point center = arguments[0];
if (center.x <= oldX)
throw 'Sliding to the right did not make the center of the radius slide to the right.';
oldX = center.x;
return true;
}));
}
await gesture.up();
await tester.pumpUntilNoTransientCallbacks(const Duration(seconds: 1));
expect(painter, doesNotOverscroll);
});
group('Flipping direction of scrollable doesn\'t change overscroll behavior', () {
testWidgets('down', (WidgetTester tester) async {
await tester.pumpWidget(
new Scrollable2(
axisDirection: AxisDirection.down,
children: <Widget>[
new SliverToBoxAdapter(child: new SizedBox(height: 20.0)),
],
),
);
RenderObject painter = tester.renderObject(find.byType(CustomPaint));
await slowDrag(tester, const Point(200.0, 200.0), const Offset(0.0, 5.0));
expect(painter, paints..save()..circle()..restore()..save()..scale(y: -1.0)..restore()..restore());
await tester.pumpUntilNoTransientCallbacks(const Duration(seconds: 1));
expect(painter, doesNotOverscroll);
});
testWidgets('up', (WidgetTester tester) async {
await tester.pumpWidget(
new Scrollable2(
axisDirection: AxisDirection.up,
children: <Widget>[
new SliverToBoxAdapter(child: new SizedBox(height: 20.0)),
],
),
);
RenderObject painter = tester.renderObject(find.byType(CustomPaint));
await slowDrag(tester, const Point(200.0, 200.0), const Offset(0.0, 5.0));
expect(painter, paints..save()..scale(y: -1.0)..restore()..save()..circle()..restore()..restore());
await tester.pumpUntilNoTransientCallbacks(const Duration(seconds: 1));
expect(painter, doesNotOverscroll);
});
});
testWidgets('Overscroll in both directions', (WidgetTester tester) async {
await tester.pumpWidget(
new Scrollable2(
axisDirection: AxisDirection.down,
children: <Widget>[
new SliverToBoxAdapter(child: new SizedBox(height: 20.0)),
],
),
);
RenderObject painter = tester.renderObject(find.byType(CustomPaint));
await slowDrag(tester, const Point(200.0, 200.0), const Offset(0.0, 5.0));
expect(painter, paints..circle());
expect(painter, isNot(paints..circle()..circle()));
await slowDrag(tester, const Point(200.0, 200.0), const Offset(0.0, -5.0));
expect(painter, paints..circle()..circle());
await tester.pumpUntilNoTransientCallbacks(const Duration(seconds: 1));
expect(painter, doesNotOverscroll);
});
testWidgets('Overscroll horizontally', (WidgetTester tester) async {
await tester.pumpWidget(
new Scrollable2(
axisDirection: AxisDirection.right,
children: <Widget>[
new SliverToBoxAdapter(child: new SizedBox(height: 20.0)),
],
),
);
RenderObject painter = tester.renderObject(find.byType(CustomPaint));
await slowDrag(tester, const Point(200.0, 200.0), const Offset(5.0, 0.0));
expect(painter, paints..rotate(angle: -math.PI / 2.0)..circle()..scale(y: -1.0));
expect(painter, isNot(paints..circle()..circle()));
await slowDrag(tester, const Point(200.0, 200.0), const Offset(-5.0, 0.0));
expect(painter, paints..rotate(angle: -math.PI / 2.0)..circle()
..rotate(angle: -math.PI / 2.0)..scale(y: -1.0)..circle());
await tester.pumpUntilNoTransientCallbacks(const Duration(seconds: 1));
expect(painter, doesNotOverscroll);
});
testWidgets('Changing settings', (WidgetTester tester) async {
RenderObject painter;
await tester.pumpWidget(
new Scrollable2(
axisDirection: AxisDirection.left,
scrollBehavior: new TestScrollBehavior1(),
children: <Widget>[
new SliverToBoxAdapter(child: new SizedBox(height: 20.0)),
],
),
);
painter = tester.renderObject(find.byType(CustomPaint));
await slowDrag(tester, const Point(200.0, 200.0), const Offset(5.0, 0.0));
expect(painter, paints..scale(y: -1.0)..rotate(angle: -math.PI / 2.0)..circle(color: const Color(0x0A00FF00)));
expect(painter, isNot(paints..circle()..circle()));
await tester.pumpUntilNoTransientCallbacks(const Duration(seconds: 1));
await tester.pumpWidget(
new Scrollable2(
axisDirection: AxisDirection.right,
scrollBehavior: new TestScrollBehavior2(),
children: <Widget>[
new SliverToBoxAdapter(child: new SizedBox(height: 20.0)),
],
),
);
painter = tester.renderObject(find.byType(CustomPaint));
await slowDrag(tester, const Point(200.0, 200.0), const Offset(5.0, 0.0));
expect(painter, paints..rotate(angle: -math.PI / 2.0)..circle(color: const Color(0x0A0000FF))..scale(y: -1.0));
expect(painter, isNot(paints..circle()..circle()));
});
}
class TestScrollBehavior1 extends ViewportScrollBehavior {
@override
Color getGlowColor(BuildContext context) {
return const Color(0xFF00FF00);
}
}
class TestScrollBehavior2 extends ViewportScrollBehavior {
@override
Color getGlowColor(BuildContext context) {
return const Color(0xFF0000FF);
}
}
// Copyright 2017 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 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
class TestScrollPosition extends ScrollPosition {
TestScrollPosition(
this.extentMultiplier,
Scrollable2State state,
Tolerance scrollTolerances,
ScrollPosition oldPosition,
) : _pixels = 100.0, super(state, scrollTolerances, oldPosition);
final double extentMultiplier;
double _min, _viewport, _max, _pixels;
@override
double get pixels => _pixels;
@override
double setPixels(double value) {
double oldPixels = _pixels;
_pixels = value;
dispatchNotification(activity.createScrollUpdateNotification(state, _pixels - oldPixels));
return 0.0;
}
@override
void correctBy(double correction) {
_pixels += correction;
}
@override
void applyViewportDimension(double viewportDimension) {
_viewport = viewportDimension;
super.applyViewportDimension(viewportDimension);
}
@override
bool applyContentDimensions(double minScrollExtent, double maxScrollExtent) {
_min = minScrollExtent;
_max = maxScrollExtent;
return super.applyContentDimensions(minScrollExtent, maxScrollExtent);
}
@override
ScrollableMetrics getMetrics() {
double insideExtent = _viewport;
double beforeExtent = _pixels - _min;
double afterExtent = _max - _pixels;
if (insideExtent > 0.0) {
return new ScrollableMetrics(
extentBefore: extentMultiplier * beforeExtent / insideExtent,
extentInside: extentMultiplier,
extentAfter: extentMultiplier * afterExtent / insideExtent,
);
} else {
return new ScrollableMetrics(
extentBefore: 0.0,
extentInside: 0.0,
extentAfter: 0.0,
);
}
}
}
class TestScrollBehavior extends ScrollBehavior2 {
TestScrollBehavior(this.extentMultiplier);
final double extentMultiplier;
@override
Widget wrap(BuildContext context, Widget child, AxisDirection axisDirection) => child;
@override
ScrollPosition createScrollPosition(BuildContext context, Scrollable2State state, ScrollPosition oldPosition) {
return new TestScrollPosition(extentMultiplier, state, ViewportScrollBehavior.defaultScrollTolerances, oldPosition);
}
@override
bool shouldNotify(TestScrollBehavior oldDelegate) {
return extentMultiplier != oldDelegate.extentMultiplier;
}
}
void main() {
testWidgets('Changing the scroll behavior dynamically', (WidgetTester tester) async {
GlobalKey<Scrollable2State> key = new GlobalKey<Scrollable2State>();
await tester.pumpWidget(new Scrollable2(
key: key,
scrollBehavior: new TestScrollBehavior(1.0),
children: <Widget>[
new SliverToBoxAdapter(child: new SizedBox(height: 2000.0)),
],
));
expect(key.currentState.position.getMetrics().extentInside, 1.0);
await tester.pumpWidget(new Scrollable2(
key: key,
scrollBehavior: new TestScrollBehavior(2.0),
children: <Widget>[
new SliverToBoxAdapter(child: new SizedBox(height: 2000.0)),
],
));
expect(key.currentState.position.getMetrics().extentInside, 2.0);
});
}
\ No newline at end of file
// Copyright 2017 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 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
Future<Null> pumpTest(WidgetTester tester, TargetPlatform platform) async {
await tester.pumpWidget(new MaterialApp(
theme: new ThemeData(
platform: platform,
),
home: new Scrollable2(
children: <Widget>[
new SliverToBoxAdapter(child: new SizedBox(height: 2000.0)),
],
),
));
await tester.pump(const Duration(seconds: 5)); // to let the theme animate
return null;
}
const double dragOffset = 200.0;
double getScrollOffset(WidgetTester tester) {
RenderViewport2 viewport = tester.renderObject(find.byType(Viewport2));
return viewport.offset.pixels;
}
void resetScrollOffset(WidgetTester tester) {
RenderViewport2 viewport = tester.renderObject(find.byType(Viewport2));
AbsoluteScrollPosition position = viewport.offset;
position.jumpTo(0.0);
}
void main() {
testWidgets('Flings on different platforms', (WidgetTester tester) async {
await pumpTest(tester, TargetPlatform.android);
await tester.fling(find.byType(Viewport2), const Offset(0.0, -dragOffset), 1000.0);
expect(getScrollOffset(tester), dragOffset);
await tester.pump(); // trigger fling
expect(getScrollOffset(tester), dragOffset);
await tester.pump(const Duration(seconds: 5));
final double result1 = getScrollOffset(tester);
resetScrollOffset(tester);
await pumpTest(tester, TargetPlatform.iOS);
await tester.fling(find.byType(Viewport2), const Offset(0.0, -dragOffset), 1000.0);
expect(getScrollOffset(tester), dragOffset);
await tester.pump(); // trigger fling
expect(getScrollOffset(tester), dragOffset);
await tester.pump(const Duration(seconds: 5));
final double result2 = getScrollOffset(tester);
expect(result1, lessThan(result2)); // iOS (result2) is slipperier than Android (result1)
});
testWidgets('Flings on different platforms', (WidgetTester tester) async {
await pumpTest(tester, TargetPlatform.iOS);
await tester.fling(find.byType(Viewport2), const Offset(0.0, -dragOffset), 1000.0);
expect(getScrollOffset(tester), dragOffset);
await tester.pump(); // trigger fling
expect(getScrollOffset(tester), dragOffset);
await tester.pump(const Duration(seconds: 5));
final double result1 = getScrollOffset(tester);
resetScrollOffset(tester);
await pumpTest(tester, TargetPlatform.android);
await tester.fling(find.byType(Viewport2), const Offset(0.0, -dragOffset), 1000.0);
expect(getScrollOffset(tester), dragOffset);
await tester.pump(); // trigger fling
expect(getScrollOffset(tester), dragOffset);
await tester.pump(const Duration(seconds: 5));
final double result2 = getScrollOffset(tester);
expect(result1, greaterThan(result2)); // iOS (result1) is slipperier than Android (result2)
});
}
\ No newline at end of file
// 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:math' as math;
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
void verifyPaintPosition(GlobalKey key, Offset ideal) {
RenderObject target = key.currentContext.findRenderObject();
expect(target.parent, new isInstanceOf<RenderViewport2>());
SliverPhysicalParentData parentData = target.parentData;
Offset actual = parentData.paintOffset;
expect(actual, ideal);
}
void main() {
testWidgets('Sliver protocol', (WidgetTester tester) async {
final GlobalKey<Scrollable2State> scrollableKey = new GlobalKey<Scrollable2State>();
GlobalKey key1, key2, key3, key4, key5;
await tester.pumpWidget(
new Scrollable2(
key: scrollableKey,
axisDirection: AxisDirection.down,
children: <Widget>[
new BigSliver(key: key1 = new GlobalKey()),
new OverlappingSliver(key: key2 = new GlobalKey()),
new OverlappingSliver(key: key3 = new GlobalKey()),
new BigSliver(key: key4 = new GlobalKey()),
new BigSliver(key: key5 = new GlobalKey()),
],
),
);
AbsoluteScrollPosition position = scrollableKey.currentState.position;
final double max = RenderBigSliver.height * 3.0 + (RenderOverlappingSliver.totalHeight) * 2.0 - 600.0; // 600 is the height of the test viewport
assert(max < 10000.0);
expect(max, 1450.0);
expect(position.pixels, 0.0);
expect(position.minScrollExtent, 0.0);
expect(position.maxScrollExtent, max);
position.animate(to: 10000.0, curve: Curves.linear, duration: const Duration(minutes: 1));
await tester.pumpUntilNoTransientCallbacks(const Duration(milliseconds: 10));
expect(position.pixels, max);
expect(position.minScrollExtent, 0.0);
expect(position.maxScrollExtent, max);
verifyPaintPosition(key1, new Offset(0.0, 0.0));
verifyPaintPosition(key2, new Offset(0.0, 0.0));
verifyPaintPosition(key3, new Offset(0.0, 0.0));
verifyPaintPosition(key4, new Offset(0.0, 0.0));
verifyPaintPosition(key5, new Offset(0.0, 50.0));
});
}
class RenderBigSliver extends RenderSliver {
static const double height = 550.0;
double get paintExtent => (height - constraints.scrollOffset).clamp(0.0, constraints.remainingPaintExtent);
@override
void performLayout() {
geometry = new SliverGeometry(
scrollExtent: height,
paintExtent: paintExtent,
maxPaintExtent: height,
);
}
}
class BigSliver extends LeafRenderObjectWidget {
BigSliver({ Key key }) : super(key: key);
@override
RenderBigSliver createRenderObject(BuildContext context) {
return new RenderBigSliver();
}
}
class RenderOverlappingSliver extends RenderSliver {
static const double totalHeight = 200.0;
static const double fixedHeight = 100.0;
double get paintExtent {
return math.min(
math.max(
fixedHeight,
totalHeight - constraints.scrollOffset,
),
constraints.remainingPaintExtent,
);
}
double get layoutExtent {
return (totalHeight - constraints.scrollOffset).clamp(0.0, constraints.remainingPaintExtent);
}
@override
void performLayout() {
geometry = new SliverGeometry(
scrollExtent: totalHeight,
paintExtent: paintExtent,
layoutExtent: layoutExtent,
maxPaintExtent: totalHeight,
);
}
}
class OverlappingSliver extends LeafRenderObjectWidget {
OverlappingSliver({ Key key }) : super(key: key);
@override
RenderOverlappingSliver createRenderObject(BuildContext context) {
return new RenderOverlappingSliver();
}
}
// 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 'package:flutter_test/flutter_test.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter/rendering.dart';
Future<Null> test(WidgetTester tester, double offset, { double anchor: 0.0 }) {
return tester.pumpWidget(new Viewport2(
anchor: anchor / 600.0,
offset: new ViewportOffset.fixed(offset),
children: <Widget>[
new SliverToBoxAdapter(child: new SizedBox(height: 400.0)),
new SliverToBoxAdapter(child: new SizedBox(height: 400.0)),
new SliverToBoxAdapter(child: new SizedBox(height: 400.0)),
new SliverToBoxAdapter(child: new SizedBox(height: 400.0)),
new SliverToBoxAdapter(child: new SizedBox(height: 400.0)),
],
));
}
void verify(WidgetTester tester, List<Point> idealPositions, List<bool> idealVisibles) {
List<Point> actualPositions = tester.renderObjectList/*<RenderBox>*/(find.byType(SizedBox)).map/*<Point>*/(
(RenderBox target) => target.localToGlobal(const Point(0.0, 0.0))
).toList();
List<bool> actualVisibles = tester.renderObjectList/*<RenderSliverToBoxAdapter>*/(find.byType(SliverToBoxAdapter)).map/*<bool>*/(
(RenderSliverToBoxAdapter target) => target.geometry.visible
).toList();
expect(actualPositions, equals(idealPositions));
expect(actualVisibles, equals(idealVisibles));
}
void main() {
testWidgets('Viewport2 basic test', (WidgetTester tester) async {
await test(tester, 0.0);
expect(tester.renderObject/*<RenderBox>*/(find.byType(Viewport2)).size, equals(const Size(800.0, 600.0)));
verify(tester, <Point>[
const Point(0.0, 0.0),
const Point(0.0, 400.0),
const Point(0.0, 600.0),
const Point(0.0, 600.0),
const Point(0.0, 600.0),
], <bool>[true, true, false, false, false]);
await test(tester, 200.0);
verify(tester, <Point>[
const Point(0.0, -200.0),
const Point(0.0, 200.0),
const Point(0.0, 600.0),
const Point(0.0, 600.0),
const Point(0.0, 600.0),
], <bool>[true, true, false, false, false]);
await test(tester, 600.0);
verify(tester, <Point>[
const Point(0.0, -600.0),
const Point(0.0, -200.0),
const Point(0.0, 200.0),
const Point(0.0, 600.0),
const Point(0.0, 600.0),
], <bool>[false, true, true, false, false]);
await test(tester, 900.0);
verify(tester, <Point>[
const Point(0.0, -900.0),
const Point(0.0, -500.0),
const Point(0.0, -100.0),
const Point(0.0, 300.0),
const Point(0.0, 600.0),
], <bool>[false, false, true, true, false]);
});
testWidgets('Viewport2 anchor test', (WidgetTester tester) async {
await test(tester, 0.0, anchor: 100.0);
expect(tester.renderObject/*<RenderBox>*/(find.byType(Viewport2)).size, equals(const Size(800.0, 600.0)));
verify(tester, <Point>[
const Point(0.0, 100.0),
const Point(0.0, 500.0),
const Point(0.0, 600.0),
const Point(0.0, 600.0),
const Point(0.0, 600.0),
], <bool>[true, true, false, false, false]);
await test(tester, 200.0, anchor: 100.0);
verify(tester, <Point>[
const Point(0.0, -100.0),
const Point(0.0, 300.0),
const Point(0.0, 600.0),
const Point(0.0, 600.0),
const Point(0.0, 600.0),
], <bool>[true, true, false, false, false]);
await test(tester, 600.0, anchor: 100.0);
verify(tester, <Point>[
const Point(0.0, -500.0),
const Point(0.0, -100.0),
const Point(0.0, 300.0),
const Point(0.0, 600.0),
const Point(0.0, 600.0),
], <bool>[false, true, true, false, false]);
await test(tester, 900.0, anchor: 100.0);
verify(tester, <Point>[
const Point(0.0, -800.0),
const Point(0.0, -400.0),
const Point(0.0, 0.0),
const Point(0.0, 400.0),
const Point(0.0, 600.0),
], <bool>[false, false, true, true, false]);
});
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment