范围:测量与排列逻辑、脏标记传播,以及 StackPanel、Canvas、EntryRow 三种容器的行为。
前置:元素系统(Element 字段与 setter 定义;dirty、setter 与 measure-arrange 机制由布局系统定义)。
惰性布局流水线
布局系统界定界面何时重新计算几何,规则为只有脏元素才重排。一次完整的重排从状态变更开始,经 markDirty() 沿父链冒泡,最终由 layout() 执行测量与排列:
状态变更 → markDirty() → Element.layout() → measure() + arrange() + clearDirty()
这条链上有四个关键点。第一,markDirty() 沿父元素链向上冒泡,所以修改一个孙子元素后,它的所有祖先容器都会重新测量。第二,layout() 只在 dirty == true 时执行,干净的子树每帧被调用也只是执行一次比较。第三,链式 setter(width()、height()、size()、margin()、visible() 等)在赋值后自动调用 markDirty(),因此正常编写代码即触发重排。第四,由此产生一条硬约束:不得绕过 setter 直接写入 width = ...,因为直接改字段不会置脏,祖先容器不会重新测量,界面停留在旧布局上。方法名是 layout();源码中不存在 layoutElement() 这样的方法。
测量与排列的分工是:measure() 计算元素自身所需尺寸,arrange() 确定各子元素的摆放位置。LayoutElement.layout() 先执行自身的三步,再对每个子元素递归调用 layout();脏标记沿父链冒泡,因此需要变更的元素都会重排,未标脏的子树不会重复计算。
坐标系统
同一个 (x, y) 有两个坐标空间,混用空间是布局 bug 的主要来源。相对坐标 x/y 是元素相对其父元素左上角的位置,通过 topLeft(x, y) 或 setPos(x, y) 设置,arrange() 阶段安排的就是它。绝对坐标 getAbsoluteX()/getAbsoluteY() 沿父元素链累加位置、并减去链上每个 LayoutElement 祖先的滚动偏移,渲染与碰撞检测使用的都是绝对值。换算公式如下:
绝对 X = x + parent.x + grandparent.x + ... − Σ(链中 LayoutElement.scrollX)
(scrollY 同理作用于纵轴。)该公式解释了「父容器滚动后子元素命中区域随之移动」,因为 isHit 使用的绝对坐标已扣除滚动量。
锚点定位由 Element 上的 record UIPoint(int x, int y) 支持,并附带 tl()、tr()、bl()、br()、center() 五个便捷方法(细节见 元素系统);Canvas 的锚点摆放会使用它们。
OverflowMode 与滚动
容器用 OverflowMode 决定子元素超出边界时的处理方式。三种模式的行为与用途分别是:
| 模式 | 行为 |
|---|---|
VISIBLE | 不裁剪,子元素可以溢出绘制 |
HIDDEN | 用剪刀(scissor)裁剪,溢出部分既画不出也点不到 |
SCROLL | 裁剪 + 平滑滚动 + 可选滚动条 |
裁剪的触发条件是「HIDDEN 或 SCROLL」二选一:StackPanel 在 extractRenderState 中用 overflowMode == HIDDEN || overflowMode == SCROLL 决定是否 enableScissor,命中侧的 isMouseWithinClipBounds 使用同一条件,保证被裁掉的区域不可命中。滚动还需要每帧推进:UiTickDriver.onRenderFrame 经 TickWalker 对根树与覆盖层树全部节点调用 tick()(不看可见性),滚动插值在各容器的 tick() 中完成(步长与遍历口径见 生命周期系统,取值见 元素系统 的滚动模型一节)。
StackPanel 线性堆叠
StackPanel 将子元素沿主轴依次排列,是最常用的容器。构建器覆盖其几乎全部可调项:
new StackPanel(Orientation.VERTICAL)
.orientation(Orientation.HORIZONTAL) // 主轴方向
.direction(StackDirection.TOP_TO_BOTTOM) // LTR / RTL / BTT
.spacing(8) // 子元素之间的间距
.centered(true) // 交叉轴居中
.autoWidth(true) / .autoHeight(true) // 尺寸 = 内容总和
.overflow(OverflowMode.SCROLL) // 启用滚动
.scrollbar(true, 0x40FFFFFF) // 自定义滚动条颜色
.bgColor(0x80000000) // 背景色 (0 = 无)
.add(child);
-
测量:测量时,StackPanel 沿主轴累加每个子元素的 margin、尺寸与 spacing 得到总长(首个子元素不计 spacing),交叉轴取子元素的最大值;启用
autoWidth或者autoHeight时,该轴尺寸直接设为内容总量,否则width <= 0的轴会取内容尺寸。 -
排列:排列按
direction决定正向或反向顺序(TOP_TO_LEFT/BOTTOM_TO_TOP对应反向),centered(true)把子元素在交叉轴上居中。方向、间距、auto 系列与centered的 setter 都会markDirty;bgColor/scrollbar只影响外观样式、不触发重排。
滚动条宽 4 像素(SB_W = 4),长度按比例计算且最小 16 像素,悬停与拖拽时提亮。提亮同时提升 alpha(×1.6 封顶)与 RGB(+40 封顶);只提升 RGB 对半透明白色无效,因为该颜色本已处于上限。仅在 overflow == SCROLL 且内容溢出时绘制。滚轮步进为每次 ±v × 20 写入 targetScrollX/targetScrollY 并夹紧在合法范围,随后由 tick 插值逼近目标。对照参考:ListView 的滚动条最小高为 20、滚轮步进为 va × 30 并带 120ms 缓动;两组取值刻意不同,改动时不得将其对齐。
滚动条本体是独立元素对象 ScrollBar,即容器的一个普通子元素:StackPanel 构造时将其 add 进 children,layout() 每帧确保它保持在末位。框架口径是「列表位置即层级」,末位即本容器最高层;命中查找逆序扫描时先扫描到它,位于条带下方的内容元素无法取得落在条带上的点击;绘制同样经通用子元素流程完成,末位即绘制在最上层。条带整体落在容器矩形内,容器启用的剪刀裁剪的正是这个矩形,不会裁剪条带。仅两处与普通子元素不同:容器在 mH/mV/aH/aV/cw/ch 六处内容尺寸与排布遍历时按字段引用直接跳过它;定位覆写 getAbsoluteX/getAbsoluteY 使用视口坐标,不跟随内容滚动。按下、拖动与松手事件均由它自身处理,不覆写 pressFlags(),因为默认的 CAPTURE_DRAG | CAPTURE_RELEASE 已满足需求,InteractionManager 会把拖动路由给 pressOwner。
容器滚轮判定只包含两个条件:overflow == SCROLL 且点在容器矩形内(含裁剪判定)。滚轮归属由 InteractionManager.mouseScrolled 的分发链决定:从命中元素起沿父链向上,第一个消费滚轮的元素即截断;点在容器空白处时,起点是 Hit.deepestLayoutAt 给出的那个矩形含点的最深布局容器。因此命中点落在 Stepper、Slider 这类自身也消费滚轮的子元素上时,容器不随之滚动。
Canvas 自由定位容器
Canvas 不做自动布局,measure() 为空实现,不为子元素计算尺寸;子元素的摆放位置由调用方通过 add 的参数给定,Canvas 按该坐标放置。四个 add 重载覆盖从最简到最精确的摆放方式:
Canvas c = new Canvas(w, h);
c.add(child); // 放置在 (0,0)
c.add(child, x, y); // 指定坐标
c.add(child, Anchor.CENTER); // 锚点定位
c.add(child, Anchor.BOTTOM_RIGHT, -8, -8); // 锚点 + 偏移(先 child.layout() 再换算)
锚点共 9 个位置:TOP_LEFT / TOP_CENTER / TOP_RIGHT / CENTER_LEFT / CENTER / CENTER_RIGHT / BOTTOM_LEFT / BOTTOM_CENTER / BOTTOM_RIGHT,带偏移的重载会先调用 child.layout() 取到尺寸,再按锚点换算出左上角坐标并 setPos。visible(v) 将可见性传播给所有子元素;measure() 是空实现,因为 Canvas 不计算子元素尺寸,宽高须在构造时给出,或由外层容器确定。
Canvas 与 StackPanel 有一处刻意不同:extractRenderState 按添加顺序绘制子元素(后添加的元素覆盖先添加的),与 LayoutElement 默认的加入顺序一致。需要置于最上层时,应经 overlays().push 推入覆盖层,机制见 覆盖层系统。
EntryRow 标签与控件行
EntryRow 是设置页最常见的「左标签、右控件」两列行。
left() 与 right() 名义上是两个槽位,实现上均为 addChild,区分发生在 arrange():children 索引 0 作为左列、索引 1 作为右列。因此该容器假定「先 left 后 right」的添加顺序,乱序添加会导致左右互换:
new EntryRow(fullWidth, leftWidth)
.left(labelElement) // 左列(索引 0,标签)
.right(controlWidget) // 右列(索引 1,控件)
.maxRightWidth(320); // 覆盖最大右列宽度(默认 260)
两列的几何按两步计算:
-
测量:行高取
max(20, max(左列高, 右列高)),即最矮不低于 20 像素。 -
排列:左列贴左垂直居中;右列先计算可用宽
avail = fullWidth − GAP(其中GAP = 8),再把右列宽夹到w = min(min(右列固有宽, avail), maxRightW)(maxRightW默认 260),最后右对齐摆放,因此控件始终贴右,宽度不会覆盖标签列。
不变量
- setter 是几何变更的唯一入口:
width/height/size/margin/visible等 setter 才调用markDirty();直接改字段会跳过脏标记、丢掉重排,父容器继续用旧几何摆放子元素。 - 重排只有一个入口:
Element.layout()在dirty时执行measure() → arrange() → clearDirty();任何绕开它的手动重算都会与惰性机制冲突。源码没有layoutElement()方法。 - 脏标记必须沿父链冒泡:
markDirty()置位自身后递归父级;截断冒泡会让祖先容器漏测,孙子变了而父容器不重排。 - 裁剪条件是 HIDDEN ∥ SCROLL:渲染(StackPanel 的
extractRenderState)与命中(isMouseWithinClipBounds)共用它;只改一边会让「看不见但仍能点到」或反之的不一致。 - EntryRow 按 children 索引分列:0 为左、1 为右,依赖添加顺序;改
left()/right()的实现或索引约定必须同步排列逻辑。
改动波及
- 改
markDirty()或layout()的触发条件,三种容器与全部 15 个组件的重排时机一起变,元素侧的字段语义(元素系统)也要跟着核对。 - 改 StackPanel 的测量/排列公式,会影响所有用它做表单与工具栏的界面;滚动步进(±v×20)与滚动条最小长(16)改动会单独波及滚动手感。
- 改坐标换算(
getAbsoluteX/Y的累加或滚动扣除),会同时影响渲染定位与isHit命中;命中链根在 交互引擎。 - 改 EntryRow 的
GAP、maxRightW默认值(260)或索引约定,会波及所有设置页的两列对齐。 - 改
OverflowMode的裁剪语义,会波及 StackPanel 渲染、命中裁剪与Canvas上叠放的可滚动面板(两棵树的绘制顺序见 根树与层级系统)。
相关文档
- 元素系统:
Element/LayoutElement基类、setter 与 pressFlags 的定义处。 - 交互引擎:布局得到的几何如何进入碰撞检测与事件分发。
- 覆盖层系统:需要浮在布局之上的内容应经
overlays().push推入,而非 Canvas 叠放。 - Developer: 动画:Animator 可以驱动数值属性做补间;布局本身不提供动画 API,尺寸变化仍经脏标记重排。
- Developer: 常见使用模式:设置页、表单、可拖拽面板等整页布局范例。
- Developer: 布局容器:在界面中摆放控件的使用侧口径