mirror of
https://github.com/rive-app/rive-flutter
synced 2025-07-05 21:55:58 +00:00
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:
@ -1 +1 @@
|
||||
423366fb78e2370b998c9015b4bda621d0047ace
|
||||
95beaa4f50086409b35317a34cf9c2b341e468c5
|
||||
|
BIN
example/assets/hit_test_consume.riv
Normal file
BIN
example/assets/hit_test_consume.riv
Normal file
Binary file not shown.
@ -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
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
|
@ -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();
|
||||
}
|
||||
|
3
test/assets/hit_test_consume.riv
Normal file
3
test/assets/hit_test_consume.riv
Normal file
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a170d016dde9426d0ba45626de1879c2424a0dbc887fa65513a274b9e7d3cf9a
|
||||
size 202
|
3
test/assets/hit_test_pass_through.riv
Normal file
3
test/assets/hit_test_pass_through.riv
Normal 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
158
test/hit_test.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user