← 返回索引

范围:测量与排列逻辑、脏标记传播,以及 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: 布局容器:在界面中摆放控件的使用侧口径