From 313411c311a6a3c2d36e12abf16bdd27ae801f29 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 12 Oct 2023 11:23:01 +0200 Subject: [PATCH] feat: Add `IgnoreEvents` mixin to ignore events for the whole subtree (#2811) Currently the event system is quite inefficient when you have a lot of components since it goes through the whole component tree, with this mixin you can avoid traversing down sub trees that you know don't handle any events. I'll add docs as soon as #2809 is merged to avoid merge conflicts. --- doc/flame/inputs/inputs.md | 4 +- doc/flame/inputs/other_inputs.md | 13 +- packages/flame/lib/components.dart | 1 + .../lib/src/components/core/component.dart | 7 +- .../src/components/mixins/ignore_events.dart | 15 ++ .../component_mixins/ignore_events_test.dart | 144 ++++++++++++++++++ 6 files changed, 180 insertions(+), 4 deletions(-) create mode 100644 packages/flame/lib/src/components/mixins/ignore_events.dart create mode 100644 packages/flame/test/events/component_mixins/ignore_events_test.dart diff --git a/doc/flame/inputs/inputs.md b/doc/flame/inputs/inputs.md index 0cffbae2c2b..719277e7f89 100644 --- a/doc/flame/inputs/inputs.md +++ b/doc/flame/inputs/inputs.md @@ -1,10 +1,10 @@ # Inputs +- [Tap Events](tap_events.md) - [Drag Events](drag_events.md) - [Gesture Input](gesture_input.md) - [Keyboard Input](keyboard_input.md) -- [Other Inputs](other_inputs.md) -- [Tap Events](tap_events.md) +- [Other Inputs and Helpers](other_inputs.md) - [Hardware Keyboard Detector](hardware_keyboard_detector.md) ```{toctree} diff --git a/doc/flame/inputs/other_inputs.md b/doc/flame/inputs/other_inputs.md index 8f2427189cb..8599dbbd2c3 100644 --- a/doc/flame/inputs/other_inputs.md +++ b/doc/flame/inputs/other_inputs.md @@ -1,4 +1,4 @@ -# Other Inputs +# Other Inputs and Helpers This includes documentation for input methods besides keyboard and mouse. @@ -172,3 +172,14 @@ In addition to the already existing skins, the [ToggleButtonComponent] contains - `hoverAndSelectedSkin`: Hover on selectable and selected button (desktop and web). - `disabledAndSelectedSkin`: For when the button is selected and in the disabled state. - `defaultSelectedLabel`: Component shown on top of the skins when button is selected. + + +## IgnoreEvents mixin + +If you don't want a component subtree to receive events, you can use the `IgnoreEvents` mixin. +Once you have added this mixin you can turn off events to reach a component and its descendants by +setting `ignoreEvents = true` (default when the mixin is added), and then set it to `false` when you +want to receive events again. + +This can be done for optimization purposes, since all events currently go through the whole +component tree. diff --git a/packages/flame/lib/components.dart b/packages/flame/lib/components.dart index c5a3b1b0408..a92e40f9004 100644 --- a/packages/flame/lib/components.dart +++ b/packages/flame/lib/components.dart @@ -29,6 +29,7 @@ export 'src/components/mixins/has_paint.dart'; export 'src/components/mixins/has_time_scale.dart'; export 'src/components/mixins/has_visibility.dart'; export 'src/components/mixins/has_world.dart'; +export 'src/components/mixins/ignore_events.dart'; export 'src/components/mixins/keyboard_handler.dart'; export 'src/components/mixins/notifier.dart'; export 'src/components/mixins/parent_is_a.dart'; diff --git a/packages/flame/lib/src/components/core/component.dart b/packages/flame/lib/src/components/core/component.dart index 87182cde377..e382f4cb2f5 100644 --- a/packages/flame/lib/src/components/core/component.dart +++ b/packages/flame/lib/src/components/core/component.dart @@ -707,6 +707,9 @@ class Component { nestedPoints?.add(point); if (_children != null) { for (final child in _children!.reversed()) { + if (child is IgnoreEvents && child.ignoreEvents) { + continue; + } Vector2? childPoint = point; if (child is CoordinateTransform) { childPoint = (child as CoordinateTransform).parentToLocal(point); @@ -716,7 +719,9 @@ class Component { } } } - if (containsLocalPoint(point)) { + final shouldIgnoreEvents = + this is IgnoreEvents && (this as IgnoreEvents).ignoreEvents; + if (containsLocalPoint(point) && !shouldIgnoreEvents) { yield this; } nestedPoints?.removeLast(); diff --git a/packages/flame/lib/src/components/mixins/ignore_events.dart b/packages/flame/lib/src/components/mixins/ignore_events.dart new file mode 100644 index 00000000000..eb23abea36e --- /dev/null +++ b/packages/flame/lib/src/components/mixins/ignore_events.dart @@ -0,0 +1,15 @@ +import 'package:flame/src/components/core/component.dart'; + +/// This mixin allows a component and all it's descendants to ignore events. +/// +/// Do note that this will also ignore the component and its descendants in +/// calls to [Component.componentsAtPoint]. +/// +/// If you want to dynamically use this mixin, you can add it and set +/// [ignoreEvents] true or false at runtime. +/// +/// This mixin is to be used when you have a large subtree of components that +/// shouldn't receive any events and you want to optimize the event handling. +mixin IgnoreEvents on Component { + bool ignoreEvents = true; +} diff --git a/packages/flame/test/events/component_mixins/ignore_events_test.dart b/packages/flame/test/events/component_mixins/ignore_events_test.dart new file mode 100644 index 00000000000..f4c7c01821d --- /dev/null +++ b/packages/flame/test/events/component_mixins/ignore_events_test.dart @@ -0,0 +1,144 @@ +import 'package:flame/components.dart'; +import 'package:flame/events.dart'; +import 'package:flame/src/events/flame_game_mixins/multi_tap_dispatcher.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('IgnoreEvents', () { + testWithFlameGame( + 'correctly ignores events all the way down the subtree', + (game) async { + final grandChild = _IgnoreTapCallbacksComponent(); + final child = _IgnoreTapCallbacksComponent(children: [grandChild]); + final component = _IgnoreTapCallbacksComponent( + position: Vector2.all(10), + children: [child], + ); + + await game.ensureAdd(component); + final dispatcher = game.firstChild()!; + + dispatcher.onTapDown( + createTapDownEvents( + game: game, + localPosition: const Offset(12, 12), + globalPosition: const Offset(12, 12), + ), + ); + expect(component.tapDownEvent, equals(0)); + expect(component.tapUpEvent, equals(0)); + expect(component.tapCancelEvent, equals(0)); + expect(child.tapDownEvent, equals(0)); + expect(child.tapUpEvent, equals(0)); + expect(child.tapCancelEvent, equals(0)); + expect(grandChild.tapDownEvent, equals(0)); + expect(grandChild.tapUpEvent, equals(0)); + expect(grandChild.tapCancelEvent, equals(0)); + + // [onTapUp] will call, if there was an [onTapDown] event before + dispatcher.onTapUp( + createTapUpEvents( + game: game, + localPosition: const Offset(12, 12), + globalPosition: const Offset(12, 12), + ), + ); + + expect(component.tapDownEvent, equals(0)); + expect(component.tapUpEvent, equals(0)); + expect(component.tapCancelEvent, equals(0)); + expect(child.tapDownEvent, equals(0)); + expect(child.tapUpEvent, equals(0)); + expect(child.tapCancelEvent, equals(0)); + expect(grandChild.tapDownEvent, equals(0)); + expect(grandChild.tapUpEvent, equals(0)); + expect(grandChild.tapCancelEvent, equals(0)); + }, + ); + + testWithFlameGame( + 'correctly accepts events all the way down the subtree when ignoreEvents ' + 'is false', + (game) async { + final grandChild = _IgnoreTapCallbacksComponent()..ignoreEvents = false; + final child = _IgnoreTapCallbacksComponent(children: [grandChild]) + ..ignoreEvents = false; + final component = _IgnoreTapCallbacksComponent( + position: Vector2.all(10), + children: [child], + )..ignoreEvents = false; + + await game.ensureAdd(component); + final dispatcher = game.firstChild()!; + + dispatcher.onTapDown( + createTapDownEvents( + game: game, + localPosition: const Offset(12, 12), + globalPosition: const Offset(12, 12), + ), + ); + expect(component.tapDownEvent, equals(1)); + expect(component.tapUpEvent, equals(0)); + expect(component.tapCancelEvent, equals(0)); + expect(child.tapDownEvent, equals(1)); + expect(child.tapUpEvent, equals(0)); + expect(child.tapCancelEvent, equals(0)); + expect(grandChild.tapDownEvent, equals(1)); + expect(grandChild.tapUpEvent, equals(0)); + expect(grandChild.tapCancelEvent, equals(0)); + + // [onTapUp] will call, if there was an [onTapDown] event before + dispatcher.onTapUp( + createTapUpEvents( + game: game, + localPosition: const Offset(12, 12), + globalPosition: const Offset(12, 12), + ), + ); + + expect(component.tapDownEvent, equals(1)); + expect(component.tapUpEvent, equals(1)); + expect(component.tapCancelEvent, equals(0)); + expect(child.tapDownEvent, equals(1)); + expect(child.tapUpEvent, equals(1)); + expect(child.tapCancelEvent, equals(0)); + expect(grandChild.tapDownEvent, equals(1)); + expect(grandChild.tapUpEvent, equals(1)); + expect(grandChild.tapCancelEvent, equals(0)); + }, + ); + }); +} + +mixin _TapCounter on TapCallbacks { + int tapDownEvent = 0; + int tapUpEvent = 0; + int longTapDownEvent = 0; + int tapCancelEvent = 0; + + @override + void onTapDown(TapDownEvent event) { + event.continuePropagation = true; + tapDownEvent++; + } + + @override + void onTapUp(TapUpEvent event) { + event.continuePropagation = true; + tapUpEvent++; + } + + @override + void onTapCancel(TapCancelEvent event) { + event.continuePropagation = true; + tapCancelEvent++; + } +} + +class _IgnoreTapCallbacksComponent extends PositionComponent + with TapCallbacks, _TapCounter, IgnoreEvents { + _IgnoreTapCallbacksComponent({super.position, super.children}) + : super(size: Vector2.all(10)); +}