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

在这里插入图片描述
在这里插入图片描述


目录

  1. 前言
  2. 为什么需要自定义布局
  3. HarmonyOS 自定义布局体系概览
    • 3.1 两种自定义布局路径
    • 3.2 onMeasureSize —— 测量子组件
    • 3.3 onPlaceChildren —— 布局子组件
    • 3.4 生命周期与调用顺序
  4. 扇形布局的需求分析
    • 4.1 扇形菜单的交互模型
    • 4.2 关键技术指标
  5. 三角函数与扇形坐标计算
    • 5.1 角度制与弧度制转换
    • 5.2 扇形路径推导
    • 5.3 线性插值实现展开/收拢
  6. 完整代码逐段解析
    • 6.1 入口组件 Index —— 状态管理与交互
    • 6.2 @Builder 子组件构造
    • 6.3 FanLayout 组件 —— 自定义布局核心
    • 6.4 onMeasureSize 测量逻辑
    • 6.5 onPlaceChildren 扇形布局逻辑
    • 6.6 线性插值动画
  7. 动画机制详解
    • 7.1 animateTo 与状态驱动
    • 7.2 多属性协同动画
    • 7.3 弹性曲线 FastOutSlowIn
  8. API 24 兼容性要点
    • 8.1 fill 属性的版本问题
    • 8.2 animateTo 的弃用与替代
    • 8.3 @Builder 函数的绑定约束
    • 8.4 GeometryInfo 的 width/height
  9. 性能优化与最佳实践
    • 9.1 避免在布局回调中修改状态
    • 9.2 合理使用 ForEach 和 key
    • 9.3 测量与布局的对称性原则
    • 9.4 子组件复用策略
  10. 踩坑记录
    • 10.1 @BuilderParam builder 初始化
    • 10.2 Function.bind 在 ArkTS 中的限制
    • 10.3 测量尺寸为 0 导致不渲染
    • 10.4 build() 方法中只能存在 this.builder()
  11. 扩展思路
    • 11.1 环形布局
    • 11.2 响应式扇形半径
    • 11.3 拖拽扇形菜单
    • 11.4 扇形滚动列表
  12. 总结

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 }) 也能实现扇形排列。但这种方案存在几个问题:

  1. position 是绝对定位,会脱离文档流,容器无法获知子组件的真实尺寸和位置;
  2. 无法正确测量,如果子组件包含自适应内容(如不同长度的文本),position 无法自动调整布局;
  3. 动画性能受限position 的动画需要为每个子组件单独编写,难以统一控制;
  4. 语义缺失,代码意图不清晰,维护成本高。

CustomLayout 将测量和布局逻辑封装在一个独立组件中,子组件通过 @Builder 插槽传入,代码结构清晰、逻辑集中、性能可控。


3. HarmonyOS 自定义布局体系概览

3.1 两种自定义布局路径

HarmonyOS 自 API 9 开始提供自定义布局能力。从 API 10 开始,官方推荐使用基于 onMeasureSizeonPlaceChildren 的新 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 容器自身的布局信息,包含 widthheight(API 10+)
children Array<Measurable> 所有子组件的可测量代理,每个元素提供 measure() 方法
constraint ConstraintSizeOptions 父容器对当前组件施加的尺寸约束
返回值 SizeResult 组件自身计算后的最终尺寸,包含 widthheight

核心职责:

  1. 遍历所有子组件,为每个子组件调用 child.measure(constraint) 确定其尺寸;
  2. 计算容器自身尺寸,基于所有子组件的测量结果和容器的布局策略,返回一个 SizeResult

重要约束

  • 每个子组件必须onMeasureSize 中被测量,否则在 onPlaceChildren 中无法获取其尺寸,且不会被渲染;
  • onMeasureSize禁止修改状态变量,否则可能导致无限循环;
  • selfLayoutInfo.widthselfLayoutInfo.height 仅在已设置容器宽高时有效,若未设置则为 0。

3.3 onPlaceChildren —— 布局子组件

onPlaceChildren 在测量完成后执行,负责为每个子组件分配最终的屏幕位置。其函数签名如下:

onPlaceChildren(
  selfLayoutInfo: GeometryInfo,
  children: Array<Layoutable>,
  constraint: ConstraintSizeOptions
): void

参数说明:

参数 类型 说明
selfLayoutInfo GeometryInfo 容器最终布局信息,width / height 来自 onMeasureSize 的返回值
children Array<Layoutable> 所有子组件的可布局代理,提供 measureResultlayout() 方法
constraint ConstraintSizeOptions 父容器的约束条件

核心职责:

  • 读取每个子组件的 measureResult(已在 onMeasureSize 中确定);
  • 调用 child.layout({ x, y }) 设置子组件在容器中的位置(相对于容器左上角)。

重要约束

  • 禁止修改子组件尺寸;
  • 禁止修改状态变量;
  • 未调用 layout() 的子组件将不会被渲染。

3.4 生命周期与调用顺序

ArkUI 组件的布局流程遵循严格的三个阶段:

约束传递 → 测量阶段 → 布局阶段

在自定义布局组件中,这个流程映射为:

父容器约束传递给自定义组件
    ↓
onMeasureSize 被调用(测量子组件、确定容器自身尺寸)
    ↓
onPlaceChildren 被调用(为子组件分配位置)
    ↓
子组件渲染

当任意状态变量发生变化时,ArkUI 会重新触发上述流程,从而实现响应式更新。这也是我们实现展开/收拢动画的基础——改变 progress 状态变量,触发重新布局,子组件位置随之更新。


4. 扇形布局的需求分析

4.1 扇形菜单的交互模型

我们设计的扇形菜单包含两种状态:

  1. 收拢状态(collapsed):所有子项重叠在圆心,不可见或半透明,中心按钮显示「+」;
  2. 展开状态(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 / 2childH / 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 状态管理的核心。当 isExpandedprogress 变化时,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;
}

测量逻辑分为两步:

  1. 子组件测量:将每个子组件的宽高固定为 itemSize(48vp),通过 child.measure() 完成;
  2. 容器自身尺寸:取 constraint.maxWidthconstraint.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 });
  }
}

这段代码是扇形布局的核心,包含三个关键计算:

  1. 扇形坐标:由圆心、半径、角度通过三角函数计算;
  2. 圆心坐标:所有子项重叠在容器中心;
  3. 线性插值:通过 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。它的工作原理可以概括为:

  1. 记录起点:在调用 animateTo 时,框架记录当前所有与动画相关的属性值;
  2. 应用变更:执行闭包中的代码,修改状态变量;
  3. 计算差值:框架对比起点和终点的状态值;
  4. 插值渲染:在指定时长内,按照动画曲线逐步应用中间值,触发每一次渲染。

在我们的扇形菜单中,这个过程具体为:

点击按钮
  → 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.RedColor.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 })

在我们的示例中,由于需要在点击后同时改变 isExpandedprogress 两个状态变量,使用 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.widthselfLayoutInfo.height 来获取容器的最终尺寸。需要注意的是:

  • 这些值来自 onMeasureSize 中返回的 SizeResult
  • 如果容器没有显式设置宽高,且 onMeasureSize 返回了固定的宽高值,那么 selfLayoutInfo.widthselfLayoutInfo.height 将是这些固定值;
  • onMeasureSize 中,selfLayoutInfo.width 可能为 0(因为测量阶段尚未完成),因此获取容器宽高应该在 onPlaceChildren 中进行。

9. 性能优化与最佳实践

9.1 避免在布局回调中修改状态

这是自定义布局开发中最常见的错误。在 onMeasureSizeonPlaceChildren 内部修改 @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 测量与布局的对称性原则

onMeasureSizeonPlaceChildren 必须遵循对称性原则:

  • 子组件一致性:两个方法中处理的子组件集合必须一致。如果在 onMeasureSize 中测量了 7 个子组件,那么在 onPlaceChildren 中必须处理这 7 个子组件。

  • 计数一致性:两个方法中,children.length 必须相同,否则会导致布局错乱。

  • 尺寸一致性onPlaceChildrenchild.measureResult 的值来源于 onMeasureSize 的测量结果,不应在布局阶段修改。

9.4 子组件复用策略

虽然 CustomLayout 暂不支持 LazyForEach,但在子组件数量较多时仍有优化空间:

  • 固定子组件尺寸:避免在测量阶段进行复杂的尺寸计算;
  • 使用轻量级组件:如 TextImageShape 组件,避免深层嵌套的容器结构;
  • 减少属性绑定:不必要的 @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 在设计上禁止了 bindcallapply 等动态函数绑定操作。

解决:使用箭头函数包装:

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 扇形菜单,涉及的关键知识点包括:

  1. 自定义布局体系onMeasureSize 负责测量子组件尺寸并返回容器尺寸,onPlaceChildren 负责分配子组件的最终位置。两个方法必须同时实现,且遵循严格的调用顺序。

  2. 扇形坐标计算:基于三角函数将角度转换为屏幕坐标,通过线性插值实现展开和收拢的平滑过渡。

  3. 状态驱动动画:利用 @State + animateTo 的组合,让布局状态的变化自动驱动子组件位置的动画过渡,不需要手动管理动画帧。

  4. API 24 兼容性:关注 fill 属性的版本问题、animateTo 的弃用替代、Function.bind 的限制以及 @BuilderParam 的使用约束。

  5. 最佳实践:避免在布局回调中修改状态、合理使用 ForEach key、保持测量与布局的对称性。

扇形菜单是自定义布局能力的一个典型缩影。掌握了 CustomLayout 的核心机制,你就可以自由地实现任何非常规布局——螺旋布局、网格布局、瀑布流布局、圆形布局、放射布局……这些在传统框架中需要大量 workaround 的效果,在 HarmonyOS 自定义布局面前都将迎刃而解。

最后,再次强调自定义布局的三条黄金法则:

  • 法则一onMeasureSizeonPlaceChildren 必须同时实现;
  • 法则二:所有子组件必须在 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();
  }
}

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐