add click event support
this PR adds click support to listeners.
Click is a new event type (like pointer down, pointer move).
It encompasses two stages (pointer down + pointer up) in the same object or group of objects that belong to the listener.
It processes all the phases of a click gesture
this PR also:
- guarantees that the click gesture is applied only once per frame (no double actions from overlapping shapes)
- supports starting the click gesture in one shape and ending it in another shape of the same listener (by promoting the hover state to the group and not on individual shapes)
Diffs=
405ca998b add click event support (#7668)
Co-authored-by: hernan <hernan@rive.app>
diff --git a/.rive_head b/.rive_head
index 4329462..e4ff0e3 100644
--- a/.rive_head
+++ b/.rive_head
@@ -1 +1 @@
-4732c37b5e8afae84ec54d662682380598292ac3
+405ca998b7729cb4f84448fe681e9c4a82243099
diff --git a/include/rive/animation/state_machine_instance.hpp b/include/rive/animation/state_machine_instance.hpp
index a2c8600..093d05b 100644
--- a/include/rive/animation/state_machine_instance.hpp
+++ b/include/rive/animation/state_machine_instance.hpp
@@ -27,6 +27,7 @@
class StateMachineLayerInstance;
class HitComponent;
class HitShape;
+class ListenerGroup;
class NestedArtboard;
class NestedEventListener;
class NestedEventNotifier;
@@ -146,6 +147,7 @@
}
return nullptr;
}
+ const LayerState* layerState(size_t index);
#endif
void updateDataBinds();
@@ -157,6 +159,7 @@
size_t m_layerCount;
StateMachineLayerInstance* m_layers;
std::vector<std::unique_ptr<HitComponent>> m_hitComponents;
+ std::vector<std::unique_ptr<ListenerGroup>> m_listenerGroups;
StateMachineInstance* m_parentStateMachineInstance = nullptr;
NestedArtboard* m_parentNestedArtboard = nullptr;
std::vector<DataBind*> m_dataBinds;
@@ -178,6 +181,7 @@
{}
virtual ~HitComponent() {}
virtual HitResult processEvent(Vec2D position, ListenerType hitType, bool canHit) = 0;
+ virtual void prepareEvent(Vec2D position, ListenerType hitType) = 0;
#ifdef WITH_RIVE_TOOLS
virtual bool hitTest(Vec2D position) const = 0;
#endif
diff --git a/include/rive/gesture_click_phase.hpp b/include/rive/gesture_click_phase.hpp
new file mode 100644
index 0000000..2daf21a
--- /dev/null
+++ b/include/rive/gesture_click_phase.hpp
@@ -0,0 +1,12 @@
+#ifndef _RIVE_GESTURE_CLICK_PHASE_HPP_
+#define _RIVE_GESTURE_CLICK_PHASE_HPP_
+namespace rive
+{
+enum class GestureClickPhase : int
+{
+ out = 0,
+ down = 1,
+ clicked = 2,
+};
+}
+#endif
\ No newline at end of file
diff --git a/include/rive/listener_type.hpp b/include/rive/listener_type.hpp
index d79f68c..7fab809 100644
--- a/include/rive/listener_type.hpp
+++ b/include/rive/listener_type.hpp
@@ -10,6 +10,7 @@
up = 3,
move = 4,
event = 5,
+ click = 6,
};
}
#endif
\ No newline at end of file
diff --git a/src/animation/state_machine_instance.cpp b/src/animation/state_machine_instance.cpp
index 9bbe875..219200f 100644
--- a/src/animation/state_machine_instance.cpp
+++ b/src/animation/state_machine_instance.cpp
@@ -26,6 +26,7 @@
#include "rive/animation/state_machine_fire_event.hpp"
#include "rive/data_bind_flags.hpp"
#include "rive/event_report.hpp"
+#include "rive/gesture_click_phase.hpp"
#include "rive/hit_result.hpp"
#include "rive/math/aabb.hpp"
#include "rive/math/hit_test.hpp"
@@ -190,7 +191,6 @@
{
uint32_t totalWeight = 0;
auto stateFrom = stateFromInstance->state();
- // printf("stateFrom->transitionCount(): %zu\n", stateFrom->transitionCount());
for (size_t i = 0, length = stateFrom->transitionCount(); i < length; i++)
{
auto transition = stateFrom->transition(i);
@@ -425,6 +425,45 @@
float m_holdTime = 0.0f;
};
+class ListenerGroup
+{
+public:
+ ListenerGroup(const StateMachineListener* listener) : m_listener(listener) {}
+ void consume() { m_isConsumed = true; }
+ //
+ void hover() { m_isHovered = true; }
+ void reset()
+ {
+ m_isConsumed = false;
+ m_prevIsHovered = m_isHovered;
+ m_isHovered = false;
+ if (m_clickPhase == GestureClickPhase::clicked)
+ {
+ m_clickPhase = GestureClickPhase::out;
+ }
+ }
+ bool isConsumed() { return m_isConsumed; }
+ bool isHovered() { return m_isHovered; }
+ bool prevHovered() { return m_prevIsHovered; }
+ void clickPhase(GestureClickPhase value) { m_clickPhase = value; }
+ GestureClickPhase clickPhase() { return m_clickPhase; }
+ const StateMachineListener* listener() const { return m_listener; };
+ // A vector storing the previous position for this specific listener gorup
+ Vec2D previousPosition;
+
+private:
+ // Consumed listeners aren't processed again in the current frame
+ bool m_isConsumed = false;
+ // This variable holds the hover status of the the listener itself so it can
+ // be shared between all shapes that target it
+ bool m_isHovered = false;
+ // Variable storing the previous hovered state to check for hover changes
+ bool m_prevIsHovered = false;
+ // A click gesture is composed of three phases and is shared between all shapes
+ GestureClickPhase m_clickPhase = GestureClickPhase::out;
+ const StateMachineListener* m_listener;
+};
+
/// Representation of a Shape from the Artboard Instance and all the listeners it
/// triggers. Allows tracking hover and performing hit detection only once on
/// shapes that trigger multiple listeners.
@@ -444,8 +483,7 @@
bool hasDownListener = false;
bool hasUpListener = false;
float hitRadius = 2;
- Vec2D previousPosition;
- std::vector<const StateMachineListener*> listeners;
+ std::vector<ListenerGroup*> listeners;
bool hitTest(Vec2D position) const
#ifdef WITH_RIVE_TOOLS
@@ -466,6 +504,29 @@
return shape->hitTest(hitArea);
}
+ void prepareEvent(Vec2D position, ListenerType hitType) override
+ {
+ if (canEarlyOut && (hitType != ListenerType::down || !hasDownListener) &&
+ (hitType != ListenerType::up || !hasUpListener))
+ {
+#ifdef TESTING
+ earlyOutCount++;
+#endif
+ return;
+ }
+ isHovered = hitTest(position);
+
+ // // iterate all listeners associated with this hit shape
+ if (isHovered)
+ {
+ for (auto listenerGroup : listeners)
+ {
+
+ listenerGroup->hover();
+ }
+ }
+ }
+
HitResult processEvent(Vec2D position, ListenerType hitType, bool canHit) override
{
// If the shape doesn't have any ListenerType::move / enter / exit and the event
@@ -475,53 +536,90 @@
if (canEarlyOut && (hitType != ListenerType::down || !hasDownListener) &&
(hitType != ListenerType::up || !hasUpListener))
{
-#ifdef TESTING
- earlyOutCount++;
-#endif
return HitResult::none;
}
auto shape = m_component->as<Shape>();
- bool isOver = canHit ? hitTest(position) : false;
- bool hoverChange = isHovered != isOver;
- isHovered = isOver;
- if (hoverChange && isHovered)
- {
- previousPosition.x = position.x;
- previousPosition.y = position.y;
- }
// // iterate all listeners associated with this hit shape
- for (auto listener : listeners)
+ for (auto listenerGroup : listeners)
{
+ if (listenerGroup->isConsumed())
+ {
+ continue;
+ }
+
+ bool isGroupHovered = canHit ? listenerGroup->isHovered() : false;
+ bool hoverChange = listenerGroup->prevHovered() != isGroupHovered;
+ // If hover has changes, it means that the element is hovered for the
+ // first time. Previous positions need to be reset to avoid jumps.
+ if (hoverChange && isGroupHovered)
+ {
+ listenerGroup->previousPosition.x = position.x;
+ listenerGroup->previousPosition.y = position.y;
+ }
+
+ // Handle click gesture phases. A click gesture has two phases.
+ // First one attached to a pointer down actions, second one attached to a
+ // pointer up action. Both need to act on a shape of the listener group.
+ if (isGroupHovered)
+ {
+ if (hitType == ListenerType::down)
+ {
+ listenerGroup->clickPhase(GestureClickPhase::down);
+ }
+ else if (hitType == ListenerType::up &&
+ listenerGroup->clickPhase() == GestureClickPhase::down)
+ {
+ listenerGroup->clickPhase(GestureClickPhase::clicked);
+ }
+ }
+ else
+ {
+ if (hitType == ListenerType::down || hitType == ListenerType::up)
+ {
+ listenerGroup->clickPhase(GestureClickPhase::out);
+ }
+ }
+ auto listener = listenerGroup->listener();
// Always update hover states regardless of which specific listener type
// we're trying to trigger.
- if (hoverChange)
+ // If hover has changed and:
+ // - it's hovering and the listener is of type enter
+ // - it's not hovering and the listener is of type exit
+ if (hoverChange &&
+ ((isGroupHovered && listener->listenerType() == ListenerType::enter) ||
+ (!isGroupHovered && listener->listenerType() == ListenerType::exit)))
{
- if (isOver && listener->listenerType() == ListenerType::enter)
- {
- listener->performChanges(m_stateMachineInstance, position, previousPosition);
- m_stateMachineInstance->markNeedsAdvance();
- }
- else if (!isOver && listener->listenerType() == ListenerType::exit)
- {
- listener->performChanges(m_stateMachineInstance, position, previousPosition);
- m_stateMachineInstance->markNeedsAdvance();
- }
- }
- if (isOver && hitType == listener->listenerType())
- {
- listener->performChanges(m_stateMachineInstance, position, previousPosition);
+ listener->performChanges(m_stateMachineInstance,
+ position,
+ listenerGroup->previousPosition);
m_stateMachineInstance->markNeedsAdvance();
+ listenerGroup->consume();
}
+ // Perform changes if:
+ // - the click gesture is complete and the listener is of type click
+ // - the event type matches the listener type and it is hovering the group
+ if ((listenerGroup->clickPhase() == GestureClickPhase::clicked &&
+ listener->listenerType() == ListenerType::click) ||
+ (isGroupHovered && hitType == listener->listenerType()))
+ {
+ listener->performChanges(m_stateMachineInstance,
+ position,
+ listenerGroup->previousPosition);
+ m_stateMachineInstance->markNeedsAdvance();
+ listenerGroup->consume();
+ }
+ listenerGroup->previousPosition.x = position.x;
+ listenerGroup->previousPosition.y = position.y;
}
- previousPosition.x = position.x;
- previousPosition.y = position.y;
- return isOver ? shape->isTargetOpaque() ? HitResult::hitOpaque : HitResult::hit
- : HitResult::none;
+ return (isHovered && canHit)
+ ? shape->isTargetOpaque() ? HitResult::hitOpaque : HitResult::hit
+ : HitResult::none;
}
- void addListener(const StateMachineListener* stateMachineListener)
+ void addListener(ListenerGroup* listenerGroup)
{
+ auto stateMachineListener = listenerGroup->listener();
auto listenerType = stateMachineListener->listenerType();
if (listenerType == ListenerType::enter || listenerType == ListenerType::exit ||
listenerType == ListenerType::move)
@@ -530,16 +628,16 @@
}
else
{
- if (listenerType == ListenerType::down)
+ if (listenerType == ListenerType::down || listenerType == ListenerType::click)
{
hasDownListener = true;
}
- else if (listenerType == ListenerType::up)
+ if (listenerType == ListenerType::up || listenerType == ListenerType::click)
{
hasUpListener = true;
}
}
- listeners.push_back(stateMachineListener);
+ listeners.push_back(listenerGroup);
}
};
class HitNestedArtboard : public HitComponent
@@ -615,6 +713,7 @@
case ListenerType::enter:
case ListenerType::exit:
case ListenerType::event:
+ case ListenerType::click:
break;
}
}
@@ -630,6 +729,7 @@
case ListenerType::enter:
case ListenerType::exit:
case ListenerType::event:
+ case ListenerType::click:
break;
}
}
@@ -637,7 +737,9 @@
}
return hitResult;
}
+ void prepareEvent(Vec2D position, ListenerType hitType) override {}
};
+
} // namespace rive
HitResult StateMachineInstance::updateListeners(Vec2D position, ListenerType hitType)
@@ -647,9 +749,19 @@
position -= Vec2D(m_artboardInstance->originX() * m_artboardInstance->width(),
m_artboardInstance->originY() * m_artboardInstance->height());
}
-
+ // First reset all listener groups before processing the events
+ for (const auto& listenerGroup : m_listenerGroups)
+ {
+ listenerGroup.get()->reset();
+ }
+ // Next prepare the event to set the common hover status for each group
+ for (const auto& hitShape : m_hitComponents)
+ {
+ hitShape->prepareEvent(position, hitType);
+ }
bool hitSomething = false;
bool hitOpaque = false;
+ // Finally process the events
for (const auto& hitShape : m_hitComponents)
{
// TODO: quick reject.
@@ -706,6 +818,17 @@
return updateListeners(position, ListenerType::exit);
}
+#ifdef TESTING
+const LayerState* StateMachineInstance::layerState(size_t index)
+{
+ if (index < m_machine->layerCount())
+ {
+ return m_layers[index].currentState();
+ }
+ return nullptr;
+}
+#endif
+
StateMachineInstance::StateMachineInstance(const StateMachine* machine,
ArtboardInstance* instance) :
Scene(instance), m_machine(machine)
@@ -791,6 +914,7 @@
{
continue;
}
+ auto listenerGroup = rivestd::make_unique<ListenerGroup>(listener);
auto target = m_artboardInstance->resolve(listener->targetId());
if (target != nullptr && target->is<ContainerComponent>())
{
@@ -811,11 +935,12 @@
{
hitShape = itr->second;
}
- hitShape->addListener(listener);
+ hitShape->addListener(listenerGroup.get());
}
return true;
});
}
+ m_listenerGroups.push_back(std::move(listenerGroup));
}
for (auto nestedArtboard : instance->nestedArtboards())
diff --git a/test/assets/click_event.riv b/test/assets/click_event.riv
new file mode 100644
index 0000000..583f6b4
--- /dev/null
+++ b/test/assets/click_event.riv
Binary files differ
diff --git a/test/hittest_test.cpp b/test/hittest_test.cpp
index debc2d4..6c12c9f 100644
--- a/test/hittest_test.cpp
+++ b/test/hittest_test.cpp
@@ -8,6 +8,7 @@
#include <rive/animation/state_machine_instance.hpp>
#include <rive/animation/state_machine_input_instance.hpp>
#include <rive/animation/nested_state_machine.hpp>
+#include <rive/animation/animation_state.hpp>
#include "rive_file_reader.hpp"
#include <catch.hpp>
@@ -246,4 +247,140 @@
REQUIRE(hitComponentOnlyPointerDown->earlyOutCount == 4);
delete stateMachineInstance;
+}
+
+TEST_CASE("click event", "[hittest]")
+{
+ // This test has two rectangles of size [200, 200]
+ // positioned at [100,100] and [200, 200]
+ // they overlap between coordinates [100,100]-[200, 200]
+ // they are inside a group that has a listener attached to it
+ // that listener should fire an event on "Click"
+ auto file = ReadRiveFile("../../test/assets/click_event.riv");
+
+ auto artboard = file->artboard("art-1");
+ auto artboardInstance = artboard->instance();
+ auto stateMachine = artboard->stateMachine("sm-1");
+
+ REQUIRE(artboardInstance != nullptr);
+ REQUIRE(artboardInstance->stateMachineCount() == 1);
+
+ REQUIRE(stateMachine != nullptr);
+
+ rive::StateMachineInstance* stateMachineInstance =
+ new rive::StateMachineInstance(stateMachine, artboardInstance.get());
+
+ stateMachineInstance->advance(0.0f);
+ artboardInstance->advance(0.0f);
+ REQUIRE(stateMachineInstance->needsAdvance() == true);
+ stateMachineInstance->advance(0.0f);
+ // There is a single listener with two shapes in it
+ REQUIRE(stateMachineInstance->hitComponentsCount() == 2);
+ auto layerCount = stateMachine->layerCount();
+ REQUIRE(layerCount == 1);
+ REQUIRE(stateMachineInstance->reportedEventCount() == 0);
+ // Click in place should trigger a click event
+ stateMachineInstance->pointerDown(rive::Vec2D(75.0f, 75.0f));
+ stateMachineInstance->pointerUp(rive::Vec2D(75.0f, 75.0f));
+ REQUIRE(stateMachineInstance->reportedEventCount() == 1);
+ // Pointer down inside shape but Pointer up outside the shape
+ // should not trigger a click event
+ stateMachineInstance->pointerDown(rive::Vec2D(75.0f, 75.0f));
+ stateMachineInstance->pointerUp(rive::Vec2D(300.0f, 75.0f));
+ REQUIRE(stateMachineInstance->reportedEventCount() == 1);
+ // Pointer down outside shape but Pointer up inside the shape
+ // should not trigger a click event
+ stateMachineInstance->pointerDown(rive::Vec2D(300.0f, 75.0f));
+ stateMachineInstance->pointerUp(rive::Vec2D(75.0f, 75.0f));
+ REQUIRE(stateMachineInstance->reportedEventCount() == 1);
+ // Pointer down in shape 1 Pointer up in shape 2 of the same group
+ // should trigger a click event
+ stateMachineInstance->pointerDown(rive::Vec2D(75.0f, 75.0f));
+ stateMachineInstance->pointerUp(rive::Vec2D(225.0f, 225.0f));
+ REQUIRE(stateMachineInstance->reportedEventCount() == 2);
+ // Pointer down and up in area where both shapes overlap
+ // should trigger a single click event
+ stateMachineInstance->pointerDown(rive::Vec2D(150.0f, 150.0f));
+ stateMachineInstance->pointerUp(rive::Vec2D(150.0f, 150.0f));
+ REQUIRE(stateMachineInstance->reportedEventCount() == 3);
+
+ delete stateMachineInstance;
+}
+
+TEST_CASE("multiple shapes with mouse movement behavior", "[hittest]")
+{
+ // This test has two rectangles of size [200, 200]
+ // positioned at [100,100] and [100, 200]
+ // they overlap between coordinates [100,0]-[200, 200]
+ // they are inside a group that has a Pointer enter and a Pointer out
+ // listeners that toggle between two states (red and green)
+ // starting at "red"
+ auto file = ReadRiveFile("../../test/assets/click_event.riv");
+
+ auto artboard = file->artboard("art-2");
+ auto artboardInstance = artboard->instance();
+ auto stateMachine = artboard->stateMachine("sm-1");
+
+ REQUIRE(artboardInstance != nullptr);
+ REQUIRE(artboardInstance->stateMachineCount() == 1);
+
+ REQUIRE(stateMachine != nullptr);
+
+ rive::StateMachineInstance* stateMachineInstance =
+ new rive::StateMachineInstance(stateMachine, artboardInstance.get());
+
+ stateMachineInstance->advance(0.0f);
+ artboardInstance->advance(0.0f);
+ REQUIRE(stateMachineInstance->needsAdvance() == true);
+ stateMachineInstance->advance(0.0f);
+ // There is a single listener with two shapes in it
+ REQUIRE(stateMachineInstance->hitComponentsCount() == 2);
+ auto layerCount = stateMachine->layerCount();
+ REQUIRE(layerCount == 1);
+ // Move over the first shape
+ stateMachineInstance->pointerMove(rive::Vec2D(75.0f, 75.0f));
+ artboardInstance->advance(0.0f);
+ stateMachineInstance->advanceAndApply(0.0f);
+
+ {
+ auto state = stateMachineInstance->layerState(0);
+ REQUIRE(state->is<rive::AnimationState>());
+ auto animation = state->as<rive::AnimationState>()->animation();
+ REQUIRE(animation->name() == "green");
+ }
+ // Move over the second shape, nothing should change
+ stateMachineInstance->pointerMove(rive::Vec2D(200.0f, 75.0f));
+ artboardInstance->advance(0.0f);
+ stateMachineInstance->advanceAndApply(0.0f);
+
+ {
+ auto state = stateMachineInstance->layerState(0);
+ REQUIRE(state->is<rive::AnimationState>());
+ auto animation = state->as<rive::AnimationState>()->animation();
+ REQUIRE(animation->name() == "green");
+ }
+ // Move out of the second shape, should go back to red
+ stateMachineInstance->pointerMove(rive::Vec2D(400.0f, 75.0f));
+ artboardInstance->advance(0.0f);
+ stateMachineInstance->advanceAndApply(0.0f);
+
+ {
+ auto state = stateMachineInstance->layerState(0);
+ REQUIRE(state->is<rive::AnimationState>());
+ auto animation = state->as<rive::AnimationState>()->animation();
+ REQUIRE(animation->name() == "red");
+ }
+ // Move back into the second shape, should go to green
+ stateMachineInstance->pointerMove(rive::Vec2D(200.0f, 75.0f));
+ artboardInstance->advance(0.0f);
+ stateMachineInstance->advanceAndApply(0.0f);
+
+ {
+ auto state = stateMachineInstance->layerState(0);
+ REQUIRE(state->is<rive::AnimationState>());
+ auto animation = state->as<rive::AnimationState>()->animation();
+ REQUIRE(animation->name() == "green");
+ }
+
+ delete stateMachineInstance;
}
\ No newline at end of file