用代码画一幅星图
做了个星座运势小应用,三个页面里星图页面最难搞。不是逻辑复杂,而是那堆星点要精确放在圆圈里——每个星座 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 提供了Grid和Flex两种布局组件。区别是:
- 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(newPos2D(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(newPos2D(70,70))}.width(200).height(200).margin({top:8})Stack 的层叠机制
Stack组件的特点是子组件可以重叠,后声明的子组件会显示在上层。这个例子中:
- 第一层:200×200 的圆形背景
- 第二层:多个星点,通过
.position()定位 - 第三层:60×60 的星座图标,定位在中心
这种"层叠"效果适合做复杂视觉,比如卡牌游戏、地图标记、图表叠加。
坐标系统:.position() 的用法
.position()方法把组件定位到指定坐标:
.position(newPos2D(pt.x-2,pt.y-2))坐标原点在哪里?
在Stack中,坐标原点在左上角,而不是中心。所以:
- 星点的坐标
(pt.x, pt.y)是相对于Stack左上角的偏移 pt.x - 2和pt.y - 2是为了把星点的中心对准坐标点(因为 Text 组件的锚点在左上角)
为什么星座图标定位在 (70, 70)?
Stack容器是 200×200,星座图标是 60×60。要让图标居中,需要定位在(200/2 - 60/2, 200/2 - 60/2) = (70, 70)。
星点数据的设计
每个星点的数据结构是:
classStarPt{x:number=0;// X 坐标y:number=0;// Y 坐标sz:number=0;// 大小}以白羊座为例:
newSignData('白羊座','♈','3.21-4.19','火','热情勇敢·行动力强',[newStarPt(100,30,3),// 第一颗星newStarPt(60,70,2),// 第二颗星newStarPt(140,70,2),// 第三颗星newStarPt(100,110,4),// 第四颗星(主星,最大)newStarPt(100,170,3),// 第五颗星newStarPt(40,140,2),// 第六颗星newStarPt(160,140,2)// 第七颗星])这些坐标是手工标注的,模拟了白羊座的星点分布。在实际项目中,可以用天文数据自动生成,或者让设计师在画布上标注坐标后导出。
星点大小的映射
Text('·').fontSize(pt.sz*2.5).fontColor('#FFFFFF')星点的大小通过pt.sz * 2.5映射到字体大小:
sz = 2→fontSize = 5(小星)sz = 3→fontSize = 7.5(中星)sz = 4→fontSize = 10(大星)sz = 5→fontSize = 12.5(主星)
乘以 2.5 是为了让星点在视觉上更明显。如果直接用sz作为字体大小,星点会太小,看不清楚。
数据驱动:从数据到 UI 的映射
这个页面的核心思想是"数据驱动"——UI 完全由数据决定,不需要手动操作。
数据流向
SIGNS 数组 → ForEach 渲染网格 → 点击事件更新 selectedSign → 条件渲染显示星图详情 → ForEach 渲染星点整个流程是单向的:数据变化 → UI 更新。这是声明式 UI 的核心思想。
为什么不用命令式操作?
命令式操作(如document.getElementById('xxx').style.display = 'block')需要手动查找元素、修改属性。在复杂应用中,这种方式容易出错,而且性能差。
声明式 UI 只需要修改状态,框架会自动更新 UI。开发者不需要关心"怎么更新",只需要关心"更新成什么"。
状态管理的选择
这个页面用了@State来管理选中状态:
@StateselectedSign:number=-1;-1表示没有选中,0-11表示选中的星座索引。
为什么不用 boolean 数组?
如果用boolean[]来记录每个星座的选中状态,代码会更复杂:
@StatesignSelected: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,动画播放。如果再次点击金牛座,selectedSign从1变为-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()的curve和delay参数来错开动画,但在这个应用中,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 的简单扩展,而是一个有自己规则的框架。遵循框架的最佳实践,才能写出可维护、高性能的代码。