HarmonyOS 扇形自定义布局深度解析 —— CustomLayout + onMeasureSize / onPlaceChildren 实战
HarmonyOS 扇形自定义布局深度解析 —— CustomLayout + onMeasureSize / onPlaceChildren 实战


目录
- 前言
- 为什么需要自定义布局
- HarmonyOS 自定义布局体系概览
- 3.1 两种自定义布局路径
- 3.2 onMeasureSize —— 测量子组件
- 3.3 onPlaceChildren —— 布局子组件
- 3.4 生命周期与调用顺序
- 扇形布局的需求分析
- 4.1 扇形菜单的交互模型
- 4.2 关键技术指标
- 三角函数与扇形坐标计算
- 5.1 角度制与弧度制转换
- 5.2 扇形路径推导
- 5.3 线性插值实现展开/收拢
- 完整代码逐段解析
- 6.1 入口组件 Index —— 状态管理与交互
- 6.2 @Builder 子组件构造
- 6.3 FanLayout 组件 —— 自定义布局核心
- 6.4 onMeasureSize 测量逻辑
- 6.5 onPlaceChildren 扇形布局逻辑
- 6.6 线性插值动画
- 动画机制详解
- 7.1 animateTo 与状态驱动
- 7.2 多属性协同动画
- 7.3 弹性曲线 FastOutSlowIn
- API 24 兼容性要点
- 8.1 fill 属性的版本问题
- 8.2 animateTo 的弃用与替代
- 8.3 @Builder 函数的绑定约束
- 8.4 GeometryInfo 的 width/height
- 性能优化与最佳实践
- 9.1 避免在布局回调中修改状态
- 9.2 合理使用 ForEach 和 key
- 9.3 测量与布局的对称性原则
- 9.4 子组件复用策略
- 踩坑记录
- 10.1 @BuilderParam builder 初始化
- 10.2 Function.bind 在 ArkTS 中的限制
- 10.3 测量尺寸为 0 导致不渲染
- 10.4 build() 方法中只能存在 this.builder()
- 扩展思路
- 11.1 环形布局
- 11.2 响应式扇形半径
- 11.3 拖拽扇形菜单
- 11.4 扇形滚动列表
- 总结
1. 前言
在移动应用开发中,扇形菜单(Fan Menu / Radial Menu)因其独特的视觉效果和空间利用效率,一直被广泛用于音乐播放器、拍照工具、社交应用等场景。与传统的线性菜单不同,扇形菜单将子项沿圆弧排列,形成一个以中心点为圆心的扇面,既节省了屏幕空间,又赋予了交互以仪式感。
HarmonyOS 自 API 9 开始提供了强大的自定义布局能力,历经 API 10 的 onMeasureSize / onPlaceChildren 重构,到 API 24(HarmonyOS 6.1.1)已形成成熟稳定的布局体系。本文将以一个完整的扇形菜单项目为线索,从三角函数推导、布局委托实现、动画插值到 API 兼容性,深入浅出地讲解如何在 HarmonyOS 中构建一个生产级别的自定义布局组件。
无论你是刚接触 HarmonyOS 开发的新手,还是希望深入了解 ArkUI 自定义布局底层机制的老手,本文都能为你提供有价值的参考。
2. 为什么需要自定义布局
ArkUI 提供了 Row、Column、Flex、Stack、Grid 等丰富的布局容器,这些容器覆盖了绝大多数日常开发场景。但是,当我们需要实现以下效果时,系统容器往往力不从心:
- 不规则排列:子组件沿圆弧、螺旋线、波浪线等非正交路径排列;
- 动态计算位置:子组件的位置依赖运行时计算,如碰撞检测、物理模拟;
- 特殊动画过渡:子组件需要在不同布局状态之间平滑过渡,如展开/收拢;
- 极致性能要求:需要精确控制每个子组件的测量和布局过程,避免不必要的重排。
扇形菜单恰好同时命中了上述四个特征:子组件沿圆弧排列(不规则)、位置依赖三角函数计算(动态计算)、展开和收拢需要平滑动画(特殊过渡)、列表项较多时需精确控制布局(性能要求)。
为什么不用 Stack + position 手动计算?
这是很多开发者首先想到的方案。确实,使用 Stack 作为容器,再为每个子组件设置 .position({ x, y }) 也能实现扇形排列。但这种方案存在几个问题:
position是绝对定位,会脱离文档流,容器无法获知子组件的真实尺寸和位置;- 无法正确测量,如果子组件包含自适应内容(如不同长度的文本),
position无法自动调整布局; - 动画性能受限,
position的动画需要为每个子组件单独编写,难以统一控制; - 语义缺失,代码意图不清晰,维护成本高。
而 CustomLayout 将测量和布局逻辑封装在一个独立组件中,子组件通过 @Builder 插槽传入,代码结构清晰、逻辑集中、性能可控。
3. HarmonyOS 自定义布局体系概览
3.1 两种自定义布局路径
HarmonyOS 自 API 9 开始提供自定义布局能力。从 API 10 开始,官方推荐使用基于 onMeasureSize 和 onPlaceChildren 的新 API,而旧的 onLayout / onMeasure 方案已被标记为弃用。
| 特性 | 旧 API(API 9,已弃用) | 新 API(API 10+,推荐) |
|---|---|---|
| 测量方法 | onMeasure |
onMeasureSize |
| 布局方法 | onLayout |
onPlaceChildren |
| 子组件类型 | LayoutChild |
Measurable / Layoutable |
| 容器尺寸返回 | 通过参数设置 | 通过 SizeResult 返回值 |
| 适用版本 | API 9 ~ API 23 | API 24 及以后 |
本文使用的 API 24 版本,因此我们采用 onMeasureSize + onPlaceChildren 的全新方案。
3.2 onMeasureSize —— 测量子组件
onMeasureSize 是自定义布局的第一步,在 ArkUI 布局流程中优先于 onPlaceChildren 执行。其函数签名如下:
onMeasureSize(
selfLayoutInfo: GeometryInfo,
children: Array<Measurable>,
constraint: ConstraintSizeOptions
): SizeResult
参数说明:
| 参数 | 类型 | 说明 |
|---|---|---|
selfLayoutInfo |
GeometryInfo |
容器自身的布局信息,包含 width 和 height(API 10+) |
children |
Array<Measurable> |
所有子组件的可测量代理,每个元素提供 measure() 方法 |
constraint |
ConstraintSizeOptions |
父容器对当前组件施加的尺寸约束 |
| 返回值 | SizeResult |
组件自身计算后的最终尺寸,包含 width 和 height |
核心职责:
- 遍历所有子组件,为每个子组件调用
child.measure(constraint)确定其尺寸; - 计算容器自身尺寸,基于所有子组件的测量结果和容器的布局策略,返回一个
SizeResult。
重要约束:
- 每个子组件必须在
onMeasureSize中被测量,否则在onPlaceChildren中无法获取其尺寸,且不会被渲染; - 在
onMeasureSize中禁止修改状态变量,否则可能导致无限循环; selfLayoutInfo.width和selfLayoutInfo.height仅在已设置容器宽高时有效,若未设置则为 0。
3.3 onPlaceChildren —— 布局子组件
onPlaceChildren 在测量完成后执行,负责为每个子组件分配最终的屏幕位置。其函数签名如下:
onPlaceChildren(
selfLayoutInfo: GeometryInfo,
children: Array<Layoutable>,
constraint: ConstraintSizeOptions
): void
参数说明:
| 参数 | 类型 | 说明 |
|---|---|---|
selfLayoutInfo |
GeometryInfo |
容器最终布局信息,width / height 来自 onMeasureSize 的返回值 |
children |
Array<Layoutable> |
所有子组件的可布局代理,提供 measureResult 和 layout() 方法 |
constraint |
ConstraintSizeOptions |
父容器的约束条件 |
核心职责:
- 读取每个子组件的
measureResult(已在onMeasureSize中确定); - 调用
child.layout({ x, y })设置子组件在容器中的位置(相对于容器左上角)。
重要约束:
- 禁止修改子组件尺寸;
- 禁止修改状态变量;
- 未调用
layout()的子组件将不会被渲染。
3.4 生命周期与调用顺序
ArkUI 组件的布局流程遵循严格的三个阶段:
约束传递 → 测量阶段 → 布局阶段
在自定义布局组件中,这个流程映射为:
父容器约束传递给自定义组件
↓
onMeasureSize 被调用(测量子组件、确定容器自身尺寸)
↓
onPlaceChildren 被调用(为子组件分配位置)
↓
子组件渲染
当任意状态变量发生变化时,ArkUI 会重新触发上述流程,从而实现响应式更新。这也是我们实现展开/收拢动画的基础——改变 progress 状态变量,触发重新布局,子组件位置随之更新。
4. 扇形布局的需求分析
4.1 扇形菜单的交互模型
我们设计的扇形菜单包含两种状态:
- 收拢状态(collapsed):所有子项重叠在圆心,不可见或半透明,中心按钮显示「+」;
- 展开状态(expanded):所有子项沿圆弧均匀分布,完全可见,中心按钮显示「✕」。
用户点击中心按钮时,菜单在两种状态之间平滑切换。
为什么选择 150° 扇形?
经过实际用户体验测试,150° 的扇形展开角度具有以下优势:
- 覆盖屏幕上半部分约五分之二的区域,视觉上均衡不偏颇;
- 每个子项之间的角度间隔均匀(7 个子项,间隔 25°),不会过于拥挤;
- 子项在水平方向和垂直方向都有足够的空间距离,避免误触。
4.2 关键技术指标
| 指标 | 值 | 说明 |
|---|---|---|
| 子项数量 | 7 | 适中数量,覆盖常用功能入口 |
| 子项尺寸 | 48vp | 足够容纳图标或单字符,触摸友好 |
| 扇形半径 | 130vp | 在 360×640 模拟器上居中良好 |
| 总角度 | 150° | 从上方向两侧各 75°,视觉对称 |
| 动画时长 | 400ms | 适中,不拖沓也不仓促 |
| 动画曲线 | FastOutSlowIn | 起始快、结束舒缓,符合物理直觉 |
| 子项缩放 | 0.3 → 1.0 | 展开时由小到大,增强层次感 |
| 子项透明度 | 0 → 1 | 展开时淡入,收拢时淡出 |
5. 三角函数与扇形坐标计算
扇形布局的核心是三角函数。我们需要将每个子项在扇形上的角度位置,转换为容器中的 (x, y) 坐标。
5.1 角度制与弧度制转换
屏幕坐标系以左上角为原点,x 轴向右为正,y 轴向下为正。数学单位圆中,0° 指向右方,90° 指向上方。在扇形菜单中,我们习惯用「向上为 0°、向左/右展开」的直观方式表述角度。
角度转弧度公式:
弧度 = 角度 × π / 180
5.2 扇形路径推导
假设圆心在容器中心 (cx, cy),扇形半径 R,总角度 totalAngle,子项数量 n。
起始角度:为了保证扇形的对称性,我们让扇形中心指向正上方(90° 在屏幕坐标系中指向下方,但我们希望在视觉上指向上方,因此数学角度取 -90° 的等价表示)。
在本文实现中:
const startAngle = 90 - totalAngle / 2; // 90 - 75 = 15°
这意味着第一个子项在 15°(右上方),最后一个子项在 165°(左上方),中心子项在 90°(正上方)。
子项 i 的角度:
const angleDeg = startAngle + (totalAngle / (n - 1)) * i;
子项 i 的目标位置:
const angleRad = angleDeg * Math.PI / 180;
const tx = cx + R * Math.cos(angleRad) - childW / 2;
const ty = cy - R * Math.sin(angleRad) - childH / 2;
注意:屏幕坐标系 y 轴向下,因此使用 cy - ...(减去 sin 值使子项向上偏移)。同时减去 childW / 2 和 childH / 2 是为了让子项的中心对齐到计算出的坐标,而非左上角。
5.3 线性插值实现展开/收拢
展开和收拢本质上是子项在「圆心位置」和「扇形位置」之间的平滑过渡。我们使用最简单的线性插值:
// 收拢位置(所有子项重叠在圆心)
const cx0 = cx - childW / 2;
const cy0 = cy - childH / 2;
// 展开位置(扇形上的坐标)
const tx = /* 由三角函数计算 */;
const ty = /* 由三角函数计算 */;
// 线性插值
const x = cx0 + (tx - cx0) * progress;
const y = cy0 + (ty - cy0) * progress;
当 progress = 0 时,所有子项在圆心;当 progress = 1 时,所有子项在扇形位置;中间值产生平滑的放射状展开效果。
6. 完整代码逐段解析
6.1 入口组件 Index —— 状态管理与交互
@Entry
@Component
struct Index {
@State isExpanded: boolean = false;
@State progress: number = 0;
// ...
}
@State 装饰器是 ArkTS 状态管理的核心。当 isExpanded 或 progress 变化时,ArkUI 框架会自动重新渲染依赖这些状态的 UI 部分。
isExpanded 用于决定中心按钮的图标(「+」或「✕」)和旋转角度;progress 则直接驱动子项的布局位置、透明度和缩放。
6.2 @Builder 子组件构造
@Builder
fanMenuBuilder() {
ForEach(this.menuItems, (item: string, index: number) => {
Stack() {
Circle().width(this.itemSize).height(this.itemSize)
.fill(this.colors[index % this.colors.length])
Text(item).fontSize(18).fontColor(Color.White)
.fontWeight(FontWeight.Bold)
}
.width(this.itemSize).height(this.itemSize)
.alignContent(Alignment.Center)
.opacity(this.progress === 0 ? 0 : this.progress)
.scale({
x: 0.3 + 0.7 * this.progress,
y: 0.3 + 0.7 * this.progress,
})
}, (item: string) => item)
}
@Builder 装饰的方法类似于一个 UI 模板函数,它返回子组件树,通过 @BuilderParam 传入自定义布局组件。
关键点:
- ForEach:遍历
menuItems数组,(item) => item作为 key 标识; - opacity 与 scale:绑定
progress实现淡入/缩放效果; - Stack 包裹:确保 Circle 和 Text 层叠居中。
6.3 FanLayout 组件 —— 自定义布局核心
@Component
struct FanLayout {
progress: number = 0;
fanRadius: number = 130;
fanTotalAngle: number = 150;
itemSize: number = 48;
@Builder
doNothingBuilder() {}
@BuilderParam builder: () => void = this.doNothingBuilder;
result: SizeResult = { width: 0, height: 0 };
// ...
}
@BuilderParam 接收从父组件传入的 @Builder 函数,这是自定义布局的子组件传入通道。
特别注意:@BuilderParam 的默认值 this.doNothingBuilder 是必需的,因为 ArkTS 要求在 struct 初始化时所有成员都已有确定值。
6.4 onMeasureSize 测量逻辑
onMeasureSize(
selfLayoutInfo: GeometryInfo,
children: Array<Measurable>,
constraint: ConstraintSizeOptions
): SizeResult {
for (let i = 0; i < children.length; i++) {
const child = children[i];
child.measure({
minWidth: this.itemSize,
maxWidth: this.itemSize,
minHeight: this.itemSize,
maxHeight: this.itemSize,
});
}
const maxW = constraint.maxWidth as number;
const maxH = constraint.maxHeight as number;
this.result.width = maxW ?? 360;
this.result.height = maxH ?? 640;
return this.result;
}
测量逻辑分为两步:
- 子组件测量:将每个子组件的宽高固定为
itemSize(48vp),通过child.measure()完成; - 容器自身尺寸:取
constraint.maxWidth和constraint.maxHeight作为容器尺寸,即占满父容器全部可用空间。
为什么固定子组件尺寸:在扇形布局中,子组件的位置完全由圆心、半径和角度决定,允许子组件尺寸自适应变化会破坏扇形排列的精确性。如果需要不同大小的子项,可以在 @Builder 中独立设置每个子项的尺寸,并在 FanLayout 中通过 layoutId 来区分。
6.5 onPlaceChildren 扇形布局逻辑
onPlaceChildren(
selfLayoutInfo: GeometryInfo,
children: Array<Layoutable>,
constraint: ConstraintSizeOptions
): void {
const containerW = selfLayoutInfo.width;
const containerH = selfLayoutInfo.height;
const cx = containerW / 2;
const cy = containerH / 2;
const count = children.length;
if (count === 0) return;
const startAngle = 90 - this.fanTotalAngle / 2;
for (let i = 0; i < count; i++) {
const child = children[i];
const childW = child.measureResult.width;
const childH = child.measureResult.height;
const angleDeg = startAngle + (this.fanTotalAngle / Math.max(count - 1, 1)) * i;
const angleRad = angleDeg * Math.PI / 180;
const tx = cx + this.fanRadius * Math.cos(angleRad) - childW / 2;
const ty = cy - this.fanRadius * Math.sin(angleRad) - childH / 2;
const cx0 = cx - childW / 2;
const cy0 = cy - childH / 2;
const x = cx0 + (tx - cx0) * this.progress;
const y = cy0 + (ty - cy0) * this.progress;
child.layout({ x: x, y: y });
}
}
这段代码是扇形布局的核心,包含三个关键计算:
- 扇形坐标:由圆心、半径、角度通过三角函数计算;
- 圆心坐标:所有子项重叠在容器中心;
- 线性插值:通过
progress在两种坐标之间平滑过渡。
6.6 线性插值动画
.onClick(() => {
animateTo(
{ duration: 400, curve: Curve.FastOutSlowIn },
() => {
this.isExpanded = !this.isExpanded;
this.progress = this.isExpanded ? 1 : 0;
}
);
})
animateTo 将函数闭包内的状态变更封装为动画过渡。当 this.progress 从 0 变为 1 时,ArkUI 不会立即跳变,而是在 400ms 内按照 FastOutSlowIn 曲线逐步更新,每次更新都会触发 FanLayout 的重新布局,从而产生流畅的展开动画。
7. 动画机制详解
7.1 animateTo 与状态驱动
animateTo 是 HarmonyOS ArkUI 中最常用的显式动画 API。它的工作原理可以概括为:
- 记录起点:在调用
animateTo时,框架记录当前所有与动画相关的属性值; - 应用变更:执行闭包中的代码,修改状态变量;
- 计算差值:框架对比起点和终点的状态值;
- 插值渲染:在指定时长内,按照动画曲线逐步应用中间值,触发每一次渲染。
在我们的扇形菜单中,这个过程具体为:
点击按钮
→ animateTo 开始
→ progress: 0 → 1(或 1 → 0)
→ 每次 progress 变化都触发 FanLayout 重建
→ onPlaceChildren 用新的 progress 计算位置
→ 子组件位置逐步从圆心移到扇形(或反向)
→ 400ms 后动画结束
7.2 多属性协同动画
扇形菜单在展开时,子组件有三个属性同时动画:
| 属性 | 收拢状态 | 展开状态 | 变化规律 |
|---|---|---|---|
| 位置 (x, y) | 圆心 | 扇形 | 线性插值 |
| 透明度 (opacity) | 0 | 1 | 线性变化 |
| 缩放 (scale) | 0.3 | 1.0 | 线性变化 |
这三种动画共用同一个 progress 驱动,天然同步,不需要额外的协调机制。这也是状态驱动动画相比于传统帧动画的一大优势——所有 UI 变化源自同一数据源,保持一致性。
7.3 弹性曲线 FastOutSlowIn
Curve.FastOutSlowIn 是 Material Design 中定义的
标准运动曲线,其特点是:
- 起始段:快速加速,给用户即时的响应反馈;
- 结束段:缓慢减速,让运动平稳着陆,符合物理惯性直觉。
对比其他常用曲线:
| 曲线 | 特点 | 适用场景 |
|---|---|---|
Linear |
匀速 | 进度条、加载指示器 |
EaseIn |
先慢后快 | 进入场景 |
EaseOut |
先快后慢 | 退出场景 |
EaseInOut |
两端慢中间快 | 过渡动画 |
FastOutSlowIn |
起始加速快、结束减速慢 | 交互反馈、展开动画 |
对于扇形菜单,FastOutSlowIn 提供给用户的感受是:点击后子项迅速弹出(响应灵敏),然后在接近目标位置时优雅减速(视觉舒适)。
8. API 24 兼容性要点
API 24(HarmonyOS 6.1.1)是 HarmonyOS NEXT 演进过程中的一个重要里程碑。在实际开发中,有几个兼容性问题需要特别关注。
8.1 fill 属性的版本问题
在编译时,我们遇到了如下警告:
The 'fill' API is supported since SDK version 26.0.0.
However, the current compatible SDK version is 6.1.1(24).
原因分析:Circle.fill() 方法从 API 9 开始就支持 Color 枚举值(如 Color.Red、Color.Pink),但接受字符串格式颜色值(如 '#FF6B6B')的重载版本是在更高的 SDK 版本中才加入的。API 24 的兼容 SDK 版本对应的是 6.1.1,而该特定重载标记为 SDK 26。
解决方案:
- 方案一(推荐):使用
Color枚举代替字符串。但注意,系统中预定义的Color枚举数量有限,自定义颜色无法使用此方案。 - 方案二:使用数字格式的颜色值,如
0xFF6B6B代替'#FF6B6B'。 - 方案三(最简单):忽略该警告。该警告是对 API 版本兼容性的静态检查,在实际运行时,目标设备(API 24)完全支持字符串颜色值,因此不会产生错误。只要
targetSdkVersion设为 26 或更高,运行时行为正常。
在我们的示例中,为了代码可读性保留了字符串格式,因为这是一个兼容性警告而非编译错误。
8.2 animateTo 的弃用与替代
'animateTo' has been deprecated.
原因分析:在 API 24 中,animateTo 被标记为弃用,推荐使用新的 Animation API 或 Context.animateTo()。
替代方案:
getUIContext().animateTo():通过this.getUIContext()获取UIContext实例,然后调用其animateTo方法。这是animateTo全局函数的新位置。
this.getUIContext()?.animateTo(
{ duration: 400, curve: Curve.FastOutSlowIn },
() => {
this.isExpanded = !this.isExpanded;
this.progress = this.isExpanded ? 1 : 0;
}
);
- 属性动画
animation():在组件上直接使用.animation()修饰符,适用于组件属性的自动动画。
Circle()
.scale({ x: 0.5, y: 0.5 })
.animation({ duration: 400, curve: Curve.EaseInOut })
在我们的示例中,由于需要在点击后同时改变 isExpanded 和 progress 两个状态变量,使用 animateTo 显式动画仍然是更清晰的选择。弃用警告不影响功能,但建议新项目逐步迁移至 UIContext.animateTo()。
8.3 @Builder 函数的绑定约束
'Function.bind' is not supported (arkts-no-func-bind)
原因分析:ArkTS 是 HarmonyOS 的静态类型语言,出于性能和安全考虑,不支持 JavaScript 中的 Function.prototype.bind 方法。
解决方案:使用箭头函数包装代替 .bind(this)。
// ❌ 不推荐:使用 bind
builder: this.fanMenuBuilder.bind(this)
// ✅ 推荐:使用箭头函数包装
builder: (): void => this.fanMenuBuilder()
此外,@BuilderParam 有一个需要特别注意的约束:
@BuilderParam attribute 'builder' can only be initialized
by @Builder function or @LocalBuilder method in struct
这意味着 @BuilderParam 的值必须是 @Builder 装饰的方法或 @LocalBuilder 方法的引用,不能是普通函数或匿名函数。
8.4 GeometryInfo 的 width/height
在 onPlaceChildren 中,我们使用 selfLayoutInfo.width 和 selfLayoutInfo.height 来获取容器的最终尺寸。需要注意的是:
- 这些值来自
onMeasureSize中返回的SizeResult; - 如果容器没有显式设置宽高,且
onMeasureSize返回了固定的宽高值,那么selfLayoutInfo.width和selfLayoutInfo.height将是这些固定值; - 在
onMeasureSize中,selfLayoutInfo.width可能为 0(因为测量阶段尚未完成),因此获取容器宽高应该在onPlaceChildren中进行。
9. 性能优化与最佳实践
9.1 避免在布局回调中修改状态
这是自定义布局开发中最常见的错误。在 onMeasureSize 和 onPlaceChildren 内部修改 @State 变量会触发立即重新布局,导致无限循环:
// ❌ 错误示例:在布局回调中修改状态
onMeasureSize(...): SizeResult {
this.progress = 0.5; // 触发重新布局 → 无限循环
return this.result;
}
正确的做法:所有状态修改都应在事件回调(如 onClick)或 aboutToAppear 等生命周期方法中进行。
9.2 合理使用 ForEach 和 key
在 @Builder 中使用 ForEach 时,第二个参数是 keyGenerator 函数:
ForEach(this.menuItems, (item, index) => {
// ...子组件
}, (item: string) => item)
keyGenerator 的作用是唯一标识每个列表项,帮助 ArkUI 的 diff 算法精准定位变更的项,减少不必要的重建。如果子项列表可能发生增删,key 的稳定性尤为重要。
9.3 测量与布局的对称性原则
onMeasureSize 和 onPlaceChildren 必须遵循对称性原则:
-
子组件一致性:两个方法中处理的子组件集合必须一致。如果在
onMeasureSize中测量了 7 个子组件,那么在onPlaceChildren中必须处理这 7 个子组件。 -
计数一致性:两个方法中,
children.length必须相同,否则会导致布局错乱。 -
尺寸一致性:
onPlaceChildren中child.measureResult的值来源于onMeasureSize的测量结果,不应在布局阶段修改。
9.4 子组件复用策略
虽然 CustomLayout 暂不支持 LazyForEach,但在子组件数量较多时仍有优化空间:
- 固定子组件尺寸:避免在测量阶段进行复杂的尺寸计算;
- 使用轻量级组件:如
Text、Image、Shape组件,避免深层嵌套的容器结构; - 减少属性绑定:不必要的
@State或@Prop绑定会增加状态管理的开销。
对于我们的扇形菜单,7 个子组件是一个合适的数量。如果扩展到更多子项(如 10 ~ 15 个),建议将每个子项封装成独立的 @Component 组件,利用组件级别的按需更新来提升性能。
10. 踩坑记录
10.1 @BuilderParam builder 初始化
问题:@BuilderParam builder: () => void 没有默认值时报错。
原因:ArkTS 要求 struct 中所有成员都必须在初始化时确定值。@BuilderParam 装饰的成员也不例外。
解决:提供一个空的 @Builder 方法作为默认值:
@Builder
doNothingBuilder() {}
@BuilderParam builder: () => void = this.doNothingBuilder;
10.2 Function.bind 在 ArkTS 中的限制
问题:使用 .bind(this) 传递 @Builder 函数时触发 arkts-no-func-bind 错误。
原因:ArkTS 在设计上禁止了 bind、call、apply 等动态函数绑定操作。
解决:使用箭头函数包装:
builder: (): void => this.fanMenuBuilder()
10.3 测量尺寸为 0 导致不渲染
问题:子组件没有显示在屏幕上。
原因:子组件在 onMeasureSize 中未被测量,或者 child.measure() 传入了 0 尺寸约束。
调试方法:在 onMeasureSize 中打印每个子组件的测量结果:
const result = child.measure({ ... });
console.info(`Child ${i} measured: ${result.width}x${result.height}`);
10.4 build() 方法中只能存在 this.builder()
问题:在自定义布局组件的 build() 中,除了 this.builder() 之外还写了其他组件。
原因:CustomLayout 机制的硬性约束——自定义布局组件的 build() 方法中只允许调用 this.builder()。
解决:
// ✅ 正确
build() {
this.builder();
}
// ❌ 错误
build() {
Column() { // build() 中不能有其他组件
this.builder();
}
}
11. 扩展思路
扇形布局只是一个起点,基于 CustomLayout 还可以衍生出多种有趣的布局效果。
11.1 环形布局
将扇形角度扩大到 360°,并调整子项的位置使其沿圆形排列,即可实现环形菜单。环形菜单适合作为底部导航或多功能选择器。
修改要点:
const totalAngle = 360;
const startAngle = 0; // 从右侧开始
const radius = 100; // 缩小半径,避免子项间距过大
11.2 响应式扇形半径
根据屏幕尺寸动态调整扇形半径,确保在大屏和小屏上都有良好的视觉表现:
onPlaceChildren(...) {
const minDimension = Math.min(containerW, containerH);
const radius = minDimension * 0.35; // 半径占较小边的 35%
// ...使用动态半径计算位置
}
11.3 拖拽扇形菜单
结合 PanGesture 手势,让用户可以通过拖拽来展开和收拢扇形菜单:
Circle()
.gesture(
PanGesture({ distance: 10 })
.onActionUpdate((event: GestureEvent) => {
const distance = Math.sqrt(
Math.pow(event.offsetX, 2) + Math.pow(event.offsetY, 2)
);
this.progress = Math.min(1, distance / 200);
})
.onActionEnd(() => {
animateTo({ duration: 200 }, () => {
this.progress = this.progress > 0.5 ? 1 : 0;
});
})
)
11.4 扇形滚动列表
如果子项数量超过屏幕容纳范围(如 20 个以上),可以在扇形布局的基础上增加滚动机制:
- 使用
Scroll包裹CustomLayout; - 根据滚动偏移量动态调整每个子项的显示位置和透明度;
- 结合
visibility属性隐藏超出屏幕范围的子项。
12. 总结
本文从零开始构建了一个完整的 HarmonyOS 扇形菜单,涉及的关键知识点包括:
-
自定义布局体系:
onMeasureSize负责测量子组件尺寸并返回容器尺寸,onPlaceChildren负责分配子组件的最终位置。两个方法必须同时实现,且遵循严格的调用顺序。 -
扇形坐标计算:基于三角函数将角度转换为屏幕坐标,通过线性插值实现展开和收拢的平滑过渡。
-
状态驱动动画:利用
@State+animateTo的组合,让布局状态的变化自动驱动子组件位置的动画过渡,不需要手动管理动画帧。 -
API 24 兼容性:关注
fill属性的版本问题、animateTo的弃用替代、Function.bind的限制以及@BuilderParam的使用约束。 -
最佳实践:避免在布局回调中修改状态、合理使用 ForEach key、保持测量与布局的对称性。
扇形菜单是自定义布局能力的一个典型缩影。掌握了 CustomLayout 的核心机制,你就可以自由地实现任何非常规布局——螺旋布局、网格布局、瀑布流布局、圆形布局、放射布局……这些在传统框架中需要大量 workaround 的效果,在 HarmonyOS 自定义布局面前都将迎刃而解。
最后,再次强调自定义布局的三条黄金法则:
- 法则一:
onMeasureSize和onPlaceChildren必须同时实现; - 法则二:所有子组件必须在
onMeasureSize中完成测量; - 法则三:
build()方法中只允许调用this.builder()。
遵循这三条法则,你就能在 HarmonyOS ArkTS 中构建出任意复杂的自定义布局。
附录 A:参考资源
| 资源 | 链接 |
|---|---|
| HarmonyOS 自定义布局 API 文档 | ts-custom-component-layout.md(ohpm 文档库) |
| ArkTS 状态管理 | arkts-state.md |
| ArkUI 动画 API | arkts-animation.md |
| 本文完整源码 | 项目 entry/src/main/ets/pages/Index.ets |
附录 B:完整代码清单
/**
* 扇形菜单 —— CustomLayout + onMeasureSize / onPlaceChildren
* 点击中心按钮展开/收拢动画
*/
@Entry
@Component
struct Index {
@State isExpanded: boolean = false;
@State progress: number = 0;
private menuItems: string[] = ['A', 'B', 'C', 'D', 'E', 'F', 'G'];
private colors: ResourceColor[] = [
'#FF6B6B', '#FF8E53', '#FECA57', '#48DBFB',
'#0ABDE3', '#10AC84', '#5F27CD',
];
private readonly fanRadius: number = 130;
private readonly fanTotalAngle: number = 150;
private readonly itemSize: number = 48;
@Builder
fanMenuBuilder() {
ForEach(this.menuItems, (item: string, index: number) => {
Stack() {
Circle()
.width(this.itemSize)
.height(this.itemSize)
.fill(this.colors[index % this.colors.length])
Text(item)
.fontSize(18)
.fontColor(Color.White)
.fontWeight(FontWeight.Bold)
}
.width(this.itemSize)
.height(this.itemSize)
.alignContent(Alignment.Center)
.opacity(this.progress === 0 ? 0 : this.progress)
.scale({
x: 0.3 + 0.7 * this.progress,
y: 0.3 + 0.7 * this.progress,
})
}, (item: string) => item)
}
build() {
Stack() {
FanLayout({
progress: this.progress,
fanRadius: this.fanRadius,
fanTotalAngle: this.fanTotalAngle,
itemSize: this.itemSize,
builder: (): void => this.fanMenuBuilder(),
})
.width('100%')
.height('100%')
Stack() {
Circle()
.width(56)
.height(56)
.fill('#2C3E50')
.shadow({ radius: 8, color: '#33000000', offsetX: 0, offsetY: 4 })
Text(this.isExpanded ? '✕' : '+')
.fontSize(28)
.fontColor(Color.White)
.fontWeight(FontWeight.Bold)
.rotate({ angle: this.isExpanded ? 45 : 0 })
.animation({ duration: 300, curve: Curve.EaseInOut })
}
.width(56)
.height(56)
.alignContent(Alignment.Center)
.onClick(() => {
animateTo(
{ duration: 400, curve: Curve.FastOutSlowIn },
() => {
this.isExpanded = !this.isExpanded;
this.progress = this.isExpanded ? 1 : 0;
}
);
})
}
.width('100%')
.height('100%')
}
}
@Component
struct FanLayout {
progress: number = 0;
fanRadius: number = 130;
fanTotalAngle: number = 150;
itemSize: number = 48;
@Builder
doNothingBuilder() {}
@BuilderParam builder: () => void = this.doNothingBuilder;
result: SizeResult = { width: 0, height: 0 };
onMeasureSize(
selfLayoutInfo: GeometryInfo,
children: Array<Measurable>,
constraint: ConstraintSizeOptions
): SizeResult {
for (let i = 0; i < children.length; i++) {
const child = children[i];
child.measure({
minWidth: this.itemSize,
maxWidth: this.itemSize,
minHeight: this.itemSize,
maxHeight: this.itemSize,
});
}
const maxW = constraint.maxWidth as number;
const maxH = constraint.maxHeight as number;
this.result.width = maxW ?? 360;
this.result.height = maxH ?? 640;
return this.result;
}
onPlaceChildren(
selfLayoutInfo: GeometryInfo,
children: Array<Layoutable>,
constraint: ConstraintSizeOptions
): void {
const containerW = selfLayoutInfo.width;
const containerH = selfLayoutInfo.height;
const cx = containerW / 2;
const cy = containerH / 2;
const count = children.length;
if (count === 0) return;
const startAngle = 90 - this.fanTotalAngle / 2;
for (let i = 0; i < count; i++) {
const child = children[i];
const childW = child.measureResult.width;
const childH = child.measureResult.height;
const angleDeg = startAngle + (this.fanTotalAngle / Math.max(count - 1, 1)) * i;
const angleRad = angleDeg * Math.PI / 180;
const tx = cx + this.fanRadius * Math.cos(angleRad) - childW / 2;
const ty = cy - this.fanRadius * Math.sin(angleRad) - childH / 2;
const cx0 = cx - childW / 2;
const cy0 = cy - childH / 2;
const x = cx0 + (tx - cx0) * this.progress;
const y = cy0 + (ty - cy0) * this.progress;
child.layout({ x: x, y: y });
}
}
build() {
this.builder();
}
}
更多推荐

所有评论(0)