blob: 815ac055b6954de975f7581df602a404a84d78c8 [file]
#include "rive/constraints/scrolling/scroll_constraint.hpp"
#include "rive/artboard_component_list.hpp"
#include "rive/constraints/scrolling/scroll_constraint_proxy.hpp"
#include "rive/constraints/scrolling/scroll_virtualizer.hpp"
#include "rive/constraints/scrolling/virtual_layout.hpp"
#include "rive/constraints/transform_constraint.hpp"
#include "rive/core_context.hpp"
#include "rive/layout/grid_track.hpp"
#include "rive/layout/layout_component_style.hpp"
#include "rive/layout/layout_node_provider.hpp"
#include "rive/transform_component.hpp"
#include "rive/virtualizing_component.hpp"
#include "rive/math/mat2d.hpp"
using namespace rive;
ScrollConstraint::~ScrollConstraint()
{
if (m_virtualizer != nullptr)
{
delete m_virtualizer;
m_virtualizer = nullptr;
}
delete m_virtualLayout;
m_virtualLayout = nullptr;
m_layoutChildren.clear();
delete m_physics;
}
// Virtualized content along the scroll axis. Matches the non-virtualized
// layout size; an infinite carousel's cycle length stays padding free.
float ScrollConstraint::virtualContentExtent(float padding)
{
auto layout = ensureVirtualLayout();
if (layout == nullptr)
{
return 0.0f;
}
if (infinite())
{
return layout->cycleExtent();
}
// A hugging grid is as tall as layout makes it from every item, which
// percent rows can run past.
auto style = content()->style();
if (layout->isGrid() && layout->rowsLaidOut() && style != nullptr &&
style->layoutHeightScaleType() == (uint8_t)LayoutScaleType::hug)
{
return content()->layoutHeight();
}
return layout->extent() + padding;
}
float ScrollConstraint::contentWidth()
{
if (virtualize() && !virtualAxisIsColumn())
{
return virtualContentExtent(
content()->paddingLeft() + content()->paddingRight());
}
if (loopsX())
{
// A grid's columns cycle on from their copy one cycle along.
auto layout = ensureVirtualLayout();
return layout != nullptr ? layout->columnCycleExtent() : 0.0f;
}
return content()->layoutWidth();
}
float ScrollConstraint::contentHeight()
{
if (virtualize() && virtualAxisIsColumn())
{
return virtualContentExtent(
content()->paddingTop() + content()->paddingBottom());
}
return content()->layoutHeight();
}
float ScrollConstraint::viewportWidth()
{
return direction() == DraggableConstraintDirection::vertical
? viewport()->layoutWidth()
: std::max(0.0f, viewport()->layoutWidth() - content()->layoutX());
}
float ScrollConstraint::viewportHeight()
{
return direction() == DraggableConstraintDirection::horizontal
? viewport()->layoutHeight()
: std::max(
0.0f,
viewport()->layoutHeight() - content()->layoutY());
}
float ScrollConstraint::visibleWidthRatio()
{
if (contentWidth() == 0)
{
return 1;
}
return std::min(1.0f, viewportWidth() / contentWidth());
}
float ScrollConstraint::visibleHeightRatio()
{
if (contentHeight() == 0)
{
return 1;
}
return std::min(1.0f, viewportHeight() / contentHeight());
}
float ScrollConstraint::minOffsetX()
{
if (loopsX())
{
return std::numeric_limits<float>::infinity();
}
return 0;
}
float ScrollConstraint::minOffsetY()
{
if (loopsY())
{
return std::numeric_limits<float>::infinity();
}
return 0;
}
float ScrollConstraint::maxOffsetX()
{
if (loopsX())
{
return -std::numeric_limits<float>::infinity();
}
return std::min(
0.0f,
viewportWidth() - contentWidth() - viewport()->paddingRight());
}
float ScrollConstraint::maxOffsetY()
{
if (loopsY())
{
return -std::numeric_limits<float>::infinity();
}
return std::min(
0.0f,
viewportHeight() - contentHeight() - viewport()->paddingBottom());
}
float ScrollConstraint::clampedOffsetX()
{
if (loopsX())
{
return offsetX();
}
if (maxOffsetX() > 0)
{
return 0;
}
if (m_physics != nullptr && m_physics->enabled())
{
return m_physics
->clamp(
Vec2D(maxOffsetX(), maxOffsetY()),
Vec2D(minOffsetX(), minOffsetY()),
Vec2D(m_offsetX, m_offsetY))
.x;
}
return math::clamp(m_offsetX, maxOffsetX(), 0);
}
float ScrollConstraint::clampedOffsetY()
{
if (loopsY())
{
return offsetY();
}
if (maxOffsetY() > 0)
{
return 0;
}
if (m_physics != nullptr && m_physics->enabled())
{
return m_physics
->clamp(
Vec2D(maxOffsetX(), maxOffsetY()),
Vec2D(minOffsetX(), minOffsetY()),
Vec2D(m_offsetX, m_offsetY))
.y;
}
return math::clamp(m_offsetY, maxOffsetY(), 0);
}
void ScrollConstraint::offsetX(float value)
{
if (m_offsetX == value)
{
return;
}
m_offsetX = value;
content()->markWorldTransformDirty();
}
void ScrollConstraint::offsetY(float value)
{
if (m_offsetY == value)
{
return;
}
m_offsetY = value;
content()->markWorldTransformDirty();
}
bool ScrollConstraint::mainAxisIsColumn()
{
return content() != nullptr && content()->mainAxisIsColumn();
}
bool ScrollConstraint::virtualizesGrid()
{
if (!virtualize() || content() == nullptr || !content()->isGridContainer() ||
direction() == DraggableConstraintDirection::horizontal)
{
return false;
}
// Anything but a list keeps its cell while its row is unrealized, which
// would shift realized items off their columns.
return scrollChildrenAreLists();
}
bool ScrollConstraint::virtualizesGridColumns()
{
// Columns past the viewport's sides are windowed too, however it scrolls.
// A carousel only windows them when it cycles them, scrolled both ways.
return virtualizesGrid() &&
(!infinite() || direction() == DraggableConstraintDirection::all);
}
bool ScrollConstraint::indexesGridCells()
{
return direction() == DraggableConstraintDirection::all &&
virtualizesGridColumns();
}
bool ScrollConstraint::indexesHorizontally()
{
// Scrolled both ways, only the virtualized axis has an item order.
if (virtualize() && direction() == DraggableConstraintDirection::all)
{
return !virtualAxisIsColumn();
}
return constrainsHorizontal();
}
// A carousel cycles its virtualized axis, and a grid's columns when it
// scrolls both ways; any other axis it scrolls is bounded.
bool ScrollConstraint::loopsX()
{
return infinite() && (!virtualAxisIsColumn() || virtualizesGridColumns());
}
bool ScrollConstraint::loopsY() { return infinite() && virtualAxisIsColumn(); }
bool ScrollConstraint::scrollChildrenAreLists()
{
for (auto child : scrollChildren())
{
if (VirtualizingComponent::fromScrollChild(child) == nullptr)
{
return false;
}
}
return true;
}
static float innerSize(LayoutComponent* layout, bool vertical)
{
return vertical ? layout->layoutHeight() - layout->paddingTop() -
layout->paddingBottom()
: layout->layoutWidth() - layout->paddingLeft() -
layout->paddingRight();
}
// The content's margins along its lines' flow axis: points, or percents of
// its parent's inner width, as layout resolves them.
static float flowMargins(
LayoutComponent* content,
LayoutComponent* parent,
bool flowIsVertical)
{
auto style = content->style();
if (style == nullptr)
{
return 0.0f;
}
float reference = innerSize(parent, false);
auto resolve = [&](float value, uint8_t unit) {
const uint8_t point = 1, percent = 2;
return unit == point ? value
: unit == percent ? value / 100.0f * reference
: 0.0f;
};
return flowIsVertical
? resolve(style->marginTop(), style->marginTopUnitsValue()) +
resolve(
style->marginBottom(),
style->marginBottomUnitsValue())
: resolve(style->marginLeft(), style->marginLeftUnitsValue()) +
resolve(
style->marginRight(),
style->marginRightUnitsValue());
}
bool ScrollConstraint::virtualAxisIsColumn()
{
// Grid rows stack down the block axis.
if (virtualizesGrid())
{
return true;
}
// Wrapped lines stack along the cross axis, so that's what scrolls.
bool isColumn = mainAxisIsColumn();
return virtualize() && content() != nullptr && content()->wrapsLines()
? !isColumn
: isColumn;
}
void ScrollConstraint::constrain(TransformComponent* component)
{
resolveScrollIntents();
m_scrollTransform = Mat2D::fromTranslate(
constrainsHorizontal() ? clampedOffsetX() : 0,
constrainsVertical() ? clampedOffsetY() : 0);
m_childConstraintAppliedCount = 0;
}
void ScrollConstraint::constrainChild(LayoutNodeProvider* child)
{
auto component = child->transformComponent();
if (component == nullptr)
{
return;
}
auto targetTransform = offsetInParentFrame(component, m_scrollTransform);
TransformConstraint::constrainWorld(
component,
component->worldTransform(),
m_componentsA,
targetTransform,
m_componentsB,
strength());
m_childConstraintAppliedCount += 1;
constrainVirtualized();
}
void ScrollConstraint::constrainVirtualized(bool force)
{
if (virtualize() && m_virtualizer != nullptr)
{
// By reference: this runs for every constrained child, and the list
// only changes in buildDependencies.
auto& children = scrollChildren();
if (m_childConstraintAppliedCount < children.size() && !force)
{
return;
}
refreshVirtualLayout();
auto isColumn = virtualAxisIsColumn();
anchorScroll(
m_virtualizer->anchorMoved(*m_virtualLayout, children),
isColumn);
auto virtualDirection = isColumn ? VirtualizedDirection::vertical
: VirtualizedDirection::horizontal;
auto offset = isColumn ? clampedOffsetY() : clampedOffsetX();
// Items move along their lines when the scroll moves that axis.
bool scrollsFlow = isColumn ? constrainsHorizontal() : constrainsVertical();
float flowOffset = scrollsFlow
? -(isColumn ? clampedOffsetX() : clampedOffsetY())
: 0.0f;
// A grid windows the columns the viewport shows, in content space.
bool columns = virtualizesGridColumns();
m_virtualizer->constrain(
this,
children,
offset,
virtualDirection,
flowOffset,
flowOffset - content()->layoutX(),
columns ? viewport()->layoutWidth() : 0.0f,
columns);
if (m_contributionsStale)
{
syncVirtualGridContributions();
}
}
}
// Size of a scroll child's item.
static Vec2D virtualItemSize(LayoutNodeProvider* child, int index)
{
auto virt = VirtualizingComponent::fromScrollChild(child);
if (virt != nullptr)
{
return virt->itemSize(index);
}
auto bounds = child->layoutBounds();
return Vec2D(bounds.width(), bounds.height());
}
bool ScrollConstraint::refreshVirtualLayout()
{
if (m_virtualLayout == nullptr)
{
return false;
}
bool isHorizontal = !virtualAxisIsColumn();
auto contentGap = gap();
auto layout = content();
auto& children = scrollChildren();
// Reused across calls, since this runs every frame.
auto& inputs = m_pendingInputs;
auto& versions = m_pendingVersions;
inputs.clear();
versions.clear();
for (auto child : children)
{
auto virt = VirtualizingComponent::fromScrollChild(child);
versions.push_back(virt != nullptr ? virt->itemsVersion() : 0);
if (virt == nullptr && child != nullptr)
{
auto size = virtualItemSize(child, 0);
inputs.push_back(size.x);
inputs.push_back(size.y);
inputs.push_back((float)child->numLayoutNodes());
}
}
bool grid = virtualizesGrid();
bool wraps = !grid && layout->wrapsLines();
auto alignment = layout->childAlignment();
inputs.insert(
inputs.end(),
{(float)grid,
(float)wraps,
(float)isHorizontal,
contentGap.x,
contentGap.y,
(float)alignment.main,
(float)alignment.cross});
int columns = 0;
int rows = 0;
float flowStart = 0.0f;
float flowExtent = 0.0f;
bool hugs = false;
bool flowFromLayout = true;
if (grid)
{
// Layout sizes every track from the contents we give it (see
// syncVirtualGridContributions); rows and columns sit on its lines,
// spaced as it spaced them.
for (auto track : layout->children())
{
if (!track->is<GridTrack>())
{
continue;
}
switch (track->as<GridTrack>()->gridCollection())
{
case GridTrackCollection::templateColumns:
columns++;
break;
case GridTrackCollection::templateRows:
rows++;
break;
default:
break;
}
}
float columnGap = layout->gridLines(false, m_columnLines);
float rowGap = layout->gridLines(true, m_rowLines);
if (!m_columnLines.empty())
{
contentGap.x = columnGap;
}
if (!m_rowLines.empty())
{
contentGap.y = rowGap;
}
inputs.insert(
inputs.end(),
{contentGap.x,
contentGap.y,
(float)columns,
(float)rows,
(float)m_columnLines.size()});
}
else if (wraps)
{
// Lines run along the flow axis, inside the content's padding. Hugging
// content breaks them at the space its parent offers instead (its own
// size only reflects the realized lines), less its margins.
hugs = layout->hugsLines();
auto bounds = hugs ? viewport() : layout;
flowStart = isHorizontal ? layout->paddingTop() : layout->paddingLeft();
flowExtent = innerSize(bounds, isHorizontal);
if (hugs)
{
flowExtent -= isHorizontal
? layout->paddingTop() + layout->paddingBottom()
: layout->paddingLeft() + layout->paddingRight();
flowExtent -= flowMargins(layout, viewport(), isHorizontal);
}
// Layout positions items along a line exactly (grow, margins) when it
// sees whole lines from a line start, as we realize them. A non-list
// child keeps its slot while its line is unrealized, a carousel adds
// its tail after its head, and hugging content justifies within its
// realized lines only; those positions are ours.
flowFromLayout = !hugs && !infinite() && scrollChildrenAreLists();
inputs.insert(
inputs.end(),
{flowStart, flowExtent, (float)hugs, (float)flowFromLayout});
}
// A grid's last build kept the lines it was placed on.
if (inputs == m_virtualInputs && versions == m_virtualVersions &&
m_virtualLayout->segmentCount() == (int)children.size() &&
(!grid || (m_columnLines == m_virtualLayout->gridColumnStarts() &&
m_rowLines == m_virtualLayout->gridRowStarts())))
{
return false;
}
std::swap(m_virtualInputs, inputs);
std::swap(m_virtualVersions, versions);
m_contributionsStale = true;
if (grid)
{
m_virtualLayout
->beginGrid(contentGap.y, columns, alignment.cross, contentGap.x, rows);
m_virtualLayout->gridColumnStarts() = m_columnLines;
m_virtualLayout->gridRowStarts() = m_rowLines;
}
else if (wraps)
{
m_virtualLayout->beginWrap(
isHorizontal ? contentGap.x : contentGap.y,
isHorizontal ? contentGap.y : contentGap.x,
flowStart,
flowExtent,
alignment.main,
alignment.cross,
hugs);
m_virtualLayout->flowFromLayout(flowFromLayout);
}
else
{
m_virtualLayout->beginLinear(isHorizontal ? contentGap.x : contentGap.y);
}
for (auto child : children)
{
// Every child gets a segment, so segments index like scrollChildren.
m_virtualLayout->beginSegment();
if (child == nullptr)
{
continue;
}
int count = (int)child->numLayoutNodes();
for (int j = 0; j < count; j++)
{
auto size = virtualItemSize(child, j);
m_virtualLayout->addItem(
isHorizontal ? size.x : size.y,
isHorizontal ? size.y : size.x);
}
}
m_virtualLayout->end();
return true;
}
void ScrollConstraint::anchorScroll(float moved, bool isColumn)
{
// Content before the first item on screen changed size (it measured
// differently from its authored size): move with it so what's on screen
// stays put. At the start nothing is above to anchor against, so growth
// shows below instead.
// The live offset: direct writes (scrollToPosition, intents, tests) move it
// without the stored scroll offset.
float offset = isColumn ? offsetY() : offsetX();
if (moved == 0.0f || offset >= 0.0f)
{
return;
}
if (isColumn)
{
scrollOffsetY(offset - moved);
offsetY(offset - moved);
m_lastFrameOffsetY -= moved;
}
else
{
scrollOffsetX(offset - moved);
offsetX(offset - moved);
m_lastFrameOffsetX -= moved;
}
if (m_physics != nullptr)
{
m_physics->shift(isColumn ? Vec2D(0.0f, -moved) : Vec2D(-moved, 0.0f));
}
}
void ScrollConstraint::syncVirtualGridContributions()
{
m_contributionsStale = false;
static const std::vector<float> none;
auto layout = content();
if (layout == nullptr)
{
return;
}
bool grid = virtualizesGrid() && m_virtualLayout != nullptr &&
m_virtualLayout->isGrid();
layout->virtualGridContributions(
grid ? m_virtualLayout->gridRowContents() : none,
grid ? m_virtualLayout->gridColumnContents() : none);
}
const VirtualLayout* ScrollConstraint::ensureVirtualLayout()
{
// Asked before the first constrain: build on demand.
if (m_virtualLayout != nullptr &&
m_virtualLayout->segmentCount() != (int)scrollChildren().size())
{
refreshVirtualLayout();
}
return m_virtualLayout;
}
Vec2D ScrollConstraint::virtualItemPosition(
LayoutNodeProvider* child,
int index)
{
if (m_virtualLayout == nullptr)
{
return Vec2D();
}
auto& children = scrollChildren();
auto it = std::find(children.begin(), children.end(), child);
if (it == children.end())
{
return Vec2D();
}
int segment = (int)(it - children.begin());
ensureVirtualLayout();
// Asked before a grown list's constrain: build on demand.
if (m_virtualLayout->segmentStart(segment) + index >=
m_virtualLayout->itemCount())
{
refreshVirtualLayout();
}
int item = m_virtualLayout->segmentStart(segment) + index;
if (item >= m_virtualLayout->itemCount())
{
return Vec2D();
}
float scroll = m_virtualLayout->lineStart(m_virtualLayout->lineOfItem(item)) +
m_virtualLayout->itemLineOffset(item);
float flow = m_virtualLayout->itemFlowOffset(item);
return virtualAxisIsColumn() ? Vec2D(flow, scroll) : Vec2D(scroll, flow);
}
void ScrollConstraint::addLayoutChild(LayoutNodeProvider* child)
{
m_layoutChildren.push_back(child);
}
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)
{
if (trackVelocity)
{
m_physics->accumulate(scaledDelta, timeStamp);
}
else
{
m_physics->markTime(timeStamp);
}
scrollOffsetX(offsetX() + scaledDelta.x);
scrollOffsetY(offsetY() + scaledDelta.y);
return;
}
// Without physics nothing ever pulls the stored offset back into range,
// so overscroll would accumulate out of view and eat the next drag.
float x = offsetX() + scaledDelta.x;
float y = offsetY() + scaledDelta.y;
if (!loopsX())
{
x = maxOffsetX() > 0 ? 0 : math::clamp(x, maxOffsetX(), 0);
}
if (!loopsY())
{
y = maxOffsetY() > 0 ? 0 : math::clamp(y, maxOffsetY(), 0);
}
scrollOffsetX(x);
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()
{
return (constrainsHorizontal() && !loopsX() &&
offsetX() != clampResolvedOffset(offsetX(), true)) ||
(constrainsVertical() && !loopsY() &&
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;
return (wantsX && (loopsX() || maxOffsetX() < 0)) ||
(wantsY && (loopsY() || 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;
// A looping axis wraps forever, so any delta on it moves.
bool movesX = loopsX() || clampResolvedOffset(offsetX() + delta.x, true) !=
clampResolvedOffset(offsetX(), true);
bool movesY = loopsY() || clampResolvedOffset(offsetY() + delta.y, false) !=
clampResolvedOffset(offsetY(), false);
return (wantsX && movesX) || (wantsY && movesY);
}
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;
if (content() == nullptr)
{
return snappingPoints;
}
for (auto child : scrollChildren())
{
if (child == nullptr)
{
continue;
}
int nodeCount = (int)child->numLayoutNodes();
for (int j = 0; j < nodeCount; j++)
{
auto bounds = child->layoutBoundsForNode(j);
if (isBoundsCollapsed(bounds))
{
continue;
}
snappingPoints.push_back(Vec2D(bounds.left(), bounds.top()));
}
}
return snappingPoints;
}
void ScrollConstraint::runPhysics()
{
m_isDragging = false;
startPhysics();
}
void ScrollConstraint::startPhysics()
{
std::vector<Vec2D> snappingPoints = snap() ? collectSnapPoints()
: std::vector<Vec2D>();
if (m_physics != nullptr)
{
m_physics->run(
Vec2D(maxOffsetX(), maxOffsetY()),
Vec2D(minOffsetX(), minOffsetY()),
Vec2D(offsetX(), offsetY()),
snappingPoints,
Vec2D(contentWidth(), contentHeight()),
Vec2D(viewportWidth(), viewportHeight()));
}
}
bool ScrollConstraint::advanceComponent(
float elapsedSeconds,
AdvanceFlags flags)
{
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 m_isScrolling;
}
if (m_physics->isRunning())
{
auto offset = m_physics->advance(elapsedSeconds);
scrollOffsetX(offset.x);
scrollOffsetY(offset.y);
}
// 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 || m_isScrolling) && !hasMoved)
{
clearVelocity();
}
m_lastFrameOffsetX = scrollOffsetX();
m_lastFrameOffsetY = scrollOffsetY();
}
// 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.
// 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()
{
std::vector<DraggableProxy*> items;
items.push_back(new ViewportDraggableProxy(this, viewport()->proxy()));
return items;
}
void ScrollConstraint::buildDependencies()
{
Super::buildDependencies();
// draggables() hands the viewport's proxy out as the drag hit target, so it
// must be injected into the draw order even if the viewport never paints or
// clips. Stamp it here, before the artboard's one-time proxy injection.
if (parent() != nullptr && parent()->parent() != nullptr &&
parent()->parent()->is<LayoutComponent>())
{
viewport()->markInteractionTarget();
}
m_hasListChildren = false;
for (auto child : content()->children())
{
auto layout = LayoutNodeProvider::from(child);
if (layout != nullptr)
{
addDependent(child);
layout->addLayoutConstraint(static_cast<LayoutConstraint*>(this));
}
if (child->is<ArtboardComponentList>())
{
m_hasListChildren = true;
}
}
}
Core* ScrollConstraint::clone() const
{
auto cloned = ScrollConstraintBase::clone();
if (physics() != nullptr)
{
auto constraint = cloned->as<ScrollConstraint>();
auto clonedPhysics = physics()->clone()->as<ScrollPhysics>();
constraint->physics(clonedPhysics);
}
return cloned;
}
StatusCode ScrollConstraint::import(ImportStack& importStack)
{
auto backboardImporter = importStack.latest<BackboardImporter>(
BackboardBase::typeKey);
if (backboardImporter != nullptr)
{
std::vector<ScrollPhysics*> physicsObjects = backboardImporter->physics();
if (physicsId() != -1 && physicsId() < physicsObjects.size())
{
auto phys = physicsObjects[physicsId()];
if (phys != nullptr)
{
auto cloned = phys->clone()->as<ScrollPhysics>();
physics(cloned);
}
}
}
else
{
return StatusCode::MissingObject;
}
return Super::import(importStack);
}
void ScrollConstraint::virtualizeChanged()
{
if (virtualize())
{
// The next pass recycles whatever the lists hold off screen.
if (m_virtualizer == nullptr)
{
m_virtualizer = new ScrollVirtualizer();
}
if (m_virtualLayout == nullptr)
{
m_virtualLayout = new VirtualLayout();
}
markConstraintDirty();
return;
}
delete m_virtualizer;
m_virtualizer = nullptr;
delete m_virtualLayout;
m_virtualLayout = nullptr;
if (!hasLayoutParent())
{
return;
}
// Layout takes every item from here: undo the contents and cells we gave
// it, and realize what the window left out.
syncVirtualGridContributions();
std::vector<int> held;
for (auto child : scrollChildren())
{
auto virt = VirtualizingComponent::fromScrollChild(child);
if (virt == nullptr)
{
continue;
}
held.clear();
virt->realizedIndices(held);
for (int index : held)
{
virt->setVirtualizableCell(index, -1, -1);
}
virt->clearVirtualWindow();
for (int index = 0; index < virt->itemCount(); index++)
{
if (virt->item(index) == nullptr)
{
virt->addVirtualizable(index);
}
}
virt->virtualizableChanged();
}
markConstraintDirty();
}
StatusCode ScrollConstraint::onAddedDirty(CoreContext* context)
{
StatusCode result = Super::onAddedDirty(context);
if (virtualize())
{
m_virtualizer = new ScrollVirtualizer();
m_virtualLayout = new VirtualLayout();
}
offsetX(scrollOffsetX());
offsetY(scrollOffsetY());
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();
m_lastFrameOffsetY = scrollOffsetY();
if (m_physics != nullptr)
{
m_physics->prepare(direction());
}
}
void ScrollConstraint::stopPhysics()
{
if (m_physics != nullptr)
{
m_physics->reset();
}
}
void ScrollConstraint::clearVelocity()
{
if (m_physics != nullptr)
{
m_physics->clearVelocity();
}
}
float ScrollConstraint::maxOffsetXForPercent()
{
return loopsX() ? contentWidth() : maxOffsetX();
}
float ScrollConstraint::maxOffsetYForPercent()
{
return loopsY() ? contentHeight() : maxOffsetY();
}
float ScrollConstraint::velocityX()
{
return m_physics != nullptr ? m_physics->speed().x : 0.0f;
}
float ScrollConstraint::velocityY()
{
return m_physics != nullptr ? m_physics->speed().y : 0.0f;
}
bool ScrollConstraint::scrollActive()
{
return m_isDragging || m_isScrollBarDragging || m_isScrolling ||
(m_physics != nullptr && m_physics->isRunning());
}
float ScrollConstraint::scrollPercentX()
{
if (m_intentX.space == ScrollSpace::percent)
{
return m_intentX.value;
}
return maxOffsetX() != 0 ? scrollOffsetX() / maxOffsetXForPercent() : 0;
}
float ScrollConstraint::scrollPercentY()
{
if (m_intentY.space == ScrollSpace::percent)
{
return m_intentY.value;
}
return maxOffsetY() != 0 ? scrollOffsetY() / maxOffsetYForPercent() : 0;
}
float ScrollConstraint::scrollIndex()
{
const ScrollAxisIntent& intent = indexesHorizontally() ? m_intentX
: m_intentY;
if (intent.space == ScrollSpace::index)
{
return intent.value;
}
return indexAtPosition(Vec2D(scrollOffsetX(), scrollOffsetY()));
}
void ScrollConstraint::setScrollPercentX(float value)
{
if (m_isDragging)
{
return;
}
stopPhysics();
setIntentX({ScrollSpace::percent, value});
}
void ScrollConstraint::setScrollPercentY(float value)
{
if (m_isDragging)
{
return;
}
stopPhysics();
setIntentY({ScrollSpace::percent, value});
}
void ScrollConstraint::setScrollIndex(float value)
{
if (m_isDragging)
{
return;
}
stopPhysics();
if (constrainsHorizontal())
{
setIntentX({ScrollSpace::index, value});
}
if (constrainsVertical())
{
setIntentY({ScrollSpace::index, value});
}
}
// False while hidden or before the first layout pass on the given axis.
bool ScrollConstraint::scrollLayoutResolvable(bool isX)
{
auto vp = viewport();
if (vp == nullptr)
{
return false;
}
return isX ? vp->layoutWidth() > 0 : vp->layoutHeight() > 0;
}
// Resolved offsets clamp to the scrollable range.
float ScrollConstraint::clampResolvedOffset(float value, bool isX)
{
if (isX ? loopsX() : loopsY())
{
return value;
}
return math::clamp(value, isX ? maxOffsetX() : maxOffsetY(), 0.0f);
}
// Convert an intent to an offset; false if layout can't resolve yet.
bool ScrollConstraint::resolveIntent(
const ScrollAxisIntent& intent,
bool isX,
float& outOffset)
{
// Non-finite indices resolve to the origin without needing layout.
if (intent.space == ScrollSpace::index &&
(std::isnan(intent.value) ||
(infinite() && !std::isfinite(intent.value))))
{
outOffset = 0;
return true;
}
// Gate both spaces: virtualized lists compute item bounds from authored
// sizes, which resolve even while the viewport layout is collapsed.
if (!scrollLayoutResolvable(isX))
{
return false;
}
switch (intent.space)
{
case ScrollSpace::percent:
{
float contentSize = isX ? contentWidth() : contentHeight();
if (contentSize <= 0)
{
// No measured content — can't resolve.
return false;
}
float maxForPercent = isX ? maxOffsetXForPercent()
: maxOffsetYForPercent();
outOffset = clampResolvedOffset(intent.value * maxForPercent, isX);
return true;
}
case ScrollSpace::index:
{
Vec2D to;
if (!positionAtIndex(intent.value, to))
{
return false;
}
outOffset = clampResolvedOffset(isX ? to.x : to.y, isX);
return true;
}
case ScrollSpace::none:
break;
}
return false;
}
// Resolve eagerly; unresolved intents are held and retried in constrain().
void ScrollConstraint::setIntentX(ScrollAxisIntent intent)
{
float resolved;
if (!resolveIntent(intent, true, resolved))
{
m_intentX = intent;
return;
}
m_intentX.space = ScrollSpace::none;
scrollOffsetX(resolved);
}
void ScrollConstraint::setIntentY(ScrollAxisIntent intent)
{
float resolved;
if (!resolveIntent(intent, false, resolved))
{
m_intentY = intent;
return;
}
m_intentY.space = ScrollSpace::none;
scrollOffsetY(resolved);
}
// Retry held intents; called from constrain() where layout is fresh.
void ScrollConstraint::resolveScrollIntents()
{
if (m_intentX.space != ScrollSpace::none)
{
setIntentX(m_intentX);
}
if (m_intentY.space != ScrollSpace::none)
{
setIntentY(m_intentY);
}
}
bool ScrollConstraint::positionAtIndex(float index, Vec2D& outPosition)
{
if (std::isnan(index) || (infinite() && !std::isfinite(index)))
{
outPosition = Vec2D();
return true;
}
auto count = scrollItemCount();
if (content() == nullptr || count == 0)
{
return false;
}
Vec2D contentGap = gap();
float normalizedIndex;
if (infinite())
{
normalizedIndex = std::fmod(index, (float)count);
if (normalizedIndex < 0)
{
normalizedIndex += (float)count;
}
}
else
{
normalizedIndex = std::max(index, 0.0f);
// Past the last item: target the far end (clamps to max scroll).
if (normalizedIndex >= (float)count)
{
if (contentWidth() <= 0 && contentHeight() <= 0)
{
// No measured content — can't resolve.
return false;
}
outPosition = Vec2D(-contentWidth(), -contentHeight());
return true;
}
}
float floorIndex = std::floor(normalizedIndex);
float mod = normalizedIndex - floorIndex;
uint32_t targetIndex = (uint32_t)floorIndex;
// No list children: O(1) direct index into scrollChildren.
if (!m_hasListChildren)
{
// Target is visible — return its position with fractional offset.
auto bounds = boundsForFlatIndex(targetIndex);
if (!isBoundsCollapsed(bounds))
{
outPosition = Vec2D(
-bounds.left() - (bounds.width() + contentGap.x) * mod,
-bounds.top() - (bounds.height() + contentGap.y) * mod);
return true;
}
// Target is collapsed — walk forward to next visible item.
for (uint32_t k = targetIndex + 1; k < (uint32_t)count; k++)
{
auto b = boundsForFlatIndex(k);
if (!isBoundsCollapsed(b))
{
outPosition = Vec2D(-b.left(), -b.top());
return true;
}
}
if (infinite())
{
// Carousel: wrap around from the beginning.
for (uint32_t k = 0; k < targetIndex; k++)
{
auto b = boundsForFlatIndex(k);
if (!isBoundsCollapsed(b))
{
outPosition = Vec2D(-b.left(), -b.top());
return true;
}
}
}
else
{
// End of list: walk backward to last visible item.
for (int k = (int)targetIndex - 1; k >= 0; k--)
{
auto b = boundsForFlatIndex(k);
if (!isBoundsCollapsed(b))
{
outPosition = Vec2D(-b.left(), -b.top());
return true;
}
}
}
// Nothing visible — can't resolve.
return false;
}
// Has list children: single-pass through nested child→node structure.
auto& children = scrollChildren();
uint32_t flatIndex = 0;
Vec2D lastVisibleBeforeTarget;
bool hasVisibleBeforeTarget = false;
bool reachedTarget = false;
for (auto child : children)
{
if (child == nullptr)
{
continue;
}
int nodeCount = (int)child->numLayoutNodes();
for (int j = 0; j < nodeCount; j++, flatIndex++)
{
auto bounds = child->layoutBoundsForNode(j);
// Before target: track last visible for backward fallback.
if (flatIndex < targetIndex)
{
if (!isBoundsCollapsed(bounds))
{
lastVisibleBeforeTarget = Vec2D(-bounds.left(), -bounds.top());
hasVisibleBeforeTarget = true;
}
continue;
}
// At target: return position if visible, otherwise keep walking.
if (flatIndex == targetIndex)
{
reachedTarget = true;
if (!isBoundsCollapsed(bounds))
{
outPosition = Vec2D(
-bounds.left() - (bounds.width() + contentGap.x) * mod,
-bounds.top() - (bounds.height() + contentGap.y) * mod);
return true;
}
continue;
}
// Past target: return first visible item found.
if (!isBoundsCollapsed(bounds))
{
outPosition = Vec2D(-bounds.left(), -bounds.top());
return true;
}
}
}
if (!reachedTarget)
{
return false;
}
// No visible item after target.
if (infinite())
{
// Carousel: wrap around from the beginning.
flatIndex = 0;
for (auto child : children)
{
if (child == nullptr)
{
continue;
}
int nodeCount = (int)child->numLayoutNodes();
for (int j = 0; j < nodeCount; j++, flatIndex++)
{
if (flatIndex >= targetIndex)
{
// Nothing visible — can't resolve.
return false;
}
auto bounds = child->layoutBoundsForNode(j);
if (!isBoundsCollapsed(bounds))
{
outPosition = Vec2D(-bounds.left(), -bounds.top());
return true;
}
}
}
}
else if (hasVisibleBeforeTarget)
{
// End of list: fall back to last visible item before target.
outPosition = lastVisibleBeforeTarget;
return true;
}
// Nothing visible — can't resolve.
return false;
}
float ScrollConstraint::indexAtPosition(Vec2D pos)
{
if (content() == nullptr || content()->children().size() == 0)
{
return 0;
}
// A grid scrolled both ways: the row at y, then the column at x.
if (indexesGridCells() && m_virtualLayout != nullptr)
{
ensureVirtualLayout();
if (m_virtualLayout->lineCount() == 0)
{
return 0;
}
// A carousel's position wraps into its first cycle.
float y = -pos.y;
float cycle = m_virtualLayout->cycleExtent();
if (loopsY() && cycle > 0.0f)
{
y -= std::floor(y / cycle) * cycle;
}
int row = m_virtualLayout->window(y, 0.0f, loopsY(), 0).visibleStart;
int column = m_virtualLayout->wrapColumn(
m_virtualLayout->columnWindow(-pos.x, 0.0f, 0, loopsX()).visibleStart);
return (float)std::min(
m_virtualLayout->lineFirstItem(row) + column,
m_virtualLayout->lineLastItem(row));
}
Vec2D contentGap = gap();
if (!m_hasListChildren)
{
size_t count = scrollChildren().size();
if (indexesHorizontally())
{
for (size_t i = 0; i < count; i++)
{
auto bounds = scrollChildren()[i]->layoutBoundsForNode(0);
float step = bounds.width() + contentGap.x;
if (pos.x > -bounds.left() - step)
{
return step != 0 ? (float)i + (-pos.x - bounds.left()) / step
: (float)i;
}
}
return (float)count;
}
else if (constrainsVertical())
{
for (size_t i = 0; i < count; i++)
{
auto bounds = scrollChildren()[i]->layoutBoundsForNode(0);
float step = bounds.height() + contentGap.y;
if (pos.y > -bounds.top() - step)
{
return step != 0 ? (float)i + (-pos.y - bounds.top()) / step
: (float)i;
}
}
return (float)count;
}
return 0;
}
// Has list children: nested iteration visiting each node once.
float flatIndex = 0.0f;
if (indexesHorizontally())
{
for (auto child : scrollChildren())
{
if (child == nullptr)
{
continue;
}
int nodeCount = (int)child->numLayoutNodes();
for (int j = 0; j < nodeCount; j++)
{
auto bounds = child->layoutBoundsForNode(j);
float step = bounds.width() + contentGap.x;
if (pos.x > -bounds.left() - step)
{
return step != 0 ? (flatIndex + j) + (-pos.x - bounds.left()) / step
: (flatIndex + j);
}
}
flatIndex += nodeCount;
}
return flatIndex;
}
else if (constrainsVertical())
{
for (auto child : scrollChildren())
{
if (child == nullptr)
{
continue;
}
int nodeCount = (int)child->numLayoutNodes();
for (int j = 0; j < nodeCount; j++)
{
auto bounds = child->layoutBoundsForNode(j);
float step = bounds.height() + contentGap.y;
if (pos.y > -bounds.top() - step)
{
return step != 0 ? (flatIndex + j) + (-pos.y - bounds.top()) / step
: (flatIndex + j);
(bounds.height() + contentGap.y);
}
}
flatIndex += nodeCount;
}
return flatIndex;
}
return 0;
}
bool ScrollConstraint::isBoundsCollapsed(AABB bounds)
{
return (constrainsHorizontal() && bounds.width() <= 0) ||
(constrainsVertical() && bounds.height() <= 0);
}
size_t ScrollConstraint::scrollItemCount()
{
if (!m_hasListChildren)
{
return scrollChildren().size();
}
size_t count = 0;
for (auto child : scrollChildren())
{
if (child == nullptr)
{
continue;
}
count += child->numLayoutNodes();
}
return count;
}
AABB ScrollConstraint::boundsForFlatIndex(size_t index)
{
auto& children = scrollChildren();
if (!m_hasListChildren)
{
if (index < children.size() && children[index] != nullptr)
{
return children[index]->layoutBoundsForNode(0);
}
return AABB();
}
size_t flatIndex = 0;
for (auto child : children)
{
if (child == nullptr)
{
continue;
}
size_t nodeCount = child->numLayoutNodes();
if (index < flatIndex + nodeCount)
{
return child->layoutBoundsForNode((int)(index - flatIndex));
}
flatIndex += nodeCount;
}
return AABB();
}
Vec2D ScrollConstraint::gap()
{
if (content() == nullptr)
{
return Vec2D();
}
return Vec2D(content()->gapHorizontal(), content()->gapVertical());
}
void ScrollConstraint::scrollToPosition(float targetX, float targetY)
{
// Direct offset writes supersede held intents.
clearScrollIntents();
if (m_physics == nullptr)
{
// No physics, just set offset directly
scrollOffsetX(targetX);
scrollOffsetY(targetY);
return;
}
Vec2D current(m_offsetX, m_offsetY);
Vec2D target(targetX, targetY);
Vec2D rangeMin(maxOffsetX(), maxOffsetY());
Vec2D rangeMax(0.0f, 0.0f);
m_physics->scrollToPosition(
current,
target,
rangeMin,
rangeMax,
constrainsHorizontal(),
constrainsVertical());
}
static float nearestSnapInDirection(
float current,
float target,
const std::vector<Vec2D>& snapPoints,
bool useX)
{
if (current == target)
{
return target;
}
bool scrollingNegative = target < current;
float best = target;
bool found = false;
float bestDist = 0.0f;
for (const auto& p : snapPoints)
{
float c = useX ? -p.x : -p.y;
if (scrollingNegative ? c > target : c < target)
{
continue;
}
float d = scrollingNegative ? target - c : c - target;
if (!found || d < bestDist)
{
bestDist = d;
best = c;
found = true;
}
}
return found ? best : target;
}
Vec2D ScrollConstraint::nearestSnapOffsetInDirection(
Vec2D current,
Vec2D target)
{
if (!snap())
{
return target;
}
auto snapPoints = collectSnapPoints();
if (snapPoints.empty())
{
return target;
}
float snappedX = constrainsHorizontal() ? nearestSnapInDirection(
current.x,
target.x,
snapPoints,
true)
: target.x;
float snappedY = constrainsVertical() ? nearestSnapInDirection(
current.y,
target.y,
snapPoints,
false)
: target.y;
return Vec2D(snappedX, snappedY);
}
float ScrollConstraint::effectiveScrollOffsetX()
{
if (m_physics != nullptr && m_physics->isRunning() && m_physics->hasTargetX())
{
return m_physics->targetX();
}
return scrollOffsetX();
}
float ScrollConstraint::effectiveScrollOffsetY()
{
if (m_physics != nullptr && m_physics->isRunning() && m_physics->hasTargetY())
{
return m_physics->targetY();
}
return scrollOffsetY();
}