diff --git a/.rive_head b/.rive_head index 43294622..e4ff0e3d 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 a2c86008..093d05bb 100644 --- a/include/rive/animation/state_machine_instance.hpp +++ b/include/rive/animation/state_machine_instance.hpp @@ -27,6 +27,7 @@ class Shape; class StateMachineLayerInstance; class HitComponent; class HitShape; +class ListenerGroup; class NestedArtboard; class NestedEventListener; class NestedEventNotifier; @@ -146,6 +147,7 @@ class StateMachineInstance : public Scene, public NestedEventNotifier, public Ne } return nullptr; } + const LayerState* layerState(size_t index); #endif void updateDataBinds(); @@ -157,6 +159,7 @@ class StateMachineInstance : public Scene, public NestedEventNotifier, public Ne size_t m_layerCount; StateMachineLayerInstance* m_layers; std::vector> m_hitComponents; + std::vector> m_listenerGroups; StateMachineInstance* m_parentStateMachineInstance = nullptr; NestedArtboard* m_parentNestedArtboard = nullptr; std::vector m_dataBinds; @@ -178,6 +181,7 @@ class HitComponent {} 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 00000000..2daf21ae --- /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 d79f68cc..7fab8095 100644 --- a/include/rive/listener_type.hpp +++ b/include/rive/listener_type.hpp @@ -10,6 +10,7 @@ enum class ListenerType : int 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 9bbe8756..219200f3 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 @@ class StateMachineLayerInstance { 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 @@ class StateMachineLayerInstance 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 @@ class HitShape : public HitComponent bool hasDownListener = false; bool hasUpListener = false; float hitRadius = 2; - Vec2D previousPosition; - std::vector listeners; + std::vector listeners; bool hitTest(Vec2D position) const #ifdef WITH_RIVE_TOOLS @@ -466,6 +504,29 @@ class HitShape : public HitComponent 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 @@ class HitShape : public HitComponent if (canEarlyOut && (hitType != ListenerType::down || !hasDownListener) && (hitType != ListenerType::up || !hasUpListener)) { -#ifdef TESTING - earlyOutCount++; -#endif return HitResult::none; } auto shape = m_component->as(); - 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) { - // Always update hover states regardless of which specific listener type - // we're trying to trigger. - if (hoverChange) + 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) { - if (isOver && listener->listenerType() == ListenerType::enter) + 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) { - listener->performChanges(m_stateMachineInstance, position, previousPosition); - m_stateMachineInstance->markNeedsAdvance(); + listenerGroup->clickPhase(GestureClickPhase::down); } - else if (!isOver && listener->listenerType() == ListenerType::exit) + else if (hitType == ListenerType::up && + listenerGroup->clickPhase() == GestureClickPhase::down) { - listener->performChanges(m_stateMachineInstance, position, previousPosition); - m_stateMachineInstance->markNeedsAdvance(); + listenerGroup->clickPhase(GestureClickPhase::clicked); } } - if (isOver && hitType == listener->listenerType()) + else { - listener->performChanges(m_stateMachineInstance, position, previousPosition); + 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 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))) + { + 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 @@ class HitShape : public HitComponent } 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 @@ class HitNestedArtboard : public HitComponent case ListenerType::enter: case ListenerType::exit: case ListenerType::event: + case ListenerType::click: break; } } @@ -630,6 +729,7 @@ class HitNestedArtboard : public HitComponent case ListenerType::enter: case ListenerType::exit: case ListenerType::event: + case ListenerType::click: break; } } @@ -637,7 +737,9 @@ class HitNestedArtboard : public HitComponent } return hitResult; } + void prepareEvent(Vec2D position, ListenerType hitType) override {} }; + } // namespace rive HitResult StateMachineInstance::updateListeners(Vec2D position, ListenerType hitType) @@ -647,9 +749,19 @@ HitResult StateMachineInstance::updateListeners(Vec2D position, ListenerType hit 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 @@ HitResult StateMachineInstance::pointerExit(Vec2D position) 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 @@ StateMachineInstance::StateMachineInstance(const StateMachine* machine, { continue; } + auto listenerGroup = rivestd::make_unique(listener); auto target = m_artboardInstance->resolve(listener->targetId()); if (target != nullptr && target->is()) { @@ -811,11 +935,12 @@ StateMachineInstance::StateMachineInstance(const StateMachine* machine, { 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 00000000..583f6b4e Binary files /dev/null and b/test/assets/click_event.riv differ diff --git a/test/hittest_test.cpp b/test/hittest_test.cpp index debc2d47..6c12c9f8 100644 --- a/test/hittest_test.cpp +++ b/test/hittest_test.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include "rive_file_reader.hpp" #include @@ -245,5 +246,141 @@ TEST_CASE("early out on listeners", "[hittest]") REQUIRE(hitComponentOpaque->earlyOutCount == 0); 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()); + auto animation = state->as()->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()); + auto animation = state->as()->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()); + auto animation = state->as()->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()); + auto animation = state->as()->animation(); + REQUIRE(animation->name() == "green"); + } + delete stateMachineInstance; } \ No newline at end of file