Skip to content
Permalink
Branch: master
Find file Copy path
Find file Copy path
13 contributors

Users who have contributed to this file

@Hixie @a14n @gspencergoog @abarth @yjbanov @pq @mdebbar @mraleph @matthew-carroll @jslavitz @goderbauer @dkwingsmt @cbracken
683 lines (619 sloc) 26.5 KB
// Copyright 2016 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'all_elements.dart';
import 'finders.dart';
import 'test_async_utils.dart';
import 'test_pointer.dart';
/// The default drag touch slop used to break up a large drag into multiple
/// smaller moves.
///
/// This value must be greater than [kTouchSlop].
const double kDragSlopDefault = 20.0;
/// Class that programmatically interacts with widgets.
///
/// For a variant of this class suited specifically for unit tests, see
/// [WidgetTester]. For one suitable for live tests on a device, consider
/// [LiveWidgetController].
///
/// Concrete subclasses must implement the [pump] method.
abstract class WidgetController {
/// Creates a widget controller that uses the given binding.
WidgetController(this.binding);
/// A reference to the current instance of the binding.
final WidgetsBinding binding;
// FINDER API
// TODO(ianh): verify that the return values are of type T and throw
// a good message otherwise, in all the generic methods below
/// Checks if `finder` exists in the tree.
bool any(Finder finder) {
TestAsyncUtils.guardSync();
return finder.evaluate().isNotEmpty;
}
/// All widgets currently in the widget tree (lazy pre-order traversal).
///
/// Can contain duplicates, since widgets can be used in multiple
/// places in the widget tree.
Iterable<Widget> get allWidgets {
TestAsyncUtils.guardSync();
return allElements.map<Widget>((Element element) => element.widget);
}
/// The matching widget in the widget tree.
///
/// Throws a [StateError] if `finder` is empty or matches more than
/// one widget.
///
/// * Use [firstWidget] if you expect to match several widgets but only want the first.
/// * Use [widgetList] if you expect to match several widgets and want all of them.
T widget<T extends Widget>(Finder finder) {
TestAsyncUtils.guardSync();
return finder.evaluate().single.widget;
}
/// The first matching widget according to a depth-first pre-order
/// traversal of the widget tree.
///
/// Throws a [StateError] if `finder` is empty.
///
/// * Use [widget] if you only expect to match one widget.
T firstWidget<T extends Widget>(Finder finder) {
TestAsyncUtils.guardSync();
return finder.evaluate().first.widget;
}
/// The matching widgets in the widget tree.
///
/// * Use [widget] if you only expect to match one widget.
/// * Use [firstWidget] if you expect to match several but only want the first.
Iterable<T> widgetList<T extends Widget>(Finder finder) {
TestAsyncUtils.guardSync();
return finder.evaluate().map<T>((Element element) {
final T result = element.widget;
return result;
});
}
/// All elements currently in the widget tree (lazy pre-order traversal).
///
/// The returned iterable is lazy. It does not walk the entire widget tree
/// immediately, but rather a chunk at a time as the iteration progresses
/// using [Iterator.moveNext].
Iterable<Element> get allElements {
TestAsyncUtils.guardSync();
return collectAllElementsFrom(binding.renderViewElement, skipOffstage: false);
}
/// The matching element in the widget tree.
///
/// Throws a [StateError] if `finder` is empty or matches more than
/// one element.
///
/// * Use [firstElement] if you expect to match several elements but only want the first.
/// * Use [elementList] if you expect to match several elements and want all of them.
T element<T extends Element>(Finder finder) {
TestAsyncUtils.guardSync();
return finder.evaluate().single;
}
/// The first matching element according to a depth-first pre-order
/// traversal of the widget tree.
///
/// Throws a [StateError] if `finder` is empty.
///
/// * Use [element] if you only expect to match one element.
T firstElement<T extends Element>(Finder finder) {
TestAsyncUtils.guardSync();
return finder.evaluate().first;
}
/// The matching elements in the widget tree.
///
/// * Use [element] if you only expect to match one element.
/// * Use [firstElement] if you expect to match several but only want the first.
Iterable<T> elementList<T extends Element>(Finder finder) {
TestAsyncUtils.guardSync();
return finder.evaluate();
}
/// All states currently in the widget tree (lazy pre-order traversal).
///
/// The returned iterable is lazy. It does not walk the entire widget tree
/// immediately, but rather a chunk at a time as the iteration progresses
/// using [Iterator.moveNext].
Iterable<State> get allStates {
TestAsyncUtils.guardSync();
return allElements.whereType<StatefulElement>().map<State>((StatefulElement element) => element.state);
}
/// The matching state in the widget tree.
///
/// Throws a [StateError] if `finder` is empty, matches more than
/// one state, or matches a widget that has no state.
///
/// * Use [firstState] if you expect to match several states but only want the first.
/// * Use [stateList] if you expect to match several states and want all of them.
T state<T extends State>(Finder finder) {
TestAsyncUtils.guardSync();
return _stateOf<T>(finder.evaluate().single, finder);
}
/// The first matching state according to a depth-first pre-order
/// traversal of the widget tree.
///
/// Throws a [StateError] if `finder` is empty or if the first
/// matching widget has no state.
///
/// * Use [state] if you only expect to match one state.
T firstState<T extends State>(Finder finder) {
TestAsyncUtils.guardSync();
return _stateOf<T>(finder.evaluate().first, finder);
}
/// The matching states in the widget tree.
///
/// Throws a [StateError] if any of the elements in `finder` match a widget
/// that has no state.
///
/// * Use [state] if you only expect to match one state.
/// * Use [firstState] if you expect to match several but only want the first.
Iterable<T> stateList<T extends State>(Finder finder) {
TestAsyncUtils.guardSync();
return finder.evaluate().map<T>((Element element) => _stateOf<T>(element, finder));
}
T _stateOf<T extends State>(Element element, Finder finder) {
TestAsyncUtils.guardSync();
if (element is StatefulElement)
return element.state;
throw StateError('Widget of type ${element.widget.runtimeType}, with ${finder.description}, is not a StatefulWidget.');
}
/// Render objects of all the widgets currently in the widget tree
/// (lazy pre-order traversal).
///
/// This will almost certainly include many duplicates since the
/// render object of a [StatelessWidget] or [StatefulWidget] is the
/// render object of its child; only [RenderObjectWidget]s have
/// their own render object.
Iterable<RenderObject> get allRenderObjects {
TestAsyncUtils.guardSync();
return allElements.map<RenderObject>((Element element) => element.renderObject);
}
/// The render object of the matching widget in the widget tree.
///
/// Throws a [StateError] if `finder` is empty or matches more than
/// one widget (even if they all have the same render object).
///
/// * Use [firstRenderObject] if you expect to match several render objects but only want the first.
/// * Use [renderObjectList] if you expect to match several render objects and want all of them.
T renderObject<T extends RenderObject>(Finder finder) {
TestAsyncUtils.guardSync();
return finder.evaluate().single.renderObject;
}
/// The render object of the first matching widget according to a
/// depth-first pre-order traversal of the widget tree.
///
/// Throws a [StateError] if `finder` is empty.
///
/// * Use [renderObject] if you only expect to match one render object.
T firstRenderObject<T extends RenderObject>(Finder finder) {
TestAsyncUtils.guardSync();
return finder.evaluate().first.renderObject;
}
/// The render objects of the matching widgets in the widget tree.
///
/// * Use [renderObject] if you only expect to match one render object.
/// * Use [firstRenderObject] if you expect to match several but only want the first.
Iterable<T> renderObjectList<T extends RenderObject>(Finder finder) {
TestAsyncUtils.guardSync();
return finder.evaluate().map<T>((Element element) {
final T result = element.renderObject;
return result;
});
}
/// Returns a list of all the [Layer] objects in the rendering.
List<Layer> get layers => _walkLayers(binding.renderView.debugLayer).toList();
Iterable<Layer> _walkLayers(Layer layer) sync* {
TestAsyncUtils.guardSync();
yield layer;
if (layer is ContainerLayer) {
final ContainerLayer root = layer;
Layer child = root.firstChild;
while (child != null) {
yield* _walkLayers(child);
child = child.nextSibling;
}
}
}
// INTERACTION
/// Dispatch a pointer down / pointer up sequence at the center of
/// the given widget, assuming it is exposed.
///
/// If the center of the widget is not exposed, this might send events to
/// another object.
Future<void> tap(Finder finder, {int pointer, int buttons = kPrimaryButton}) {
return tapAt(getCenter(finder), pointer: pointer, buttons: buttons);
}
/// Dispatch a pointer down / pointer up sequence at the given location.
Future<void> tapAt(Offset location, {int pointer, int buttons = kPrimaryButton}) {
return TestAsyncUtils.guard<void>(() async {
final TestGesture gesture = await startGesture(location, pointer: pointer, buttons: buttons);
await gesture.up();
});
}
/// Dispatch a pointer down at the center of the given widget, assuming it is
/// exposed.
///
/// If the center of the widget is not exposed, this might send events to
/// another object.
Future<TestGesture> press(Finder finder, {int pointer, int buttons = kPrimaryButton}) {
return TestAsyncUtils.guard<TestGesture>(() {
return startGesture(getCenter(finder), pointer: pointer, buttons: buttons);
});
}
/// Dispatch a pointer down / pointer up sequence (with a delay of
/// [kLongPressTimeout] + [kPressTimeout] between the two events) at the
/// center of the given widget, assuming it is exposed.
///
/// If the center of the widget is not exposed, this might send events to
/// another object.
Future<void> longPress(Finder finder, {int pointer, int buttons = kPrimaryButton}) {
return longPressAt(getCenter(finder), pointer: pointer, buttons: buttons);
}
/// Dispatch a pointer down / pointer up sequence at the given location with
/// a delay of [kLongPressTimeout] + [kPressTimeout] between the two events.
Future<void> longPressAt(Offset location, {int pointer, int buttons = kPrimaryButton}) {
return TestAsyncUtils.guard<void>(() async {
final TestGesture gesture = await startGesture(location, pointer: pointer, buttons: buttons);
await pump(kLongPressTimeout + kPressTimeout);
await gesture.up();
});
}
/// Attempts a fling gesture starting from the center of the given
/// widget, moving the given distance, reaching the given speed.
///
/// If the middle of the widget is not exposed, this might send
/// events to another object.
///
/// This can pump frames. See [flingFrom] for a discussion of how the
/// `offset`, `velocity` and `frameInterval` arguments affect this.
///
/// The `speed` is in pixels per second in the direction given by `offset`.
///
/// A fling is essentially a drag that ends at a particular speed. If you
/// just want to drag and end without a fling, use [drag].
///
/// The `initialOffset` argument, if non-zero, causes the pointer to first
/// apply that offset, then pump a delay of `initialOffsetDelay`. This can be
/// used to simulate a drag followed by a fling, including dragging in the
/// opposite direction of the fling (e.g. dragging 200 pixels to the right,
/// then fling to the left over 200 pixels, ending at the exact point that the
/// drag started).
Future<void> fling(
Finder finder,
Offset offset,
double speed, {
int pointer,
int buttons = kPrimaryButton,
Duration frameInterval = const Duration(milliseconds: 16),
Offset initialOffset = Offset.zero,
Duration initialOffsetDelay = const Duration(seconds: 1),
}) {
return flingFrom(
getCenter(finder),
offset,
speed,
pointer: pointer,
buttons: buttons,
frameInterval: frameInterval,
initialOffset: initialOffset,
initialOffsetDelay: initialOffsetDelay,
);
}
/// Attempts a fling gesture starting from the given location, moving the
/// given distance, reaching the given speed.
///
/// Exactly 50 pointer events are synthesized.
///
/// The offset and speed control the interval between each pointer event. For
/// example, if the offset is 200 pixels down, and the speed 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 synthesized, whichever is rarer.
///
/// A fling is essentially a drag that ends at a particular speed. If you
/// just want to drag and end without a fling, use [dragFrom].
///
/// The `initialOffset` argument, if non-zero, causes the pointer to first
/// apply that offset, then pump a delay of `initialOffsetDelay`. This can be
/// used to simulate a drag followed by a fling, including dragging in the
/// opposite direction of the fling (e.g. dragging 200 pixels to the right,
/// then fling to the left over 200 pixels, ending at the exact point that the
/// drag started).
Future<void> flingFrom(
Offset startLocation,
Offset offset,
double speed, {
int pointer,
int buttons = kPrimaryButton,
Duration frameInterval = const Duration(milliseconds: 16),
Offset initialOffset = Offset.zero,
Duration initialOffsetDelay = const Duration(seconds: 1),
}) {
assert(offset.distance > 0.0);
assert(speed > 0.0); // speed is pixels/second
return TestAsyncUtils.guard<void>(() async {
final TestPointer testPointer = TestPointer(pointer ?? _getNextPointer(), PointerDeviceKind.touch, null, buttons);
final HitTestResult result = hitTestOnBinding(startLocation);
const int kMoveCount = 50; // Needs to be >= kHistorySize, see _LeastSquaresVelocityTrackerStrategy
final double timeStampDelta = 1000.0 * offset.distance / (kMoveCount * speed);
double timeStamp = 0.0;
double lastTimeStamp = timeStamp;
await sendEventToBinding(testPointer.down(startLocation, timeStamp: Duration(milliseconds: timeStamp.round())), result);
if (initialOffset.distance > 0.0) {
await sendEventToBinding(testPointer.move(startLocation + initialOffset, timeStamp: Duration(milliseconds: timeStamp.round())), result);
timeStamp += initialOffsetDelay.inMilliseconds;
await pump(initialOffsetDelay);
}
for (int i = 0; i <= kMoveCount; i += 1) {
final Offset location = startLocation + initialOffset + Offset.lerp(Offset.zero, offset, i / kMoveCount);
await sendEventToBinding(testPointer.move(location, timeStamp: Duration(milliseconds: timeStamp.round())), result);
timeStamp += timeStampDelta;
if (timeStamp - lastTimeStamp > frameInterval.inMilliseconds) {
await pump(Duration(milliseconds: (timeStamp - lastTimeStamp).truncate()));
lastTimeStamp = timeStamp;
}
}
await sendEventToBinding(testPointer.up(timeStamp: Duration(milliseconds: timeStamp.round())), result);
});
}
/// 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 [WidgetTester] subclass implements this by deferring to the [binding].
///
/// See also [SchedulerBinding.endOfFrame], which returns a future that could
/// be appropriate to return in the implementation of this method.
Future<void> pump(Duration duration);
/// Attempts to drag the given widget by the given offset, by
/// starting a drag in the middle of the widget.
///
/// If the middle of the widget is not exposed, this might send
/// events to another object.
///
/// If you want the drag to end with a speed so that the gesture recognition
/// system identifies the gesture as a fling, consider using [fling] instead.
///
/// {@template flutter.flutter_test.drag}
/// By default, if the x or y component of offset is greater than [kTouchSlop], the
/// gesture is broken up into two separate moves calls. Changing 'touchSlopX' or
/// `touchSlopY` will change the minimum amount of movement in the respective axis
/// before the drag will be broken into multiple calls. To always send the
/// drag with just a single call to [TestGesture.moveBy], `touchSlopX` and `touchSlopY`
/// should be set to 0.
///
/// Breaking the drag into multiple moves is necessary for accurate execution
/// of drag update calls with a [DragStartBehavior] variable set to
/// [DragStartBehavior.start]. Without such a change, the dragUpdate callback
/// from a drag recognizer will never be invoked.
///
/// To force this function to a send a single move event, the 'touchSlopX' and
/// 'touchSlopY' variables should be set to 0. However, generally, these values
/// should be left to their default values.
/// {@end template}
Future<void> drag(
Finder finder,
Offset offset, {
int pointer,
int buttons = kPrimaryButton,
double touchSlopX = kDragSlopDefault,
double touchSlopY = kDragSlopDefault,
}) {
assert(kDragSlopDefault > kTouchSlop);
return dragFrom(
getCenter(finder),
offset,
pointer: pointer,
buttons: buttons,
touchSlopX: touchSlopX,
touchSlopY: touchSlopY,
);
}
/// Attempts a drag gesture consisting of a pointer down, a move by
/// the given offset, and a pointer up.
///
/// If you want the drag to end with a speed so that the gesture recognition
/// system identifies the gesture as a fling, consider using [flingFrom]
/// instead.
///
/// {@macro flutter.flutter_test.drag}
Future<void> dragFrom(
Offset startLocation,
Offset offset, {
int pointer,
int buttons = kPrimaryButton,
double touchSlopX = kDragSlopDefault,
double touchSlopY = kDragSlopDefault,
}) {
assert(kDragSlopDefault > kTouchSlop);
return TestAsyncUtils.guard<void>(() async {
final TestGesture gesture = await startGesture(startLocation, pointer: pointer, buttons: buttons);
assert(gesture != null);
final double xSign = offset.dx.sign;
final double ySign = offset.dy.sign;
final double offsetX = offset.dx;
final double offsetY = offset.dy;
final bool separateX = offset.dx.abs() > touchSlopX && touchSlopX > 0;
final bool separateY = offset.dy.abs() > touchSlopY && touchSlopY > 0;
if (separateY || separateX) {
final double offsetSlope = offsetY / offsetX;
final double inverseOffsetSlope = offsetX / offsetY;
final double slopSlope = touchSlopY / touchSlopX;
final double absoluteOffsetSlope = offsetSlope.abs();
final double signedSlopX = touchSlopX * xSign;
final double signedSlopY = touchSlopY * ySign;
if (absoluteOffsetSlope != slopSlope) {
// The drag goes through one or both of the extents of the edges of the box.
if (absoluteOffsetSlope < slopSlope) {
assert(offsetX.abs() > touchSlopX);
// The drag goes through the vertical edge of the box.
// It is guaranteed that the |offsetX| > touchSlopX.
final double diffY = offsetSlope.abs() * touchSlopX * ySign;
// The vector from the origin to the vertical edge.
await gesture.moveBy(Offset(signedSlopX, diffY));
if (offsetY.abs() <= touchSlopY) {
// The drag ends on or before getting to the horizontal extension of the horizontal edge.
await gesture.moveBy(Offset(offsetX - signedSlopX, offsetY - diffY));
} else {
final double diffY2 = signedSlopY - diffY;
final double diffX2 = inverseOffsetSlope * diffY2;
// The vector from the edge of the box to the horizontal extension of the horizontal edge.
await gesture.moveBy(Offset(diffX2, diffY2));
await gesture.moveBy(Offset(offsetX - diffX2 - signedSlopX, offsetY - signedSlopY));
}
} else {
assert(offsetY.abs() > touchSlopY);
// The drag goes through the horizontal edge of the box.
// It is guaranteed that the |offsetY| > touchSlopY.
final double diffX = inverseOffsetSlope.abs() * touchSlopY * xSign;
// The vector from the origin to the vertical edge.
await gesture.moveBy(Offset(diffX, signedSlopY));
if (offsetX.abs() <= touchSlopX) {
// The drag ends on or before getting to the vertical extension of the vertical edge.
await gesture.moveBy(Offset(offsetX - diffX, offsetY - signedSlopY));
} else {
final double diffX2 = signedSlopX - diffX;
final double diffY2 = offsetSlope * diffX2;
// The vector from the edge of the box to the vertical extension of the vertical edge.
await gesture.moveBy(Offset(diffX2, diffY2));
await gesture.moveBy(Offset(offsetX - signedSlopX, offsetY - diffY2 - signedSlopY));
}
}
} else { // The drag goes through the corner of the box.
await gesture.moveBy(Offset(signedSlopX, signedSlopY));
await gesture.moveBy(Offset(offsetX - signedSlopX, offsetY - signedSlopY));
}
} else { // The drag ends inside the box.
await gesture.moveBy(offset);
}
await gesture.up();
});
}
/// The next available pointer identifier.
///
/// This is the default pointer identifier that will be used the next time the
/// [startGesture] method is called without an explicit pointer identifier.
int nextPointer = 1;
int _getNextPointer() {
final int result = nextPointer;
nextPointer += 1;
return result;
}
/// Creates gesture and returns the [TestGesture] object which you can use
/// to continue the gesture using calls on the [TestGesture] object.
///
/// You can use [startGesture] instead if your gesture begins with a down
/// event.
Future<TestGesture> createGesture({
int pointer,
PointerDeviceKind kind = PointerDeviceKind.touch,
int buttons = kPrimaryButton,
}) async {
return TestGesture(
hitTester: hitTestOnBinding,
dispatcher: sendEventToBinding,
kind: kind,
pointer: pointer ?? _getNextPointer(),
buttons: buttons,
);
}
/// Creates a gesture with an initial down gesture at a particular point, and
/// returns the [TestGesture] object which you can use to continue the
/// gesture.
///
/// You can use [createGesture] if your gesture doesn't begin with an initial
/// down gesture.
Future<TestGesture> startGesture(
Offset downLocation, {
int pointer,
PointerDeviceKind kind = PointerDeviceKind.touch,
int buttons = kPrimaryButton,
}) async {
final TestGesture result = await createGesture(
pointer: pointer,
kind: kind,
buttons: buttons,
);
await result.down(downLocation);
return result;
}
/// Forwards the given location to the binding's hitTest logic.
HitTestResult hitTestOnBinding(Offset location) {
final HitTestResult result = HitTestResult();
binding.hitTest(result, location);
return result;
}
/// Forwards the given pointer event to the binding.
Future<void> sendEventToBinding(PointerEvent event, HitTestResult result) {
return TestAsyncUtils.guard<void>(() async {
binding.dispatchEvent(event, result);
});
}
// GEOMETRY
/// Returns the point at the center of the given widget.
Offset getCenter(Finder finder) {
return _getElementPoint(finder, (Size size) => size.center(Offset.zero));
}
/// Returns the point at the top left of the given widget.
Offset getTopLeft(Finder finder) {
return _getElementPoint(finder, (Size size) => Offset.zero);
}
/// Returns the point at the top right of the given widget. This
/// point is not inside the object's hit test area.
Offset getTopRight(Finder finder) {
return _getElementPoint(finder, (Size size) => size.topRight(Offset.zero));
}
/// Returns the point at the bottom left of the given widget. This
/// point is not inside the object's hit test area.
Offset getBottomLeft(Finder finder) {
return _getElementPoint(finder, (Size size) => size.bottomLeft(Offset.zero));
}
/// Returns the point at the bottom right of the given widget. This
/// point is not inside the object's hit test area.
Offset getBottomRight(Finder finder) {
return _getElementPoint(finder, (Size size) => size.bottomRight(Offset.zero));
}
Offset _getElementPoint(Finder finder, Offset sizeToPoint(Size size)) {
TestAsyncUtils.guardSync();
final Element element = finder.evaluate().single;
final RenderBox box = element.renderObject;
assert(box != null);
return box.localToGlobal(sizeToPoint(box.size));
}
/// Returns the size of the given widget. This is only valid once
/// the widget's render object has been laid out at least once.
Size getSize(Finder finder) {
TestAsyncUtils.guardSync();
final Element element = finder.evaluate().single;
final RenderBox box = element.renderObject;
assert(box != null);
return box.size;
}
/// Returns the rect of the given widget. This is only valid once
/// the widget's render object has been laid out at least once.
Rect getRect(Finder finder) => getTopLeft(finder) & getSize(finder);
}
/// Variant of [WidgetController] that can be used in tests running
/// on a device.
///
/// This is used, for instance, by [FlutterDriver].
class LiveWidgetController extends WidgetController {
/// Creates a widget controller that uses the given binding.
LiveWidgetController(WidgetsBinding binding) : super(binding);
@override
Future<void> pump(Duration duration) async {
if (duration != null)
await Future<void>.delayed(duration);
binding.scheduleFrame();
await binding.endOfFrame;
}
}
You can’t perform that action at this time.