用代码画一幅星图

做了个星座运势小应用,三个页面里星图页面最难搞。不是逻辑复杂,而是那堆星点要精确放在圆圈里——每个星座 6-8 颗星,坐标还不能偏。

平时写 UI 组件位置都是 Flex 或 Grid 自动算的,根本不用操心坐标。但星图不行,12 个星座的星点分布各不相同,得一个一个标坐标。ArkUI 的 .position() 组件正好能干这事,但用起来有几个坑,踩完才搞明白。
完整效果
在这里插入图片描述

页面结构:三层嵌套的布局逻辑

星座星图页面的布局可以拆分成三层:

┌─────────────────────────────────┐
│          标题区域                │
│  "星座星图" + "点击星座·点亮星空" │
├─────────────────────────────────┤
│          星图详情                │
│  ┌─────────────────────────┐    │
│  │     圆形星点容器         │    │
│  │   · · · · · · ·        │    │
│  │     · 星座图标 ·        │    │
│  │   · · · · · · ·        │    │
│  └─────────────────────────┘    │
│  元素标签 + 日期标签             │
│  星座特质描述                    │
│  收起星图按钮                    │
├─────────────────────────────────┤
│          星座网格                │
│  ┌──────┐ ┌──────┐ ┌──────┐    │
│  │ 白羊 │ │ 金牛 │ │ 双子 │    │
│  │  ♈  │ │  ♉  │ │  ♊  │    │
│  └──────┘ └──────┘ └──────┘    │
│  ┌──────┐ ┌──────┐ ┌──────┐    │
│  │ 巨蟹 │ │ 狮子 │ │ 处女 │    │
│  │  ♋  │ │  ♌  │ │  ♍  │    │
│  └──────┘ └──────┘ └──────┘    │
│  ... (共 12 个)                 │
└─────────────────────────────────┘

这种"标题 + 详情 + 列表"的结构在移动端很常见。但这个页面的特殊之处在于"详情"区域——它用坐标系统精确定位了每个星点。

Grid 组件:3×4 网格的实现

先看星座网格的实现:

Grid() {
  ForEach(SIGNS, (s: SignData, i: number) => {
    GridItem() {
      Column() {
        Text(s.emoji)
          .fontSize(i === this.selectedSign ? 36 : 28)
          .animation({ duration: 300 })
        Text(s.name)
          .fontSize(10)
          .fontWeight(i === this.selectedSign ? FontWeight.Bold : FontWeight.Normal)
          .fontColor(i === this.selectedSign ? '#FFD700' : '#8B7BC0')
          .animation({ duration: 300 })
        Text(s.date)
          .fontSize(8).fontColor('#4B3F70').margin({ top: 2 })
      }
      .width('100%').height(90).justifyContent(FlexAlign.Center)
      .borderRadius(12)
      .backgroundColor(i === this.selectedSign ? '#1C1545' : '#0E0B25')
      .border({
        width: i === this.selectedSign ? 1 : 0.5,
        color: i === this.selectedSign ? '#FFD700' : '#1C1545',
        style: BorderStyle.Solid
      })
      .onClick(() => {
        if (this.selectedSign === i) { this.selectedSign = -1; }
        else { this.selectedSign = i; }
      })
    }
  })
}
.columnsTemplate('1fr 1fr 1fr')
.rowsGap(10).columnsGap(10)
.width('100%').padding({ left: 16, right: 16 })

在这里插入图片描述

Grid vs Flex:什么时候用哪个?

ArkUI 提供了 GridFlex 两种布局组件。区别是:

  • Grid:二维布局,适合网格状排列(如相册、商品列表)
  • Flex:一维布局,适合一行或一列排列(如导航栏、表单)

这个应用需要 3 列 4 行的网格,用 Grid 更合适。.columnsTemplate('1fr 1fr 1fr') 定义了三等分的列宽,1fr 表示占剩余空间的比例。

如果用 Flex 实现,需要嵌套多层:

// 用 Flex 实现 3×4 网格(不推荐)
Column() {
  Row() { /* 第一行 */ }.width('100%')
  Row() { /* 第二行 */ }.width('100%')
  Row() { /* 第三行 */ }.width('100%')
  Row() { /* 第四行 */ }.width('100%')
}

代码冗长且不易维护,Grid 是更好的选择。

选中状态的视觉反馈

代码通过三元运算符控制选中状态的视觉效果:

.fontSize(i === this.selectedSign ? 36 : 28)
.fontWeight(i === this.selectedSign ? FontWeight.Bold : FontWeight.Normal)
.fontColor(i === this.selectedSign ? '#FFD700' : '#8B7BC0')
.backgroundColor(i === this.selectedSign ? '#1C1545' : '#0E0B25')

这种"条件样式"是声明式 UI 的核心思想——样式由状态决定,不需要手动操作 DOM。

为什么选中时 emoji 放大到 36?

这是为了给用户明确的视觉反馈。选中的星座不仅边框变成金色,emoji 也放大,让用户一眼就能看出哪个被选中了。这种"多维度反馈"比只改边框颜色更有效。

交互逻辑:点击切换

.onClick(() => {
  if (this.selectedSign === i) { this.selectedSign = -1; }
  else { this.selectedSign = i; }
})

这里实现了"点击切换"逻辑:再次点击同一个星座会取消选中。selectedSign = -1 表示没有选中任何星座。

这种交互设计很常见——用户可以点击查看详情,再点击收起。比"点击展开,需要单独按钮收起"更高效。

Stack 组件:星点的坐标定位

星图详情区域是这个页面最复杂的部分,用了 Stack 组件来叠加多层:

Stack() {
  // 底层:圆形背景
  Column()
    .width(200).height(200).borderRadius(100)
    .backgroundColor('#0C0A20')
    .border({ width: 1, color: '#1C1545', style: BorderStyle.Solid })
  
  // 中层:星点(通过坐标定位)
  ForEach(SIGNS[this.selectedSign].stars, (pt: StarPt) => {
    Text('·')
      .fontSize(pt.sz * 2.5).fontColor('#FFFFFF')
      .position(new Pos2D(pt.x - 2, pt.y - 2))
  })
  
  // 顶层:星座图标
  Column() {
    Text(SIGNS[this.selectedSign].emoji).fontSize(42)
    Text(SIGNS[this.selectedSign].elem + '象').fontSize(9).fontColor('#8B7BC0')
  }
  .width(60).height(60).borderRadius(30)
  .backgroundColor('#0C0A20')
  .border({ width: 1, color: '#3B2F78', style: BorderStyle.Solid })
  .position(new Pos2D(70, 70))
}
.width(200).height(200).margin({ top: 8 })

在这里插入图片描述

Stack 的层叠机制

Stack 组件的特点是子组件可以重叠,后声明的子组件会显示在上层。这个例子中:

  1. 第一层:200×200 的圆形背景
  2. 第二层:多个星点,通过 .position() 定位
  3. 第三层:60×60 的星座图标,定位在中心

这种"层叠"效果适合做复杂视觉,比如卡牌游戏、地图标记、图表叠加。

坐标系统:.position() 的用法

.position() 方法把组件定位到指定坐标:

.position(new Pos2D(pt.x - 2, pt.y - 2))

坐标原点在哪里?

Stack 中,坐标原点在左上角,而不是中心。所以:

  • 星点的坐标 (pt.x, pt.y) 是相对于 Stack 左上角的偏移
  • pt.x - 2pt.y - 2 是为了把星点的中心对准坐标点(因为 Text 组件的锚点在左上角)

为什么星座图标定位在 (70, 70)?

Stack 容器是 200×200,星座图标是 60×60。要让图标居中,需要定位在 (200/2 - 60/2, 200/2 - 60/2) = (70, 70)

星点数据的设计

每个星点的数据结构是:

class StarPt {
  x: number = 0;   // X 坐标
  y: number = 0;   // Y 坐标
  sz: number = 0;  // 大小
}

以白羊座为例:

new SignData('白羊座', '♈', '3.21-4.19', '火', '热情勇敢·行动力强', [
  new StarPt(100, 30, 3),   // 第一颗星
  new StarPt(60, 70, 2),    // 第二颗星
  new StarPt(140, 70, 2),   // 第三颗星
  new StarPt(100, 110, 4),  // 第四颗星(主星,最大)
  new StarPt(100, 170, 3),  // 第五颗星
  new StarPt(40, 140, 2),   // 第六颗星
  new StarPt(160, 140, 2)   // 第七颗星
])

这些坐标是手工标注的,模拟了白羊座的星点分布。在实际项目中,可以用天文数据自动生成,或者让设计师在画布上标注坐标后导出。

星点大小的映射

Text('·')
  .fontSize(pt.sz * 2.5)
  .fontColor('#FFFFFF')

星点的大小通过 pt.sz * 2.5 映射到字体大小:

  • sz = 2fontSize = 5(小星)
  • sz = 3fontSize = 7.5(中星)
  • sz = 4fontSize = 10(大星)
  • sz = 5fontSize = 12.5(主星)

乘以 2.5 是为了让星点在视觉上更明显。如果直接用 sz 作为字体大小,星点会太小,看不清楚。

数据驱动:从数据到 UI 的映射

这个页面的核心思想是"数据驱动"——UI 完全由数据决定,不需要手动操作。

数据流向

SIGNS 数组 → ForEach 渲染网格 → 点击事件更新 selectedSign 
→ 条件渲染显示星图详情 → ForEach 渲染星点

整个流程是单向的:数据变化 → UI 更新。这是声明式 UI 的核心思想。

为什么不用命令式操作?

命令式操作(如 document.getElementById('xxx').style.display = 'block')需要手动查找元素、修改属性。在复杂应用中,这种方式容易出错,而且性能差。

声明式 UI 只需要修改状态,框架会自动更新 UI。开发者不需要关心"怎么更新",只需要关心"更新成什么"。

状态管理的选择

这个页面用了 @State 来管理选中状态:

@State selectedSign: number = -1;

-1 表示没有选中,0-11 表示选中的星座索引。

为什么不用 boolean 数组?

如果用 boolean[] 来记录每个星座的选中状态,代码会更复杂:

@State signSelected: boolean[] = [false, false, false, ...]; // 12 个元素

而且需要额外逻辑来保证"只能选中一个"。用单个数字更简洁,也更容易扩展(比如支持多选)。

动画效果:让交互更自然

代码中多处使用了 .animation() 来添加过渡效果:

Text(s.emoji)
  .fontSize(i === this.selectedSign ? 36 : 28)
  .animation({ duration: 300 })

动画的触发条件

.animation() 只在属性值变化时触发。如果属性值不变,动画不会播放。

比如点击金牛座时,selectedSign-1 变为 1,金牛座的 emoji 从 28 变为 36,动画播放。如果再次点击金牛座,selectedSign1 变为 -1,emoji 从 36 变回 28,动画再次播放。

动画时长的选择

代码中用了 duration: 300(300 毫秒)。这个时长是经过考虑的:

  • 太短(<100ms):用户看不清动画,感觉生硬
  • 太长(>500ms):用户等待时间长,感觉卡顿
  • 300ms:刚好能看清变化,又不会觉得慢

这是移动端动画的"黄金时长",适合大多数过渡效果。

星图详情的展开/收起

星图详情没有用显式的动画,而是通过条件渲染实现:

if (this.selectedSign >= 0) {
  this.StarDetail();
}

selectedSign-1 变为有效值时,StarDetail() 被渲染;反之被销毁。这种"销毁/创建"的方式没有过渡动画,但实现简单。

如果要添加展开/收起动画,可以用 if + animation() 或者 animateTo()。但对于这个应用,当前的实现已经够用了。

踩坑记录

在开发这个页面时,遇到了几个值得注意的问题:

坑 1:Stack 的坐标原点

Stack 的坐标原点在左上角,而不是中心。这意味着:

  • 星点的坐标需要从左上角开始计算
  • 要让组件居中,需要手动计算偏移量

这是初学者最容易犯的错误——假设坐标原点在中心,结果组件位置偏移。

坑 2:ForEach 的性能

ForEach 会在数据变化时重新渲染所有项。如果星点很多(比如几十个),性能可能有问题。

解决方案是用 List + 懒加载,但星点数量通常不多(7-10 个),ForEach 就够了。

坑 3:Grid 的高度计算

Grid 组件的高度是自动计算的,但如果子组件高度不一致,可能导致布局错乱。代码中给每个 GridItem 设置了固定高度(90),避免了这个问题。

坑 4:动画的叠加

如果多个属性同时变化,动画会叠加。比如点击星座时,emoji 大小、字体粗细、颜色、背景色都变化,四个动画同时播放。这可能导致视觉混乱。

解决方案是用 animation()curvedelay 参数来错开动画,但在这个应用中,300ms 的短时长让叠加效果可以接受。

代码改进建议

虽然这个页面功能完整,但还有一些可以优化的地方:

1. 坐标数据的自动化

当前星点坐标是手工标注的,修改起来很麻烦。建议:

  • 用 SVG 或 Canvas 工具绘制星图,导出坐标数据
  • 或者写一个脚本,从天文数据库自动生成坐标

2. 星点的连线

当前星点是独立的,没有连线。如果要更真实的星图效果,可以用 Canvas 组件绘制连线:

Canvas(context) {
  // 绘制星点之间的连线
  context.beginPath();
  context.moveTo(stars[0].x, stars[0].y);
  context.lineTo(stars[1].x, stars[1].y);
  // ...
  context.stroke();
}

3. 星点的动画

当前星点是静态的。如果要更生动的效果,可以添加闪烁动画:

Text('·')
  .fontSize(pt.sz * 2.5)
  .fontColor('#FFFFFF')
  .opacity(0.7 + 0.3 * Math.sin(Date.now() / 500))

但这需要定时器和状态更新,性能开销较大,建议只对主星添加。

4. 响应式布局

当前星图固定 200×200,在大屏设备上可能显得太小。建议根据屏幕宽度动态计算:

.width('50%').aspectRatio(1)  // 宽度为屏幕一半,保持正方形

总结

星座星图页面虽然功能简单,但涉及了 ArkUI 开发的多个核心知识点:Grid 网格布局、Stack 坐标定位、数据驱动渲染、条件样式、交互动画。在实际开发中,这些知识点会反复出现,只是复杂度不同。

适用边界:这个页面适合用作 ArkUI 坐标系统和数据驱动布局的学习案例,涵盖了 Grid、Stack、position、ForEach、条件渲染等核心知识点。但如果要上架应用商店,还需要补充坐标数据自动化、星点连线、响应式布局、无障碍支持等内容。建议在此基础上逐步扩展,而不是一次性做完所有功能。

对于 ArkTS 新手,建议从类似的小项目入手,逐步理解框架的设计哲学。ArkTS 的声明式 UI 和 React 有相似之处,但状态管理和生命周期有明显区别,需要花时间适应。

对于有经验的开发者,重点是理解 ArkTS 的约束——它不是 TypeScript 的简单扩展,而是一个有自己规则的框架。遵循框架的最佳实践,才能写出可维护、高性能的代码。

Logo

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

更多推荐