UI 适配与布局
3.1 Canvas 与设计分辨率的真实位置
我第一版把设计分辨率写在 Canvas 组件上,那是 2.x 的做法,3.x 里找不到这个入口。3.8 的 Canvas 组件属性检查器上只剩两项:Camera Component 和 Align Canvas With Screen。官方文档对前者的描述是「与此 canvas 对齐的相机组件」,后者是「Canvas 关联的相机是否要与 Canvas 对齐,如果想要自己控制相机位置请勿勾选此选项(卷轴游戏等)」。
设计分辨率、适配屏幕宽度、适配屏幕高度,全部搬到了 项目 -> 项目设置 -> 项目数据 -> 默认 Canvas 设置。这个改动第一次遇到时我以为是文档没跟上编辑器,点了三遍才找到地方。
Canvas 继承自 RenderRoot2D,是 2D 渲染组件的数据收集入口。文档里有句话我抄在这里:场景中 Canvas 节点可以有多个,但所有 2D 渲染元素都必须作为 RenderRoot2D 的子节点才能被渲染。它不是「UI 容器」,更像一个渲染根。你在层级管理器里新建 2D 对象时编辑器自动给它挂一个 Canvas 父节点,就是这个机制。
还有一点和 2.x 的直觉相反:Canvas 本身和相机并无关系,它的渲染只取决于和其节点 layer 对应的 camera。Skyline Courier 是固定俯视视角,没有滚屏,所以我把 Align Canvas With Screen 保持勾选,让相机自动跟随屏幕尺寸。
设计分辨率我定 720×1280,竖屏。
3.2 fitWidth 与 fitHeight 的取舍
适配模式由两个勾选框的组合决定,官方多分辨率适配文档给了四种情况。
| 勾选 | 效果 | 代价 | 我用在 |
|---|---|---|---|
| 只勾适配高度 | 高度撑满,宽度随屏幕比例变 | 宽屏两侧内容被裁 | 主界面、关卡 |
| 只勾适配宽度 | 宽度撑满,高度随比例变 | 上下内容被裁 | 横屏界面 |
| 两个都勾 | 取较小缩放比,内容全显示 | 可能出现黑边 | 结算页、说明页 |
| 都不勾 | 按屏幕比例自动选宽或高 | 另一方向被裁 | 背景铺满型页面 |
我的第一版两个都勾了,结果在 iPhone 上上下各一条黑边,背景图中间断了一截。改成只勾适配高度之后黑边没了,但订单列表右上角的"接单"按钮在 19.5:9 的屏幕上被裁掉了一半。
这就是宽屏适配的核心矛盾:只保证一个方向完整,另一个方向一定会被裁。处理办法不是改适配模式,而是让 UI 元素自己知道边界在哪儿。这就是 Widget 存在的意义。
顺带说下宽屏留白的另一种解法:背景图不按屏幕尺寸铺,改成用一张超宽的图加左右两片纯色延伸块。我的关卡背景是平铺瓦片,本来就没这个问题。
编辑器里看不出问题、真机上一片狼藉,这种事我遇到过好几次,所以现在固定用两个工具对拍。编辑器顶部的预览按钮左边有个下拉菜单,可以选「编辑器内」「浏览器」「模拟器」。浏览器预览那一排控制按钮里,最左边是预览窗口比例,用来模拟不同移动设备;旁边还有 Rotate 切换横竖屏。设备分辨率不够用的时候,去 Cocos Creator/File -> 偏好设置 -> 设备管理器 里自己加。
浏览器预览的价值在于 F12。我把窗口调成 19.5:9 的比例,一眼就能看出订单列表的按钮是不是被裁了,比在真机上等二维码快得多。但模拟器预览的价值在别处:它走的是原生运行时,脚本日志和报错直接显示在控制台面板里,WebGL 上下文和纹理压缩的行为也更接近真机。
我现在的规矩是:适配问题先在浏览器比例预览里过一遍,怀疑跟平台相关再去模拟器,最后以构建后的版本为准。官方文档也提醒过,模拟器运行的效果和最终发布到原生平台可能有差别,重要功能一律以构建版本为准。
3.3 Widget 的对齐模式与 target
Widget 的属性比 2.x 细致。核心是一组 isAlignXxx 开关加一组 isAbsoluteXxx 开关。
isAlignTop / isAlignBottom / isAlignLeft / isAlignRight / isAlignHorizontalCenter / isAlignVerticalCenter 决定开哪几个方向,top、bottom、left、right、horizontalCenter、verticalCenter 是对应的边距值。isAbsoluteTop 这类属性决定边距单位:true 表示像素,false 表示相对父节点尺寸的比例(0 到 1)。
import { _decorator, Component, Widget } from 'cc';
const { ccclass } = _decorator;
@ccclass('SafeMargin')
export class SafeMargin extends Component {
start () {
const widget = this.getComponent(Widget)!;
widget.isAbsoluteTop = false;
widget.isAbsoluteBottom = false;
widget.bottom = 0.1; // 父节点高度的 10%
widget.top = 0.1;
}
}
target 属性指定对齐参照节点,只能是当前节点的某个父节点,为空时用直接父级。这是我做嵌套面板时的关键:结算弹窗挂在一个居中的 Panel 上,弹窗里的"确认"按钮要相对 Panel 排版而不是相对 Canvas,就要把 target 显式指到 Panel。
alignMode 决定何时刷新,值是 Widget.AlignMode.ONCE、ALWAYS、ON_WINDOW_RESIZE。默认是 ON_WINDOW_RESIZE,官方文档说,只要在组件初始化时执行过一次对齐定位,引擎就会自动把 Widget 的 enabled 设为 false,关掉之后的每帧更新。元素多的场景用 ALWAYS 是白白烧 CPU。
但要注意另一个副作用:文档明确写了,Align Mode 设为 ALWAYS 时,节点的位置和尺寸属性会被限制,不能通过 API 或动画系统自由修改,因为 Widget 的对齐在每帧最后阶段处理,会把值重置掉。想同时做到对齐和运行时改位置,两条路:把 alignMode 设成 ONCE,或者改 Widget 的 top/bottom/left/right 这些边距属性本身。
3.4 Layout 与 ScrollView:订单列表怎么搭
Skyline Courier 的订单列表是 ScrollView 加 Layout。
节点结构是三层:ScrollView 节点本身带 Mask(滚动区域由 view 限定),下面挂 content,content 里面放订单条目。ScrollView 的 content 属性指向 content 节点,vertical 打开、horizontal 关掉。
content 的锚点要设成 (0.5, 1),即顶部对齐。因为 ScrollView 是按 content 在滚动方向上的长度算滚动位置的,content 锚点不对,列表会往错误方向生长。
Layout 挂在 content 上,type 设 VERTICAL,resizeMode 设 CONTAINER,让容器高度跟着子节点数量长。spacingY 控制条目间距。
import { Layout, ScrollView, Widget } from 'cc';
const layout = this.node.getComponent(Layout)!;
layout.type = Layout.Type.VERTICAL;
layout.resizeMode = Layout.ResizeMode.CONTAINER;
layout.spacingY = 12;
const scrollView = this.getComponent(ScrollView)!;
scrollView.vertical = true;
scrollView.horizontal = false;
scrollView.inertia = true;
scrollView.brake = 0.5;
scrollView.elastic = true;
scrollView.bounceDuration = 0.3;
inertia 是惯性,brake 是松手后的减速系数,取值 0 到 1,1 表示立刻停,0 表示一路滚到 content 边界。elastic 控制能否越界并回弹,bounceDuration 是回弹时长,取值 0 到 10。
有个坑:官方文档专门强调,content 节点上 Widget 和 Layout 不能同时挂,会产生不可预料的后果。我第一版想用 Widget 让 content 高度自适应,又想用 Layout 排子节点,结果列表高度算错,滚了两屏就没了。
还有一个坑是 cancelInnerEvents,默认 true,会取消子节点上注册的触摸事件。订单条目上的"接单"按钮如果绑的是节点 touch 事件,在拖动列表时会被吃掉。官方文档提到子节点上的 touchstart 仍然会触发,触点移动非常短的情况下 touchmove 和 touchend 也不受影响,但保险的做法还是用 Button 组件配 click 事件。
滚动事件用节点事件注册就行,scrollview.node.on('scroll-to-top', ...),除了 scroll-to-top 还有 scrolling、touch-up、scroll-began。走 scrollEvents 面板加回调的话,函数签名是 (scrollview, eventType, customEventData)。
3.5 安全区、层级与点击穿透
刘海屏这块我一开始想自己算,后来发现有现成组件。
SafeArea 组件启用时会通过 sys.getSafeAreaRect() 拿到当前设备的安全区域,并通过 Widget 组件实现适配,如果节点上没有 Widget,组件会自动添加一个。可适配 Android 和 iOS 设备,通常加在 UI 交互区域的顶层节点。
我的做法是:Canvas 下面挂一个 SafeArea 节点,所有可点击 UI 都放它里面。结算弹窗的按钮就不会被刘海挡住。注意 SafeArea 会自动加 Widget,如果我又在同一个节点上手动加 Widget 做别的对齐,两个组件会打架。
层级上我用兄弟节点顺序控制上下,遮挡层放最后。
点击穿透用 BlockInputEvents 组件。它会拦截所属节点 bounding box 内的所有输入事件,防止穿透到下层节点,一般用于上层 UI 的背景。这个组件没有任何 API,添加到场景即生效。
我做暂停弹窗的时候踩过这个:弹窗背景挡住了场景,但背景上点击时,下面的无人机还能被选中,角色会跟着手指乱跑。给背景节点加上 BlockInputEvents 就好了。
还有个顺序问题:BlockInputEvents 是按节点顺序拦截的。如果它挂在遮挡层上而遮挡层不是最上面的节点,上层节点的点击会先被处理。遮挡层必须是最后一个子节点。
