diff --git a/packages/flutter/lib/src/foundation/assertions.dart b/packages/flutter/lib/src/foundation/assertions.dart index 4457dbdee93..f87665c13d3 100644 --- a/packages/flutter/lib/src/foundation/assertions.dart +++ b/packages/flutter/lib/src/foundation/assertions.dart @@ -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 lines = StackTrace.current.toString().trimRight().split('\n'); + if (maxFrames != null) + lines = lines.take(maxFrames); + debugPrint(FlutterError.defaultStackFilter(lines).join('\n')); +} diff --git a/packages/flutter/lib/src/foundation/platform.dart b/packages/flutter/lib/src/foundation/platform.dart index 3881055e19e..4854155da07 100644 --- a/packages/flutter/lib/src/foundation/platform.dart +++ b/packages/flutter/lib/src/foundation/platform.dart @@ -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; } diff --git a/packages/flutter/lib/src/foundation/print.dart b/packages/flutter/lib/src/foundation/print.dart index c9aecadb0a8..2d15441b8a8 100644 --- a/packages/flutter/lib/src/foundation/print.dart +++ b/packages/flutter/lib/src/foundation/print.dart @@ -160,10 +160,3 @@ Iterable 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()); -} diff --git a/packages/flutter/lib/src/material/app.dart b/packages/flutter/lib/src/material/app.dart index d79d3d2e4b9..094b6116691 100644 --- a/packages/flutter/lib/src/material/app.dart +++ b/packages/flutter/lib/src/material/app.dart @@ -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 {}, - this.overscrollStyle, this.onGenerateRoute, this.onLocaleChanged, this.debugShowMaterialGrid: false, @@ -112,11 +102,6 @@ class MaterialApp extends StatefulWidget { /// build the page instead. final Map 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 { HeroController _heroController; @@ -195,19 +201,11 @@ class _MaterialAppState extends State { } 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; } diff --git a/packages/flutter/lib/src/material/drop_down.dart b/packages/flutter/lib/src/material/drop_down.dart index 68269609932..2b3bf40fc95 100644 --- a/packages/flutter/lib/src/material/drop_down.dart +++ b/packages/flutter/lib/src/material/drop_down.dart @@ -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 extends StatefulWidget { _DropDownMenu({ @@ -170,7 +179,7 @@ class _DropDownMenuState extends State<_DropDownMenu> { 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, diff --git a/packages/flutter/lib/src/material/input.dart b/packages/flutter/lib/src/material/input.dart index df4365fa9f3..468de7455cd 100644 --- a/packages/flutter/lib/src/material/input.dart +++ b/packages/flutter/lib/src/material/input.dart @@ -205,6 +205,7 @@ class _InputState extends State { selectionColor: themeData.textSelectionColor, selectionHandleBuilder: buildTextSelectionHandle, selectionToolbarBuilder: buildTextSelectionToolbar, + platform: Theme.of(context).platform, keyboardType: config.keyboardType, onChanged: onChanged, onSubmitted: onSubmitted diff --git a/packages/flutter/lib/src/material/tabs.dart b/packages/flutter/lib/src/material/tabs.dart index a85f2d8debd..d28d055c0f9 100644 --- a/packages/flutter/lib/src/material/tabs.dart +++ b/packages/flutter/lib/src/material/tabs.dart @@ -1090,10 +1090,13 @@ class _TabBarViewState extends PageableListState> 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 selection) { _selection = selection; if (_selection != null) { diff --git a/packages/flutter/lib/src/widgets/editable.dart b/packages/flutter/lib/src/widgets/editable.dart index f224fa47673..b10e1e67069 100644 --- a/packages/flutter/lib/src/widgets/editable.dart +++ b/packages/flutter/lib/src/widgets/editable.dart @@ -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 { TextSelectionOverlay _selectionOverlay; @override - ScrollBehavior createScrollBehavior() => new BoundedBehavior(); + ScrollBehavior createScrollBehavior() => new BoundedBehavior(platform: config.platform); @override BoundedBehavior get scrollBehavior => super.scrollBehavior; diff --git a/packages/flutter/lib/src/widgets/pageable_list.dart b/packages/flutter/lib/src/widgets/pageable_list.dart index 611f60ff746..a6ab2136c88 100644 --- a/packages/flutter/lib/src/widgets/pageable_list.dart +++ b/packages/flutter/lib/src/widgets/pageable_list.dart @@ -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 extends ScrollableState { @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; diff --git a/packages/flutter/lib/src/widgets/scroll_behavior.dart b/packages/flutter/lib/src/widgets/scroll_behavior.dart index f286be3cd0b..51dc774311d 100644 --- a/packages/flutter/lib/src/widgets/scroll_behavior.dart +++ b/packages/flutter/lib/src/widgets/scroll_behavior.dart @@ -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 // 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 { + /// 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 { /// 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 description = []; @@ -64,8 +105,15 @@ abstract class ExtentScrollBehavior extends ScrollBehavior { /// 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; diff --git a/packages/flutter/lib/src/widgets/scroll_configuration.dart b/packages/flutter/lib/src/widgets/scroll_configuration.dart index ed54658f73b..dda1ca39b57 100644 --- a/packages/flutter/lib/src/widgets/scroll_configuration.dart +++ b/packages/flutter/lib/src/widgets/scroll_configuration.dart @@ -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 diff --git a/packages/flutter/lib/src/widgets/scrollable.dart b/packages/flutter/lib/src/widgets/scrollable.dart index b502a49d4e4..6ba23b20287 100644 --- a/packages/flutter/lib/src/widgets/scrollable.dart +++ b/packages/flutter/lib/src/widgets/scrollable.dart @@ -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'; diff --git a/packages/flutter/test/widget/lazy_block_fling_test.dart b/packages/flutter/test/widget/lazy_block_fling_test.dart new file mode 100644 index 00000000000..8e1cb3bcac0 --- /dev/null +++ b/packages/flutter/test/widget/lazy_block_fling_test.dart @@ -0,0 +1,42 @@ +// 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 +} diff --git a/packages/flutter/test/widget/scroll_behavior_test.dart b/packages/flutter/test/widget/scroll_behavior_test.dart index 30f65d9b049..79dd83b4c41 100644 --- a/packages/flutter/test/widget/scroll_behavior_test.dart +++ b/packages/flutter/test/widget/scroll_behavior_test.dart @@ -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)); diff --git a/packages/flutter/test/widget/scroll_notification_test.dart b/packages/flutter/test/widget/scroll_notification_test.dart index be1d41ffc9f..dd64e93ed22 100644 --- a/packages/flutter/test/widget/scroll_notification_test.dart +++ b/packages/flutter/test/widget/scroll_notification_test.dart @@ -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( @@ -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 depth0Kinds = []; final List depth1Kinds = []; final List depth0Values = []; diff --git a/packages/flutter/test/widget/scrollable_fling_test.dart b/packages/flutter/test/widget/scrollable_fling_test.dart new file mode 100644 index 00000000000..108da0b1c06 --- /dev/null +++ b/packages/flutter/test/widget/scrollable_fling_test.dart @@ -0,0 +1,61 @@ +// 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 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) + }); +} diff --git a/packages/flutter_test/lib/src/controller.dart b/packages/flutter_test/lib/src/controller.dart index 7899c44215e..2cd215bebdd 100644 --- a/packages/flutter_test/lib/src/controller.dart +++ b/packages/flutter_test/lib/src/controller.dart @@ -264,13 +264,29 @@ class WidgetController { /// /// If the middle of the widget is not exposed, this might send /// events to another object. - Future 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 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 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 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 pump(Duration duration) => new Future.value(null); + /// Attempts to drag the given widget by the given offset, by /// starting a drag in the middle of the widget. /// diff --git a/packages/flutter_test/lib/src/test_async_utils.dart b/packages/flutter_test/lib/src/test_async_utils.dart index 6aad085eeed..e0726be7cdd 100644 --- a/packages/flutter_test/lib/src/test_async_utils.dart +++ b/packages/flutter_test/lib/src/test_async_utils.dart @@ -67,7 +67,7 @@ class TestAsyncUtils { _AsyncScope scope = new _AsyncScope(StackTrace.current, zone); _scopeStack.add(scope); Future 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 { diff --git a/packages/flutter_test/lib/src/widget_tester.dart b/packages/flutter_test/lib/src/widget_tester.dart index 06fdbe5e6da..8b96c598992 100644 --- a/packages/flutter_test/lib/src/widget_tester.dart +++ b/packages/flutter_test/lib/src/widget_tester.dart @@ -36,9 +36,9 @@ typedef Future 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 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 pump([ Duration duration, EnginePhase phase = EnginePhase.sendSemanticsTree