blob: 03a52dd3981c025e801ecb671e2324f895555762 [file]
#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;
}