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;
+}