feat: flutter hit test self on rive render object

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 <pggordonhayes@gmail.com>
This commit is contained in:
HayesGordon
2023-12-18 09:00:21 +00:00
parent 3b03b0565e
commit 07c6d25de0
11 changed files with 327 additions and 5 deletions

View File

@ -1 +1 @@
423366fb78e2370b998c9015b4bda621d0047ace
95beaa4f50086409b35317a34cf9c2b341e468c5

Binary file not shown.

View File

@ -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

View File

@ -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;
@ -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.
///
@ -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,
@ -92,7 +120,8 @@ class Rive extends LeafRenderObjectWidget {
..clipRect = clipRect
..tickerModeEnabled = tickerModeValue
..enableHitTests = enablePointerEvents
..cursor = cursor;
..cursor = cursor
..behavior = behavior;
}
@override
@ -109,7 +138,8 @@ class Rive extends LeafRenderObjectWidget {
..clipRect = clipRect
..tickerModeEnabled = tickerModeValue
..enableHitTests = enablePointerEvents
..cursor = cursor;
..cursor = cursor
..behavior = behavior;
}
}
@ -117,6 +147,7 @@ class RiveRenderObject extends RiveRenderBox implements MouseTrackerAnnotation {
RuntimeArtboard _artboard;
RiveRenderObject(
this._artboard, {
this.behavior = RiveHitTestBehavior.opaque,
MouseCursor cursor = MouseCursor.defer,
bool validForMouseTracker = true,
}) : _cursor = cursor,
@ -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));
@ -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);

View File

@ -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);
@ -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);

View File

@ -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,

View File

@ -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);

View File

@ -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:*
@ -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,
@ -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,
@ -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,
@ -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,
@ -327,6 +334,7 @@ class RiveAnimationState extends State<RiveAnimation> {
useArtboardSize: widget.useArtboardSize,
clipRect: widget.clipRect,
enablePointerEvents: _shouldAddHitTesting,
behavior: widget.behavior,
)
: widget.placeHolder ?? const SizedBox();
}

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a170d016dde9426d0ba45626de1879c2424a0dbc887fa65513a274b9e7d3cf9a
size 202

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2b96d1eb4564c2a0fe4d3dde81154317c10e92bd07a1c93c7e35a5cd3996f1ec
size 121

158
test/hit_test.dart Normal file
View File

@ -0,0 +1,158 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:rive/rive.dart';
import 'src/utils.dart';
/// `hit_test_pass_through.riv` does not have any listeners. Only a
/// [RiveHitTestBehavior] of type `opaque` should block content underneath.
///
/// `hit_test_consumer.riv` has a listener that covers the entire artboard.
/// Only a [RiveHitTestBehavior] of type `transparent` should allow hits
/// for content underneath.
void main() {
testWidgets('Hit test pass through artboard to widget beneath',
(tester) async {
final riveBytes = loadFile('assets/hit_test_pass_through.riv');
final riveFile = RiveFile.import(riveBytes);
int count = 0;
await tester.pumpWidget(HitTestWidget(
file: riveFile,
behavior: RiveHitTestBehavior.translucent,
onTap: () {
count++;
},
));
await tester.pumpAndSettle();
final titleFinder = find.text(textButtonTitle);
await tester.tap(titleFinder);
expect(count, 1);
});
testWidgets('Hit test blocked by default opaque behavior', (tester) async {
final riveBytes = loadFile('assets/hit_test_pass_through.riv');
final riveFile = RiveFile.import(riveBytes);
int count = 0;
await tester.pumpWidget(HitTestWidget(
file: riveFile,
behavior: RiveHitTestBehavior.opaque, // default
onTap: () {
count++;
},
));
await tester.pumpAndSettle();
final titleFinder = find.text(textButtonTitle);
await tester.tap(titleFinder);
expect(count, 0);
});
testWidgets('Hit test artboard consume hit with opaque', (tester) async {
final riveBytes = loadFile('assets/hit_test_consume.riv');
final riveFile = RiveFile.import(riveBytes);
int count = 0;
await tester.pumpWidget(HitTestWidget(
file: riveFile,
behavior: RiveHitTestBehavior.opaque,
onTap: () {
count++;
},
));
await tester.pumpAndSettle();
final titleFinder = find.text(textButtonTitle);
await tester.tap(titleFinder);
expect(count, 0);
});
testWidgets('Hit test artboard consume hit with translucent', (tester) async {
final riveBytes = loadFile('assets/hit_test_consume.riv');
final riveFile = RiveFile.import(riveBytes);
int count = 0;
await tester.pumpWidget(HitTestWidget(
file: riveFile,
behavior: RiveHitTestBehavior.translucent,
onTap: () {
count++;
},
));
await tester.pumpAndSettle();
final titleFinder = find.text(textButtonTitle);
await tester.tap(titleFinder);
expect(count, 0);
});
testWidgets('Hit test artboard pass through transparent behavior',
(tester) async {
final riveBytes = loadFile('assets/hit_test_consume.riv');
final riveFile = RiveFile.import(riveBytes);
int count = 0;
await tester.pumpWidget(HitTestWidget(
file: riveFile,
behavior: RiveHitTestBehavior.transparent,
onTap: () {
count++;
},
));
await tester.pumpAndSettle();
final titleFinder = find.text(textButtonTitle);
await tester.tap(titleFinder);
expect(count, 1);
});
}
const textButtonTitle = "Widget to click";
class HitTestWidget extends StatelessWidget {
const HitTestWidget({
required this.file,
required this.onTap,
required this.behavior,
super.key,
});
final RiveFile file;
final VoidCallback onTap;
final RiveHitTestBehavior behavior;
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: SizedBox(
width: 500,
height: 500,
child: Stack(
children: [
Center(
child: GestureDetector(
onTap: onTap,
child: const Text(textButtonTitle),
),
),
RiveAnimation.direct(
file,
stateMachines: const ['State Machine 1'],
fit: BoxFit.cover,
behavior: behavior,
),
],
),
),
),
);
}
}