mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
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.
This commit is contained in:
parent
cf2b2078d4
commit
17cdc88958
@ -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';
|
||||
|
||||
42
packages/flutter/test/widget/lazy_block_fling_test.dart
Normal file
42
packages/flutter/test/widget/lazy_block_fling_test.dart
Normal file
@ -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
|
||||
}
|
||||
@ -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>[];
|
||||
|
||||
61
packages/flutter/test/widget/scrollable_fling_test.dart
Normal file
61
packages/flutter/test/widget/scrollable_fling_test.dart
Normal file
@ -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<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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user