背包列表行高变化时,滚动位置应跟随条目锚点
背包列表已经滚到装备 C,上方两条物品说明完成测量,从各 40 个布局单位变成 80 和 60。玩家没有拖动,C 却向下跳了 60。滚动值仍然是 90,数值没有变化,阅读位置却变了:内容坐标里的同一个距离,已经不再对应同一件装备。
这种更新可以发生在文本换行、详情展开或图片尺寸确定之后。本文把渲染和测量暂时剥离,用独立 C# 工程验证一个提交规则:旧布局负责捕获阅读锚点,新布局根据同一条目的稳定 ID 恢复滚动值。验证覆盖布局数值,不声称已经接入 Unity、Godot 的滚动组件。

滚动值没动,为什么装备换了位置
约定列表从上向下排列,内容顶部为零,所有长度使用同一布局单位。没有额外间距或内边距,视口高度为 80;条目 A 至 E 初始高度均为 40。C 的顶部前缀和为 80,滚动值为 90,所以它在视口中的顶部坐标为 80 - 90 = -10,表示顶部十单位已经被裁掉。
记录 (C, 10) 就足够描述这次阅读位置。这里的 10 称为 Inset,等于滚动值减去条目顶部前缀和。高度更新后,C 的前缀变成 140,期望滚动值应为 140 + 10 = 150。如果仍用 90,C 的屏幕顶部变为 50;如果保存的是数组下标,前面插入新条目时还会认错对象。
因此锚点保存稳定条目 ID,而不保存可复用的行视图实例。ID 必须在这一轮布局前后保持身份含义,不能把排序位置当成 ID;同一对象池单元下一次可能展示另一件物品。本文用 A、B、C 表示数据身份,与渲染实例无关。

一次布局更新只捕获一次锚点
ScrollAnchor.cs 的完整核心如下。ListLayout 在构造时复制行数据,建立只读前缀表;Capture 在旧表上找第一个可见条目,Restore 在新表上按 ID 查询。条目区间采用左闭右开约定,因此滚动值恰好为 80 时,捕获的是 C,而不是底边已经离开视口的 B。
public readonly record struct Row(string Id, double Height);
public readonly record struct Anchor(string Id, double Inset);
public readonly record struct Placement(double Scroll, double Drift);
public sealed class ListLayout
{
private readonly Row[] rows;
private readonly double[] tops;
public double Total => tops[^1];
public ListLayout(IEnumerable<Row> source)
{
rows = source.ToArray();
tops = new double[rows.Length + 1];
var ids = new HashSet<string>(StringComparer.Ordinal);
for (int i = 0; i < rows.Length; i++)
{
var row = rows[i];
if (string.IsNullOrEmpty(row.Id) || !ids.Add(row.Id) ||
!double.IsFinite(row.Height) || row.Height <= 0)
throw new ArgumentException("Invalid row");
tops[i + 1] = tops[i] + row.Height;
if (!double.IsFinite(tops[i + 1]) || tops[i + 1] <= tops[i])
throw new ArgumentException("Unrepresentable prefix");
}
}
private double Limit(double scroll, double viewport)
{
if (!double.IsFinite(scroll) || !double.IsFinite(viewport) || viewport <= 0)
throw new ArgumentException("Invalid viewport or scroll");
return Math.Clamp(scroll, 0, Math.Max(0, Total - viewport));
}
public Anchor? Capture(double scroll, double viewport)
{
scroll = Limit(scroll, viewport);
for (int i = 0; i < rows.Length; i++)
if (scroll < tops[i + 1])
return new Anchor(rows[i].Id, scroll - tops[i]);
return null;
}
public Placement Restore(Anchor anchor, double viewport)
{
if (!double.IsFinite(anchor.Inset) || anchor.Inset < 0)
throw new ArgumentException("Invalid anchor inset");
int index = Array.FindIndex(rows, row => row.Id == anchor.Id);
if (index < 0) throw new InvalidOperationException("Anchor removed");
double desired = tops[index] + anchor.Inset;
double actual = Limit(desired, viewport);
// 正值表示锚点顶部比原屏幕位置更靠下。
return new Placement(actual, desired - actual);
}
}
实际接入应由列表控制器持有当前布局和滚动状态:收集这一批有效测量结果,在当前布局上捕获一次锚点,构造新布局,恢复位置,再把新布局与滚动值一起交给可见行计算。若先让新行高参与一次绘制,下一次回调才补偿滚动,用户仍可能看到中间帧。这个顺序是接口约束,当前数值工程没有验证具体引擎的布局回调时机。
异步测量还需要先确认结果仍对应当前数据及测量宽度,才能进入这一批更新。锚点公式不会识别过期的文本测量。用户拖动则以提交前的实际位置重新捕获,不能拿请求测量时保存的旧锚点覆盖这期间的滚动输入。惯性速度、拖动手势和是否贴底仍归滚动控制器管理。
线性建表、捕获和恢复各为 O(n),空间为 O(n)。对于频繁单行更新的大列表,可以换成支持前缀查询的树结构和 ID 索引,但应先保留同一组屏幕位置断言;数据结构只改变查找方式,不应改变阅读锚点的含义。本篇没有给出性能收益或组件帧耗时。
保住位置与限制滚动范围会发生冲突
把新高度改成 [40,40,20,10,10],总长只有 120。原锚点仍为 (C,10),期望滚动值是 90,视口高 80 却只允许滚到 40。若强行写入 90,底部将出现超出本合同的空白区域。实现优先遵守合法滚动范围,返回 Scroll=40, Drift=50;C 的顶部从 -10 变为 40,这 50 是明确的阅读位置损失。
Drift 定义为新屏幕顶部减旧屏幕顶部,正值表示向下移动。调用方可以据此决定是否接受跳动或使用过渡,但不能在钳制之后仍报告“位置完全保持”。同理,锚点条目本身变矮时,保留的是条目顶部关系,不是某个字或内部子控件的位置;Inset 甚至可能超过新行高。需要逐段文本阅读锚点时,应再引入段落身份和内部偏移,不能偷偷把旧 Inset 改小。
锚点被删除时,Restore 显式抛出异常,由控制器选择相邻条目或其他回退政策;它不会把原下标处的新对象冒充旧对象。空列表捕获结果为 null,不应继续恢复;非空但不足一屏时滚动上限为零。零高度、非有限数、重复 ID 和无法表示的累计前缀在构造阶段拒绝,避免后续二义性进入布局。
下面两个回归保留了开篇的错误路径和滚动上限反例,来自 Program.cs,Require 与 Near 分别检查布尔条件和绝对误差小于 1e-9:
Test("unchanged-offset-fails", () => {
Require(updated.Capture(90, 80)!.Value.Id == "B");
Near(140 - 90, 50);
Near(140 - restored.Scroll, -10);
});
Test("bottom-clamp", () => {
var result = new ListLayout(Rows(40,40,20,10,10))
.Restore(anchor,80);
Near(result.Scroll,40);
Near(result.Drift,50);
});
本次于 2026-09-24 在 .NET 9.0.3 运行。$PROBE_ROOT 表示包含上述源码及工程文件的目录,执行命令与决定性输出为:
dotnet build "$PROBE_ROOT/ScrollAnchorLab.csproj" -c Release
dotnet run --project "$PROBE_ROOT/ScrollAnchorLab.csproj" -c Release --no-build
build: exit=0 warnings=0 errors=0 elapsed=9.00s
run: exit=0 passed=17 failed=0 skipped=0
samples=12100 elapsedMs=128.6201
anchor=C inset=10 oldScroll=90 newScroll=150
C.screenTop: before=-10 unchangedScroll=50 restored=-10
clamp: desired=90 actual=40 drift=50
insert X(height=25) before A: scroll=115
remove A before C: scroll=50
Systematic 枚举前两行高度各 10 至 100、步长 10,以及旧滚动值 0 至 120 的全部整数,共 12,100 组。测试单独求和得到新旧条目顶部,再比较屏幕位置之差与 Drift,同时检查滚动范围。其余测试覆盖边界归属、插删、输入数组修改和无效数字;计时包含整个断言过程,不是滚动性能基准。
对于开头的背包,控制器最终提交的滚动值由 90 改为 150,玩家继续看到 C 的同一顶部位置。值得固定在 UI 架构中的合同是“稳定身份、旧布局内偏移、恢复后的实际漂移”三者:前两个表达想保留什么,最后一个诚实表达布局边界允许保留多少。它既允许更换测量器和虚拟化数据结构,也让删除条目、内容缩短和手势输入各自保有明确的处理位置。