Commit 17cdc889 authored by Ian Hickson's avatar Ian Hickson Committed by GitHub

Improve our scroll physics on iOS (#5340)

Changes in this patch:
- iOS now uses a different scrollDrag constant than Android.
   - ScrollConfigurationDelegate now knows about target platforms.
   - ScrollBehaviors now know about target platforms.
   - RawInputLine now has to be told what platform it's targetting.
   - PageableList now has a concept of target platform.
- make debugPrintStack filter its stack.
   - move debugPrintStack to `assertions.dart`.
- add support for limiting the number of frames to debugPrintStack.
- make defaultTargetPlatform default to android in test environments.
- remove OverscrollStyle and MaterialApp's overscrollStyle argument. You
  can now control the overscroll style using Theme.platform.
- the default scroll configuration is now private to avoid people
  relying on the defaultTargetPlatform getter in their subclasses (since
  they really should use Theme.of(context).platform).
- fix some typos I noticed in some tests.
- added a test for flinging scrollables, that checks that the behavior
  differs on the two target platforms.
- made flingFrom and fling in the test API pump the frames.
- added more docs to the test API.
- made the TestAsyncUtils.guard() method report uncaught errors to help
  debug errors when using that API.
parent cf2b2078
......@@ -326,3 +326,17 @@ class FlutterError extends AssertionError {
onError(details);
}
}
/// Dump the current stack to the console using [debugPrint] and
/// [FlutterError.defaultStackFilter].
///
/// The current stack is obtained using [StackTrace.current].
///
/// The `maxFrames` argument can be given to limit the stack to the given number
/// of lines. By default, all non-filtered stack lines are shown.
void debugPrintStack({ int maxFrames }) {
List<String> lines = StackTrace.current.toString().trimRight().split('\n');
if (maxFrames != null)
lines = lines.take(maxFrames);
debugPrint(FlutterError.defaultStackFilter(lines).join('\n'));
}
......@@ -15,15 +15,33 @@ enum TargetPlatform {
iOS,
}
/// The [TargetPlatform] that matches the platform on which the framework is currently executing.
/// The [TargetPlatform] that matches the platform on which the framework is
/// currently executing.
///
/// In a test environment, the platform returned is [TargetPlatform.android]
/// regardless of the host platform. (Android was chosen because the tests were
/// originally written assuming Android-like behavior, and we added platform
/// adaptations for iOS later). Tests can check iOS behavior by using the
/// platform override APIs (like in [ThemeData.platform] in the material
/// library).
TargetPlatform get defaultTargetPlatform {
if (Platform.isIOS || Platform.isMacOS)
return TargetPlatform.iOS;
if (Platform.isAndroid || Platform.isLinux)
return TargetPlatform.android;
throw new FlutterError(
'Unknown platform\n'
'${Platform.operatingSystem} was not recognized as a target platform. '
'Consider updating the list of TargetPlatforms to include this platform.'
);
TargetPlatform result;
if (Platform.isIOS || Platform.isMacOS) {
result = TargetPlatform.iOS;
} else if (Platform.isAndroid || Platform.isLinux) {
result = TargetPlatform.android;
}
assert(() {
if (Platform.environment.containsKey('FLUTTER_TEST'))
result = TargetPlatform.android;
return true;
});
if (result == null) {
throw new FlutterError(
'Unknown platform.\n'
'${Platform.operatingSystem} was not recognized as a target platform. '
'Consider updating the list of TargetPlatforms to include this platform.'
);
}
return result;
}
......@@ -160,10 +160,3 @@ Iterable<String> debugWordWrap(String message, int width) sync* {
}
}
}
/// Dump the current stack to the console using [debugPrint].
///
/// The current stack is obtained using [StackTrace.current].
void debugPrintStack() {
debugPrint(StackTrace.current.toString());
}
......@@ -23,15 +23,6 @@ const TextStyle _errorTextStyle = const TextStyle(
decorationStyle: TextDecorationStyle.double
);
/// The visual and interaction design for overscroll.
enum OverscrollStyle {
/// Overscrolls are clamped and indicated with a glow.
glow,
/// Overscrolls are not clamped and indicated with elastic physics.
bounce
}
/// An application that uses material design.
///
/// A convenience widget that wraps a number of widgets that are commonly
......@@ -60,7 +51,6 @@ class MaterialApp extends StatefulWidget {
this.theme,
this.home,
this.routes: const <String, WidgetBuilder>{},
this.overscrollStyle,
this.onGenerateRoute,
this.onLocaleChanged,
this.debugShowMaterialGrid: false,
......@@ -112,11 +102,6 @@ class MaterialApp extends StatefulWidget {
/// build the page instead.
final Map<String, WidgetBuilder> routes;
/// The visual and interaction design for overscroll.
///
/// Defaults to being adapted to the current [TargetPlatform].
final OverscrollStyle overscrollStyle;
/// The route generator callback used when the app is navigated to a
/// named route.
final RouteFactory onGenerateRoute;
......@@ -158,13 +143,34 @@ class MaterialApp extends StatefulWidget {
_MaterialAppState createState() => new _MaterialAppState();
}
class _IndicatorScrollConfigurationDelegate extends ScrollConfigurationDelegate {
class _ScrollLikeCupertinoDelegate extends ScrollConfigurationDelegate {
const _ScrollLikeCupertinoDelegate();
@override
Widget wrapScrollWidget(Widget scrollWidget) => new OverscrollIndicator(child: scrollWidget);
TargetPlatform get platform => TargetPlatform.iOS;
@override
ExtentScrollBehavior createScrollBehavior() => new OverscrollWhenScrollableBehavior(platform: TargetPlatform.iOS);
@override
bool updateShouldNotify(ScrollConfigurationDelegate old) => false;
}
final ScrollConfigurationDelegate _glowScroll = new _IndicatorScrollConfigurationDelegate();
final ScrollConfigurationDelegate _bounceScroll = new ScrollConfigurationDelegate();
class _ScrollLikeMountainViewDelegate extends ScrollConfigurationDelegate {
const _ScrollLikeMountainViewDelegate();
@override
TargetPlatform get platform => TargetPlatform.android;
@override
ExtentScrollBehavior createScrollBehavior() => new OverscrollWhenScrollableBehavior(platform: TargetPlatform.android);
@override
Widget wrapScrollWidget(Widget scrollWidget) => new OverscrollIndicator(child: scrollWidget);
@override
bool updateShouldNotify(ScrollConfigurationDelegate old) => false;
}
class _MaterialAppState extends State<MaterialApp> {
HeroController _heroController;
......@@ -195,19 +201,11 @@ class _MaterialAppState extends State<MaterialApp> {
}
ScrollConfigurationDelegate _getScrollDelegate(TargetPlatform platform) {
if (config.overscrollStyle != null) {
switch (config.overscrollStyle) {
case OverscrollStyle.glow:
return _glowScroll;
case OverscrollStyle.bounce:
return _bounceScroll;
}
}
switch (platform) {
case TargetPlatform.android:
return _glowScroll;
return const _ScrollLikeMountainViewDelegate();
case TargetPlatform.iOS:
return _bounceScroll;
return const _ScrollLikeCupertinoDelegate();
}
return null;
}
......
......@@ -78,12 +78,21 @@ class _DropDownMenuPainter extends CustomPainter {
// Do not use the platform-specific default scroll configuration.
// Dropdown menus should never overscroll or display an overscroll indicator.
class _DropDownScrollConfigurationDelegate extends ScrollConfigurationDelegate {
const _DropDownScrollConfigurationDelegate();
const _DropDownScrollConfigurationDelegate(this._platform);
@override
TargetPlatform get platform => _platform;
final TargetPlatform _platform;
@override
ExtentScrollBehavior createScrollBehavior() => new OverscrollWhenScrollableBehavior(platform: platform);
@override
Widget wrapScrollWidget(Widget scrollWidget) => new ClampOverscrolls(value: true, child: scrollWidget);
@override
bool updateShouldNotify(ScrollConfigurationDelegate old) => platform != old.platform;
}
final ScrollConfigurationDelegate _dropDownScroll = const _DropDownScrollConfigurationDelegate();
class _DropDownMenu<T> extends StatefulWidget {
_DropDownMenu({
......@@ -170,7 +179,7 @@ class _DropDownMenuState<T> extends State<_DropDownMenu<T>> {
type: MaterialType.transparency,
textStyle: route.style,
child: new ScrollConfiguration(
delegate: _dropDownScroll,
delegate: new _DropDownScrollConfigurationDelegate(Theme.of(context).platform),
child: new Scrollbar(
child: new ScrollableList(
scrollableKey: config.route.scrollableKey,
......
......@@ -205,6 +205,7 @@ class _InputState extends State<Input> {
selectionColor: themeData.textSelectionColor,
selectionHandleBuilder: buildTextSelectionHandle,
selectionToolbarBuilder: buildTextSelectionToolbar,
platform: Theme.of(context).platform,
keyboardType: config.keyboardType,
onChanged: onChanged,
onSubmitted: onSubmitted
......
......@@ -1090,10 +1090,13 @@ class _TabBarViewState<T> extends PageableListState<TabBarView<T>> implements Ta
@override
ExtentScrollBehavior get scrollBehavior {
_boundedBehavior ??= new BoundedBehavior();
_boundedBehavior ??= new BoundedBehavior(platform: platform);
return _boundedBehavior;
}
@override
TargetPlatform get platform => Theme.of(context).platform;
void _initSelection(TabBarSelectionState<T> selection) {
_selection = selection;
if (_selection != null) {
......
......@@ -169,6 +169,7 @@ class RawInputLine extends Scrollable {
this.selectionColor,
this.selectionHandleBuilder,
this.selectionToolbarBuilder,
@required this.platform,
this.keyboardType,
this.onChanged,
this.onSubmitted
......@@ -206,6 +207,12 @@ class RawInputLine extends Scrollable {
/// text selection (e.g. copy and paste).
final TextSelectionToolbarBuilder selectionToolbarBuilder;
/// The platform whose behavior should be approximated, in particular
/// for scroll physics. (See [ScrollBehavior.platform].)
///
/// Must not be null.
final TargetPlatform platform;
/// The type of keyboard to use for editing the text.
final KeyboardType keyboardType;
......@@ -229,7 +236,7 @@ class RawInputLineState extends ScrollableState<RawInputLine> {
TextSelectionOverlay _selectionOverlay;
@override
ScrollBehavior<double, double> createScrollBehavior() => new BoundedBehavior();
ScrollBehavior<double, double> createScrollBehavior() => new BoundedBehavior(platform: config.platform);
@override
BoundedBehavior get scrollBehavior => super.scrollBehavior;
......
......@@ -9,6 +9,7 @@ import 'package:flutter/rendering.dart' show RenderList, ViewportDimensions;
import 'basic.dart';
import 'framework.dart';
import 'scroll_behavior.dart';
import 'scroll_configuration.dart';
import 'scrollable.dart';
import 'virtual_viewport.dart';
......@@ -292,13 +293,18 @@ abstract class PageableState<T extends Pageable> extends ScrollableState<T> {
@override
ExtentScrollBehavior get scrollBehavior {
if (config.itemsWrap) {
_unboundedBehavior ??= new UnboundedBehavior();
_unboundedBehavior ??= new UnboundedBehavior(platform: platform);
return _unboundedBehavior;
}
_overscrollBehavior ??= new OverscrollBehavior();
_overscrollBehavior ??= new OverscrollBehavior(platform: platform);
return _overscrollBehavior;
}
/// Returns the style of scrolling to use.
///
/// By default, defers to the nearest [ScrollConfiguration].
TargetPlatform get platform => ScrollConfiguration.of(context)?.platform;
@override
ExtentScrollBehavior createScrollBehavior() => scrollBehavior;
......
......@@ -4,21 +4,49 @@
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/physics.dart';
import 'package:meta/meta.dart';
const double _kScrollDrag = 0.025;
export 'package:flutter/foundation.dart' show TargetPlatform;
const double _kScrollDragMountainView = 0.025;
const double _kScrollDragCupertino = 0.125;
final SpringDescription _kScrollSpring = new SpringDescription.withDampingRatio(mass: 0.5, springConstant: 100.0, ratio: 1.1);
Simulation _createScrollSimulation(double position, double velocity, double minScrollOffset, double maxScrollOffset, double scrollDrag) {
return new ScrollSimulation(position, velocity, minScrollOffset, maxScrollOffset, _kScrollSpring, scrollDrag);
}
Simulation _createSnapScrollSimulation(double startOffset, double endOffset, double startVelocity, double endVelocity) {
return new FrictionSimulation.through(startOffset, endOffset, startVelocity, endVelocity);
}
// TODO(hansmuller): Simplify these classes. We're no longer using the ScrollBehavior<T, U>
// base class directly. Only LazyBlock uses BoundedBehavior's updateExtents minScrollOffset
// parameter; simpler to move that into ExtentScrollBehavior. All of the classes should
// be called FooScrollBehavior.
// be called FooScrollBehavior. See https://github.com/flutter/flutter/issues/5281
/// An interface for controlling the behavior of scrollable widgets.
///
/// The type argument T is the type that describes the scroll offset.
/// The type argument U is the type that describes the scroll velocity.
abstract class ScrollBehavior<T, U> {
/// Abstract const constructor. This constructor enables subclasses to provide
/// const constructors so that they can be used in const expressions.
///
/// The [platform] must not be null.
const ScrollBehavior({
@required this.platform
});
/// The platform for which physics constants should be approximated.
///
/// This is what makes flings go further on iOS than Android.
///
/// Must not be null.
final TargetPlatform platform;
/// Returns a simulation that propels the scrollOffset.
///
/// This function is called when a drag gesture ends.
......@@ -41,6 +69,19 @@ abstract class ScrollBehavior<T, U> {
/// Whether this scroll behavior currently permits scrolling
bool get isScrollable => true;
/// The scroll drag constant to use for physics simulations created by this
/// ScrollBehavior.
double get scrollDrag {
assert(platform != null);
switch (platform) {
case TargetPlatform.android:
return _kScrollDragMountainView;
case TargetPlatform.iOS:
return _kScrollDragCupertino;
}
return null;
}
@override
String toString() {
List<String> description = <String>[];
......@@ -64,8 +105,15 @@ abstract class ExtentScrollBehavior extends ScrollBehavior<double, double> {
/// Creates a scroll behavior for a scrollable widget with linear extent.
/// We start with an INFINITE contentExtent so that we don't accidentally
/// clamp a scrollOffset until we receive an accurate value in updateExtents.
ExtentScrollBehavior({ double contentExtent: double.INFINITY, double containerExtent: 0.0 })
: _contentExtent = contentExtent, _containerExtent = containerExtent;
///
/// The extents and the [platform] must not be null.
ExtentScrollBehavior({
double contentExtent: double.INFINITY,
double containerExtent: 0.0,
@required TargetPlatform platform
}) : _contentExtent = contentExtent,
_containerExtent = containerExtent,
super(platform: platform);
/// The linear extent of the content inside the scrollable widget.
double get contentExtent => _contentExtent;
......@@ -115,9 +163,14 @@ class BoundedBehavior extends ExtentScrollBehavior {
BoundedBehavior({
double contentExtent: double.INFINITY,
double containerExtent: 0.0,
double minScrollOffset: 0.0
double minScrollOffset: 0.0,
@required TargetPlatform platform
}) : _minScrollOffset = minScrollOffset,
super(contentExtent: contentExtent, containerExtent: containerExtent);
super(
contentExtent: contentExtent,
containerExtent: containerExtent,
platform: platform
);
double _minScrollOffset;
......@@ -151,25 +204,23 @@ class BoundedBehavior extends ExtentScrollBehavior {
}
}
Simulation _createScrollSimulation(double position, double velocity, double minScrollOffset, double maxScrollOffset) {
final SpringDescription spring = new SpringDescription.withDampingRatio(mass: 1.0, springConstant: 170.0, ratio: 1.1);
return new ScrollSimulation(position, velocity, minScrollOffset, maxScrollOffset, spring, _kScrollDrag);
}
Simulation _createSnapScrollSimulation(double startOffset, double endOffset, double startVelocity, double endVelocity) {
return new FrictionSimulation.through(startOffset, endOffset, startVelocity, endVelocity);
}
/// A scroll behavior that does not prevent the user from exeeding scroll bounds.
/// A scroll behavior that does not prevent the user from exceeding scroll bounds.
class UnboundedBehavior extends ExtentScrollBehavior {
/// Creates a scroll behavior with no scrolling limits.
UnboundedBehavior({ double contentExtent: double.INFINITY, double containerExtent: 0.0 })
: super(contentExtent: contentExtent, containerExtent: containerExtent);
UnboundedBehavior({
double contentExtent: double.INFINITY,
double containerExtent: 0.0,
@required TargetPlatform platform
}) : super(
contentExtent: contentExtent,
containerExtent: containerExtent,
platform: platform
);
@override
Simulation createScrollSimulation(double position, double velocity) {
return new BoundedFrictionSimulation(
_kScrollDrag, position, velocity, double.NEGATIVE_INFINITY, double.INFINITY
scrollDrag, position, velocity, double.NEGATIVE_INFINITY, double.INFINITY
);
}
......@@ -193,12 +244,21 @@ class UnboundedBehavior extends ExtentScrollBehavior {
/// A scroll behavior that lets the user scroll beyond the scroll bounds with some resistance.
class OverscrollBehavior extends BoundedBehavior {
/// Creates a scroll behavior that resists, but does not prevent, scrolling beyond its limits.
OverscrollBehavior({ double contentExtent: double.INFINITY, double containerExtent: 0.0, double minScrollOffset: 0.0 })
: super(contentExtent: contentExtent, containerExtent: containerExtent, minScrollOffset: minScrollOffset);
OverscrollBehavior({
double contentExtent: double.INFINITY,
double containerExtent: 0.0,
double minScrollOffset: 0.0,
@required TargetPlatform platform
}) : super(
contentExtent: contentExtent,
containerExtent: containerExtent,
minScrollOffset: minScrollOffset,
platform: platform
);
@override
Simulation createScrollSimulation(double position, double velocity) {
return _createScrollSimulation(position, velocity, minScrollOffset, maxScrollOffset);
return _createScrollSimulation(position, velocity, minScrollOffset, maxScrollOffset, scrollDrag);
}
@override
......@@ -227,8 +287,17 @@ class OverscrollBehavior extends BoundedBehavior {
/// A scroll behavior that lets the user scroll beyond the scroll bounds only when the bounds are disjoint.
class OverscrollWhenScrollableBehavior extends OverscrollBehavior {
/// Creates a scroll behavior that allows overscrolling only when some amount of scrolling is already possible.
OverscrollWhenScrollableBehavior({ double contentExtent: double.INFINITY, double containerExtent: 0.0, double minScrollOffset: 0.0 })
: super(contentExtent: contentExtent, containerExtent: containerExtent, minScrollOffset: minScrollOffset);
OverscrollWhenScrollableBehavior({
double contentExtent: double.INFINITY,
double containerExtent: 0.0,
double minScrollOffset: 0.0,
@required TargetPlatform platform
}) : super(
contentExtent: contentExtent,
containerExtent: containerExtent,
minScrollOffset: minScrollOffset,
platform: platform
);
@override
bool get isScrollable => contentExtent > containerExtent;
......
......@@ -3,6 +3,7 @@
// found in the LICENSE file.
import 'package:meta/meta.dart';
import 'package:flutter/foundation.dart';
import 'framework.dart';
import 'scroll_behavior.dart';
......@@ -10,13 +11,18 @@ import 'scroll_behavior.dart';
/// Controls how [Scrollable] widgets in a subtree behave.
///
/// Used by [ScrollConfiguration].
class ScrollConfigurationDelegate {
/// Creates a delegate with sensible default behaviors.
abstract class ScrollConfigurationDelegate {
/// Abstract const constructor. This constructor enables subclasses to provide
/// const constructors so that they can be used in const expressions.
const ScrollConfigurationDelegate();
/// Returns the platform whose scroll physics should be approximated. See
/// [ScrollBehavior.platform].
TargetPlatform get platform;
/// Returns the ScrollBehavior to be used by generic scrolling containers like
/// [Block]. Returns a new [OverscrollWhenScrollableBehavior] by default.
ExtentScrollBehavior createScrollBehavior() => new OverscrollWhenScrollableBehavior();
/// [Block].
ExtentScrollBehavior createScrollBehavior();
/// Generic scrolling containers like [Block] will apply this function to the
/// Scrollable they create. It can be used to add widgets that wrap the
......@@ -24,9 +30,22 @@ class ScrollConfigurationDelegate {
/// [scrollWidget] parameter is returned unchanged.
Widget wrapScrollWidget(Widget scrollWidget) => scrollWidget;
/// Overrides should return true if the this ScrollConfigurationDelegate has
/// changed in a way that requires rebuilding its scrolling container descendants.
/// Returns false by default.
/// Overrides should return true if this ScrollConfigurationDelegate differs
/// from the provided old delegate in a way that requires rebuilding its
/// scrolling container descendants.
bool updateShouldNotify(ScrollConfigurationDelegate old);
}
class _DefaultScrollConfigurationDelegate extends ScrollConfigurationDelegate {
const _DefaultScrollConfigurationDelegate();
@override
TargetPlatform get platform => defaultTargetPlatform;
@override
ExtentScrollBehavior createScrollBehavior() => new OverscrollWhenScrollableBehavior(platform: platform);
@override
bool updateShouldNotify(ScrollConfigurationDelegate old) => false;
}
......@@ -49,7 +68,7 @@ class ScrollConfiguration extends InheritedWidget {
@required Widget child
}) : super(key: key, child: child);
static const ScrollConfigurationDelegate _defaultDelegate = const ScrollConfigurationDelegate();
static const ScrollConfigurationDelegate _defaultDelegate = const _DefaultScrollConfigurationDelegate();
/// Defines the ScrollBehavior and scrollable wrapper for descendants.
final ScrollConfigurationDelegate delegate;
......@@ -57,8 +76,10 @@ class ScrollConfiguration extends InheritedWidget {
/// The delegate property of the closest instance of this class that encloses
/// the given context.
///
/// If no such instance exists, returns an instance of the
/// [ScrollConfigurationDelegate] base class.
/// If no such instance exists, returns a default
/// [ScrollConfigurationDelegate] that approximates the scrolling physics of
/// the current platform (see [defaultTargetPlatform]) using a
/// [OverscrollWhenScrollableBehavior] behavior model.
static ScrollConfigurationDelegate of(BuildContext context) {
ScrollConfiguration configuration = context.inheritFromWidgetOfExactType(ScrollConfiguration);
return configuration?.delegate ?? _defaultDelegate;
......@@ -66,7 +87,7 @@ class ScrollConfiguration extends InheritedWidget {
/// A utility function that calls [ScrollConfigurationDelegate.wrapScrollWidget].
static Widget wrap(BuildContext context, Widget scrollWidget) {
return of(context).wrapScrollWidget(scrollWidget);
return ScrollConfiguration.of(context).wrapScrollWidget(scrollWidget);
}
@override
......
......@@ -6,8 +6,8 @@ import 'dart:async';
import 'dart:math' as math;
import 'dart:ui' as ui show window;
import 'package:flutter/physics.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/physics.dart';
import 'package:meta/meta.dart';
import 'basic.dart';
......
// 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_test/flutter_test.dart';
import 'package:flutter/material.dart';
const double kHeight = 10.0;
const double kFlingOffset = kHeight * 20.0;
class TestDelegate extends LazyBlockDelegate {
@override
Widget buildItem(BuildContext context, int index) {
return new Container(height: kHeight);
}
@override
double estimateTotalExtent(int firstIndex, int lastIndex, double minOffset, double firstStartOffset, double lastEndOffset) {
return double.INFINITY;
}
@override
bool shouldRebuild(LazyBlockDelegate oldDelegate) => false;
}
double currentOffset;
void main() {
testWidgets('Flings don\'t stutter', (WidgetTester tester) async {
await tester.pumpWidget(new LazyBlock(
delegate: new TestDelegate(),
onScroll: (double scrollOffset) { currentOffset = scrollOffset; },
));
await tester.fling(find.byType(LazyBlock), const Offset(0.0, -kFlingOffset), 1000.0);
expect(currentOffset, kFlingOffset);
while (tester.binding.transientCallbackCount > 0) {
double lastOffset = currentOffset;
await tester.pump(const Duration(milliseconds: 20));
expect(currentOffset, greaterThan(lastOffset));
}
}, skip: true); // see https://github.com/flutter/flutter/issues/5339
}
......@@ -10,7 +10,8 @@ void main() {
BoundedBehavior behavior = new BoundedBehavior(
contentExtent: 150.0,
containerExtent: 75.0,
minScrollOffset: -100.0
minScrollOffset: -100.0,
platform: TargetPlatform.iOS
);
expect(behavior.minScrollOffset, equals(-100.0));
expect(behavior.maxScrollOffset, equals(-25.0));
......
......@@ -6,7 +6,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/widgets.dart';
void main() {
testWidgets('Scroll notifcation basics', (WidgetTester tester) async {
testWidgets('Scroll notification basics', (WidgetTester tester) async {
ScrollNotification notification;
await tester.pumpWidget(new NotificationListener<ScrollNotification>(
......@@ -48,7 +48,7 @@ void main() {
expect(notification.dragEndDetails.velocity, equals(Velocity.zero));
});
testWidgets('Scroll notifcation depth', (WidgetTester tester) async {
testWidgets('Scroll notification depth', (WidgetTester tester) async {
final List<ScrollNotificationKind> depth0Kinds = <ScrollNotificationKind>[];
final List<ScrollNotificationKind> depth1Kinds = <ScrollNotificationKind>[];
final List<int> depth0Values = <int>[];
......
// 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_test/flutter_test.dart';
import 'package:flutter/material.dart';
class TestDelegate extends LazyBlockDelegate {
@override
Widget buildItem(BuildContext context, int index) {
return new Text('$index');
}
@override
double estimateTotalExtent(int firstIndex, int lastIndex, double minOffset, double firstStartOffset, double lastEndOffset) {
return double.INFINITY;
}
@override
bool shouldRebuild(LazyBlockDelegate oldDelegate) => false;
}
double currentOffset;
Future<Null> pumpTest(WidgetTester tester, TargetPlatform platform) async {
await tester.pumpWidget(new Container());
await tester.pumpWidget(new MaterialApp(
theme: new ThemeData(
platform: platform
),
home: new LazyBlock(
delegate: new TestDelegate(),
onScroll: (double scrollOffset) { currentOffset = scrollOffset; },
),
));
return null;
}
const double dragOffset = 213.82;
void main() {
testWidgets('Flings on different platforms', (WidgetTester tester) async {
await pumpTest(tester, TargetPlatform.android);
await tester.fling(find.byType(LazyBlock), const Offset(0.0, -dragOffset), 1000.0);
expect(currentOffset, dragOffset);
await tester.pump(); // trigger fling
expect(currentOffset, dragOffset);
await tester.pump(const Duration(seconds: 5));
final double result1 = currentOffset;
await pumpTest(tester, TargetPlatform.iOS);
await tester.fling(find.byType(LazyBlock), const Offset(0.0, -dragOffset), 1000.0);
expect(currentOffset, dragOffset);
await tester.pump(); // trigger fling
expect(currentOffset, dragOffset);
await tester.pump(const Duration(seconds: 5));
final double result2 = currentOffset;
expect(result1, lessThan(result2)); // iOS (result2) is slipperier than Android (result1)
});
}
......@@ -264,13 +264,29 @@ class WidgetController {
///
/// If the middle of the widget is not exposed, this might send
/// events to another object.
Future<Null> fling(Finder finder, Offset offset, double velocity, { int pointer: 1 }) {
return flingFrom(getCenter(finder), offset, velocity, pointer: pointer);
///
/// This can pump frames. See [flingFrom] for a discussion of how the
/// `offset`, `velocity` and `frameInterval` arguments affect this.
Future<Null> fling(Finder finder, Offset offset, double velocity, { int pointer: 1, Duration frameInterval: const Duration(milliseconds: 16) }) {
return flingFrom(getCenter(finder), offset, velocity, pointer: pointer, frameInterval: frameInterval);
}
/// Attempts a fling gesture starting from the given location,
/// moving the given distance, reaching the given velocity.
Future<Null> flingFrom(Point startLocation, Offset offset, double velocity, { int pointer: 1 }) {
///
/// Exactly 50 pointer events are synthesized.
///
/// The offset and velocity control the interval between each pointer event.
/// For example, if the offset is 200 pixels, and the velocity is 800 pixels
/// per second, the pointer events will be sent for each increment of 4 pixels
/// (200/50), over 250ms (200/800), meaning events will be sent every 1.25ms
/// (250/200).
///
/// To make tests more realistic, frames may be pumped during this time (using
/// calls to [pump]). If the total duration is longer than `frameInterval`,
/// then one frame is pumped each time that amount of time elapses while
/// sending events, or each time an event is synthesised, whichever is rarer.
Future<Null> flingFrom(Point startLocation, Offset offset, double velocity, { int pointer: 1, Duration frameInterval: const Duration(milliseconds: 16) }) {
return TestAsyncUtils.guard(() async {
assert(offset.distance > 0.0);
assert(velocity != 0.0); // velocity is pixels/second
......@@ -279,17 +295,32 @@ class WidgetController {
const int kMoveCount = 50; // Needs to be >= kHistorySize, see _LeastSquaresVelocityTrackerStrategy
final double timeStampDelta = 1000.0 * offset.distance / (kMoveCount * velocity);
double timeStamp = 0.0;
double lastTimeStamp = timeStamp;
await sendEventToBinding(p.down(startLocation, timeStamp: new Duration(milliseconds: timeStamp.round())), result);
for (int i = 0; i <= kMoveCount; i++) {
for (int i = 0; i <= kMoveCount; i += 1) {
final Point location = startLocation + Offset.lerp(Offset.zero, offset, i / kMoveCount);
await sendEventToBinding(p.move(location, timeStamp: new Duration(milliseconds: timeStamp.round())), result);
timeStamp += timeStampDelta;
if (timeStamp - lastTimeStamp > frameInterval.inMilliseconds) {
await pump(new Duration(milliseconds: (timeStamp - lastTimeStamp).truncate()));
lastTimeStamp = timeStamp;
}
}
await sendEventToBinding(p.up(timeStamp: new Duration(milliseconds: timeStamp.round())), result);
return null;
});
}
/// Called to indicate that time should advance.
///
/// This is invoked by [flingFrom], for instance, so that the sequence of
/// pointer events occurs over time.
///
/// The default implementation does nothing.
///
/// The [WidgetTester] subclass implements this by deferring to the [binding].
Future<Null> pump(Duration duration) => new Future<Null>.value(null);
/// Attempts to drag the given widget by the given offset, by
/// starting a drag in the middle of the widget.
///
......
......@@ -67,7 +67,7 @@ class TestAsyncUtils {
_AsyncScope scope = new _AsyncScope(StackTrace.current, zone);
_scopeStack.add(scope);
Future<Null> result = scope.zone.run(body);
result = result.whenComplete(() {
void completionHandler(dynamic error, StackTrace stack) {
assert(_scopeStack.isNotEmpty);
assert(_scopeStack.contains(scope));
bool leaked = false;
......@@ -77,8 +77,10 @@ class TestAsyncUtils {
closedScope = _scopeStack.removeLast();
if (closedScope == scope)
break;
leaked = true;
message.writeln('Asynchronous call to guarded function leaked. You must use "await" with all Future-returning test APIs.');
if (!leaked) {
message.writeln('Asynchronous call to guarded function leaked. You must use "await" with all Future-returning test APIs.');
leaked = true;
}
final _StackEntry originalGuarder = _findResponsibleMethod(closedScope.creationStack, 'guard', message);
if (originalGuarder != null) {
message.writeln(
......@@ -90,10 +92,20 @@ class TestAsyncUtils {
);
}
}
if (leaked)
if (leaked) {
if (error != null) {
message.writeln('An uncaught exception may have caused the guarded function leak. The exception was:');
message.writeln('$error');
message.writeln('The stack trace associated with this exception was:');
FlutterError.defaultStackFilter(stack.toString().trimRight().split('\n')).forEach(message.writeln);
}
throw new FlutterError(message.toString().trimRight());
});
return result;
}
}
return result.then(
(Null value) => completionHandler(null, null),
onError: completionHandler
);
}
static Zone get _currentScopeZone {
......
......@@ -36,9 +36,9 @@ typedef Future<Null> WidgetTesterCallback(WidgetTester widgetTester);
///
/// Example:
///
/// testWidgets('MyWidget', (WidgetTester tester) {
/// tester.pumpWidget(new MyWidget());
/// tester.tap(find.text('Save'));
/// testWidgets('MyWidget', (WidgetTester tester) async {
/// await tester.pumpWidget(new MyWidget());
/// await tester.tap(find.text('Save'));
/// expect(tester, hasWidget(find.text('Success')));
/// });
void testWidgets(String description, WidgetTesterCallback callback, {
......@@ -77,12 +77,12 @@ void testWidgets(String description, WidgetTesterCallback callback, {
///
/// main() async {
/// assert(false); // fail in checked mode
/// await benchmarkWidgets((WidgetTester tester) {
/// tester.pumpWidget(new MyWidget());
/// await benchmarkWidgets((WidgetTester tester) async {
/// await tester.pumpWidget(new MyWidget());
/// final Stopwatch timer = new Stopwatch()..start();
/// for (int index = 0; index < 10000; index += 1) {
/// tester.tap(find.text('Tap me'));
/// tester.pump();
/// await tester.tap(find.text('Tap me'));
/// await tester.pump();
/// }
/// timer.stop();
/// debugPrint('Time taken: ${timer.elapsedMilliseconds}ms');
......@@ -99,7 +99,7 @@ Future<Null> benchmarkWidgets(WidgetTesterCallback callback) {
print('│ enabled will not accurately reflect the performance │');
print('│ that will be experienced by end users using release ╎');
print('│ builds. Benchmarks should be run using this command ┆');
print('│ line: flutter run --release -t benchmark.dart ┊');
print('│ line: flutter run --release benchmark.dart ┊');
print('│ ');
print('└─────────────────────────────────────────────────╌┄┈ 🐢');
return true;
......@@ -172,6 +172,7 @@ class WidgetTester extends WidgetController implements HitTestDispatcher {
///
/// This is a convenience function that just calls
/// [TestWidgetsFlutterBinding.pump].
@override
Future<Null> pump([
Duration duration,
EnginePhase phase = EnginePhase.sendSemanticsTree
......
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