Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add drag until visible #59

Merged
merged 7 commits into from
Jun 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 117 additions & 22 deletions lib/src/act/act.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spot/spot.dart';
import 'package:spot/src/act/gestures.dart';
import 'package:spot/src/spot/snapshot.dart';

/// Top level entry point to interact with widgets on the screen.
Expand Down Expand Up @@ -76,33 +77,17 @@ class Act {

return TestAsyncUtils.guard<void>(() async {
return _alwaysPropagateDevicePointerEvents(() async {
// Find the associated RenderObject to get the position of the element on the screen
final element = snapshot.discoveredElement!;
final renderObject = element.renderObject;
if (renderObject == null) {
throw TestFailure(
"Widget '${selector.toStringBreadcrumb()}' has no associated RenderObject.\n"
"Spot does not know where the widget is located on the screen.",
);
}
if (renderObject is! RenderBox) {
throw TestFailure(
"Widget '${selector.toStringBreadcrumb()}' is associated to $renderObject which "
"is not a RenderObject in the 2D Cartesian coordinate system "
"(implements RenderBox).\n"
"Spot does not know how to hit test such a widget.",
);
}
_validateViewBounds(renderObject, selector: selector);
final renderBox = _getRenderBoxOrThrow(selector);
_validateViewBounds(renderBox, selector: selector);

final centerPosition =
renderObject.localToGlobal(renderObject.size.center(Offset.zero));
renderBox.localToGlobal(renderBox.size.center(Offset.zero));

// Before tapping the widget, we need to make sure that the widget is not
// covered by another widget, or outside the viewport.
_pokeRenderObject(
position: centerPosition,
target: renderObject,
target: renderBox,
snapshot: snapshot,
);

Expand All @@ -120,10 +105,118 @@ class Act {
});
}

/// Repeatedly drags at the position of `dragStart` by `moveStep` until `dragTarget` is visible.
///
/// Between each drag, advances the clock by `duration`.
///
/// Throws a [TestFailure] if `dragTarget` is not found after `maxIteration`
/// drags.
///
/// usage:
/// ```dart
/// final firstItem = spotText('Item at index: 3', exact: true)..existsOnce();
/// final secondItem = spotText('Item at index: 27', exact: true)..doesNotExist();
/// await act.dragUntilVisible(
/// dragStart: firstItem,
/// dragTarget: secondItem,
/// maxIteration: 30,
/// moveStep: const Offset(0, -100),
/// );
/// secondItem.existsOnce();
/// ```
Future<void> dragUntilVisible({
required WidgetSelector<Widget> dragStart,
required WidgetSelector<Widget> dragTarget,
required Offset moveStep,
int maxIteration = 50,
Duration duration = const Duration(milliseconds: 50),
}) {
// Check if widget is in the widget tree. Throws if not.
dragStart.snapshot().existsOnce();

return TestAsyncUtils.guard<void>(() async {
return _alwaysPropagateDevicePointerEvents(() async {
final renderBox = _getRenderBoxOrThrow(dragStart);

final binding = TestWidgetsFlutterBinding.instance;

bool isTargetVisible() {
final renderObject = _renderObjectFromSelector(dragTarget);
if (renderObject is RenderBox) {
return _validateViewBounds(
renderObject,
selector: dragTarget,
throwIfInvisible: false,
);
} else {
return false;
}
}

final dragPosition =
renderBox.localToGlobal(renderBox.size.center(Offset.zero));

final targetName = dragTarget.toStringBreadcrumb();

bool isVisible = isTargetVisible();

if (isVisible) {
return;
}

int iterations = 0;
while (iterations < maxIteration && !isVisible) {
await gestures.drag(dragPosition, moveStep);
await binding.pump(duration);
iterations++;
isVisible = isTargetVisible();
}

final totalDragged = moveStep * iterations.toDouble();

if (!isVisible) {
throw TestFailure(
"$targetName is not visible after dragging $iterations times and a total dragged offset of $totalDragged.",
);
}
});
});
}

/// Returns the `RenderBox` of a widget based on the given selector.
/// Throws `TestFailure` if the widget's render object is null or not a `RenderBox`.
RenderBox _getRenderBoxOrThrow(WidgetSelector<Widget> selector) {
final renderObject = _renderObjectFromSelector(selector);
if (renderObject == null) {
throw TestFailure(
"Widget '${selector.toStringBreadcrumb()}' has no associated RenderObject.\n"
"Spot does not know where the widget is located on the screen.",
);
}
if (renderObject is! RenderBox) {
throw TestFailure(
"Widget '${selector.toStringBreadcrumb()}' is associated to $renderObject which "
"is not a RenderObject in the 2D Cartesian coordinate system "
"(implements RenderBox).\n"
"Spot does not know how to hit test such a widget.",
);
}
return renderObject;
}

/// Returns the `RenderObject` of a widget based on the given selector.
/// Returns `null` if the widget's render object is null.
RenderObject? _renderObjectFromSelector(WidgetSelector<Widget> selector) {
final snapshot = selector.snapshot();
final element = snapshot.discoveredElement;
return element?.renderObject;
}

// Validates that the widget is at least partially visible in the viewport.
void _validateViewBounds(
bool _validateViewBounds(
RenderBox renderBox, {
required WidgetSelector selector,
bool throwIfInvisible = true,
}) {
// ignore: deprecated_member_use
final view = WidgetsBinding.instance.renderView;
Expand All @@ -132,11 +225,13 @@ class Act {
renderBox.localToGlobal(Offset.zero) & renderBox.paintBounds.size;

final intersection = viewport.intersect(location);
if (intersection.width < 0 || intersection.height < 0) {
final isNotVisible = intersection.width < 0 || intersection.height < 0;
if (isNotVisible && throwIfInvisible) {
throw TestFailure(
"Widget '${selector.toStringBreadcrumb()}' is located outside the viewport ($location).",
);
}
return !isNotVisible;
// TODO handle case when location is partially outside viewport
// TODO what if the center is outside the viewport, should we move the touch location or error?
}
Expand Down
218 changes: 218 additions & 0 deletions lib/src/act/gestures.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import 'package:flutter/gestures.dart';
import 'package:flutter_test/flutter_test.dart';

/// Top level entry point for gestures on the screen.
const gestures = Gestures._();

/// A class that provides methods for simulating gestures in tests.
class Gestures {
const Gestures._();

/// 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 get nextPointer => _nextPointer;

static int _nextPointer = 1;

static int _getNextPointer() {
final int result = _nextPointer;
_nextPointer += 1;
return result;
}

/// Attempts to drag the given widget by the given offset, by
/// starting a drag in the middle of the widget.
///
/// By default, if the x or y component of offset is greater than
/// [kDragSlopDefault], 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.
Future<void> drag(
Offset position,
Offset offset, {
int? pointer,
int buttons = kPrimaryButton,
double touchSlopX = kDragSlopDefault,
double touchSlopY = kDragSlopDefault,
bool warnIfMissed = true,
PointerDeviceKind kind = PointerDeviceKind.touch,
}) {
return dragFrom(
position,
offset,
pointer: pointer,
buttons: buttons,
touchSlopX: touchSlopX,
touchSlopY: touchSlopY,
kind: kind,
);
}

/// Attempts a drag gesture consisting of a pointer down, a move by
/// the given offset, and a pointer up.
Future<void> dragFrom(
Offset startLocation,
Offset offset, {
int? pointer,
int buttons = kPrimaryButton,
double touchSlopX = kDragSlopDefault,
double touchSlopY = kDragSlopDefault,
PointerDeviceKind kind = PointerDeviceKind.touch,
}) {
assert(kDragSlopDefault > kTouchSlop);
return TestAsyncUtils.guard<void>(() async {
final TestGesture gesture = await startGesture(
startLocation,
pointer: pointer,
buttons: buttons,
kind: kind,
);

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();
});
}

/// Creates a gesture with an initial appropriate starting gesture at a
/// particular point, and returns the [TestGesture] object which you can use
/// to continue the gesture. Usually, the starting gesture will be a down event,
/// but if [kind] is set to [PointerDeviceKind.trackpad], the gesture will start
/// with a panZoomStart gesture.
///
/// You can use [createGesture] if your gesture doesn't begin with an initial
/// down or panZoomStart gesture.
///
/// See also:
/// * [WidgetController.drag], a method to simulate a drag.
/// * [WidgetController.timedDrag], a method to simulate the drag of a given
/// widget in a given duration. It sends move events at a given frequency and
/// it is useful when there are listeners involved.
/// * [WidgetController.fling], a method to simulate a fling.
Future<TestGesture> startGesture(
Offset downLocation, {
int? pointer,
PointerDeviceKind kind = PointerDeviceKind.touch,
int buttons = kPrimaryButton,
}) async {
final TestGesture result =
_createGesture(pointer: pointer, kind: kind, buttons: buttons);
if (kind == PointerDeviceKind.trackpad) {
await result.panZoomStart(downLocation);
} else {
await result.down(downLocation);
}
return result;
}

TestGesture _createGesture({
int? pointer,
required PointerDeviceKind kind,
required int buttons,
}) {
return TestGesture(
dispatcher: sendEventToBinding,
kind: kind,
pointer: pointer ?? _getNextPointer(),
buttons: buttons,
);
}

/// Forwards the given pointer event to the binding.
Future<void> sendEventToBinding(PointerEvent event) {
return TestAsyncUtils.guard<void>(() async {
final binding = TestWidgetsFlutterBinding.instance;
binding.handlePointerEvent(event);
});
}
}
Loading
Loading