| #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(); | |
| } |