#include "ScrollArea.h" #include "../UserInterface/Primitives.h" #include #include #include namespace UI { namespace { constexpr float MaxOverscroll = 64.0f; constexpr float ScrollFriction = 5.0f; constexpr float OverscrollSpring = 180.0f; constexpr float OverscrollDamping = 20.0f; constexpr float SnapSpring = 360.0f; constexpr float SnapDamping = 20.0f; } float ScrollArea::updateScrollAxis(float position, float& velocity, float minPosition, float maxPosition, float deltaSeconds) { const float boundary = std::clamp(position, minPosition, maxPosition); const bool inOverscroll = (position < minPosition || position > maxPosition); if (inOverscroll) { velocity += (boundary - position) * OverscrollSpring * deltaSeconds; velocity *= std::exp(-OverscrollDamping * deltaSeconds); } else if (pageDivisions > 0) { float totalRange = maxPosition - minPosition; float pageSize = totalRange / static_cast(pageDivisions); // 1. Where are we right now? float relativePos = position - minPosition; float currentTargetPage = std::round(relativePos / pageSize); // 2. Select target page purely based on swipe direction (Velocity) // If moving right/down significantly, target next page. If left/up, previous page. float targetPage = currentTargetPage; if (velocity > 150.0f) { targetPage = std::floor(relativePos / pageSize) + 1.0f; } else if (velocity < -150.0f) { targetPage = std::ceil(relativePos / pageSize) - 1.0f; } // Keep page within valid limits targetPage = std::clamp(targetPage, 0.0f, static_cast(pageDivisions)); float targetPosition = minPosition + (targetPage * pageSize); // 3. CAP TERMINAL VELOCITY IN THE DIRECTION OF THE SNAP // Prevents a massive swipe from turning into a rocket launch. float distanceToTarget = targetPosition - position; if (distanceToTarget > 0.0f) { velocity = std::min(velocity, distanceToTarget * 8.0f); // Cap positive speed } else if (distanceToTarget < 0.0f) { velocity = std::max(velocity, distanceToTarget * 8.0f); // Cap negative speed } // 4. Smoothly pull into the page target velocity += distanceToTarget * SnapSpring * deltaSeconds; velocity *= std::exp(-SnapDamping * deltaSeconds); } else { velocity *= std::exp(-ScrollFriction * deltaSeconds); } if (std::abs(velocity) < 1.0f) { velocity = 0.0f; } return std::clamp( position + velocity * deltaSeconds, minPosition - MaxOverscroll, maxPosition + MaxOverscroll ); } void ScrollArea::Measure(Size availableSpace) { const bool horizontal = (scrollingDirections & OverflowFlags::Horizontal) != OverflowFlags::None; const bool vertical = (scrollingDirections & OverflowFlags::Vertical) != OverflowFlags::None; const float unconstrained = std::numeric_limits::max() / 4.0f; Size childAvailable{horizontal ? unconstrained : availableSpace.width, vertical ? unconstrained : availableSpace.height}; float childWidth = 0.0f; float childHeight = 0.0f; for (auto& child : children) { if (!child->IsVisible()) { continue; } child->Measure(childAvailable); childWidth = std::max(childWidth, child->GetDesiredSize().width); childHeight = std::max(childHeight, child->GetDesiredSize().height); } desiredSize.width = std::clamp(horizontal ? availableSpace.width : childWidth + padding.HorizontalTotal() + margin.HorizontalTotal(), minSize.width, maxSize.width); desiredSize.height = std::clamp(vertical ? availableSpace.height : childHeight + padding.VerticalTotal() + margin.VerticalTotal(), minSize.height, maxSize.height); } void ScrollArea::Arrange(Rect finalBounds) { ArrangeCore(finalBounds); Rect contentBounds{padding.Left, padding.Top, std::max(0.0f, localBounds.Width - padding.HorizontalTotal()), std::max(0.0f, localBounds.Height - padding.VerticalTotal())}; maxScrollX = 0.0f; maxScrollY = 0.0f; for (auto& child : children) { if (!child->IsVisible()) { continue; } Size desired = child->GetDesiredSize(); maxScrollX = std::max(maxScrollX, desired.width - contentBounds.Width); maxScrollY = std::max(maxScrollY, desired.height - contentBounds.Height); } // apply setpage if early setpage if (pendingPage.has_value() && pageDivisions > 0 && maxScrollX > 0.0f) { int page = pendingPage.value(); float pageSize = maxScrollX / static_cast(pageDivisions); scrollPosition.x = -std::clamp(page * pageSize, 0.0f, maxScrollX); pendingPage.reset(); } // Allow the overscroll zone to survive layout re-passes; the spring in // UpdateScrollAxis will pull the position back to [−maxScroll, 0] once // the user releases. Hard-clamping here was the cause of the snap-back. scrollPosition.x = std::clamp(scrollPosition.x, -maxScrollX - MaxOverscroll, MaxOverscroll); scrollPosition.y = std::clamp(scrollPosition.y, -maxScrollY - MaxOverscroll, MaxOverscroll); for (auto& child : children) { if (child->IsVisible()) { child->SetScrollOffset({-scrollPosition.x, -scrollPosition.y}); } } for (auto& child : children) { if (!child->IsVisible()) { continue; } Size childDesired = child->GetDesiredSize(); Thickness childMargin = child->GetMargin(); float availW = std::max(0.0f, contentBounds.Width - childMargin.HorizontalTotal()); float availH = std::max(0.0f, contentBounds.Height - childMargin.VerticalTotal()); float naturalW = std::max(0.0f, childDesired.width - childMargin.HorizontalTotal()); float naturalH = std::max(0.0f, childDesired.height - childMargin.VerticalTotal()); auto horiz = CalculateAxisAlignment( child->GetHorizontalAlignment(), (scrollingDirections & OverflowFlags::Horizontal) != OverflowFlags::None ? naturalW : availW, naturalW, childMargin.Left ); auto vert = CalculateAxisAlignment( child->GetVerticalAlignment(), (scrollingDirections & OverflowFlags::Vertical) != OverflowFlags::None ? naturalH : availH, naturalH, childMargin.Top ); child->Arrange(Rect( contentBounds.X + horiz.offset, contentBounds.Y + vert.offset, horiz.size, vert.size )); } ClearDirty(DirtyFlags::Layout); } void ScrollArea::Draw() { if (!visible) { return; } Point previousTranslation = Primitives::GetTranslation(); // Contents include the widget's padding; only the space outside the // widget (including its margin) should be excluded from rendering. Rect clipBounds = globalBounds; Primitives::SetTranslation({ previousTranslation.x + scrollPosition.x, previousTranslation.y + scrollPosition.y }); if (clipContents) { Primitives::PushClip(clipBounds); } for (auto& child : children) { if (child->IsVisible()) { child->Draw(); } } Primitives::SetTranslation(previousTranslation); if (clipContents) { Primitives::PopClip(); } ClearDirty(DirtyFlags::Render); } void ScrollArea::Update(float deltaSeconds) { // Keep updating descendants, even while this scroll area is coasting. Widget::Update(deltaSeconds); if (dragging || deltaSeconds <= 0.0f) { return; } const float oldX = scrollPosition.x; const float oldY = scrollPosition.y; if ((scrollingDirections & OverflowFlags::Horizontal) != OverflowFlags::None) { scrollPosition.x = updateScrollAxis(scrollPosition.x, velocity.x, -maxScrollX, 0.0f, deltaSeconds); } if ((scrollingDirections & OverflowFlags::Vertical) != OverflowFlags::None) { scrollPosition.y = updateScrollAxis(scrollPosition.y, velocity.y, -maxScrollY, 0.0f, deltaSeconds); } if (scrollPosition.x != oldX || scrollPosition.y != oldY) { SetScrollOffset({-scrollPosition.x, -scrollPosition.y}); MarkDirty(DirtyFlags::Render); } } void ScrollArea::SetScrollOffset(Point offset) { for (auto& child : children) { if (child->IsVisible()) { child->SetScrollOffset(offset); } } } void ScrollArea::SetPage(int page) { if (pageDivisions <= 0) { return; } if (maxScrollX > 0.0f) { float pageSize = maxScrollX / static_cast(pageDivisions); scrollPosition.x = -std::clamp(page * pageSize, 0.0f, maxScrollX); SetScrollOffset({-scrollPosition.x, -scrollPosition.y}); MarkDirty(DirtyFlags::Render); } else { pendingPage = page; } } Widget* ScrollArea::HitTest(Point screenPoint) { if (!visible || !enabled || !globalBounds.Contains(screenPoint)) { return nullptr; } Rect contentBounds{ globalBounds.X + padding.Left, globalBounds.Y + padding.Top, std::max(0.0f, globalBounds.Width - padding.HorizontalTotal()), std::max(0.0f, globalBounds.Height - padding.VerticalTotal()) }; if (!contentBounds.Contains(screenPoint)) { return this; } Point contentPoint{ screenPoint.x - scrollPosition.x, screenPoint.y - scrollPosition.y }; for (auto it = children.rbegin(); it != children.rend(); ++it) { Widget* hit = (*it)->HitTest(contentPoint); if (hit) { return hit; } } return this; } bool ScrollArea::WantsPointerCapture(const std::shared_ptr& stream) { const auto event = stream->PeekLatestInput(); if (event.actDirection == PointerDirection::Down) { const float unitX = event.scaledX; const float unitY = event.scaledY; if (!globalBounds.Contains(unitX, unitY)) { return false; } dragging = true; movedDuringDrag = false; activePointerId = event.pointerId; lastPointerX = unitX; lastPointerY = unitY; lastPointerTimestampNanos = event.timestampNanos; velocity = {0.0f, 0.0f}; return true; } if (event.pointerId != activePointerId) { return false; } if (event.actDirection == PointerDirection::None && dragging) { const float unitX = event.scaledX; const float unitY = event.scaledY; float deltaX = unitX - lastPointerX; float deltaY = unitY - lastPointerY; lastPointerX = unitX; lastPointerY = unitY; float deltaSeconds = 1.0f / 60.0f; if (event.timestampNanos > lastPointerTimestampNanos) { deltaSeconds = static_cast(event.timestampNanos - lastPointerTimestampNanos) / 1.0e9f; } lastPointerTimestampNanos = event.timestampNanos; // Ignore pathological event gaps; the next event still moves the view, // but should not turn a stale drag into a large inertial fling. deltaSeconds = std::clamp(deltaSeconds, 1.0e-4f, 0.1f); if ((scrollingDirections & OverflowFlags::Horizontal) != OverflowFlags::None) { scrollPosition.x = std::clamp( scrollPosition.x + deltaX, -maxScrollX - MaxOverscroll, MaxOverscroll ); velocity.x = deltaX / deltaSeconds; } if ((scrollingDirections & OverflowFlags::Vertical) != OverflowFlags::None) { scrollPosition.y = std::clamp( scrollPosition.y + deltaY, -maxScrollY - MaxOverscroll, MaxOverscroll ); velocity.y = deltaY / deltaSeconds; } movedDuringDrag = movedDuringDrag || deltaX != 0.0f || deltaY != 0.0f; if (movedDuringDrag) { SetScrollOffset({-scrollPosition.x, -scrollPosition.y}); MarkDirty(DirtyFlags::Render); return true; } } if (event.actDirection == PointerDirection::Up) { bool consumed = dragging && movedDuringDrag; dragging = false; movedDuringDrag = false; return consumed; } return false; } bool ScrollArea::OnPointerStream(const std::shared_ptr& stream) { return Widget::OnPointerStream(stream); } }