feat(core runtime): Scroll wheel and trackpad support for ScrollConstraint (#14321) f42f02a06d Adds a second input channel for ScrollConstraint. Until now scroll views could only be driven by a pointer drag; they now also respond to mouse wheels and trackpad gestures. Direct touch is unchanged — a one-finger swipe on a phone is an ordinary pointer drag and already worked. What's new is indirect scroll input, where the OS classifies the gesture and sends scroll events instead of touches. Co-authored-by: Philip Chung <philterdesign@gmail.com>
diff --git a/.rive_head b/.rive_head index 7c069aa..e2f3294 100644 --- a/.rive_head +++ b/.rive_head
@@ -1 +1 @@ -8d951c92f3c97e3f4b166a54079fe494d299898c +f42f02a06d63bc4ca64b2ceb223ec4475784b289
diff --git a/include/rive/animation/nested_state_machine.hpp b/include/rive/animation/nested_state_machine.hpp index a42075d..48dca38 100644 --- a/include/rive/animation/nested_state_machine.hpp +++ b/include/rive/animation/nested_state_machine.hpp
@@ -30,6 +30,15 @@ HitResult pointerDown(Vec2D position, int pointerId = 0); HitResult pointerUp(Vec2D position, int pointerId = 0); HitResult pointerExit(Vec2D position, int pointerId = 0); + HitResult pointerScroll(Vec2D position, + const ScrollEvent& event, + float timeStamp = 0, + int pointerId = 0); + bool wantsScroll(Vec2D position, const ScrollEvent& event); + bool hasScrollLatch(); + bool hasScrollTargetAt(Vec2D position); + bool scrollOccludedAt(Vec2D position); + void cancelScroll(); HitResult dragStart(Vec2D position, float timeStamp = 0, int pointerId = 0); HitResult dragEnd(Vec2D position, float timeStamp = 0, int pointerId = 0); bool tryChangeState();
diff --git a/include/rive/animation/state_machine_instance.hpp b/include/rive/animation/state_machine_instance.hpp index dc48238..387c1e2 100644 --- a/include/rive/animation/state_machine_instance.hpp +++ b/include/rive/animation/state_machine_instance.hpp
@@ -49,6 +49,7 @@ class BindableProperty; class StateInstance; class HitDrawable; +class DraggableProxy; class ListenerViewModel; class ScriptedListenerAction; class ScriptedDrawable; @@ -90,6 +91,9 @@ void notifyEventListeners(const std::vector<EventReport>& events, NestedArtboard* source); void sortHitComponents(); + /// Maps a host-space position into artboard content space. False when a + /// degenerate self transform has collapsed the contents. + bool mapToContentSpace(Vec2D& position, Vec2D* delta = nullptr) const; double randomValue(); StateTransition* findRandomTransition( StateInstance* stateFromInstance, @@ -190,6 +194,24 @@ bool disablePointer = true, int pointerId = 0); HitResult dragEnd(Vec2D position, float timeStamp = 0, int pointerId = 0); + HitResult pointerScroll(Vec2D position, + const ScrollEvent& event, + float timeStamp = 0, + int pointerId = 0) override; + /// Whether anything under position would move for this scroll event. Lets + /// a caller chain outward without committing the event first. + bool wantsScroll(Vec2D position, const ScrollEvent& event); + /// Whether a scroll gesture is latched here or in a nested artboard. + bool hasScrollLatch(); + /// Whether an interactive scroll view sits under position at all, even + /// one that cannot move. Keeps a gesture from escaping to the host. + bool hasScrollTargetAt(Vec2D position); + /// Whether an opaque hit that cannot scroll sits under position. It + /// blocks scroll to whatever lies behind it, as hitOpaque does for + /// pointer events. + bool scrollOccludedAt(Vec2D position); + /// Ends any scroll gesture in flight here, latch included. + void cancelScroll(); bool tryChangeState(); bool hitTest(Vec2D position) const; @@ -412,6 +434,9 @@ bool m_needsAdvance = false; std::vector<std::unique_ptr<HitComponent>> m_hitComponents; std::vector<std::unique_ptr<ListenerGroup>> m_listenerGroups; + // Owns the in-flight scroll gesture. Latched for the whole gesture: + // re-picking per event would hand a flick's tail to the outer view. + HitComponent* m_scrollLatch = nullptr; StateMachineInstance* m_parentStateMachineInstance = nullptr; NestedArtboard* m_parentNestedArtboard = nullptr; @@ -495,6 +520,27 @@ } virtual void enablePointerEvents(int pointerId = 0) {} virtual void disablePointerEvents(int pointerId = 0) {} + /// Scroll dispatch. wantsScroll is the interest test; scrollGestureActive + /// keeps the latch alive between events. + virtual bool wantsScroll(Vec2D position, const ScrollEvent& event) + { + return false; + } + virtual bool scrollGestureActive() { return false; } + /// Ends any scroll gesture this component owns. + virtual void cancelScroll() {} + /// Whether an interactive scroll view sits under position at all, even + /// one that cannot move right now. + virtual bool hasScrollTarget(Vec2D position) { return false; } + /// Whether an opaque hit that cannot scroll sits under position. Ends + /// the walk: nothing behind it may take the event, as with hitOpaque. + virtual bool occludesScroll(Vec2D position) { return false; } + virtual HitResult processScroll(Vec2D position, + const ScrollEvent& event, + float timeStamp) + { + return HitResult::none; + } #ifdef TESTING int earlyOutCount = 0; #endif
diff --git a/include/rive/constraints/draggable_constraint.hpp b/include/rive/constraints/draggable_constraint.hpp index 29a7642..6ce251f 100644 --- a/include/rive/constraints/draggable_constraint.hpp +++ b/include/rive/constraints/draggable_constraint.hpp
@@ -6,6 +6,7 @@ #include "rive/listener_group.hpp" #include "rive/math/vec2d.hpp" #include "rive/process_event_result.hpp" +#include "rive/scroll_event.hpp" #include <stdio.h> namespace rive { @@ -29,6 +30,23 @@ virtual bool startDrag(Vec2D mousePosition, float timeStamp = 0) = 0; virtual bool drag(Vec2D mousePosition, float timeStamp = 0) = 0; virtual bool endDrag(Vec2D mousePosition, float timeStamp = 0) = 0; + /// Whether this proxy would move for the event. One at its edge declines, + /// handing the gesture to the next candidate under the cursor. + virtual bool wantsScroll(const ScrollEvent& event) { return false; } + /// Whether this proxy takes part in scroll gestures at all, even when it + /// cannot move. Stops a gesture escaping to the host at the edges. + virtual bool acceptsScroll() { return false; } + + /// Ends any scroll gesture on this proxy outright. + virtual void cancelScroll() {} + + /// Whether a gesture still owns this proxy; the dispatcher latches on it. + virtual bool isScrollGestureActive() { return false; } + /// Applies a scroll event. Returns true when it moved. + virtual bool scroll(const ScrollEvent& event, float timeStamp) + { + return false; + } Drawable* hittable() { return m_hittable; } }; @@ -82,6 +100,7 @@ void disable(int pointerId = 0) override {} DraggableConstraint* constraint() { return m_constraint; } + DraggableProxy* scrollProxy() override { return m_draggable; } bool canEarlyOut(Component* drawable) override { return false; }
diff --git a/include/rive/constraints/scrolling/elastic_scroll_physics.hpp b/include/rive/constraints/scrolling/elastic_scroll_physics.hpp index 40a952e..623a97a 100644 --- a/include/rive/constraints/scrolling/elastic_scroll_physics.hpp +++ b/include/rive/constraints/scrolling/elastic_scroll_physics.hpp
@@ -59,6 +59,8 @@ { return m_physicsX != nullptr || m_physicsY != nullptr; } + // reset() destroys the helpers, and only prepare() rebuilds them. + bool isPrimed() override { return enabled(); } bool isRunning() override { return (m_physicsX != nullptr && m_physicsX->isRunning()) ||
diff --git a/include/rive/constraints/scrolling/scroll_constraint.hpp b/include/rive/constraints/scrolling/scroll_constraint.hpp index 7f00dde..4f65875 100644 --- a/include/rive/constraints/scrolling/scroll_constraint.hpp +++ b/include/rive/constraints/scrolling/scroll_constraint.hpp
@@ -50,6 +50,10 @@ bool m_isDragging = false; bool m_isScrollBarDragging = false; bool m_hasListChildren = false; + // A wheel or trackpad gesture owns this view. Distinct from m_isDragging: + // no pointer is down, and a phaseless wheel ends by going idle. + bool m_isScrolling = false; + float m_scrollIdleSeconds = 0; // Unresolved writes per axis; held until layout can convert. ScrollAxisIntent m_intentX; @@ -86,7 +90,31 @@ StatusCode import(ImportStack& importStack) override; StatusCode onAddedDirty(CoreContext* context) override; Core* clone() const override; - void dragView(Vec2D delta, float timeStamp); + void dragView(Vec2D delta, float timeStamp, bool trackVelocity = true); + /// Whether wheel and trackpad input may drive this view. interactive + /// gates every kind of input; wheelInteractive gates the indirect kind. + bool wheelEnabled() { return interactive() && wheelInteractive(); } + /// Whether the delta would actually move this view; an edge-stuck one + /// declines so the gesture chains outward. + bool canConsume(Vec2D delta); + /// Whether a precise gesture can stretch this view at its edge. + bool canStretch(Vec2D delta); + /// Whether the offset currently sits outside the scrollable range. + bool isOverscrolled(); + /// The delta as it will actually be applied, after dragMultiplier. + Vec2D scaledDelta(Vec2D delta); + /// The wheel path: clamped, and no velocity accumulation, since a detent + /// never flings and must not feed the velocity passthroughs. + void scrollBy(Vec2D delta); + /// True while a wheel or trackpad gesture owns this view. + bool isScrolling() const { return m_isScrolling; } + /// True while a pointer drag owns this view. + bool isDragging() const { return m_isDragging; } + /// True when this opened a new gesture rather than continuing one. + bool beginScrollGesture(); + void endScrollGesture(); + /// Resets the idle timer that closes a phaseless gesture. + void markScrollActivity() { m_scrollIdleSeconds = 0; } void runPhysics(); void constrainChild(LayoutNodeProvider* child) override; void addLayoutChild(LayoutNodeProvider* child) override; @@ -98,7 +126,17 @@ ScrollPhysics* physics() const { return m_physics; } void physics(ScrollPhysics* physics) { m_physics = physics; } + /// Pointer drag: primes physics and marks the drag. void initPhysics(); + /// Primes the simulation and its velocity clock without claiming a + /// pointer drag; the scroll path uses this so a wheel gesture ending + /// cannot end a pointer drag that overlaps it. + void primePhysics(); + /// Runs the simulation without touching the drag flag. + void startPhysics(); + /// Re-primes physics when a settle has torn it down mid-gesture. True when + /// it primed, meaning the velocity clock has no earlier sample. + bool ensurePhysicsPrimed(); void stopPhysics(); void clearVelocity();
diff --git a/include/rive/constraints/scrolling/scroll_constraint_proxy.hpp b/include/rive/constraints/scrolling/scroll_constraint_proxy.hpp index 5ec5ca8..41385dc 100644 --- a/include/rive/constraints/scrolling/scroll_constraint_proxy.hpp +++ b/include/rive/constraints/scrolling/scroll_constraint_proxy.hpp
@@ -25,7 +25,12 @@ bool startDrag(Vec2D mousePosition, float timeStamp = 0) override; bool drag(Vec2D mousePosition, float timeStamp = 0) override; bool endDrag(Vec2D mousePosition, float timeStamp = 0) override; + bool wantsScroll(const ScrollEvent& event) override; + bool acceptsScroll() override; + void cancelScroll() override; + bool isScrollGestureActive() override; + bool scroll(const ScrollEvent& event, float timeStamp) override; }; } // namespace rive -#endif \ No newline at end of file +#endif
diff --git a/include/rive/constraints/scrolling/scroll_physics.hpp b/include/rive/constraints/scrolling/scroll_physics.hpp index 9149c90..1675782 100644 --- a/include/rive/constraints/scrolling/scroll_physics.hpp +++ b/include/rive/constraints/scrolling/scroll_physics.hpp
@@ -39,6 +39,9 @@ public: virtual bool enabled() { return isRunning(); } virtual bool isRunning() { return m_isRunning; } + /// False once a settle has torn down state that only prepare() rebuilds. + /// A gesture still in flight re-primes when it sees this. + virtual bool isPrimed() { return true; } virtual void prepare(DraggableConstraintDirection dir) { reset();
diff --git a/include/rive/generated/constraints/scrolling/scroll_constraint_base.hpp b/include/rive/generated/constraints/scrolling/scroll_constraint_base.hpp index 6ea13b4..406c386 100644 --- a/include/rive/generated/constraints/scrolling/scroll_constraint_base.hpp +++ b/include/rive/generated/constraints/scrolling/scroll_constraint_base.hpp
@@ -53,6 +53,9 @@ static const uint16_t dragMultiplierPropertyKey = 1029; static const uint16_t computedContentWidthPropertyKey = 1069; static const uint16_t computedContentHeightPropertyKey = 1070; + static const uint16_t scrollFlagsPropertyKey = 466; + static const uint16_t wheelInteractivePropertyKey = 467; + static const uint32_t wheelInteractiveBitmask = 1u << 0; protected: float m_ScrollOffsetX = 0.0f; @@ -66,6 +69,7 @@ bool m_Interactive = true; float m_Threshold = 0.0f; float m_DragMultiplier = 1.0f; + uint8_t m_ScrollFlags = 1; public: inline float scrollOffsetX() const { return m_ScrollOffsetX; } @@ -325,6 +329,36 @@ notifyPropertyChanged(computedContentHeightPropertyKey); } + inline uint8_t scrollFlags() const { return m_ScrollFlags; } + void scrollFlags(uint8_t value) + { + if (m_ScrollFlags == value) + { + return; + } + RIVE_EDITOR_CHANGING(scrollFlagsPropertyKey, &m_ScrollFlags, &value); + m_ScrollFlags = value; + RIVE_EDITOR_CHANGED(scrollFlagsChanged()); + notifyPropertyChanged(scrollFlagsPropertyKey); + } + + inline bool wheelInteractive() const + { + return (m_ScrollFlags & wheelInteractiveBitmask) != 0; + } + void wheelInteractive(bool value) + { + const bool prev = (m_ScrollFlags & wheelInteractiveBitmask) != 0; + if (prev == value) + { + return; + } + RIVE_EDITOR_CHANGING(wheelInteractivePropertyKey, &prev, &value); + m_ScrollFlags = value ? (m_ScrollFlags | wheelInteractiveBitmask) + : (m_ScrollFlags & ~wheelInteractiveBitmask); + RIVE_EDITOR_CHANGED(scrollFlagsChanged()); + notifyPropertyChanged(scrollFlagsPropertyKey); + } Core* clone() const override; void copy(const ScrollConstraintBase& object) { @@ -339,6 +373,7 @@ m_Interactive = object.m_Interactive; m_Threshold = object.m_Threshold; m_DragMultiplier = object.m_DragMultiplier; + m_ScrollFlags = object.m_ScrollFlags; DraggableConstraint::copy(object); } @@ -379,6 +414,9 @@ case dragMultiplierPropertyKey: m_DragMultiplier = CoreDoubleType::deserialize(reader); return true; + case scrollFlagsPropertyKey: + m_ScrollFlags = CoreUintType::deserialize(reader); + return true; } return DraggableConstraint::deserialize(propertyKey, reader); } @@ -403,6 +441,7 @@ virtual void dragMultiplierChanged() {} virtual void computedContentWidthChanged() {} virtual void computedContentHeightChanged() {} + virtual void scrollFlagsChanged() {} #ifdef WITH_RIVE_EDITOR #include "editor_native/generated/constraints/scrolling/scroll_constraint_ext.inl" #endif
diff --git a/include/rive/generated/core_registry.hpp b/include/rive/generated/core_registry.hpp index d49787d..89eb73b 100644 --- a/include/rive/generated/core_registry.hpp +++ b/include/rive/generated/core_registry.hpp
@@ -1381,6 +1381,9 @@ case ScrollConstraintBase::virtualizeBufferPropertyKey: object->as<ScrollConstraintBase>()->virtualizeBuffer(value); break; + case ScrollConstraintBase::scrollFlagsPropertyKey: + object->as<ScrollConstraintBase>()->scrollFlags(value); + break; case DrawableBase::blendModeValuePropertyKey: object->as<DrawableBase>()->blendModeValue(value); break; @@ -2351,6 +2354,9 @@ case ScrollConstraintBase::scrollActivePropertyKey: object->as<ScrollConstraintBase>()->scrollActive(value); break; + case ScrollConstraintBase::wheelInteractivePropertyKey: + object->as<ScrollConstraintBase>()->wheelInteractive(value); + break; case ScrollBarConstraintBase::autoSizePropertyKey: object->as<ScrollBarConstraintBase>()->autoSize(value); break; @@ -3626,6 +3632,8 @@ return object->as<ScrollConstraintBase>()->physicsTypeValue(); case ScrollConstraintBase::virtualizeBufferPropertyKey: return object->as<ScrollConstraintBase>()->virtualizeBuffer(); + case ScrollConstraintBase::scrollFlagsPropertyKey: + return object->as<ScrollConstraintBase>()->scrollFlags(); case DrawableBase::blendModeValuePropertyKey: return object->as<DrawableBase>()->blendModeValue(); case DrawableBase::additiveAmountPropertyKey: @@ -4321,6 +4329,8 @@ return object->as<ScrollConstraintBase>()->interactive(); case ScrollConstraintBase::scrollActivePropertyKey: return object->as<ScrollConstraintBase>()->scrollActive(); + case ScrollConstraintBase::wheelInteractivePropertyKey: + return object->as<ScrollConstraintBase>()->wheelInteractive(); case ScrollBarConstraintBase::autoSizePropertyKey: return object->as<ScrollBarConstraintBase>()->autoSize(); case NestedArtboardBase::isPausedPropertyKey: @@ -5115,6 +5125,7 @@ case DraggableConstraintBase::directionValuePropertyKey: case ScrollConstraintBase::physicsTypeValuePropertyKey: case ScrollConstraintBase::virtualizeBufferPropertyKey: + case ScrollConstraintBase::scrollFlagsPropertyKey: case DrawableBase::blendModeValuePropertyKey: case DrawableBase::additiveAmountPropertyKey: case DrawableBase::drawableFlagsPropertyKey: @@ -5327,6 +5338,7 @@ case ScrollConstraintBase::infinitePropertyKey: case ScrollConstraintBase::interactivePropertyKey: case ScrollConstraintBase::scrollActivePropertyKey: + case ScrollConstraintBase::wheelInteractivePropertyKey: case ScrollBarConstraintBase::autoSizePropertyKey: case NestedArtboardBase::isPausedPropertyKey: case NestedArtboardBase::isStatefulPropertyKey: @@ -5963,6 +5975,8 @@ return object->is<ScrollConstraintBase>(); case ScrollConstraintBase::virtualizeBufferPropertyKey: return object->is<ScrollConstraintBase>(); + case ScrollConstraintBase::scrollFlagsPropertyKey: + return object->is<ScrollConstraintBase>(); case DrawableBase::blendModeValuePropertyKey: return object->is<DrawableBase>(); case DrawableBase::additiveAmountPropertyKey: @@ -6377,6 +6391,8 @@ return object->is<ScrollConstraintBase>(); case ScrollConstraintBase::scrollActivePropertyKey: return object->is<ScrollConstraintBase>(); + case ScrollConstraintBase::wheelInteractivePropertyKey: + return object->is<ScrollConstraintBase>(); case ScrollBarConstraintBase::autoSizePropertyKey: return object->is<ScrollBarConstraintBase>(); case NestedArtboardBase::isPausedPropertyKey:
diff --git a/include/rive/listener_group.hpp b/include/rive/listener_group.hpp index f4b5958..e91f300 100644 --- a/include/rive/listener_group.hpp +++ b/include/rive/listener_group.hpp
@@ -13,6 +13,7 @@ namespace rive { class Component; +class DraggableProxy; class HitComponent; class StateMachineListener; @@ -60,6 +61,11 @@ virtual bool needsDownListener(Component* drawable); virtual bool needsUpListener(Component* drawable); + /// The scroll proxy this group drives, or nullptr. Only draggable groups + /// answer; scroll dispatch uses it to find a target without walking + /// listeners it can't drive. + virtual DraggableProxy* scrollProxy() { return nullptr; } + virtual ProcessEventResult processEvent( Component* component, Vec2D position,
diff --git a/include/rive/scene.hpp b/include/rive/scene.hpp index 100da44..0b96c40 100644 --- a/include/rive/scene.hpp +++ b/include/rive/scene.hpp
@@ -7,6 +7,7 @@ #include "rive/animation/keyed_callback_reporter.hpp" #include "rive/core/field_types/core_callback_type.hpp" #include "rive/hit_result.hpp" +#include "rive/scroll_event.hpp" #include "rive/refcnt.hpp" #include <string> @@ -58,6 +59,13 @@ int pointerId = 0); virtual HitResult pointerUp(Vec2D, int pointerId = 0); virtual HitResult pointerExit(Vec2D, int pointerId = 0); + /// Indirect scroll input -- a wheel, or a trackpad gesture the platform + /// already classified as a scroll. Direct touch scrolling stays on the + /// pointer path. + virtual HitResult pointerScroll(Vec2D position, + const ScrollEvent& event, + float timeStamp = 0, + int pointerId = 0); virtual size_t inputCount() const; virtual SMIInput* input(size_t index) const;
diff --git a/include/rive/scroll_event.hpp b/include/rive/scroll_event.hpp new file mode 100644 index 0000000..0888304 --- /dev/null +++ b/include/rive/scroll_event.hpp
@@ -0,0 +1,47 @@ +#ifndef _RIVE_SCROLL_EVENT_HPP_ +#define _RIVE_SCROLL_EVENT_HPP_ + +#include "rive/math/vec2d.hpp" +#include <stdint.h> + +namespace rive +{ +/// Where a scroll event sits within its gesture. A phaseless wheel only ever +/// reports update. +enum class ScrollPhase : uint8_t +{ + /// Stops any running fling and primes velocity tracking. + begin = 0, + + update = 1, + + /// Contact ended; the runtime flings from the velocity it accumulated. + /// Hosts whose platform generates its own momentum never send this. + end = 2, + + /// Platform-generated after release. Applied directly, never flung on top + /// of; closed by going idle rather than by an end. + momentum = 3, + + /// Contact landed during momentum. Halts motion; delta is ignored. + inertiaCancel = 4, +}; + +/// A scroll delta from indirect input: a wheel, or a trackpad gesture the +/// platform already classified as a scroll. Direct touch stays on the pointer +/// path. +struct ScrollEvent +{ + /// Distance the content moves, in artboard-space pixels -- the same sense + /// as ScrollConstraint::dragView, so revealing later content is negative. + /// Hosts normalize; the runtime never sees platform units. + Vec2D delta; + + ScrollPhase phase = ScrollPhase::update; + + /// Pixel-precise source (trackpad) rather than a detented wheel. + bool precise = false; +}; +} // namespace rive + +#endif
diff --git a/src/animation/nested_state_machine.cpp b/src/animation/nested_state_machine.cpp index 70e2f27..b4dd5b8 100644 --- a/src/animation/nested_state_machine.cpp +++ b/src/animation/nested_state_machine.cpp
@@ -110,6 +110,52 @@ } return HitResult::none; } +HitResult NestedStateMachine::pointerScroll(Vec2D position, + const ScrollEvent& event, + float timeStamp, + int pointerId) +{ + if (m_StateMachineInstance != nullptr) + { + return m_StateMachineInstance->pointerScroll(position, + event, + timeStamp, + pointerId); + } + return HitResult::none; +} + +bool NestedStateMachine::wantsScroll(Vec2D position, const ScrollEvent& event) +{ + return m_StateMachineInstance != nullptr && + m_StateMachineInstance->wantsScroll(position, event); +} + +bool NestedStateMachine::hasScrollTargetAt(Vec2D position) +{ + return m_StateMachineInstance != nullptr && + m_StateMachineInstance->hasScrollTargetAt(position); +} + +bool NestedStateMachine::scrollOccludedAt(Vec2D position) +{ + return m_StateMachineInstance != nullptr && + m_StateMachineInstance->scrollOccludedAt(position); +} + +void NestedStateMachine::cancelScroll() +{ + if (m_StateMachineInstance != nullptr) + { + m_StateMachineInstance->cancelScroll(); + } +} + +bool NestedStateMachine::hasScrollLatch() +{ + return m_StateMachineInstance != nullptr && + m_StateMachineInstance->hasScrollLatch(); +} HitResult NestedStateMachine::pointerExit(Vec2D position, int pointerId) {
diff --git a/src/animation/state_machine_instance.cpp b/src/animation/state_machine_instance.cpp index e0d6d1d..9ede6c4 100644 --- a/src/animation/state_machine_instance.cpp +++ b/src/animation/state_machine_instance.cpp
@@ -766,6 +766,103 @@ bool hitTest(Vec2D position) const override { return false; } + DraggableProxy* scrollProxyFor(const ScrollEvent& event) + { + // A live gesture keeps the view it started on, even once that view + // can't move; only then does an idle proxy get a chance at the delta. + for (auto listenerGroup : listeners) + { + auto proxy = listenerGroup->scrollProxy(); + if (proxy != nullptr && proxy->isScrollGestureActive()) + { + return proxy; + } + } + for (auto listenerGroup : listeners) + { + auto proxy = listenerGroup->scrollProxy(); + if (proxy != nullptr && proxy->wantsScroll(event)) + { + return proxy; + } + } + return nullptr; + } + + bool wantsScroll(Vec2D position, const ScrollEvent& event) override + { + return hitTest(position) && scrollProxyFor(event) != nullptr; + } + + bool hasScrollTarget(Vec2D position) override + { + if (!hitTest(position)) + { + return false; + } + for (auto listenerGroup : listeners) + { + auto proxy = listenerGroup->scrollProxy(); + if (proxy != nullptr && proxy->acceptsScroll()) + { + return true; + } + } + return false; + } + + bool occludesScroll(Vec2D position) override + { + // A scroll view never occludes: at its edge it declines and the + // gesture chains outward. + for (auto listenerGroup : listeners) + { + if (listenerGroup->scrollProxy() != nullptr) + { + return false; + } + } + // Same opacity test as processEvent's hitOpaque. + return (isOpaque || m_drawable->isTargetOpaque()) && hitTest(position); + } + + bool scrollGestureActive() override + { + for (auto listenerGroup : listeners) + { + auto proxy = listenerGroup->scrollProxy(); + if (proxy != nullptr && proxy->isScrollGestureActive()) + { + return true; + } + } + return false; + } + + void cancelScroll() override + { + for (auto listenerGroup : listeners) + { + auto proxy = listenerGroup->scrollProxy(); + if (proxy != nullptr) + { + proxy->cancelScroll(); + } + } + } + + HitResult processScroll(Vec2D position, + const ScrollEvent& event, + float timeStamp) override + { + auto proxy = scrollProxyFor(event); + if (proxy == nullptr || !proxy->scroll(event, timeStamp)) + { + return HitResult::none; + } + return isOpaque ? HitResult::hitOpaque : HitResult::hit; + } + void prepareEvent(Vec2D position, ListenerType hitType, int pointerId) override @@ -990,6 +1087,183 @@ } return false; } + /// Maps a parent-space delta into the nested artboard. A delta is a + /// vector, so it takes the mapping's linear part only. + bool nestedDelta(NestedArtboard* nestedArtboard, + Vec2D position, + Vec2D nestedPosition, + Vec2D delta, + Vec2D& out) + { + Vec2D shifted; + if (!nestedArtboard->worldToLocal(position + delta, &shifted)) + { + return false; + } + out = shifted - nestedPosition; + return true; + } + + bool wantsScroll(Vec2D position, const ScrollEvent& event) override + { + auto nestedArtboard = m_component->as<NestedArtboard>(); + if (nestedArtboard->isCollapsed() || nestedArtboard->isPaused()) + { + return false; + } + Vec2D nestedPosition; + if (!nestedArtboard->worldToLocal(position, &nestedPosition)) + { + return false; + } + ScrollEvent mapped = event; + if (!nestedDelta(nestedArtboard, + position, + nestedPosition, + event.delta, + mapped.delta)) + { + return false; + } + for (auto nestedAnimation : nestedArtboard->nestedAnimations()) + { + if (nestedAnimation->is<NestedStateMachine>() && + nestedAnimation->as<NestedStateMachine>()->wantsScroll( + nestedPosition, + mapped)) + { + return true; + } + } + return false; + } + + bool hasScrollTarget(Vec2D position) override + { + auto nestedArtboard = m_component->as<NestedArtboard>(); + if (nestedArtboard->isCollapsed() || nestedArtboard->isPaused()) + { + return false; + } + Vec2D nestedPosition; + if (!nestedArtboard->worldToLocal(position, &nestedPosition)) + { + return false; + } + for (auto nestedAnimation : nestedArtboard->nestedAnimations()) + { + if (nestedAnimation->is<NestedStateMachine>() && + nestedAnimation->as<NestedStateMachine>()->hasScrollTargetAt( + nestedPosition)) + { + return true; + } + } + return false; + } + + bool occludesScroll(Vec2D position) override + { + auto nestedArtboard = m_component->as<NestedArtboard>(); + if (nestedArtboard->isCollapsed() || nestedArtboard->isPaused()) + { + return false; + } + Vec2D nestedPosition; + if (!nestedArtboard->worldToLocal(position, &nestedPosition)) + { + return false; + } + for (auto nestedAnimation : nestedArtboard->nestedAnimations()) + { + if (nestedAnimation->is<NestedStateMachine>() && + nestedAnimation->as<NestedStateMachine>()->scrollOccludedAt( + nestedPosition)) + { + return true; + } + } + return false; + } + + bool scrollGestureActive() override + { + auto nestedArtboard = m_component->as<NestedArtboard>(); + if (nestedArtboard->isCollapsed() || nestedArtboard->isPaused()) + { + // A paused machine never advances, so its idle timer can never + // clear the latch. End it outright, or unpausing resurrects it. + for (auto nestedAnimation : nestedArtboard->nestedAnimations()) + { + if (nestedAnimation->is<NestedStateMachine>()) + { + nestedAnimation->as<NestedStateMachine>()->cancelScroll(); + } + } + return false; + } + for (auto nestedAnimation : nestedArtboard->nestedAnimations()) + { + if (nestedAnimation->is<NestedStateMachine>() && + nestedAnimation->as<NestedStateMachine>()->hasScrollLatch()) + { + return true; + } + } + return false; + } + + HitResult processScroll(Vec2D position, + const ScrollEvent& event, + float timeStamp) override + { + auto nestedArtboard = m_component->as<NestedArtboard>(); + Vec2D nestedPosition; + if (!nestedArtboard->worldToLocal(position, &nestedPosition)) + { + return HitResult::none; + } + ScrollEvent mapped = event; + if (!nestedDelta(nestedArtboard, + position, + nestedPosition, + event.delta, + mapped.delta)) + { + return HitResult::none; + } + // A latched machine gets the event first; only then can another + // take it. + for (auto nestedAnimation : nestedArtboard->nestedAnimations()) + { + if (nestedAnimation->is<NestedStateMachine>() && + nestedAnimation->as<NestedStateMachine>()->hasScrollLatch()) + { + return nestedAnimation->as<NestedStateMachine>()->pointerScroll( + nestedPosition, + mapped, + timeStamp); + } + } + for (auto nestedAnimation : nestedArtboard->nestedAnimations()) + { + if (!nestedAnimation->is<NestedStateMachine>()) + { + continue; + } + auto result = + nestedAnimation->as<NestedStateMachine>()->pointerScroll( + nestedPosition, + mapped, + timeStamp); + if (result != HitResult::none) + { + return result; + } + } + return HitResult::none; + } + HitResult processGamepadInvocation( const ListenerInvocation& invocation, ScriptedDrawable* alreadyDispatched) override @@ -1182,6 +1456,224 @@ } return false; } + + /// Maps a parent-space delta into item i. A delta is a vector, so it + /// takes the mapping's linear part only. + bool itemDelta(ArtboardComponentList* componentList, + int i, + Vec2D position, + Vec2D listPosition, + Vec2D delta, + Vec2D& out) + { + Vec2D shifted; + if (!componentList->worldToLocal(position + delta, &shifted, i)) + { + return false; + } + out = shifted - listPosition; + return true; + } + + // Items walk top-most first, like processEvent, and an opaque item blocks + // the items behind it the way hitOpaque clears runningCanHit there. + bool wantsScroll(Vec2D position, const ScrollEvent& event) override + { + auto componentList = m_component->as<ArtboardComponentList>(); + if (componentList->isCollapsed()) + { + return false; + } + const auto& order = componentList->orderedListIndices(); + for (auto it = order.rbegin(); it != order.rend(); ++it) + { + const int i = *it; + Vec2D listPosition; + if (!componentList->worldToLocal(position, &listPosition, i)) + { + continue; + } + auto stateMachine = componentList->stateMachineInstance(i); + if (stateMachine == nullptr) + { + continue; + } + ScrollEvent mapped = event; + if (!itemDelta(componentList, + i, + position, + listPosition, + event.delta, + mapped.delta)) + { + continue; + } + if (stateMachine->wantsScroll(listPosition, mapped)) + { + return true; + } + if (stateMachine->scrollOccludedAt(listPosition)) + { + return false; + } + } + return false; + } + + bool hasScrollTarget(Vec2D position) override + { + auto componentList = m_component->as<ArtboardComponentList>(); + if (componentList->isCollapsed()) + { + return false; + } + const auto& order = componentList->orderedListIndices(); + for (auto it = order.rbegin(); it != order.rend(); ++it) + { + const int i = *it; + Vec2D listPosition; + if (!componentList->worldToLocal(position, &listPosition, i)) + { + continue; + } + auto stateMachine = componentList->stateMachineInstance(i); + if (stateMachine == nullptr) + { + continue; + } + if (stateMachine->hasScrollTargetAt(listPosition)) + { + return true; + } + if (stateMachine->scrollOccludedAt(listPosition)) + { + return false; + } + } + return false; + } + + bool occludesScroll(Vec2D position) override + { + auto componentList = m_component->as<ArtboardComponentList>(); + if (componentList->isCollapsed()) + { + return false; + } + const auto& order = componentList->orderedListIndices(); + for (auto it = order.rbegin(); it != order.rend(); ++it) + { + const int i = *it; + Vec2D listPosition; + if (!componentList->worldToLocal(position, &listPosition, i)) + { + continue; + } + auto stateMachine = componentList->stateMachineInstance(i); + if (stateMachine != nullptr && + stateMachine->scrollOccludedAt(listPosition)) + { + return true; + } + } + return false; + } + + bool scrollGestureActive() override + { + auto componentList = m_component->as<ArtboardComponentList>(); + const auto& order = componentList->orderedListIndices(); + if (componentList->isCollapsed()) + { + // A collapsed list never advances its items, so an idle timer + // there can never clear the latch. End it outright. + for (int i : order) + { + auto stateMachine = componentList->stateMachineInstance(i); + if (stateMachine != nullptr) + { + stateMachine->cancelScroll(); + } + } + return false; + } + for (int i : order) + { + auto stateMachine = componentList->stateMachineInstance(i); + if (stateMachine != nullptr && stateMachine->hasScrollLatch()) + { + return true; + } + } + return false; + } + + void cancelScroll() override + { + auto componentList = m_component->as<ArtboardComponentList>(); + for (int i : componentList->orderedListIndices()) + { + auto stateMachine = componentList->stateMachineInstance(i); + if (stateMachine != nullptr) + { + stateMachine->cancelScroll(); + } + } + } + + HitResult processScroll(Vec2D position, + const ScrollEvent& event, + float timeStamp) override + { + auto componentList = m_component->as<ArtboardComponentList>(); + if (componentList->isCollapsed()) + { + return HitResult::none; + } + const auto& order = componentList->orderedListIndices(); + // A latched item gets the event first; only then can another take it. + for (int pass = 0; pass < 2; pass++) + { + for (auto it = order.rbegin(); it != order.rend(); ++it) + { + const int i = *it; + auto stateMachine = componentList->stateMachineInstance(i); + if (stateMachine == nullptr || + (pass == 0 && !stateMachine->hasScrollLatch())) + { + continue; + } + Vec2D listPosition; + if (!componentList->worldToLocal(position, &listPosition, i)) + { + continue; + } + ScrollEvent mapped = event; + if (!itemDelta(componentList, + i, + position, + listPosition, + event.delta, + mapped.delta)) + { + continue; + } + auto result = stateMachine->pointerScroll(listPosition, + mapped, + timeStamp); + if (pass == 0 || result != HitResult::none) + { + return result; + } + if (stateMachine->scrollOccludedAt(listPosition)) + { + return HitResult::none; + } + } + } + return HitResult::none; + } + HitResult processEvent(Vec2D position, ListenerType hitType, bool canHit, @@ -1585,34 +2077,16 @@ int pointerId, float timeStamp) { - if (m_artboardInstance->frameOrigin()) - { - position -= Vec2D( - m_artboardInstance->originX() * m_artboardInstance->layoutWidth(), - m_artboardInstance->originY() * m_artboardInstance->layoutHeight()); - } - // Invert the artboard's own rotation/scale (applied in drawInternal after - // the frame-origin translation) so listener hit-testing maps into content - // space. Mirrors the adjustment in hitTest(Vec2D). + // Listener hit-testing runs in content space, so undo the frame origin and + // the artboard's own rotation/scale first. // // A degenerate (0 scale) self transform has no inverse: the contents // collapse to nothing, so nothing in them can be hit. We still run the pass // with every group forced to miss rather than returning early, so hover // unwinds and pending exits fire, and we cancel any gesture in flight so a // press held across the collapse can't resume when the scale comes back. - bool contentsCollapsed = false; - if (m_artboardInstance->hasSelfTransform()) - { - Mat2D inverse; - if (m_artboardInstance->selfTransform().invert(&inverse)) - { - position = inverse * position; - } - else - { - contentsCollapsed = true; - } - } + bool contentsCollapsed = !mapToContentSpace(position); + // First reset all listener groups before processing the events for (const auto& listenerGroup : m_listenerGroups) { @@ -1773,6 +2247,162 @@ pointerMove(position, timeStamp, pointerId); return hit; } +bool StateMachineInstance::mapToContentSpace(Vec2D& position, + Vec2D* delta) const +{ + if (m_artboardInstance->frameOrigin()) + { + position -= Vec2D( + m_artboardInstance->originX() * m_artboardInstance->layoutWidth(), + m_artboardInstance->originY() * m_artboardInstance->layoutHeight()); + } + if (m_artboardInstance->hasSelfTransform()) + { + Mat2D inverse; + if (!m_artboardInstance->selfTransform().invert(&inverse)) + { + return false; + } + position = inverse * position; + if (delta != nullptr) + { + // A delta is a vector: linear part only, no translation. + *delta = inverse * *delta - inverse * Vec2D(); + } + } + return true; +} + +HitResult StateMachineInstance::pointerScroll(Vec2D position, + const ScrollEvent& event, + float timeStamp, + int pointerId) +{ + ScrollEvent mapped = event; + if (!mapToContentSpace(position, &mapped.delta)) + { + // Nothing can map through a singular transform, so no later event + // could close this gesture. + cancelScroll(); + return HitResult::none; + } + if (m_scrollLatch != nullptr && !m_scrollLatch->scrollGestureActive()) + { + m_scrollLatch = nullptr; + } + HitComponent* target = m_scrollLatch; + if (target == nullptr) + { + // Sorted top-most first, so the innermost view that can move wins. + // An opaque hit that cannot scroll ends the walk, as it would for a + // pointer: nothing behind it may take the event. + for (const auto& hitShape : m_hitComponents) + { + if (hitShape->wantsScroll(position, mapped)) + { + target = hitShape.get(); + break; + } + if (hitShape->occludesScroll(position)) + { + break; + } + } + } + if (target == nullptr) + { + return HitResult::none; + } + auto result = target->processScroll(position, mapped, timeStamp); + m_scrollLatch = target->scrollGestureActive() ? target : nullptr; + return result; +} + +bool StateMachineInstance::wantsScroll(Vec2D position, const ScrollEvent& event) +{ + if (hasScrollLatch()) + { + return true; + } + ScrollEvent mapped = event; + if (!mapToContentSpace(position, &mapped.delta)) + { + return false; + } + for (const auto& hitShape : m_hitComponents) + { + if (hitShape->wantsScroll(position, mapped)) + { + return true; + } + if (hitShape->occludesScroll(position)) + { + return false; + } + } + return false; +} + +bool StateMachineInstance::hasScrollTargetAt(Vec2D position) +{ + if (hasScrollLatch()) + { + return true; + } + if (!mapToContentSpace(position)) + { + return false; + } + for (const auto& hitShape : m_hitComponents) + { + if (hitShape->hasScrollTarget(position)) + { + return true; + } + if (hitShape->occludesScroll(position)) + { + return false; + } + } + return false; +} + +bool StateMachineInstance::scrollOccludedAt(Vec2D position) +{ + if (!mapToContentSpace(position)) + { + // Collapsed contents hit nothing, so they hide nothing either. + return false; + } + // Only asked once nothing here wanted the event, so the first opaque hit + // decides. + for (const auto& hitShape : m_hitComponents) + { + if (hitShape->occludesScroll(position)) + { + return true; + } + } + return false; +} + +void StateMachineInstance::cancelScroll() +{ + if (m_scrollLatch != nullptr) + { + m_scrollLatch->cancelScroll(); + m_scrollLatch = nullptr; + } +} + +bool StateMachineInstance::hasScrollLatch() +{ + if (m_scrollLatch != nullptr && !m_scrollLatch->scrollGestureActive()) + { + m_scrollLatch = nullptr; + } + return m_scrollLatch != nullptr; +} #ifdef TESTING const LayerState* StateMachineInstance::layerState(size_t index)
diff --git a/src/constraints/scrolling/scroll_constraint.cpp b/src/constraints/scrolling/scroll_constraint.cpp index e5602f9..74df9d9 100644 --- a/src/constraints/scrolling/scroll_constraint.cpp +++ b/src/constraints/scrolling/scroll_constraint.cpp
@@ -11,6 +11,9 @@ using namespace rive; +// A wheel reports no end, so its gesture closes by going quiet. +static const float scrollIdleSeconds = 0.1f; + ScrollConstraint::~ScrollConstraint() { if (m_virtualizer != nullptr) @@ -254,13 +257,18 @@ m_layoutChildren.push_back(child); } -void ScrollConstraint::dragView(Vec2D delta, float timeStamp) +void ScrollConstraint::dragView(Vec2D delta, + float timeStamp, + bool trackVelocity) { // Scale once so the inertial release phase matches the drag feel. Vec2D scaledDelta(delta.x * dragMultiplier(), delta.y * dragMultiplier()); if (m_physics != nullptr) { - m_physics->accumulate(scaledDelta, timeStamp); + if (trackVelocity) + { + m_physics->accumulate(scaledDelta, timeStamp); + } scrollOffsetX(offsetX() + scaledDelta.x); scrollOffsetY(offsetY() + scaledDelta.y); return; @@ -278,6 +286,109 @@ scrollOffsetY(y); } +// dragMultiplier is bindable and unclamped, so interest has to be judged on +// the delta as applied: zero moves nothing, negative moves the other way. +Vec2D ScrollConstraint::scaledDelta(Vec2D delta) +{ + return Vec2D(delta.x * dragMultiplier(), delta.y * dragMultiplier()); +} + +// dragView writes unclamped, so only the elastic clamp hides the excess. +bool ScrollConstraint::isOverscrolled() +{ + if (infinite()) + { + return false; + } + return (constrainsHorizontal() && + offsetX() != clampResolvedOffset(offsetX(), true)) || + (constrainsVertical() && + offsetY() != clampResolvedOffset(offsetY(), false)); +} + +// An elastic view stretches at its edge instead of chaining, so this tests +// range on the delta's axes rather than available movement. +bool ScrollConstraint::canStretch(Vec2D rawDelta) +{ + Vec2D delta = scaledDelta(rawDelta); + if (!wheelEnabled() || !hasLayoutParent() || + parent()->parent() == nullptr || + !parent()->parent()->is<LayoutComponent>() || m_physics == nullptr || + physicsType() != ScrollPhysicsType::elastic) + { + return false; + } + bool wantsX = constrainsHorizontal() && delta.x != 0; + bool wantsY = constrainsVertical() && delta.y != 0; + if (infinite()) + { + return wantsX || wantsY; + } + return (wantsX && maxOffsetX() < 0) || (wantsY && maxOffsetY() < 0); +} + +bool ScrollConstraint::canConsume(Vec2D rawDelta) +{ + Vec2D delta = scaledDelta(rawDelta); + if (!wheelEnabled() || !hasLayoutParent() || + parent()->parent() == nullptr || + !parent()->parent()->is<LayoutComponent>()) + { + return false; + } + bool wantsX = constrainsHorizontal() && delta.x != 0; + bool wantsY = constrainsVertical() && delta.y != 0; + if (infinite()) + { + // Wraps forever, so any delta on a constrained axis moves. + return wantsX || wantsY; + } + if (wantsX && clampResolvedOffset(offsetX() + delta.x, true) != + clampResolvedOffset(offsetX(), true)) + { + return true; + } + return wantsY && clampResolvedOffset(offsetY() + delta.y, false) != + clampResolvedOffset(offsetY(), false); +} + +void ScrollConstraint::scrollBy(Vec2D delta) +{ + // Shares dragMultiplier with the drag path: it is the view's sensitivity. + Vec2D scaled(delta.x * dragMultiplier(), delta.y * dragMultiplier()); + if (constrainsHorizontal()) + { + scrollOffsetX(clampResolvedOffset(offsetX() + scaled.x, true)); + } + if (constrainsVertical()) + { + scrollOffsetY(clampResolvedOffset(offsetY() + scaled.y, false)); + } +} + +bool ScrollConstraint::beginScrollGesture() +{ + markScrollActivity(); + if (m_isScrolling) + { + return false; + } + m_isScrolling = true; + // User interaction supersedes held intents. + clearScrollIntents(); + m_lastFrameOffsetX = scrollOffsetX(); + m_lastFrameOffsetY = scrollOffsetY(); + return true; +} + +void ScrollConstraint::endScrollGesture() +{ + m_isScrolling = false; + m_scrollIdleSeconds = 0; + // m_isDragging belongs to the pointer path; a pointer drag can overlap + // this gesture and must outlive it. +} + std::vector<Vec2D> ScrollConstraint::collectSnapPoints() { std::vector<Vec2D> snappingPoints; @@ -308,6 +419,11 @@ void ScrollConstraint::runPhysics() { m_isDragging = false; + startPhysics(); +} + +void ScrollConstraint::startPhysics() +{ std::vector<Vec2D> snappingPoints = snap() ? collectSnapPoints() : std::vector<Vec2D>(); if (m_physics != nullptr) @@ -324,16 +440,44 @@ bool ScrollConstraint::advanceComponent(float elapsedSeconds, AdvanceFlags flags) { - if ((flags & AdvanceFlags::AdvanceNested) != AdvanceFlags::AdvanceNested || - isCollapsed()) + if (isCollapsed()) { // offsetX(0); // offsetY(0); + // A collapsed view never reaches the idle timer below, so a gesture + // left on it would hold the state machine's latch forever. + if (m_isScrolling) + { + stopPhysics(); + clearVelocity(); + endScrollGesture(); + } return false; } + if ((flags & AdvanceFlags::AdvanceNested) != AdvanceFlags::AdvanceNested) + { + return false; + } + if (m_isScrolling && + (flags & AdvanceFlags::NewFrame) == AdvanceFlags::NewFrame) + { + m_scrollIdleSeconds += elapsedSeconds; + if (m_scrollIdleSeconds >= scrollIdleSeconds) + { + // A gesture that reports no end settles here instead. + if (snap() || isOverscrolled()) + { + // The wheel path never primed, so the helpers may not exist. + // prepare also zeroes velocity, so nothing flings from here. + primePhysics(); + startPhysics(); + } + endScrollGesture(); + } + } if (m_physics == nullptr) { - return false; + return m_isScrolling; } if (m_physics->isRunning()) { @@ -341,14 +485,15 @@ scrollOffsetX(offset.x); scrollOffsetY(offset.y); } - // Detect a paused pointer during drag: if scrollOffset hasn't changed - // since the last new-frame advance, accumulate() didn't fire and speed - // is stale. + // Detect a paused pointer during a drag, or paused fingers during a + // trackpad gesture: if scrollOffset hasn't changed since the last + // new-frame advance, accumulate() didn't fire and speed is stale. if ((flags & AdvanceFlags::NewFrame) == AdvanceFlags::NewFrame) { bool hasMoved = scrollOffsetX() != m_lastFrameOffsetX || scrollOffsetY() != m_lastFrameOffsetY; - if ((m_isScrollBarDragging || m_isDragging) && !hasMoved) + if ((m_isScrollBarDragging || m_isDragging || m_isScrolling) && + !hasMoved) { clearVelocity(); } @@ -357,7 +502,9 @@ } // Keep advancing while a drag is active so the next new-frame advance can // detect a paused pointer and clear velocity when the pointer holds still. - return m_physics->enabled() || m_isScrollBarDragging || m_isDragging; + // A live scroll gesture keeps frames coming for its idle timer. + return m_physics->enabled() || m_isScrollBarDragging || m_isDragging || + m_isScrolling; } std::vector<DraggableProxy*> ScrollConstraint::draggables() @@ -443,9 +590,31 @@ return result; } +// A finished settle calls reset(), which destroys the elastic helpers, so a +// live gesture can lose them mid-flight. Without them the elastic clamp is +// gone and the view renders pinned at its edge while dragView still +// accumulates -- the stretch reappears in one jump on the next run(). +// Asked of the physics, not of enabled(): for clamped physics enabled() is +// just isRunning(), which would re-prime on every event and never let +// velocity accumulate. +bool ScrollConstraint::ensurePhysicsPrimed() +{ + if (m_physics != nullptr && !m_physics->isPrimed()) + { + primePhysics(); + return true; + } + return false; +} + void ScrollConstraint::initPhysics() { m_isDragging = true; + primePhysics(); +} + +void ScrollConstraint::primePhysics() +{ // User interaction supersedes held intents. clearScrollIntents(); m_lastFrameOffsetX = scrollOffsetX(); @@ -494,7 +663,7 @@ bool ScrollConstraint::scrollActive() { - return m_isDragging || m_isScrollBarDragging || + return m_isDragging || m_isScrollBarDragging || m_isScrolling || (m_physics != nullptr && m_physics->isRunning()); }
diff --git a/src/constraints/scrolling/scroll_constraint_proxy.cpp b/src/constraints/scrolling/scroll_constraint_proxy.cpp index 9a0fadd..682b122 100644 --- a/src/constraints/scrolling/scroll_constraint_proxy.cpp +++ b/src/constraints/scrolling/scroll_constraint_proxy.cpp
@@ -1,6 +1,7 @@ #include "rive/constraints/scrolling/scroll_bar_constraint.hpp" #include "rive/constraints/scrolling/scroll_constraint.hpp" #include "rive/constraints/scrolling/scroll_constraint_proxy.hpp" +#include "rive/constraints/scrolling/scroll_physics.hpp" #include "rive/math/vec2d.hpp" using namespace rive; @@ -79,4 +80,121 @@ } m_constraint->runPhysics(); return true; -} \ No newline at end of file +} +bool ViewportDraggableProxy::wantsScroll(const ScrollEvent& event) +{ + // A zero-delta event can't say which axis the gesture is on, so every + // branch tests the delta and it declines; the first directional one picks. + if (event.phase == ScrollPhase::inertiaCancel) + { + // Only matters to a view already coasting. + return m_constraint->isScrolling() || + (m_constraint->physics() != nullptr && + m_constraint->physics()->isRunning()); + } + if (event.precise) + { + // An elastic view stretches at its edge instead of chaining. + return m_constraint->canConsume(event.delta) || + m_constraint->canStretch(event.delta); + } + return m_constraint->canConsume(event.delta); +} + +bool ViewportDraggableProxy::acceptsScroll() +{ + return m_constraint->wheelEnabled(); +} + +void ViewportDraggableProxy::cancelScroll() +{ + m_constraint->stopPhysics(); + m_constraint->clearVelocity(); + m_constraint->endScrollGesture(); +} + +bool ViewportDraggableProxy::isScrollGestureActive() +{ + return m_constraint->isScrolling(); +} + +bool ViewportDraggableProxy::scroll(const ScrollEvent& event, float timeStamp) +{ + if (!m_constraint->wheelEnabled()) + { + return false; + } + switch (event.phase) + { + case ScrollPhase::inertiaCancel: + m_constraint->stopPhysics(); + m_constraint->clearVelocity(); + m_constraint->endScrollGesture(); + return true; + case ScrollPhase::begin: + m_constraint->beginScrollGesture(); + m_constraint->primePhysics(); + return true; + case ScrollPhase::update: + if (event.precise) + { + // Re-prime for a new gesture, or to take the view back from a + // running simulation before it overwrites this drag. + bool started = m_constraint->beginScrollGesture(); + bool running = m_constraint->physics() != nullptr && + m_constraint->physics()->isRunning(); + bool primed = started || running; + if (primed) + { + m_constraint->primePhysics(); + } + else + { + // A settle can strip the helpers mid-gesture. + primed = m_constraint->ensurePhysicsPrimed(); + } + // A primed clock has no earlier sample, so measuring this + // delta against it would divide by microseconds. + m_constraint->dragView(event.delta, timeStamp, !primed); + } + else + { + // A detent cancels inertia rather than being overwritten by + // it on the next advance. + if (m_constraint->physics() != nullptr && + m_constraint->physics()->isRunning()) + { + m_constraint->stopPhysics(); + } + m_constraint->beginScrollGesture(); + m_constraint->scrollBy(event.delta); + } + return true; + case ScrollPhase::momentum: + { + bool settling = m_constraint->physics() != nullptr && + m_constraint->physics()->isRunning(); + if (m_constraint->isOverscrolled() || settling) + { + // Inertia at the edge bounces now rather than stretching for + // the whole tail. Stays live so the latch holds. + if (!settling) + { + m_constraint->primePhysics(); + m_constraint->startPhysics(); + } + m_constraint->markScrollActivity(); + return true; + } + // Platform momentum lands as-is; flinging on top would double it. + m_constraint->beginScrollGesture(); + m_constraint->scrollBy(event.delta); + return true; + } + case ScrollPhase::end: + m_constraint->startPhysics(); + m_constraint->endScrollGesture(); + return true; + } + return false; +}
diff --git a/src/scene.cpp b/src/scene.cpp index 4f43749..6f664ca 100644 --- a/src/scene.cpp +++ b/src/scene.cpp
@@ -22,6 +22,10 @@ } HitResult Scene::pointerUp(Vec2D, int) { return HitResult::none; } HitResult Scene::pointerExit(Vec2D, int) { return HitResult::none; } +HitResult Scene::pointerScroll(Vec2D, const ScrollEvent&, float, int) +{ + return HitResult::none; +} size_t Scene::inputCount() const { return 0; } SMIInput* Scene::input(size_t index) const { return nullptr; }
diff --git a/tests/unit_tests/assets/layout/scroll_nested.riv b/tests/unit_tests/assets/layout/scroll_nested.riv new file mode 100644 index 0000000..6328efb --- /dev/null +++ b/tests/unit_tests/assets/layout/scroll_nested.riv Binary files differ
diff --git a/tests/unit_tests/runtime/scroll_input_test.cpp b/tests/unit_tests/runtime/scroll_input_test.cpp new file mode 100644 index 0000000..03a52dd --- /dev/null +++ b/tests/unit_tests/runtime/scroll_input_test.cpp
@@ -0,0 +1,832 @@ +#include "rive/animation/state_machine_instance.hpp" +#include "rive/constraints/scrolling/clamped_scroll_physics.hpp" +#include "rive/constraints/scrolling/scroll_constraint.hpp" +#include "rive/constraints/scrolling/scroll_physics.hpp" +#include "rive/layout_component.hpp" +#include "rive/scroll_event.hpp" +#include "utils/no_op_factory.hpp" +#include "utils/serializing_factory.hpp" +#include "rive_file_reader.hpp" +#include "rive_testing.hpp" +#include <catch.hpp> +#include <cstdio> +#include <memory> + +// Indirect scroll input: wheel and trackpad. Direct touch stays on the pointer +// path and is covered by layout_scroll_test / scroll_velocity_test. +// +// assets/layout/layout_scroll_vertical.riv scrolls y over [-610, 0]. +// assets/layout/layout_scroll_horizontal.riv scrolls x over [-610, 0]. + +static rive::ScrollEvent wheel(float dx, float dy) +{ + rive::ScrollEvent event; + event.delta = rive::Vec2D(dx, dy); + event.phase = rive::ScrollPhase::update; + event.precise = false; + return event; +} + +static rive::ScrollEvent trackpad(rive::ScrollPhase phase, float dx, float dy) +{ + rive::ScrollEvent event; + event.delta = rive::Vec2D(dx, dy); + event.phase = phase; + event.precise = true; + return event; +} + +// Long enough to trip the constraint's idle timer, which closes a gesture that +// reports no end of its own. +static void advancePastIdle(rive::StateMachineInstance* smi) +{ + smi->advanceAndApply(0.2f); +} + +TEST_CASE("Wheel scrolls without flinging", "[scrollinput]") +{ + auto file = ReadRiveFile("assets/layout/layout_scroll_vertical.riv"); + auto artboard = file->artboard(); + auto artboardInstance = artboard->instance(); + auto stateMachine = artboard->stateMachine("State Machine 1"); + REQUIRE(stateMachine != nullptr); + + rive::StateMachineInstance* smi = + new rive::StateMachineInstance(stateMachine, artboardInstance.get()); + auto scroll = artboardInstance->find<rive::ScrollConstraint>()[0]; + REQUIRE(scroll != nullptr); + smi->advanceAndApply(0.0f); + + REQUIRE(scroll->offsetY() == 0.0f); + + auto hit = smi->pointerScroll(rive::Vec2D(50.0f, 250.0f), wheel(0, -50.0f)); + REQUIRE(hit != rive::HitResult::none); + REQUIRE(scroll->offsetY() == -50.0f); + + smi->pointerScroll(rive::Vec2D(50.0f, 250.0f), wheel(0, -70.0f)); + REQUIRE(scroll->offsetY() == -120.0f); + + // A detent never accumulates velocity, so nothing can fling from it. + REQUIRE(scroll->velocityY() == 0.0f); + REQUIRE(scroll->physics()->isRunning() == false); + + delete smi; +} + +TEST_CASE("Wheel reports the view as actively scrolling until it goes idle", + "[scrollinput]") +{ + auto file = ReadRiveFile("assets/layout/layout_scroll_vertical.riv"); + auto artboard = file->artboard(); + auto artboardInstance = artboard->instance(); + auto stateMachine = artboard->stateMachine("State Machine 1"); + rive::StateMachineInstance* smi = + new rive::StateMachineInstance(stateMachine, artboardInstance.get()); + auto scroll = artboardInstance->find<rive::ScrollConstraint>()[0]; + smi->advanceAndApply(0.0f); + + REQUIRE(scroll->scrollActive() == false); + + smi->pointerScroll(rive::Vec2D(50.0f, 250.0f), wheel(0, -50.0f)); + REQUIRE(scroll->scrollActive() == true); + + // Still inside the idle window. + smi->advanceAndApply(0.05f); + REQUIRE(scroll->scrollActive() == true); + + advancePastIdle(smi); + REQUIRE(scroll->scrollActive() == false); + + delete smi; +} + +TEST_CASE("A view that cannot move declines the scroll", "[scrollinput]") +{ + auto file = ReadRiveFile("assets/layout/layout_scroll_vertical.riv"); + auto artboard = file->artboard(); + auto artboardInstance = artboard->instance(); + auto stateMachine = artboard->stateMachine("State Machine 1"); + rive::StateMachineInstance* smi = + new rive::StateMachineInstance(stateMachine, artboardInstance.get()); + auto scroll = artboardInstance->find<rive::ScrollConstraint>()[0]; + smi->advanceAndApply(0.0f); + + // Already at the top: a delta that would scroll back past it is declined, + // which is what lets a gesture chain out to an enclosing view. + REQUIRE(scroll->canConsume(rive::Vec2D(0, 50.0f)) == false); + auto hit = smi->pointerScroll(rive::Vec2D(50.0f, 250.0f), wheel(0, 50.0f)); + REQUIRE(hit == rive::HitResult::none); + REQUIRE(scroll->offsetY() == 0.0f); + REQUIRE(scroll->scrollActive() == false); + + // The other direction has room. + REQUIRE(scroll->canConsume(rive::Vec2D(0, -50.0f)) == true); + REQUIRE(smi->pointerScroll(rive::Vec2D(50.0f, 250.0f), wheel(0, -50.0f)) != + rive::HitResult::none); + + delete smi; +} + +TEST_CASE("Scroll outside the viewport is not consumed", "[scrollinput]") +{ + auto file = ReadRiveFile("assets/layout/layout_scroll_vertical.riv"); + auto artboard = file->artboard(); + auto artboardInstance = artboard->instance(); + auto stateMachine = artboard->stateMachine("State Machine 1"); + rive::StateMachineInstance* smi = + new rive::StateMachineInstance(stateMachine, artboardInstance.get()); + auto scroll = artboardInstance->find<rive::ScrollConstraint>()[0]; + smi->advanceAndApply(0.0f); + + auto hit = + smi->pointerScroll(rive::Vec2D(-500.0f, -500.0f), wheel(0, -50.0f)); + REQUIRE(hit == rive::HitResult::none); + REQUIRE(scroll->offsetY() == 0.0f); + + delete smi; +} + +TEST_CASE("A gesture stays latched to its target past the edge", + "[scrollinput]") +{ + auto file = ReadRiveFile("assets/layout/layout_scroll_vertical.riv"); + auto artboard = file->artboard(); + auto artboardInstance = artboard->instance(); + auto stateMachine = artboard->stateMachine("State Machine 1"); + rive::StateMachineInstance* smi = + new rive::StateMachineInstance(stateMachine, artboardInstance.get()); + auto scroll = artboardInstance->find<rive::ScrollConstraint>()[0]; + smi->advanceAndApply(0.0f); + + rive::Vec2D over(50.0f, 250.0f); + + // Run down to the end of the range. + REQUIRE(smi->pointerScroll(over, wheel(0, -600.0f)) != + rive::HitResult::none); + REQUIRE(smi->pointerScroll(over, wheel(0, -100.0f)) != + rive::HitResult::none); + REQUIRE(scroll->offsetY() == -610.0f); + REQUIRE(scroll->canConsume(rive::Vec2D(0, -100.0f)) == false); + + // Pinned at the edge but still mid-gesture: the latch keeps the event here + // rather than handing the tail of the flick to whatever encloses it. + REQUIRE(smi->hasScrollLatch() == true); + REQUIRE(smi->pointerScroll(over, wheel(0, -100.0f)) != + rive::HitResult::none); + + // Once the gesture goes idle the latch is released, and a view that can't + // move declines again. + advancePastIdle(smi); + REQUIRE(smi->hasScrollLatch() == false); + REQUIRE(smi->pointerScroll(over, wheel(0, -100.0f)) == + rive::HitResult::none); + + delete smi; +} + +TEST_CASE("Trackpad flings on release", "[scrollinput]") +{ + auto file = ReadRiveFile("assets/layout/layout_scroll_vertical.riv"); + auto artboard = file->artboard(); + auto artboardInstance = artboard->instance(); + auto stateMachine = artboard->stateMachine("State Machine 1"); + rive::StateMachineInstance* smi = + new rive::StateMachineInstance(stateMachine, artboardInstance.get()); + auto scroll = artboardInstance->find<rive::ScrollConstraint>()[0]; + smi->advanceAndApply(0.0f); + + rive::Vec2D over(50.0f, 250.0f); + + // A zero-delta begin claims nothing; the first directional update opens + // the gesture. + smi->pointerScroll(over, trackpad(rive::ScrollPhase::begin, 0, 0)); + REQUIRE(scroll->scrollActive() == false); + + smi->pointerScroll(over, trackpad(rive::ScrollPhase::update, 0, -40.0f)); + REQUIRE(scroll->scrollActive() == true); + smi->pointerScroll(over, trackpad(rive::ScrollPhase::update, 0, -40.0f)); + REQUIRE(scroll->offsetY() == -80.0f); + + // Unlike a wheel, a precise gesture accumulates velocity to fling from. + // The opening delta primes the clock, so the second one sets the speed. + REQUIRE(scroll->velocityY() != 0.0f); + + smi->pointerScroll(over, trackpad(rive::ScrollPhase::end, 0, 0)); + REQUIRE(scroll->physics()->isRunning() == true); + + delete smi; +} + +TEST_CASE("Platform momentum never starts a fling", "[scrollinput]") +{ + auto file = ReadRiveFile("assets/layout/layout_scroll_vertical.riv"); + auto artboard = file->artboard(); + auto artboardInstance = artboard->instance(); + auto stateMachine = artboard->stateMachine("State Machine 1"); + rive::StateMachineInstance* smi = + new rive::StateMachineInstance(stateMachine, artboardInstance.get()); + auto scroll = artboardInstance->find<rive::ScrollConstraint>()[0]; + smi->advanceAndApply(0.0f); + + rive::Vec2D over(50.0f, 250.0f); + + // A host whose platform generates its own momentum sends the tail as + // momentum deltas. They land directly; flinging on top would double them. + smi->pointerScroll(over, trackpad(rive::ScrollPhase::momentum, 0, -30.0f)); + smi->pointerScroll(over, trackpad(rive::ScrollPhase::momentum, 0, -20.0f)); + + REQUIRE(scroll->offsetY() == -50.0f); + REQUIRE(scroll->physics()->isRunning() == false); + REQUIRE(scroll->velocityY() == 0.0f); + + delete smi; +} + +TEST_CASE("Inertia cancel halts a running fling", "[scrollinput]") +{ + auto file = ReadRiveFile("assets/layout/layout_scroll_vertical.riv"); + auto artboard = file->artboard(); + auto artboardInstance = artboard->instance(); + auto stateMachine = artboard->stateMachine("State Machine 1"); + rive::StateMachineInstance* smi = + new rive::StateMachineInstance(stateMachine, artboardInstance.get()); + auto scroll = artboardInstance->find<rive::ScrollConstraint>()[0]; + smi->advanceAndApply(0.0f); + + rive::Vec2D over(50.0f, 250.0f); + + smi->pointerScroll(over, trackpad(rive::ScrollPhase::begin, 0, 0)); + smi->pointerScroll(over, trackpad(rive::ScrollPhase::update, 0, -40.0f)); + smi->pointerScroll(over, trackpad(rive::ScrollPhase::end, 0, 0)); + REQUIRE(scroll->physics()->isRunning() == true); + + // Fingers back down on the trackpad while the content is still coasting. + smi->pointerScroll(over, trackpad(rive::ScrollPhase::inertiaCancel, 0, 0)); + REQUIRE(scroll->physics()->isRunning() == false); + REQUIRE(scroll->velocityY() == 0.0f); + + delete smi; +} + +TEST_CASE("Scroll only moves the axes the view constrains", "[scrollinput]") +{ + auto file = ReadRiveFile("assets/layout/layout_scroll_horizontal.riv"); + auto artboard = file->artboard(); + auto artboardInstance = artboard->instance(); + auto stateMachine = artboard->stateMachine("State Machine 1"); + REQUIRE(stateMachine != nullptr); + rive::StateMachineInstance* smi = + new rive::StateMachineInstance(stateMachine, artboardInstance.get()); + auto scroll = artboardInstance->find<rive::ScrollConstraint>()[0]; + smi->advanceAndApply(0.0f); + + rive::Vec2D over(artboardInstance->width() / 2.0f, + artboardInstance->height() / 2.0f); + + // Vertical delta over a horizontal view: nothing to move, so it declines. + REQUIRE(scroll->canConsume(rive::Vec2D(0, -50.0f)) == false); + REQUIRE(smi->pointerScroll(over, wheel(0, -50.0f)) == + rive::HitResult::none); + + REQUIRE(smi->pointerScroll(over, wheel(-50.0f, 0)) != + rive::HitResult::none); + REQUIRE(scroll->offsetX() == -50.0f); + REQUIRE(scroll->offsetY() == 0.0f); + + delete smi; +} + +TEST_CASE("A snapping view settles when a wheel gesture goes idle", + "[scrollinput]") +{ + rive::SerializingFactory silver; + auto file = ReadRiveFile("assets/layout/layout_scroll_snap.riv", &silver); + auto artboard = file->artboard("main")->instance(); + REQUIRE(artboard != nullptr); + + auto viewModelInstance = + file->createDefaultViewModelInstance(artboard.get()); + if (viewModelInstance != nullptr) + { + artboard->bindViewModelInstance(viewModelInstance); + } + + auto smi = artboard->defaultStateMachine(); + REQUIRE(smi != nullptr); + smi->advanceAndApply(0.0f); + + auto scroll = artboard->find<rive::ScrollConstraint>()[0]; + REQUIRE(scroll != nullptr); + REQUIRE(scroll->snap() == true); + + rive::Vec2D over(artboard->width() / 2.0f, artboard->height() / 2.0f); + REQUIRE(smi->pointerScroll(over, wheel(-30.0f, 0)) != + rive::HitResult::none); + + // No fling from a detent, so nothing is running yet. + REQUIRE(scroll->physics()->isRunning() == false); + + // Going idle is the only end a wheel has; a snapping view settles there. + // Stepped in frames rather than one long advance so the settle is + // caught starting, not already finished. + bool settling = false; + for (int i = 0; i < 20 && !settling; i++) + { + smi->advanceAndApply(0.016f); + settling = scroll->physics()->isRunning(); + } + REQUIRE(settling == true); +} + +TEST_CASE("A non-snapping view just stops when a wheel gesture goes idle", + "[scrollinput]") +{ + auto file = ReadRiveFile("assets/layout/layout_scroll_vertical.riv"); + auto artboard = file->artboard(); + auto artboardInstance = artboard->instance(); + auto stateMachine = artboard->stateMachine("State Machine 1"); + rive::StateMachineInstance* smi = + new rive::StateMachineInstance(stateMachine, artboardInstance.get()); + auto scroll = artboardInstance->find<rive::ScrollConstraint>()[0]; + smi->advanceAndApply(0.0f); + REQUIRE(scroll->snap() == false); + + smi->pointerScroll(rive::Vec2D(50.0f, 250.0f), wheel(0, -50.0f)); + advancePastIdle(smi); + + REQUIRE(scroll->offsetY() == -50.0f); + REQUIRE(scroll->physics()->isRunning() == false); + REQUIRE(scroll->scrollActive() == false); + + delete smi; +} + +// assets/layout/scroll_nested.riv is built from the scroll_demo CLI sample +// (packages/rive-cli/samples/scroll_demo): a vertical list with a horizontal +// strip as its fourth row. The hit point and the extents are read back from +// the resolved layout rather than hard coded. + +struct NestedScene +{ + std::unique_ptr<rive::ArtboardInstance> artboard; + rive::StateMachineInstance* smi = nullptr; + rive::ScrollConstraint* outer = nullptr; + rive::ScrollConstraint* strip = nullptr; + rive::Vec2D overStrip; +}; + +static void openNested(rive::File* file, NestedScene& scene) +{ + auto artboard = file->artboard(); + scene.artboard = artboard->instance(); + auto stateMachine = artboard->stateMachine("State Machine 1"); + REQUIRE(stateMachine != nullptr); + scene.smi = + new rive::StateMachineInstance(stateMachine, scene.artboard.get()); + scene.outer = scene.artboard->find<rive::ScrollConstraint>("Scroll V"); + scene.strip = scene.artboard->find<rive::ScrollConstraint>("Scroll H"); + REQUIRE(scene.outer != nullptr); + REQUIRE(scene.strip != nullptr); + scene.smi->advanceAndApply(0.0f); + + auto stripViewport = + scene.artboard->find<rive::LayoutComponent>("Strip Viewport"); + REQUIRE(stripViewport != nullptr); + scene.overStrip = stripViewport->worldBounds().center(); +} + +TEST_CASE("A scroll the inner view cannot use chains to the outer one", + "[scrollinput]") +{ + auto file = ReadRiveFile("assets/layout/scroll_nested.riv"); + NestedScene scene; + openNested(file.get(), scene); + + // The strip is topmost under this point but only constrains x, so a + // vertical scroll falls through to the list behind it. + REQUIRE(scene.smi->pointerScroll(scene.overStrip, wheel(0, -50.0f)) != + rive::HitResult::none); + REQUIRE(scene.outer->offsetY() == -50.0f); + REQUIRE(scene.strip->offsetX() == 0.0f); + + delete scene.smi; +} + +TEST_CASE("The inner view takes a scroll on the axis it owns", "[scrollinput]") +{ + auto file = ReadRiveFile("assets/layout/scroll_nested.riv"); + NestedScene scene; + openNested(file.get(), scene); + + REQUIRE(scene.smi->pointerScroll(scene.overStrip, wheel(-50.0f, 0)) != + rive::HitResult::none); + REQUIRE(scene.strip->offsetX() == -50.0f); + REQUIRE(scene.outer->offsetY() == 0.0f); + + delete scene.smi; +} + +TEST_CASE("A latched inner view does not hand its tail to the outer one", + "[scrollinput]") +{ + auto file = ReadRiveFile("assets/layout/scroll_nested.riv"); + NestedScene scene; + openNested(file.get(), scene); + + // Run the strip to its end. + scene.smi->pointerScroll(scene.overStrip, wheel(-700.0f, 0)); + REQUIRE(scene.strip->offsetX() == scene.strip->maxOffsetX()); + REQUIRE(scene.strip->canConsume(rive::Vec2D(-100.0f, 0)) == false); + + // Still the same gesture, now carrying a vertical component the strip + // could never use. Without the latch this is where the outer list would + // grab the rest of the flick. + scene.smi->pointerScroll(scene.overStrip, wheel(-100.0f, -100.0f)); + REQUIRE(scene.outer->offsetY() == 0.0f); + REQUIRE(scene.strip->offsetX() == scene.strip->maxOffsetX()); + + // Idle releases the latch, and the next gesture chains normally again. + advancePastIdle(scene.smi); + REQUIRE(scene.smi->hasScrollLatch() == false); + scene.smi->pointerScroll(scene.overStrip, wheel(0, -50.0f)); + REQUIRE(scene.outer->offsetY() == -50.0f); + + delete scene.smi; +} + +TEST_CASE("A gesture that reports no end closes when it goes idle", + "[scrollinput]") +{ + auto file = ReadRiveFile("assets/layout/layout_scroll_vertical.riv"); + auto artboard = file->artboard(); + auto artboardInstance = artboard->instance(); + auto stateMachine = artboard->stateMachine("State Machine 1"); + rive::StateMachineInstance* smi = + new rive::StateMachineInstance(stateMachine, artboardInstance.get()); + auto scroll = artboardInstance->find<rive::ScrollConstraint>()[0]; + smi->advanceAndApply(0.0f); + + rive::Vec2D over(50.0f, 250.0f); + + // A platform that owns its own momentum (macOS) sends begin and updates + // but never an end, so runPhysics never runs to clear what initPhysics + // set. Only the idle timer closes the gesture. + smi->pointerScroll(over, trackpad(rive::ScrollPhase::begin, 0, 0)); + smi->pointerScroll(over, trackpad(rive::ScrollPhase::update, 0, -40.0f)); + smi->pointerScroll(over, trackpad(rive::ScrollPhase::update, 0, -40.0f)); + REQUIRE(scroll->scrollActive() == true); + + advancePastIdle(smi); + + // scrollActive covers the dragging flag, so this failing means the + // artboard would keep asking to advance forever. + REQUIRE(scroll->scrollActive() == false); + REQUIRE(scroll->physics()->isRunning() == false); + + delete smi; +} + +TEST_CASE("An overscrolled gesture with no end springs back on idle", + "[scrollinput]") +{ + auto file = ReadRiveFile("assets/layout/layout_scroll_vertical.riv"); + auto artboard = file->artboard(); + auto artboardInstance = artboard->instance(); + auto stateMachine = artboard->stateMachine("State Machine 1"); + rive::StateMachineInstance* smi = + new rive::StateMachineInstance(stateMachine, artboardInstance.get()); + auto scroll = artboardInstance->find<rive::ScrollConstraint>()[0]; + smi->advanceAndApply(0.0f); + REQUIRE(scroll->snap() == false); + + rive::Vec2D over(50.0f, 250.0f); + + // A precise drag writes the offset unclamped, so pushing past the end + // leaves it out of range with only the elastic clamp hiding the excess. + smi->pointerScroll(over, trackpad(rive::ScrollPhase::begin, 0, 0)); + smi->pointerScroll(over, trackpad(rive::ScrollPhase::update, 0, -900.0f)); + REQUIRE(scroll->offsetY() < scroll->maxOffsetY()); + REQUIRE(scroll->isOverscrolled() == true); + + // No end is coming -- this is the macOS / editor path -- so the idle + // timer has to start the spring back even though the view does not snap. + // Stepped in frames: one long advance would start AND finish the settle, + // since the decay clamps at elapsed * friction >= 1. + bool settling = false; + for (int i = 0; i < 20 && !settling; i++) + { + smi->advanceAndApply(0.016f); + settling = scroll->physics()->isRunning(); + } + REQUIRE(settling == true); + + // Let it settle. + for (int i = 0; i < 300 && scroll->physics()->isRunning(); i++) + { + smi->advanceAndApply(0.016f); + } + REQUIRE(scroll->offsetY() == Approx(scroll->maxOffsetY())); + REQUIRE(scroll->isOverscrolled() == false); + + delete smi; +} + +TEST_CASE("A precise gesture stretches an elastic view at its edge", + "[scrollinput]") +{ + auto file = ReadRiveFile("assets/layout/layout_scroll_vertical.riv"); + auto artboard = file->artboard(); + auto artboardInstance = artboard->instance(); + auto stateMachine = artboard->stateMachine("State Machine 1"); + rive::StateMachineInstance* smi = + new rive::StateMachineInstance(stateMachine, artboardInstance.get()); + auto scroll = artboardInstance->find<rive::ScrollConstraint>()[0]; + smi->advanceAndApply(0.0f); + + rive::Vec2D over(50.0f, 250.0f); + + // Sitting at the top, so there is no movement available in this direction. + REQUIRE(scroll->offsetY() == 0.0f); + REQUIRE(scroll->canConsume(rive::Vec2D(0, 40.0f)) == false); + + // A detented wheel declines and chains outward... + REQUIRE(smi->pointerScroll(over, wheel(0, 40.0f)) == rive::HitResult::none); + REQUIRE(scroll->offsetY() == 0.0f); + + // ...but a precise gesture pulls the rubber band instead of passing the + // gesture on, which is what the platforms do. + REQUIRE(scroll->canStretch(rive::Vec2D(0, 40.0f)) == true); + REQUIRE(smi->pointerScroll(over, + trackpad(rive::ScrollPhase::update, 0, 40.0f)) != + rive::HitResult::none); + REQUIRE(scroll->offsetY() > 0.0f); + REQUIRE(scroll->isOverscrolled() == true); + + // The visible give is damped well below the raw pull. + REQUIRE(scroll->clampedOffsetY() < scroll->offsetY()); + + delete smi; +} + +TEST_CASE("A settle mid-gesture does not leave the view rendering pinned", + "[scrollinput]") +{ + auto file = ReadRiveFile("assets/layout/layout_scroll_vertical.riv"); + auto artboard = file->artboard(); + auto artboardInstance = artboard->instance(); + auto stateMachine = artboard->stateMachine("State Machine 1"); + rive::StateMachineInstance* smi = + new rive::StateMachineInstance(stateMachine, artboardInstance.get()); + auto scroll = artboardInstance->find<rive::ScrollConstraint>()[0]; + smi->advanceAndApply(0.0f); + + rive::Vec2D over(50.0f, 250.0f); + + // Overscroll, then let inertia bounce it back. + smi->pointerScroll(over, trackpad(rive::ScrollPhase::begin, 0, 0)); + smi->pointerScroll(over, trackpad(rive::ScrollPhase::update, 0, -900.0f)); + smi->pointerScroll(over, trackpad(rive::ScrollPhase::momentum, 0, -20.0f)); + REQUIRE(scroll->physics()->isRunning() == true); + + // Run the settle out, with the coast still feeding momentum as a real + // one does -- those events are what hold the gesture open, since the idle + // timer is reset by events and not by frames. Finishing the settle calls + // reset(), which destroys the elastic helpers mid-gesture. + for (int i = 0; i < 300 && scroll->physics()->isRunning(); i++) + { + smi->advanceAndApply(0.016f); + smi->pointerScroll(over, + trackpad(rive::ScrollPhase::momentum, 0, -1.0f)); + } + REQUIRE(scroll->isScrolling() == true); + REQUIRE(scroll->physics()->enabled() == false); + + // A new pull in the same gesture has to re-prime, or the offset + // accumulates while the rendered position stays hard-clamped at the edge. + smi->pointerScroll(over, trackpad(rive::ScrollPhase::update, 0, -60.0f)); + REQUIRE(scroll->physics()->enabled() == true); + REQUIRE(scroll->clampedOffsetY() < scroll->maxOffsetY()); + + delete smi; +} + +TEST_CASE("Interest follows dragMultiplier, not the raw delta", "[scrollinput]") +{ + auto file = ReadRiveFile("assets/layout/layout_scroll_vertical.riv"); + auto artboard = file->artboard(); + auto artboardInstance = artboard->instance(); + auto stateMachine = artboard->stateMachine("State Machine 1"); + rive::StateMachineInstance* smi = + new rive::StateMachineInstance(stateMachine, artboardInstance.get()); + auto scroll = artboardInstance->find<rive::ScrollConstraint>()[0]; + smi->advanceAndApply(0.0f); + + rive::Vec2D over(50.0f, 250.0f); + + // dragMultiplier is bindable and unclamped. At zero the view cannot move + // at all, so it must decline rather than latch and swallow the gesture. + scroll->dragMultiplier(0.0f); + REQUIRE(scroll->canConsume(rive::Vec2D(0, -50.0f)) == false); + REQUIRE(scroll->canStretch(rive::Vec2D(0, -50.0f)) == false); + REQUIRE(smi->pointerScroll(over, wheel(0, -50.0f)) == + rive::HitResult::none); + + // A negative multiplier inverts the gesture, so interest has to be judged + // at the edge the content will actually travel toward. + scroll->dragMultiplier(-1.0f); + REQUIRE(scroll->offsetY() == 0.0f); + REQUIRE(scroll->canConsume(rive::Vec2D(0, 50.0f)) == true); + REQUIRE(scroll->canConsume(rive::Vec2D(0, -50.0f)) == false); + + smi->pointerScroll(over, wheel(0, 50.0f)); + REQUIRE(scroll->offsetY() == -50.0f); + + delete smi; +} + +TEST_CASE("A no-begin gesture does not derive velocity from its first delta", + "[scrollinput]") +{ + auto file = ReadRiveFile("assets/layout/layout_scroll_vertical.riv"); + auto artboard = file->artboard(); + auto artboardInstance = artboard->instance(); + auto stateMachine = artboard->stateMachine("State Machine 1"); + rive::StateMachineInstance* smi = + new rive::StateMachineInstance(stateMachine, artboardInstance.get()); + auto scroll = artboardInstance->find<rive::ScrollConstraint>()[0]; + smi->advanceAndApply(0.0f); + + rive::Vec2D over(50.0f, 250.0f); + + // Wayland and the editor send no begin, so this delta both primes physics + // and carries movement. prepare() seeds the velocity clock to now, so + // measuring this delta against it would divide by microseconds -- the + // speed, and the acceleration derived from it, would be astronomical and + // the eventual fling wild. + smi->pointerScroll(over, trackpad(rive::ScrollPhase::update, 0, -40.0f)); + REQUIRE(scroll->offsetY() == -40.0f); + REQUIRE(scroll->velocityY() == 0.0f); + + // The next delta has a real interval behind it, so it establishes speed. + smi->pointerScroll(over, trackpad(rive::ScrollPhase::update, 0, -40.0f)); + REQUIRE(scroll->offsetY() == -80.0f); + REQUIRE(scroll->velocityY() != 0.0f); + + delete smi; +} + +TEST_CASE("A no-begin drag takes control from a running fling", "[scrollinput]") +{ + auto file = ReadRiveFile("assets/layout/layout_scroll_vertical.riv"); + auto artboard = file->artboard(); + auto artboardInstance = artboard->instance(); + auto stateMachine = artboard->stateMachine("State Machine 1"); + rive::StateMachineInstance* smi = + new rive::StateMachineInstance(stateMachine, artboardInstance.get()); + auto scroll = artboardInstance->find<rive::ScrollConstraint>()[0]; + smi->advanceAndApply(0.0f); + + rive::Vec2D over(50.0f, 250.0f); + + // Throw it, so a fling is running. + smi->pointerScroll(over, trackpad(rive::ScrollPhase::begin, 0, 0)); + smi->pointerScroll(over, trackpad(rive::ScrollPhase::update, 0, -40.0f)); + smi->pointerScroll(over, trackpad(rive::ScrollPhase::update, 0, -40.0f)); + smi->pointerScroll(over, trackpad(rive::ScrollPhase::end, 0, 0)); + REQUIRE(scroll->physics()->isRunning() == true); + smi->advanceAndApply(0.016f); + + // A host with no begin opens its next gesture with a plain update. It has + // to take the view over: otherwise the simulation keeps running and the + // next advance overwrites whatever the drag just did. + float before = scroll->offsetY(); + smi->pointerScroll(over, trackpad(rive::ScrollPhase::update, 0, -25.0f)); + REQUIRE(scroll->physics()->isRunning() == false); + REQUIRE(scroll->offsetY() == Approx(before - 25.0f)); + + // The drag survives the frame rather than being replaced by the fling. + float dragged = scroll->offsetY(); + smi->advanceAndApply(0.016f); + REQUIRE(scroll->offsetY() == Approx(dragged)); + + delete smi; +} + +TEST_CASE("A zero-delta begin does not pick the target for the gesture", + "[scrollinput]") +{ + auto file = ReadRiveFile("assets/layout/scroll_nested.riv"); + NestedScene scene; + openNested(file.get(), scene); + + // Flutter sends begin before any delta. It says nothing about the axis, + // so latching on it would pin the gesture to the strip (topmost here) and + // swallow every vertical update instead of chaining to the list. + REQUIRE( + scene.smi->pointerScroll(scene.overStrip, + trackpad(rive::ScrollPhase::begin, 0, 0)) == + rive::HitResult::none); + REQUIRE(scene.smi->hasScrollLatch() == false); + + // The first directional update chooses, and chooses correctly. + scene.smi->pointerScroll(scene.overStrip, + trackpad(rive::ScrollPhase::update, 0, -50.0f)); + REQUIRE(scene.outer->offsetY() == -50.0f); + REQUIRE(scene.strip->offsetX() == 0.0f); + + delete scene.smi; +} + +TEST_CASE("A clamped view keeps accumulating trackpad velocity", + "[scrollinput]") +{ + auto file = ReadRiveFile("assets/layout/layout_scroll_vertical.riv"); + auto artboard = file->artboard(); + auto artboardInstance = artboard->instance(); + auto stateMachine = artboard->stateMachine("State Machine 1"); + rive::StateMachineInstance* smi = + new rive::StateMachineInstance(stateMachine, artboardInstance.get()); + auto scroll = artboardInstance->find<rive::ScrollConstraint>()[0]; + // The asset's physics is elastic. Clamped physics has no helpers for a + // settle to destroy, so a gesture must never read it as unprimed and + // re-prime on every delta: that would zero the velocity each time. + delete scroll->physics(); + scroll->physics(new rive::ClampedScrollPhysics()); + smi->advanceAndApply(0.0f); + + rive::Vec2D over(50.0f, 250.0f); + smi->pointerScroll(over, trackpad(rive::ScrollPhase::update, 0, -40.0f)); + smi->pointerScroll(over, trackpad(rive::ScrollPhase::update, 0, -40.0f)); + smi->pointerScroll(over, trackpad(rive::ScrollPhase::update, 0, -40.0f)); + REQUIRE(scroll->offsetY() == -120.0f); + // Only the opening delta primes the clock; every later one sets the speed. + REQUIRE(scroll->velocityY() != 0.0f); + + delete smi; +} + +TEST_CASE("A wheel gesture going idle does not end a pointer drag", + "[scrollinput]") +{ + auto file = ReadRiveFile("assets/layout/layout_scroll_vertical.riv"); + auto artboard = file->artboard(); + auto artboardInstance = artboard->instance(); + auto stateMachine = artboard->stateMachine("State Machine 1"); + rive::StateMachineInstance* smi = + new rive::StateMachineInstance(stateMachine, artboardInstance.get()); + auto scroll = artboardInstance->find<rive::ScrollConstraint>()[0]; + smi->advanceAndApply(0.0f); + + rive::Vec2D over(50.0f, 250.0f); + smi->pointerScroll(over, wheel(0, -20.0f)); + REQUIRE(scroll->isScrolling() == true); + + // Press inside the idle window, so a pointer drag overlaps the gesture. + smi->pointerMove(over); + smi->pointerDown(over); + REQUIRE(scroll->isDragging() == true); + + // The wheel gesture closes; the drag it overlapped is still down. + advancePastIdle(smi); + REQUIRE(scroll->isScrolling() == false); + REQUIRE(scroll->isDragging() == true); + + smi->pointerUp(over); + REQUIRE(scroll->isDragging() == false); + + delete smi; +} + +TEST_CASE("Wheel input can be switched off per view while drag still works", + "[scrollinput]") +{ + auto file = ReadRiveFile("assets/layout/layout_scroll_vertical.riv"); + auto artboard = file->artboard(); + auto artboardInstance = artboard->instance(); + auto stateMachine = artboard->stateMachine("State Machine 1"); + rive::StateMachineInstance* smi = + new rive::StateMachineInstance(stateMachine, artboardInstance.get()); + auto scroll = artboardInstance->find<rive::ScrollConstraint>()[0]; + smi->advanceAndApply(0.0f); + + // wheelInteractive is bit 0 of scrollFlags and defaults on. + REQUIRE(scroll->wheelInteractive() == true); + scroll->wheelInteractive(false); + REQUIRE(scroll->interactive() == true); + + rive::Vec2D over(50.0f, 250.0f); + // Neither a target for the host to claim, nor a taker for the event. + REQUIRE(smi->hasScrollTargetAt(over) == false); + REQUIRE(smi->pointerScroll(over, wheel(0, -50.0f)) == + rive::HitResult::none); + REQUIRE(scroll->offsetY() == 0.0f); + + // Pointer drag is gated by interactive alone. + smi->pointerMove(over); + smi->pointerDown(over); + smi->pointerMove(rive::Vec2D(50.0f, 150.0f)); + REQUIRE(scroll->offsetY() < 0.0f); + smi->pointerUp(rive::Vec2D(50.0f, 150.0f)); + + delete smi; +}