Skip to content

Commit

Permalink
Add drag until visible (#59)
Browse files Browse the repository at this point in the history
  • Loading branch information
danielmolnar authored Jun 19, 2024
1 parent 8874c76 commit 387b49e
Show file tree
Hide file tree
Showing 3 changed files with 434 additions and 22 deletions.
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

0 comments on commit 387b49e

Please sign in to comment.