Add XCUI core scroll view validation in new_editor

This commit is contained in:
2026-04-05 21:27:00 +08:00
parent 05debc0499
commit 7812b92992
6 changed files with 518 additions and 34 deletions

View File

@@ -2,6 +2,10 @@
#include <XCEngine/UI/Runtime/UIScreenTypes.h>
#include <cstdint>
#include <string>
#include <unordered_map>
namespace XCEngine {
namespace UI {
namespace Runtime {
@@ -18,10 +22,31 @@ public:
class UIDocumentScreenHost final : public IUIScreenDocumentHost {
public:
struct ScrollDebugSnapshot {
std::uint64_t totalWheelEventCount = 0u;
std::uint64_t handledWheelEventCount = 0u;
UIPoint lastPointerPosition = {};
UIRect lastViewportRect = {};
UIRect primaryViewportRect = {};
float lastWheelDelta = 0.0f;
float lastOverflow = 0.0f;
float lastOffsetBefore = 0.0f;
float lastOffsetAfter = 0.0f;
float primaryOverflow = 0.0f;
std::string lastTargetStateKey = {};
std::string primaryTargetStateKey = {};
std::string lastResult = {};
};
UIScreenLoadResult LoadScreen(const UIScreenAsset& asset) override;
UIScreenFrameResult BuildFrame(
const UIScreenDocument& document,
const UIScreenFrameInput& input) override;
const ScrollDebugSnapshot& GetScrollDebugSnapshot() const;
private:
std::unordered_map<std::string, float> m_verticalScrollOffsets = {};
ScrollDebugSnapshot m_scrollDebugSnapshot = {};
};
} // namespace Runtime

View File

@@ -7,6 +7,7 @@
#include <algorithm>
#include <cctype>
#include <cmath>
#include <cstdlib>
#include <filesystem>
#include <string>
@@ -39,9 +40,15 @@ constexpr float kHeaderTextGap = 2.0f;
struct RuntimeLayoutNode {
const UIDocumentNode* source = nullptr;
std::string stateKey = {};
std::vector<RuntimeLayoutNode> children = {};
UISize desiredSize = {};
UISize minimumSize = {};
UISize contentDesiredSize = {};
UIRect rect = {};
UIRect scrollViewportRect = {};
float scrollOffsetY = 0.0f;
bool isScrollView = false;
};
UIColor ToUIColor(const Color& color) {
@@ -185,23 +192,14 @@ Layout::UILayoutThickness ParsePadding(
Layout::UILayoutItem BuildLayoutItem(
const RuntimeLayoutNode& child,
Layout::UILayoutAxis parentAxis) {
Layout::UILayoutAxis parentAxis,
const UISize& measuredContentSize) {
Layout::UILayoutItem item = {};
item.desiredContentSize = child.desiredSize;
item.desiredContentSize = measuredContentSize;
item.minSize = child.minimumSize;
item.width = ParseLengthAttribute(*child.source, "width");
item.height = ParseLengthAttribute(*child.source, "height");
// Pixel-authored lengths act as requested extents, but never below the measured content floor.
if (item.width.unit == Layout::UILayoutLengthUnit::Pixels &&
item.width.value < child.desiredSize.width) {
item.minSize.width = child.desiredSize.width;
}
if (item.height.unit == Layout::UILayoutLengthUnit::Pixels &&
item.height.value < child.desiredSize.height) {
item.minSize.height = child.desiredSize.height;
}
if (parentAxis == Layout::UILayoutAxis::Vertical &&
item.width.unit == Layout::UILayoutLengthUnit::Auto) {
item.horizontalAlignment = Layout::UILayoutAlignment::Stretch;
@@ -233,6 +231,10 @@ bool IsHorizontalTag(const std::string& tagName) {
return tagName == "Row";
}
bool IsScrollViewTag(const std::string& tagName) {
return tagName == "ScrollView";
}
bool IsContainerTag(const UIDocumentNode& node) {
if (node.children.Size() > 0u) {
return true;
@@ -242,10 +244,42 @@ bool IsContainerTag(const UIDocumentNode& node) {
return tagName == "View" ||
tagName == "Column" ||
tagName == "Row" ||
tagName == "ScrollView" ||
tagName == "Card" ||
tagName == "Button";
}
std::string BuildNodeStateKeySegment(
const UIDocumentNode& source,
std::size_t siblingIndex) {
const std::string id = GetAttribute(source, "id");
if (!id.empty()) {
return id;
}
const std::string name = GetAttribute(source, "name");
if (!name.empty()) {
return ToStdString(source.tagName) + ":" + name;
}
return ToStdString(source.tagName) + "#" + std::to_string(siblingIndex);
}
float ComputeScrollOverflow(float contentExtent, float viewportExtent) {
return (std::max)(0.0f, contentExtent - viewportExtent);
}
float ClampScrollOffset(float offset, float contentExtent, float viewportExtent) {
return (std::max)(0.0f, (std::min)(offset, ComputeScrollOverflow(contentExtent, viewportExtent)));
}
bool RectContainsPoint(const UIRect& rect, const UIPoint& point) {
return point.x >= rect.x &&
point.x <= rect.x + rect.width &&
point.y >= rect.y &&
point.y <= rect.y + rect.height;
}
Color ResolveBackgroundColor(const UIDocumentNode& node) {
const std::string tone = GetAttribute(node, "tone");
const std::string tagName = ToStdString(node.tagName);
@@ -278,12 +312,17 @@ Color ResolveBorderColor(const UIDocumentNode& node) {
return Color(0.30f, 0.30f, 0.30f, 1.0f);
}
RuntimeLayoutNode BuildLayoutTree(const UIDocumentNode& source) {
RuntimeLayoutNode BuildLayoutTree(
const UIDocumentNode& source,
const std::string& parentStateKey,
std::size_t siblingIndex) {
RuntimeLayoutNode node = {};
node.source = &source;
node.stateKey = parentStateKey + "/" + BuildNodeStateKeySegment(source, siblingIndex);
node.isScrollView = IsScrollViewTag(ToStdString(source.tagName));
node.children.reserve(source.children.Size());
for (const UIDocumentNode& child : source.children) {
node.children.push_back(BuildLayoutTree(child));
for (std::size_t index = 0; index < source.children.Size(); ++index) {
node.children.push_back(BuildLayoutTree(source.children[index], node.stateKey, index));
}
return node;
}
@@ -297,6 +336,7 @@ UISize MeasureNode(RuntimeLayoutNode& node) {
node.desiredSize = UISize(
MeasureTextWidth(text, kDefaultFontSize),
MeasureTextHeight(kDefaultFontSize));
node.minimumSize = node.desiredSize;
return node.desiredSize;
}
@@ -304,6 +344,7 @@ UISize MeasureNode(RuntimeLayoutNode& node) {
node.desiredSize = UISize(
(std::max)(160.0f, MeasureTextWidth(ResolveNodeText(source), kDefaultFontSize) + 24.0f),
44.0f);
node.minimumSize = node.desiredSize;
return node.desiredSize;
}
@@ -319,16 +360,21 @@ UISize MeasureNode(RuntimeLayoutNode& node) {
source,
tagName == "View" ? 16.0f : 12.0f);
std::vector<Layout::UILayoutItem> items = {};
items.reserve(node.children.size());
std::vector<Layout::UILayoutItem> desiredItems = {};
desiredItems.reserve(node.children.size());
std::vector<Layout::UILayoutItem> minimumItems = {};
minimumItems.reserve(node.children.size());
for (RuntimeLayoutNode& child : node.children) {
MeasureNode(child);
Layout::UILayoutItem item = BuildLayoutItem(child, options.axis);
items.push_back(item);
desiredItems.push_back(BuildLayoutItem(child, options.axis, child.desiredSize));
minimumItems.push_back(BuildLayoutItem(child, options.axis, child.minimumSize));
}
const auto measured = Layout::MeasureStackLayout(options, items);
const auto measured = Layout::MeasureStackLayout(options, desiredItems);
const auto minimumMeasured = Layout::MeasureStackLayout(options, minimumItems);
node.contentDesiredSize = measured.desiredSize;
node.desiredSize = measured.desiredSize;
node.minimumSize = minimumMeasured.desiredSize;
const float headerHeight = MeasureHeaderHeight(source);
const float headerTextWidth = MeasureHeaderTextWidth(source);
@@ -339,6 +385,16 @@ UISize MeasureNode(RuntimeLayoutNode& node) {
? headerTextWidth + options.padding.Horizontal()
: MeasureTextWidth(ResolveNodeText(source), kDefaultFontSize) + options.padding.Horizontal());
node.desiredSize.height += headerHeight;
node.minimumSize.width = (std::max)(
node.minimumSize.width,
headerTextWidth > 0.0f
? headerTextWidth + options.padding.Horizontal()
: MeasureTextWidth(ResolveNodeText(source), kDefaultFontSize) + options.padding.Horizontal());
node.minimumSize.height += headerHeight;
if (node.isScrollView) {
node.minimumSize.height = headerHeight + options.padding.Vertical();
}
float explicitWidth = 0.0f;
if (TryParseFloat(GetAttribute(source, "width"), explicitWidth)) {
@@ -353,8 +409,13 @@ UISize MeasureNode(RuntimeLayoutNode& node) {
return node.desiredSize;
}
void ArrangeNode(RuntimeLayoutNode& node, const UIRect& rect) {
void ArrangeNode(
RuntimeLayoutNode& node,
const UIRect& rect,
const std::unordered_map<std::string, float>& verticalScrollOffsets) {
node.rect = rect;
node.scrollViewportRect = {};
node.scrollOffsetY = 0.0f;
const UIDocumentNode& source = *node.source;
if (!IsContainerTag(source)) {
@@ -383,13 +444,180 @@ void ArrangeNode(RuntimeLayoutNode& node, const UIRect& rect) {
std::vector<Layout::UILayoutItem> items = {};
items.reserve(node.children.size());
for (RuntimeLayoutNode& child : node.children) {
Layout::UILayoutItem item = BuildLayoutItem(child, options.axis);
Layout::UILayoutItem item = BuildLayoutItem(child, options.axis, child.desiredSize);
items.push_back(item);
}
if (node.isScrollView) {
const auto found = verticalScrollOffsets.find(node.stateKey);
const float requestedOffset = found != verticalScrollOffsets.end() ? found->second : 0.0f;
node.scrollViewportRect = contentRect;
node.scrollOffsetY = ClampScrollOffset(
requestedOffset,
node.contentDesiredSize.height,
contentRect.height);
UIRect scrolledContentRect = contentRect;
scrolledContentRect.y -= node.scrollOffsetY;
const auto arranged = Layout::ArrangeStackLayout(options, items, scrolledContentRect);
for (std::size_t index = 0; index < node.children.size(); ++index) {
ArrangeNode(
node.children[index],
arranged.children[index].arrangedRect,
verticalScrollOffsets);
}
return;
}
const auto arranged = Layout::ArrangeStackLayout(options, items, contentRect);
for (std::size_t index = 0; index < node.children.size(); ++index) {
ArrangeNode(node.children[index], arranged.children[index].arrangedRect);
ArrangeNode(
node.children[index],
arranged.children[index].arrangedRect,
verticalScrollOffsets);
}
}
RuntimeLayoutNode* FindDeepestScrollTarget(
RuntimeLayoutNode& node,
const UIPoint& point) {
for (RuntimeLayoutNode& child : node.children) {
if (RuntimeLayoutNode* target = FindDeepestScrollTarget(child, point); target != nullptr) {
return target;
}
}
if (!node.isScrollView) {
return nullptr;
}
if (!RectContainsPoint(node.scrollViewportRect, point)) {
return nullptr;
}
if (ComputeScrollOverflow(node.contentDesiredSize.height, node.scrollViewportRect.height) <= 0.0f) {
return nullptr;
}
return &node;
}
RuntimeLayoutNode* FindDeepestHoveredScrollView(
RuntimeLayoutNode& node,
const UIPoint& point) {
for (RuntimeLayoutNode& child : node.children) {
if (RuntimeLayoutNode* hovered = FindDeepestHoveredScrollView(child, point); hovered != nullptr) {
return hovered;
}
}
if (!node.isScrollView) {
return nullptr;
}
return RectContainsPoint(node.scrollViewportRect, point)
? &node
: nullptr;
}
const RuntimeLayoutNode* FindFirstScrollView(const RuntimeLayoutNode& node) {
if (node.isScrollView) {
return &node;
}
for (const RuntimeLayoutNode& child : node.children) {
if (const RuntimeLayoutNode* found = FindFirstScrollView(child); found != nullptr) {
return found;
}
}
return nullptr;
}
bool ApplyScrollWheelInput(
RuntimeLayoutNode& root,
const UIScreenFrameInput& input,
std::unordered_map<std::string, float>& verticalScrollOffsets,
UIDocumentScreenHost::ScrollDebugSnapshot& scrollDebugSnapshot) {
bool changed = false;
for (const UIInputEvent& event : input.events) {
if (event.type != UIInputEventType::PointerWheel) {
continue;
}
++scrollDebugSnapshot.totalWheelEventCount;
scrollDebugSnapshot.lastPointerPosition = event.position;
scrollDebugSnapshot.lastWheelDelta = event.wheelDelta;
scrollDebugSnapshot.lastTargetStateKey.clear();
scrollDebugSnapshot.lastViewportRect = {};
scrollDebugSnapshot.lastOverflow = 0.0f;
scrollDebugSnapshot.lastOffsetBefore = 0.0f;
scrollDebugSnapshot.lastOffsetAfter = 0.0f;
scrollDebugSnapshot.lastResult = "No hovered ScrollView";
RuntimeLayoutNode* hoveredScrollView = FindDeepestHoveredScrollView(root, event.position);
if (hoveredScrollView == nullptr) {
continue;
}
scrollDebugSnapshot.lastTargetStateKey = hoveredScrollView->stateKey;
scrollDebugSnapshot.lastViewportRect = hoveredScrollView->scrollViewportRect;
scrollDebugSnapshot.lastOverflow = ComputeScrollOverflow(
hoveredScrollView->contentDesiredSize.height,
hoveredScrollView->scrollViewportRect.height);
if (scrollDebugSnapshot.lastOverflow <= 0.0f) {
scrollDebugSnapshot.lastResult = "Hovered ScrollView has no overflow";
continue;
}
RuntimeLayoutNode* target = FindDeepestScrollTarget(root, event.position);
if (target == nullptr) {
scrollDebugSnapshot.lastResult = "Scroll target resolution failed";
continue;
}
const auto found = verticalScrollOffsets.find(target->stateKey);
const float oldOffset = found != verticalScrollOffsets.end()
? found->second
: target->scrollOffsetY;
scrollDebugSnapshot.lastOffsetBefore = oldOffset;
const float scrollUnits = event.wheelDelta / 120.0f;
const float nextOffset = ClampScrollOffset(
oldOffset - scrollUnits * 48.0f,
target->contentDesiredSize.height,
target->scrollViewportRect.height);
scrollDebugSnapshot.lastOffsetAfter = nextOffset;
if (std::fabs(nextOffset - oldOffset) <= 0.01f) {
scrollDebugSnapshot.lastResult = "Scroll delta clamped to current offset";
continue;
}
verticalScrollOffsets[target->stateKey] = nextOffset;
++scrollDebugSnapshot.handledWheelEventCount;
scrollDebugSnapshot.lastResult = "Handled";
changed = true;
}
return changed;
}
void SyncScrollOffsets(
const RuntimeLayoutNode& node,
std::unordered_map<std::string, float>& verticalScrollOffsets) {
if (node.isScrollView) {
const float overflow = ComputeScrollOverflow(node.contentDesiredSize.height, node.scrollViewportRect.height);
if (overflow <= 0.0f || node.scrollOffsetY <= 0.0f) {
verticalScrollOffsets.erase(node.stateKey);
} else {
verticalScrollOffsets[node.stateKey] = node.scrollOffsetY;
}
}
for (const RuntimeLayoutNode& child : node.children) {
SyncScrollOffsets(child, verticalScrollOffsets);
}
}
@@ -451,9 +679,21 @@ void EmitNode(
++stats.textCommandCount;
}
const bool pushScrollClip =
node.isScrollView &&
node.scrollViewportRect.width > 0.0f &&
node.scrollViewportRect.height > 0.0f;
if (pushScrollClip) {
drawList.PushClipRect(node.scrollViewportRect);
}
for (const RuntimeLayoutNode& child : node.children) {
EmitNode(child, drawList, stats);
}
if (pushScrollClip) {
drawList.PopClipRect();
}
}
std::string ResolveDisplayName(
@@ -565,7 +805,10 @@ UIScreenFrameResult UIDocumentScreenHost::BuildFrame(
return result;
}
RuntimeLayoutNode root = BuildLayoutTree(document.viewDocument.rootNode);
const std::string stateRoot = document.sourcePath.empty()
? document.displayName
: document.sourcePath;
RuntimeLayoutNode root = BuildLayoutTree(document.viewDocument.rootNode, stateRoot, 0u);
MeasureNode(root);
UIRect viewportRect = input.viewportRect;
@@ -576,7 +819,23 @@ UIScreenFrameResult UIDocumentScreenHost::BuildFrame(
viewportRect.height = (std::max)(360.0f, root.desiredSize.height);
}
ArrangeNode(root, viewportRect);
ArrangeNode(root, viewportRect, m_verticalScrollOffsets);
if (ApplyScrollWheelInput(root, input, m_verticalScrollOffsets, m_scrollDebugSnapshot)) {
ArrangeNode(root, viewportRect, m_verticalScrollOffsets);
}
SyncScrollOffsets(root, m_verticalScrollOffsets);
if (const RuntimeLayoutNode* primaryScrollView = FindFirstScrollView(root); primaryScrollView != nullptr) {
m_scrollDebugSnapshot.primaryTargetStateKey = primaryScrollView->stateKey;
m_scrollDebugSnapshot.primaryViewportRect = primaryScrollView->scrollViewportRect;
m_scrollDebugSnapshot.primaryOverflow = ComputeScrollOverflow(
primaryScrollView->contentDesiredSize.height,
primaryScrollView->scrollViewportRect.height);
} else {
m_scrollDebugSnapshot.primaryTargetStateKey.clear();
m_scrollDebugSnapshot.primaryViewportRect = {};
m_scrollDebugSnapshot.primaryOverflow = 0.0f;
}
UIDrawList& drawList = result.drawData.EmplaceDrawList(document.displayName);
EmitNode(root, drawList, result.stats);
@@ -589,6 +848,10 @@ UIScreenFrameResult UIDocumentScreenHost::BuildFrame(
return result;
}
const UIDocumentScreenHost::ScrollDebugSnapshot& UIDocumentScreenHost::GetScrollDebugSnapshot() const {
return m_scrollDebugSnapshot;
}
} // namespace Runtime
} // namespace UI
} // namespace XCEngine