Skip to content

Commit

Permalink
feat: flutter hit test self on rive render object
Browse files Browse the repository at this point in the history
This PR adds hit testing to Flutter by overriding `hitTestSelf` on the Rive RenderObject.

Currently, the hit area is the entire bounding box of the canvas. This means that when a Rive animation is rendered above any other Flutter content (for example, a Stack) all hits are absorbed by Rive and do not pass through.

With this change, Rive will only absorb hits if the pointer comes in contact with a hittable Rive element.

With this change, `handleEvent` will only be called if `hitTestSelf` returns true. There is some duplicate work here as `_processEvent` already performs similar hit test logic, which we can look at optimizing. But `hitTest` needed to be separate method call, as `hitTestSelf` is called before `handleEvent` and `handleEvent` sends additional information (whether it's a pointer down/up etc.).

Diffs=
95beaa4f5 feat: add flutter hit test self on rive render object (#6341)
bd71143bc chore: fix broken docs link (#6360)

Co-authored-by: Gordon <[email protected]>
  • Loading branch information
HayesGordon and HayesGordon committed Dec 18, 2023
1 parent 3b03b05 commit 07c6d25
Show file tree
Hide file tree
Showing 11 changed files with 327 additions and 5 deletions.
2 changes: 1 addition & 1 deletion .rive_head
Original file line number Diff line number Diff line change
@@ -1 +1 @@
423366fb78e2370b998c9015b4bda621d0047ace
95beaa4f50086409b35317a34cf9c2b341e468c5
Binary file added example/assets/hit_test_consume.riv
Binary file not shown.
4 changes: 2 additions & 2 deletions example/macos/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,9 @@ SPEC CHECKSUMS:
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
just_audio: 9b67ca7b97c61cfc9784ea23cd8cc55eb226d489
path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943
rive_common: acedcab7802c0ece4b0d838b71d7deb637e1309a
rive_common: 0f0aadf670f0c6a7872dfe3e6186f112a5319108
url_launcher_macos: d2691c7dd33ed713bf3544850a623080ec693d95

PODFILE CHECKSUM: 353c8bcc5d5b0994e508d035b5431cfe18c1dea7

COCOAPODS: 1.12.1
COCOAPODS: 1.11.3
82 changes: 80 additions & 2 deletions lib/src/rive.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,23 @@ import 'package:rive/src/rive_render_box.dart';
import 'package:rive/src/runtime_artboard.dart';
import 'package:rive_common/math.dart';

/// How to behave during hit tests on Rive Listeners (hit targets).
enum RiveHitTestBehavior {
/// The bounds of the Rive animation will consume all hits, even if there is
/// no animation listener (hit area) at the target point. Content
/// behind the animation will not receive hits.
opaque,

/// Rive will only consume hits where there is a listener (hit area) at the
/// target point. Content behind the animation will only receive hits if
/// no animation listener was hit.
translucent,

/// All hits will pass through the animation, regardless of whether a
/// a Rive listener was hit. Rive listeners will still receive hits.
transparent,
}

class Rive extends LeafRenderObjectWidget {
/// Artboard used for drawing
final Artboard artboard;
Expand Down Expand Up @@ -58,6 +75,16 @@ class Rive extends LeafRenderObjectWidget {
/// cursor to the next region behind it in hit-test order.
final MouseCursor cursor;

/// {@template Rive.behavior}
/// How to behave during hit testing to consider targets behind this
/// animation.
///
/// Defaults to [RiveHitTestBehavior.opaque].
///
/// See [RiveHitTestBehavior] for the allowed values and their meanings.
/// {@endtemplate}
final RiveHitTestBehavior behavior;

/// {@template Rive.clipRect}
/// Clip the artboard to this rect.
///
Expand All @@ -73,6 +100,7 @@ class Rive extends LeafRenderObjectWidget {
this.antialiasing = true,
this.enablePointerEvents = false,
this.cursor = MouseCursor.defer,
this.behavior = RiveHitTestBehavior.opaque,
BoxFit? fit,
Alignment? alignment,
this.clipRect,
Expand All @@ -92,7 +120,8 @@ class Rive extends LeafRenderObjectWidget {
..clipRect = clipRect
..tickerModeEnabled = tickerModeValue
..enableHitTests = enablePointerEvents
..cursor = cursor;
..cursor = cursor
..behavior = behavior;
}

@override
Expand All @@ -109,14 +138,16 @@ class Rive extends LeafRenderObjectWidget {
..clipRect = clipRect
..tickerModeEnabled = tickerModeValue
..enableHitTests = enablePointerEvents
..cursor = cursor;
..cursor = cursor
..behavior = behavior;
}
}

class RiveRenderObject extends RiveRenderBox implements MouseTrackerAnnotation {
RuntimeArtboard _artboard;
RiveRenderObject(
this._artboard, {
this.behavior = RiveHitTestBehavior.opaque,
MouseCursor cursor = MouseCursor.defer,
bool validForMouseTracker = true,
}) : _cursor = cursor,
Expand Down Expand Up @@ -153,6 +184,50 @@ class RiveRenderObject extends RiveRenderBox implements MouseTrackerAnnotation {
}
}

@override
bool hitTest(BoxHitTestResult result, {required Offset position}) {
// super.hitTest(result, position: position)
bool hitTarget = false;
if (size.contains(position)) {
hitTarget = hitTestSelf(position);
if (hitTarget) {
// if hit add to results
result.add(BoxHitTestEntry(this, position));
}
}

// Let the hit continue to targets behind the animation.
if (behavior == RiveHitTestBehavior.transparent) {
return false;
}

// Opaque will always return true, translucent will return true if we
// hit a Rive listener target.
return hitTarget;
}

@override
bool hitTestSelf(Offset screenOffset) {
switch (behavior) {
case RiveHitTestBehavior.opaque:
return true; // Always hit
case RiveHitTestBehavior.translucent:
case RiveHitTestBehavior.transparent:
{
// test to see if any Rive animation listeners were hit
final artboardPosition = _toArtboard(screenOffset);
final stateMachineControllers = _artboard.animationControllers
.whereType<StateMachineController>();
for (final stateMachineController in stateMachineControllers) {
if (stateMachineController.hitTest(artboardPosition)) {
return true;
}
}
}
}
return false;
}

@override
void handleEvent(PointerEvent event, HitTestEntry entry) {
assert(debugHandleEvent(event, entry));
Expand Down Expand Up @@ -225,6 +300,9 @@ class RiveRenderObject extends RiveRenderBox implements MouseTrackerAnnotation {
bool get validForMouseTracker => _validForMouseTracker;
bool _validForMouseTracker;

/// {@macro Rive.behavior}
RiveHitTestBehavior behavior;

@override
void attach(PipelineOwner owner) {
super.attach(owner);
Expand Down
5 changes: 5 additions & 0 deletions lib/src/rive_core/animation/nested_state_machine.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ abstract class NestedStateMachineInstance {

void apply(covariant MountedArtboard artboard, double elapsedSeconds);

bool hitTest(Vec2D position);

void pointerMove(Vec2D position);

void pointerDown(Vec2D position, PointerDownEvent event);
Expand Down Expand Up @@ -74,6 +76,9 @@ class NestedStateMachine extends NestedStateMachineBase {
_stateMachineInstance?.setInputValue(inputId, value);
}

bool hitTest(Vec2D position) =>
_stateMachineInstance?.hitTest(position) ?? false;

void pointerMove(Vec2D position) =>
_stateMachineInstance?.pointerMove(position);

Expand Down
64 changes: 64 additions & 0 deletions lib/src/rive_core/state_machine_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,70 @@ class StateMachineController extends RiveAnimationController<CoreContext>
return hitSomething;
}

/// Hit testing. If any listeners were hit, returns true.
bool hitTest(
Vec2D position, {
PointerEvent? pointerEvent,
ListenerType? hitEvent,
}) {
var artboard = this.artboard;
if (artboard == null) {
return false;
}
if (artboard.frameOrigin) {
// ignore: parameter_assignments
position = position -
Vec2D.fromValues(
artboard.width * artboard.originX,
artboard.height * artboard.originY,
);
}
const hitRadius = 2;
var hitArea = IAABB(
(position.x - hitRadius).round(),
(position.y - hitRadius).round(),
(position.x + hitRadius).round(),
(position.y + hitRadius).round(),
);

for (final hitShape in hitShapes) {
var shape = hitShape.shape;
var bounds = shape.worldBounds;

// Quick reject
bool isOver = false;
if (bounds.contains(position)) {
// Make hit tester.
var hitTester = TransformingHitTester(hitArea);
shape.fillHitTester(hitTester);

isOver = hitTester.test();
if (isOver) {
return true; // exit early
}
}
}

for (final nestedArtboard in hitNestedArtboards) {
if (nestedArtboard.isCollapsed) {
continue;
}
var nestedPosition = nestedArtboard.worldToLocal(position);
if (nestedPosition == null) {
// Mounted artboard isn't ready or has a 0 scale transform.
continue;
}
for (final nestedStateMachine
in nestedArtboard.animations.whereType<NestedStateMachine>()) {
if (nestedStateMachine.hitTest(nestedPosition)) {
return true; // exit early
}
}
}

return false; // no hit targets found
}

void pointerMove(Vec2D position) => _processEvent(
position,
hitEvent: ListenerType.move,
Expand Down
3 changes: 3 additions & 0 deletions lib/src/runtime_nested_artboard.dart
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,9 @@ class RuntimeNestedStateMachineInstance extends NestedStateMachineInstance {
ValueListenable<bool> get isActiveChanged =>
stateMachineController.isActiveChanged;

@override
bool hitTest(Vec2D position) => stateMachineController.hitTest(position);

@override
void pointerDown(Vec2D position, PointerDownEvent event) =>
stateMachineController.pointerDown(position, event);
Expand Down
8 changes: 8 additions & 0 deletions lib/src/widgets/rive_animation.dart
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ class RiveAnimation extends StatefulWidget {
/// Headers for network requests
final Map<String, String>? headers;

/// {@macro Rive.behavior}
final RiveHitTestBehavior behavior;

/// Creates a new [RiveAnimation] from an asset bundle.
///
/// *Example:*
Expand All @@ -82,6 +85,7 @@ class RiveAnimation extends StatefulWidget {
this.clipRect,
this.controllers = const [],
this.onInit,
this.behavior = RiveHitTestBehavior.opaque,
Key? key,
}) : name = asset,
file = null,
Expand Down Expand Up @@ -109,6 +113,7 @@ class RiveAnimation extends StatefulWidget {
this.controllers = const [],
this.onInit,
this.headers,
this.behavior = RiveHitTestBehavior.opaque,
Key? key,
}) : name = url,
file = null,
Expand All @@ -134,6 +139,7 @@ class RiveAnimation extends StatefulWidget {
this.clipRect,
this.controllers = const [],
this.onInit,
this.behavior = RiveHitTestBehavior.opaque,
Key? key,
}) : name = path,
file = null,
Expand Down Expand Up @@ -163,6 +169,7 @@ class RiveAnimation extends StatefulWidget {
this.controllers = const [],
this.onInit,
Key? key,
this.behavior = RiveHitTestBehavior.opaque,
}) : name = null,
headers = null,
src = _Source.direct,
Expand Down Expand Up @@ -327,6 +334,7 @@ class RiveAnimationState extends State<RiveAnimation> {
useArtboardSize: widget.useArtboardSize,
clipRect: widget.clipRect,
enablePointerEvents: _shouldAddHitTesting,
behavior: widget.behavior,
)
: widget.placeHolder ?? const SizedBox();
}
3 changes: 3 additions & 0 deletions test/assets/hit_test_consume.riv
Git LFS file not shown
3 changes: 3 additions & 0 deletions test/assets/hit_test_pass_through.riv
Git LFS file not shown
Loading

0 comments on commit 07c6d25

Please sign in to comment.