HarmonyOS 6效果实现:Column 的 space 参数用于控制子组件之间的间距,alignItems 参数用于控制水平方向的对齐方式
本文将从声明式UI开发范式出发,深入剖析一款名为"云语·观云者与气象摄影社区"的 HarmonyOS ArkTS 应用,全面拆解其色彩系统、数据模型、状态管理、布局架构、特效层实现以及多页面 Builder 组件化方案。
一、开篇:声明式UI与HarmonyOS ArkTS技术全景

1.1 声明式UI开发范式的核心思想
声明式UI(Declarative UI)是现代前端与移动端开发的主流范式,它与传统的命令式UI有着本质的区别。在命令式开发中,开发者需要通过一系列指令逐步操作DOM或视图树,手动控制每一个状态变更对应的界面更新。而声明式UI则将开发者从繁琐的DOM操作中解放出来,开发者只需要描述"在某种状态下界面应该长什么样",框架会自动负责状态到视图的映射与刷新。
在 HarmonyOS ArkUI 开发体系中,这种声明式思想体现得淋漓尽致。开发者通过 struct 结构体定义组件,使用 @Component 装饰器标记其为可复用的UI组件,用 @Entry 装饰器标记页面入口。组件的状态通过 @State、@Observed、@Link、@Prop 等装饰器进行精细化管理,当状态发生变化时,ArkUI 渲染管线会自动检测变更并精准地重新渲染受影响的组件部分。
这种开发模式极大地提升了开发效率与代码可维护性。开发者无需手动调用 setState 或 invalidate 之类的方法,只需专注于数据与UI的映射关系。在"云语"这款气象摄影社区应用中,我们可以看到声明式UI范式的完整实践——从色彩系统的定义到数据模型的构建,从静态数据的初始化到动态交互的实现,全部遵循声明式的设计哲学。
1.2 HarmonyOS ArkTS语言与API 24的技术特性

HarmonyOS ArkTS 是基于 TypeScript 扩展的声明式开发语言,它在 TypeScript 的基础上引入了一系列装饰器(Decorator)和自定义UI组件语法,使得开发者可以用简洁的代码构建复杂的跨设备界面。API 24 作为 HarmonyOS 较新的版本,在状态管理、渲染性能、组件能力等方面都有显著增强。
在状态管理层面,@Observed 装饰器用于标记自定义类,使其属性变化可以被框架追踪。当 @Observed 装饰的类的实例属性发生变化时,所有引用了该属性的UI组件都会自动刷新。这比单纯使用 @State 装饰基本类型变量要强大得多,因为它支持嵌套对象和复杂数据结构的细粒度观察。
在布局系统方面,ArkUI 提供了 Stack、Column、Row 三大基础布局容器,配合 Flex、Grid、List 等高级布局组件,可以构建任意复杂的界面结构。Stack 采用层叠布局,子组件沿Z轴方向堆叠;Column 沿垂直方向排列子组件;Row 沿水平方向排列子组件。这三种布局容器的组合使用,构成了整个应用界面的骨架。
1.3 @Builder装饰器与组件化复用
@Builder 是 ArkTS 中一个非常实用的装饰器,它用于将一段UI构建逻辑封装为可复用的构建函数。与 @Component 装饰的自定义组件不同,@Builder 方法定义在组件内部,可以直接访问组件的状态变量和方法,无需通过参数传递。这种设计使得同一组件内的UI片段可以被抽离为独立的构建函数,提高了代码的可读性和可维护性。
在"云语"应用中,大量使用了 @Builder 装饰器来组织代码结构。从特效层 fxLayer 到头部 header,从子导航 subNav 到各个页面构建器 pageFeatured、pageClouds、pageChases 等,再到弹框组件 addModalBody、editModalBody、delModalBody,每一个相对独立的UI模块都被封装为一个 @Builder 方法。这种模块化的组织方式使得 build() 函数保持了清晰的结构,开发者可以快速定位到任何一个UI模块的代码。
1.4 Stack/Column/Row布局系统的深度应用

ArkUI 的布局系统以 Stack、Column、Row 为基石,通过嵌套组合实现复杂的界面结构。在"云语"应用中,我们可以看到这三种布局容器的精妙配合。
最外层使用 Stack 布局,将背景层、特效层、内容层和弹框层沿Z轴堆叠。背景层是一个纯色的 Column,填充整个页面;特效层 fxLayer 是一个 Stack,其中包含多个漂移的云絮元素;内容层是一个 Column,包含头部、主内容区和底部导航栏;弹框层则是根据状态条件渲染的 Stack,包含半透明遮罩和弹框内容。
在垂直方向上,Column 布局承担了主要的内容组织任务。头部信息、子导航、页面内容、底部导航栏,都是通过 Column 从上到下依次排列的。Column 的 space 参数用于控制子组件之间的间距,alignItems 参数用于控制水平方向的对齐方式。
在水平方向上,Row 布局被广泛用于卡片内部的元素排列、底部导航的图标分布、数据四格的横向布局等场景。Row 的 layoutWeight 参数可以实现子组件的权重分配,类似于 Flex 布局中的 flex-grow 属性,非常适合构建等分或按比例分配的水平布局。
1.5 ArkUI渲染管线与性能优化
ArkUI 渲染管线是 HarmonyOS 界面渲染的核心机制,它负责将声明式的UI描述转化为屏幕上的像素。渲染管线大致可以分为以下几个阶段:组件创建、布局计算、绘制命令生成、GPU渲染。在每个阶段,框架都做了大量的优化工作,确保界面流畅运行。
在"云语"应用中,有几个值得关注的性能优化点。首先是特效层使用了 hitTestBehavior(HitTestMode.None),这使得特效层的所有元素都不响应触摸事件,事件可以穿透到下层的内容区域。这种设计既保证了视觉效果,又不影响用户交互。其次是使用了 ForEach 组件的第二个参数(键生成函数),为每个列表项提供唯一的键值,帮助框架在列表数据变化时精准地复用组件,减少不必要的销毁和重建。
另外,@Builder 装饰器的使用也有助于性能优化。因为 @Builder 方法内部的UI描述会被框架编译为高效的渲染命令,而且只有当依赖的状态发生变化时才会重新执行。相比之下,如果将所有UI逻辑都写在 build() 函数中,任何一个状态的变化都可能导致整个 build() 函数重新执行,影响渲染性能。
1.6 天空蓝色彩设计系统的构建

色彩系统是一个应用视觉风格的核心。"云语"应用采用了天空蓝(#6C9BD1)与橙色(#F0A868)的主题配色方案,完美契合了气象摄影社区的产品定位。天空蓝代表着天空与云朵,是应用的主色调;橙色代表着火烧云与日出日落,是应用的强调色。
整个色彩系统包含 16 个精心设计的颜色字段,涵盖了主色、辅色、背景色、文字色、边框色、功能色等各个维度。这种系统化的色彩设计不仅保证了应用视觉风格的一致性,也使得主题切换和色彩调整变得异常简单——只需修改 COLORS 对象中的颜色值,整个应用的配色方案就会随之改变。
在具体的色彩运用上,应用遵循了"主色用于关键操作与重要信息、辅色用于强调与亮点、文字色遵循三级梯度、功能色用于状态反馈"的设计原则。这种层次分明的色彩体系,使得用户可以快速识别界面中的重要信息和可操作元素。
1.7 气象摄影社区的交互模式设计
作为一款面向观云爱好者的社区应用,"云语"在交互模式的设计上充分考虑了目标用户群体的使用习惯和需求。应用采用了底部 4 Tab + 首页 7 内容 Tab 的双层导航架构,既保证了核心功能的快速可达,又为首页提供了丰富的内容分类入口。
底部四个主 Tab 分别是首页、图鉴、课堂、我的,对应了社区应用的四大核心模块。首页是内容消费的主入口,集成了精选内容、云种图鉴、追云记录、观云营地、气象课堂、云友圈、装备库等七个子板块。每个子板块都通过序号徽章式的 Tab 进行切换,这种设计既节省了空间,又为用户提供了清晰的内容索引。
在内容呈现上,应用大量采用卡片式设计,每张卡片都是一个独立的信息单元。卡片之间通过统一的间距和圆角风格保持视觉一致性。卡片内部的信息层级分明,标题、副标题、辅助信息、操作按钮各就其位,用户可以快速扫描获取关键信息。
二、ColorPalette接口设计与天空蓝色彩系统

2.1 ColorPalette接口的整体架构
在应用的最开始,定义了一个名为 ColorPalette 的接口,它描述了整个应用色彩系统的结构。这个接口包含 16 个颜色字段,每一个字段都有明确的语义和用途。通过接口定义颜色系统,是一种非常优秀的工程实践——它为色彩的使用提供了类型安全保障,也为后续的主题扩展打下了基础。
interface ColorPalette {
primary: string;
primaryLight: string;
primaryDark: string;
accent: string;
accentLight: string;
bg: string;
cardBg: string;
textPrimary: string;
textSecondary: string;
textHint: string;
border: string;
success: string;
warning: string;
danger: string;
white: string;
sky: string;
}
接口的命名遵循了清晰的语义化原则。primary 系列表示主色调,accent 系列表示强调色调,text 系列表示文字颜色,bg 和 cardBg 表示背景色,success/warning/danger 表示功能色。这种命名方式使得开发者在使用颜色时可以一目了然地知道该颜色的用途,减少了误用的可能性。
2.2 主色系:天空蓝 #6C9BD1 的三层渐变

主色系由三个颜色组成:primary、primaryLight 和 primaryDark。这三个颜色构成了天空蓝的明暗梯度,分别适用于不同的场景。
-
primary: ‘#6C9BD1’ — 这是应用的主色调,一个清新柔和的天空蓝色。它的 RGB 值大约是 R:108, G:155, B:209,是一种中等明度、中等饱和度的蓝色。这个颜色被用于最重要的视觉元素,比如底部导航的选中态、子导航的选中背景、主要按钮的背景色、进度条的填充色等。它传达出平静、开阔、值得信赖的视觉感受,与天空、云朵的意象高度契合。
-
primaryLight: ‘#DCE8F5’ — 这是主色的浅色版本,RGB 值约为 R:220, G:232, B:245。它的明度很高,饱和度较低,呈现出一种非常淡的天蓝色。这个颜色常用于选中态的背景、卡片的轻量高亮、分割线的柔化等场景。它与主色形成了柔和的对比,既突出了选中状态,又不会过于刺眼。
-
primaryDark: ‘#43689A’ — 这是主色的深色版本,RGB 值约为 R:67, G:104, B:154。它的明度较低,饱和度适中,呈现出一种深邃的蓝灰色。这个颜色常用于深色文字、需要强调的图标和符号等场景。它比主色更有分量感,适合用于需要高对比度的文字显示。
这三个颜色的组合使用,构建了应用主色调的完整色阶。从最浅的 primaryLight 到中等的 primary 再到最深的 primaryDark,形成了一个平滑的色彩过渡,使得界面在视觉上富有层次感。
2.3 强调色系:橙色 #F0A868 的温暖点缀
强调色系由两个颜色组成:accent 和 accentLight。橙色作为天空蓝的互补色,为整体偏冷的色调增添了温暖的气息,恰如火烧云为蓝天增添了绚烂的色彩。
-
accent: ‘#F0A868’ — 这是应用的强调色,一个温暖的橙黄色。RGB 值约为 R:240, G:168, B:104。这个颜色被用于重要的操作按钮(如打卡按钮、去收集按钮)、稀有度标签、统计数据的高亮等场景。橙色与蓝色的搭配是经典的互补色组合,在视觉上非常抢眼,可以有效引导用户的注意力。
-
accentLight: ‘#FBEEDD’ — 这是强调色的浅色版本,RGB 值约为 R:251, G:238, B:221。它是一种非常淡的米橙色,几乎接近米色。这个颜色常用于状态标签的背景、轻微的高亮效果等场景。它为橙色元素提供了柔和的背景衬托,使得标签在白色卡片上依然清晰可见。
橙色系的引入,使得整个色彩系统不再单调。蓝色的冷静与橙色的温暖相互平衡,营造出既有科技感又有人情味的视觉氛围,非常契合气象摄影社区这种兼具专业内容与社交属性的产品定位。
2.4 背景色系:层次分明的页面底色
背景色系包含 bg 和 cardBg 两个字段,分别用于页面背景和卡片背景。
-
bg: ‘#EDF4FA’ — 这是页面的整体背景色,一种非常淡的蓝灰色。RGB 值约为 R:237, G:244, B:250。这个颜色接近白色,但带有淡淡的蓝色调,与天空蓝主色呼应。它为整个应用提供了柔和的底色,避免了纯白背景的刺眼感。
-
cardBg: ‘#FFFFFF’ — 这是卡片的背景色,纯白色。白色卡片在淡蓝色的页面背景上显得格外清晰,形成了明确的视觉层次。卡片式设计是现代移动应用的主流设计模式,白色卡片配合圆角和阴影(或间距),可以营造出干净整洁的界面风格。
背景色的选择体现了"内容优先"的设计原则。淡蓝色的页面背景营造了天空般的氛围,白色的卡片背景则保证了内容的可读性。两者的对比恰到好处,既不会因为对比过强而显得生硬,也不会因为对比过弱而显得模糊。
2.5 文字色系:三级梯度的信息层级
文字色系包含三个字段:textPrimary、textSecondary 和 textHint,分别对应主要文字、次要文字和提示文字。这种三级文字颜色体系是 UI 设计中的经典做法,可以清晰地区分信息的重要程度。
-
textPrimary: ‘#27384C’ — 主要文字颜色,一种深邃的蓝黑色。RGB 值约为 R:39, G:56, B:76。这个颜色接近黑色,但带有明显的蓝色调,与整体的蓝色主题保持一致。它用于标题、重要内容等需要最高对比度的文字场景。
-
textSecondary: ‘#5F7386’ — 次要文字颜色,一种中等明度的蓝灰色。RGB 值约为 R:95, G:115, B:134。它用于副标题、描述文字、辅助信息等场景。它的对比度适中,既保证了可读性,又不会与主要文字竞争注意力。
-
textHint: ‘#A3B4C4’ — 提示文字颜色,一种较浅的蓝灰色。RGB 值约为 R:163, G:180, B:196。它用于时间戳、标签说明、占位符文字等场景。它的对比度较低,属于视觉层级中最底层的信息,不会干扰主要内容的阅读。
这三级文字颜色形成了清晰的信息层级。用户在浏览界面时,可以通过颜色深浅快速判断信息的重要程度,从而提高信息获取的效率。同时,三种颜色都带有蓝色调,保持了整体色彩风格的统一性。
2.6 功能色与辅助色:状态反馈与特殊效果
除了上述主要色系外,色彩系统还包含了一系列功能色和特殊颜色。
-
border: ‘#E0EAF3’ — 边框颜色,一种非常浅的蓝灰色。RGB 值约为 R:224, G:234, B:243。它用于分割线、边框、进度条背景等场景。这个颜色与白色卡片背景形成柔和的对比,既起到了分隔作用,又不会显得过于突兀。
-
success: ‘#7BA884’ — 成功状态色,一种柔和的薄荷绿色。RGB 值约为 R:123, G:168, B:132。它用于"已收录"状态、成功提示、追云里程数据等场景。绿色传达出积极、完成、健康的语义,非常适合表示成功状态。
-
warning: ‘#D9A05B’ — 警告状态色,一种金棕色。RGB 值约为 R:217, G:160, B:91。它用于"待鉴定"状态、云币数据、通透度状态等场景。黄色/棕色传达出待处理、需要关注的语义。
-
danger: ‘#C76B5A’ — 危险状态色,一种砖红色。RGB 值约为 R:199, G:107, B:90。它用于"稀有"稀有度、删除按钮、退课按钮等场景。红色传达出危险、重要、需要确认的语义。
-
white: ‘#FFFFFF’ — 纯白色,主要用于按钮文字、深色背景上的文字等场景。
-
sky: ‘#BFD7EE’ — 天色,一种独特的天空浅蓝色。RGB 值约为 R:191, G:215, B:238。这个颜色是整个色彩系统中最具特色的颜色之一,它专门用于云絮漂移特效的渲染。它的明度和饱和度都介于 primary 和 primaryLight 之间,呈现出一种柔和朦胧的天空感,非常适合模拟漂浮的云朵效果。
三、@Observed数据模型:四类核心数据的逐字段解析
3.1 @Observed装饰器的工作原理

在深入分析各个数据模型之前,有必要先理解 @Observed 装饰器的作用机制。@Observed 是 ArkUI 状态管理体系中的一个重要装饰器,它用于标记一个类,使得该类的实例属性可以被框架追踪。当被 @Observed 装饰的类的实例属性发生变化时,所有引用了该属性的UI组件都会自动触发重新渲染。
与 @State 装饰器不同,@State 只能装饰基本类型或简单对象的变量,而 @Observed 可以装饰复杂的自定义类。当你在一个 @State 变量中存储 @Observed 类的实例数组时,数组中每个元素的属性变化都可以被精准追踪,从而实现细粒度的UI更新。
在"云语"应用中,定义了四个 @Observed 数据模型类:PostItem(云友动态)、CloudItem(云种图鉴)、ChaseItem(追云记录)和 GearItem(装备)。这四个类分别对应了应用的四个核心数据领域,构成了整个应用的数据模型基础。
3.2 PostItem:云友动态数据模型

PostItem 是云友圈动态的数据模型,它描述了社区中用户发布的每一条"云语"。
@Observed
export class PostItem {
id: number = 0
nick: string = ''
avatar: string = ''
text: string = ''
time: string = ''
likes: number = 0
// ...构造函数
}
字段解析:
-
id: number — 动态的唯一标识符,类型为数字。这个字段在
ForEach渲染列表时被用作键值生成函数的参数,确保每条动态都有唯一的标识。id 为 999 的特殊值被用于标识用户新发布的云语。 -
nick: string — 用户昵称,类型为字符串。在云友圈和精选页面的动态列表中,昵称以较粗的字重和较深的颜色显示,是每条动态的重要身份标识。示例数据中的昵称都充满了观云爱好者的特色,如"积云收藏家"、“火烧云蹲守人”、"高积云爱好者"等。
-
avatar: string — 用户头像,类型为字符串,但实际存储的是 emoji 表情符号。这种设计巧妙地利用了 emoji 的图形表现力,既避免了图片资源的加载开销,又保持了视觉的丰富性。不同的用户使用不同的 emoji 头像,如 ☁️、🌅、🌤️、🌥️、📖、🎥、⛈️、🌙 等,与用户昵称和内容主题相呼应。
-
text: string — 动态正文内容,类型为字符串。这是每条动态的核心信息,描述了用户的观云经历和感受。正文内容在列表中最多显示两行,超出部分以省略号截断,点击进入详情后才会显示完整内容。
-
time: string — 发布时间,类型为字符串。时间采用相对时间格式,如"5分钟前"、“18分钟前”、“1小时前”、"昨天"等。这种格式比绝对时间更友好,用户可以快速感知信息的新鲜度。
-
likes: number — 点赞数,类型为数字。点赞数以云朵 emoji + 数字的形式显示在动态底部,代表了这条动态受到的关注程度。点赞数的多少也从侧面反映了内容的热门程度。
3.3 CloudItem:云种图鉴数据模型
CloudItem 是云种图鉴的数据模型,它描述了一种云的分类信息和收录状态。
@Observed
export class CloudItem {
id: number = 0
name: string = ''
family: string = ''
rarity: string = ''
seen: number = 0
// ...构造函数
}
字段解析:
-
id: number — 云种的唯一标识符。
-
name: string — 云种名称,如"淡积云"、“浓积云”、“积雨云”、“卷云”、“堡状高积云”、“荚状云”、“乳状云”、"航迹云"等。这些名称来自气象学中的标准云分类,体现了应用的专业性。
-
family: string — 云族分类,描述该云种所属的云族和云属信息。如"积云族 · 低云"、“积云族 · 直展”、“直展云族”、“卷云族 · 高云”、“中云族”、“中云族 · 特殊”、“积雨云附属”、"人为云族"等。云族分类是云种识别的重要依据,也是云图鉴系统的核心分类维度。
-
rarity: string — 稀有度,描述该云种的罕见程度。取值包括"常见"、“少见”、“稀有”、"季节"等。不同的稀有度对应不同的颜色标识,通过
rarityColor函数进行映射。稀有度系统为云图鉴增加了收集和挑战的游戏化元素。 -
seen: number — 目击次数/收录百分比,类型为数字。这个数值同时承担了两个语义:在人气云种列表中表示目击次数,在云图鉴详情中则表示收录百分比(用于进度条显示)。这种设计简化了数据模型,但需要在使用时注意语义的转换。
3.4 ChaseItem:追云记录数据模型
ChaseItem 是追云记录的数据模型,它描述了用户的每一次追云行动及其进度。
@Observed
export class ChaseItem {
id: number = 0
name: string = ''
item: string = ''
status: string = ''
progress: number = 0
// ...构造函数
}
字段解析:
-
id: number — 追云记录的唯一标识符。
-
name: string — 追云名称,描述这次追云行动的主题。如"昨日 · 西郊日落火烧云"、“今晨 · 河湾辐射雾”、“周三 · 雷暴单体追踪”、“上周 · 荚状云守候”、"周六 · 雷暴云观测团"等。名称中包含了时间、地点和主题等关键信息,非常具有画面感。
-
item: string — 追云内容/项目,更详细地描述这次追云行动的具体内容。如"晚霞 · 延时 40 分钟"、“雾景 · 慢门拍摄”、“降水概率 80%”、“山顶 · 等待 3 小时”、"团体 · 带教学"等。这个字段补充了名称之外的技术细节。
-
status: string — 追云状态,描述当前的进展阶段。取值包括"已收录"、“整理中”、“追云中”、“待鉴定”、"已预约"等。不同的状态对应不同的颜色标识和进度值,通过
statusColor和statusProgress函数进行映射。状态系统是追云功能的核心,它追踪了一次追云行动从预约到最终收录的完整生命周期。 -
progress: number — 追云进度,类型为数字。虽然数据模型中定义了这个字段,但在实际渲染时,进度条更多地依赖于
statusProgress函数根据状态来计算。这种设计使得进度与状态保持一致,避免了数据不一致的问题。
3.5 GearItem:装备数据模型
GearItem 是摄影装备的数据模型,它描述了观云摄影所需的各类装备。
@Observed
export class GearItem {
id: number = 0
name: string = ''
desc: string = ''
icon: string = ''
// ...构造函数
}
字段解析:
-
id: number — 装备的唯一标识符。
-
name: string — 装备名称,如"偏振镜 CPL"、“广角镜头 16-35”、“渐变灰滤镜”、“三脚架 · 碳纤维”、“云图鉴手册”、“气象雷达 App”、“间隔快门线”、"防雨罩"等。这些装备覆盖了观云摄影的各个方面,从光学滤镜到稳定设备,从纸质工具到数字应用,体现了观云摄影的专业性和多样性。
-
desc: string — 装备描述,用简短的语言概括装备的主要用途。如"压蓝天空"、“大场景云海”、“平衡日出日落”、“延时必备”、“十云属图版”、“降水临近预报”、“延时摄影”、"雷暴拍摄"等。描述文字直接点明了装备的核心价值,帮助用户快速了解装备的用途。
-
icon: string — 装备图标,同样使用 emoji 表情符号表示。如 🔵(偏振镜)、📷(镜头)、🌗(渐变灰滤镜)、🎯(三脚架)、📖(手册)、📡(雷达)、⏱️(快门线)、🌧️(防雨罩)等。emoji 图标的使用保持了应用整体风格的一致性。
3.6 其他接口类型定义
除了四个 @Observed 类之外,应用还定义了四个接口类型,用于描述图表数据和课程数据等结构化信息。
WeekChartItem — 周打卡柱状图的数据项:
interface WeekChartItem {
label: string;
value: number;
}
包含 label(星期标签,如"周一")和 value(打卡次数)两个字段。这个接口的数据被 WEEK_CHART 数组使用,在精选页面的周打卡柱状图中展示。
CloudChartItem — 云族构成图的数据项:
interface CloudChartItem {
label: string;
value: number;
color: string;
}
在 WeekChartItem 的基础上增加了 color 字段,用于指定该云族对应的条形颜色。这个接口的数据被 CLOUD_CHART 数组使用,在精选页面的云族构成进度条图中展示。
CourseItem — 气象课程的数据项:
interface CourseItem {
title: string;
week: string;
progress: number;
color: string;
}
包含课程标题、周次、学习进度和主题色四个字段。这个接口的数据被 COURSE_LIST 数组使用,在气象课堂页面中展示。
SkyTypeItem — 玩法分类的数据项:
interface SkyTypeItem {
name: string;
desc: string;
icon: string;
hot: boolean;
}
包含分类名称、描述、图标和是否热门四个字段。这个接口的数据被 TYPE_LIST 数组使用,在精选页面的"在云语能玩什么"四格入口中展示。
四、静态数据数组:应用数据的初始化与组织
4.1 POST_LIST:云友动态列表
POST_LIST 是云友动态的静态数据源,包含了 8 条预置的云友动态。这些动态覆盖了不同类型的观云内容,从日常的淡积云观察到专业的雷暴追逐,从延时摄影到夜空云观察,展现了社区内容的丰富性。
每条动态都经过精心设计,昵称、头像、内容和点赞数之间形成了有机的关联。例如,"积云收藏家"的头像是 ☁️,内容是关于淡积云的描述;"火烧云蹲守人"的头像是 🌅,内容是关于日落火烧云的记录;"雷暴追逐者"的头像是 ⛈️,内容是关于雷暴雨幕的拍摄。这种一致性设计增强了数据的真实感和代入感。
动态的排序也体现了社区的时间线特性——最新发布的动态排在最前面,时间从"5分钟前"到"昨天"依次递增。点赞数则呈现出递减的趋势,最新的动态获得了最多的点赞,这符合社交平台的内容传播规律。
4.2 CLOUD_LIST:云种图鉴列表
CLOUD_LIST 是云种图鉴的静态数据源,包含了 8 种云。这些云覆盖了主要的云族分类,包括积云族、卷云族、中云族、直展云族和特殊云等。
云种的排序按照"从常见到稀有"的逻辑排列。淡积云、浓积云、积雨云、卷云等常见云排在前面,目击次数也较高(97、93、90、86);而堡状高积云、荚状云、乳状云等少见或稀有云排在后面,目击次数逐渐降低(81、77、72)。航迹云作为特殊的"人为云族"排在最后,目击次数为 66。
这种排序方式既符合用户的认知规律(先认识常见的,再了解稀有的),也为云图鉴的收集系统提供了天然的难度梯度——用户很容易收录前几种云,但越往后越有挑战性。
4.3 CHASE_LIST:追云记录列表
CHASE_LIST 是追云记录的静态数据源,包含了 8 条追云记录。这些记录覆盖了追云的各种状态,从"已收录"到"已预约",展示了追云行动的完整生命周期。
记录的排序也有讲究。"已收录"和"整理中"的记录排在前面,代表近期完成的行动;"追云中"和"待鉴定"的记录排在中间,代表正在进行的行动;"已预约"的记录排在后面,代表未来的计划。这种排序方式使得用户可以快速看到最近的活动和最新的进展。
每条记录的名称都非常具有画面感,如"昨日 · 西郊日落火烧云"、“今晨 · 河湾辐射雾”、"周三 · 雷暴单体追踪"等。这些名称不仅描述了追云的主题,还暗示了时间和地点,让用户仿佛身临其境。
4.4 GEAR_LIST:装备列表
GEAR_LIST 是装备库的静态数据源,包含了 8 件装备。这些装备按照"光学设备-稳定设备-知识工具-防护设备"的逻辑排列,覆盖了观云摄影的主要装备类别。
装备的选择非常具有代表性。偏振镜 CPL 是风光摄影的必备滤镜,可以压暗天空、增加饱和度;广角镜头 16-35 是拍摄大场景云海和天空的主力镜头;渐变灰滤镜用于平衡日出日落时的大光比;三脚架和快门线是延时摄影的标配;云图鉴手册和气象雷达 App 则代表了知识工具;防雨罩则是雷暴拍摄的安全装备。
4.5 WEEK_CHART:周打卡数据
WEEK_CHART 是本周观云打卡次数的统计数据,包含了周一到周日共 7 天的数据。
数据呈现出明显的周末效应:周一到周五的打卡次数较少(4、6、3、7、9),而周六和周日的打卡次数显著增加(15、11)。这种数据分布非常符合实际情况——工作日人们忙于工作,观云的时间有限;而周末有更多的闲暇时间,可以投入到观云摄影中。周末追云占比 55% 的统计数据也印证了这一点。
4.6 CLOUD_CHART:云族构成数据
CLOUD_CHART 是本季收录云族构成的统计数据,包含了 5 个云族的占比数据。
积云族以 31% 的占比位居第一,这与积云族云种的常见性相符。卷云族以 25% 位居第二,层云族 19%,中云族 15%,特殊云 10%。每个云族都有对应的颜色标识,积云族用主色蓝色,卷云族用强调色橙色,层云族用成功色绿色,中云族用警告色黄色,特殊云用危险色红色。这种颜色编码使得数据可视化更加直观。
4.7 TYPE_LIST:玩法分类数据
TYPE_LIST 是"在云语能玩什么"四格入口的数据源,包含了 4 个玩法分类。
四个分类分别是云种图鉴、追云打卡、观测团和延时课堂,对应了应用的四大核心玩法。其中前两个标记为"热门",用红色的"热门"标签突出显示,引导用户关注最受欢迎的功能。每个分类都有对应的图标和简短描述,帮助用户快速理解功能内容。
4.8 COURSE_LIST:气象课程数据
COURSE_LIST 是气象课堂的课程数据,包含了 5 门课程。
课程按照学习顺序排列,从"云属识别入门十云属"(第1周)开始,到"雷暴安全与拍摄"(第5周)结束,形成了一个循序渐进的学习路径。学习进度也是依次递减的,第一门课程已经学完(100%),第二门学到 82%,第三门 58%,第四门 32%,第五门才刚开始(7%)。这种进度分布模拟了真实的学习场景——用户通常会按照顺序学习,前面的课程完成度高,后面的课程完成度低。
每门课程都有独特的主题色,从蓝色到橙色到绿色到黄色到红色,形成了一个彩虹般的色彩序列,既美观又便于区分。
五、导航配置:双层导航架构的设计与实现
5.1 NAV_LIST:底部主导航
NAV_LIST 是底部导航栏的配置数据,包含了 4 个主 Tab。
const NAV_LIST: NavItem[] = [
{ icon: '☁️', label: '首页' },
{ icon: '📖', label: '图鉴' },
{ icon: '📚', label: '课堂' },
{ icon: '👤', label: '我的' }
];
每个导航项包含 icon 和 label 两个字段,分别表示图标和标签文字。图标使用 emoji 表情,标签使用简洁的中文名称。四个 Tab 分别对应了应用的四个核心模块:内容消费(首页)、工具查询(图鉴)、学习成长(课堂)、个人中心(我的)。
底部导航栏的实现位于 bottomBar 构建器中,使用 Row 布局将四个导航项等分排列。选中态的图标不透明度为 1.0,标签文字为主色蓝色且加粗;未选中态的图标不透明度为 0.55,标签文字为提示灰色。这种视觉差异清晰地指示了当前所在的页面。
5.2 SUB_NAV_LIST:首页子导航
SUB_NAV_LIST 是首页的子导航配置数据,包含了 7 个内容 Tab。
const SUB_NAV_LIST: string[] = ['精选', '云图鉴', '我的追云', '观云营地', '气象课堂', '云友圈', '装备库'];
子导航采用了序号徽章式的设计,每个 Tab 前面都有一个圆形的数字序号。选中态的序号背景为主色蓝色,文字为白色,Tab 背景为浅蓝(primaryLight),文字为主深色且加粗;未选中态的序号背景为边框色,文字为提示灰色,Tab 背景为页面背景色,文字为次要灰色且字重正常。
这种序号徽章式的设计有几个优点。首先,数字序号提供了额外的定位信息,用户可以通过数字快速记忆和定位内容。其次,圆形徽章的设计增加了视觉层次感,使得导航栏不再单调。最后,选中态与未选中态之间有明显的视觉差异(背景色、文字色、字重、字号都有变化),用户可以清晰地感知当前的选中状态。
子导航支持横向滚动,当 7 个 Tab 的总宽度超过屏幕宽度时,用户可以左右滑动查看更多内容。滚动条被设置为隐藏状态(BarState.Off),保持了界面的简洁性。
5.3 导航交互逻辑
导航的交互逻辑由 mainTab 和 subTab 两个 @State 变量控制。mainTab 控制底部主 Tab 的切换,subTab 控制首页子 Tab 的切换。
当用户点击底部导航栏的某个 Tab 时,mainTab 的值会被更新为对应的索引,mainContent 构建器会根据 mainTab 的值渲染不同的页面内容。当 mainTab 为 0(首页)时,页面顶部会显示子导航栏和对应的内容页面;当 mainTab 为其他值时,则直接显示对应的页面。
这种双层导航架构既保证了核心功能的快速切换(底部 4 Tab),又为首页提供了丰富的内容分类入口(7 个子 Tab)。用户可以在首页快速浏览各类内容,也可以通过底部 Tab 直达特定功能模块。
六、纯函数工具集:色彩映射与特效计算的函数式实现
6.1 barH:柱状图高度计算函数
function barH(v: number, max: number): number {
return Math.round(118 * v / max);
}
barH 函数用于计算柱状图中每个柱子的高度。它接收两个参数:v 是当前柱子的值,max 是所有柱子中的最大值。函数返回的是柱子的像素高度,计算方式是将值按比例映射到 0-118 像素的范围内,并使用 Math.round 取整。
118 像素是柱状图区域的有效高度。通过比例计算,最高的柱子(值等于 max)会占满整个高度区域,其他柱子则按比例缩短。这种实现方式简单高效,不需要复杂的图表库就可以实现基础的柱状图效果。
6.2 cloudColor:打卡数值的颜色映射
function cloudColor(v: number): string {
if (v > 9) {
return '#6C9BD1';
} else if (v > 6) {
return '#F0A868';
}
return '#D9A05B';
}
cloudColor 函数根据打卡次数返回对应的柱子颜色。颜色分为三个等级:
- 打卡次数大于 9 次:返回主色蓝色(#6C9BD1),表示打卡频率很高
- 打卡次数大于 6 次但不超过 9 次:返回强调色橙色(#F0A868),表示打卡频率中等
- 打卡次数不超过 6 次:返回警告色黄色(#D9A05B),表示打卡频率较低
这种颜色编码使得柱状图不仅展示了数值的大小,还通过颜色直观地传达了打卡频率的等级。用户一眼就可以看出哪些天打卡多,哪些天打卡少。
6.3 云絮漂移特效函数:cloudXA / cloudA / cloudYA
云絮漂移特效由三个函数协同完成,它们分别计算大云朵的 X 坐标、透明度和 Y 坐标。
function cloudXA(tick: number, i: number): number {
return ((tick * 2 + i * 233) % 700) - 60;
}
cloudXA 函数计算大云朵的水平位置。参数 tick 是时间计数器(每 90 毫秒递增 1),i 是云朵的索引。计算逻辑如下:
tick * 2表示云朵随时间向右移动,速度是每帧 2 个单位i * 233为每个云朵设置不同的初始偏移,使得它们不会重叠在一起% 700将位置限制在 0-700 的范围内,形成循环效果- 60将起始位置向左偏移 60 像素,使得云朵可以从屏幕左侧外进入
云朵的水平移动方向是从左向右的,速度由 tick * 2 控制。当云朵漂出右侧边界后,会从左侧重新进入,形成无限循环的漂移效果。
function cloudA(tick: number, i: number): number {
return 0.35 + ((tick + i) % 5) * 0.06;
}
cloudA 函数计算大云朵的透明度。透明度在 0.35 到 0.59 之间波动(0.35 + 4 * 0.06 = 0.59)。波动周期由 (tick + i) % 5 控制,每 5 个 tick 完成一个周期。不同的云朵(不同的 i 值)有不同的相位,使得它们的透明度变化不同步,增加了真实感。
function cloudYA(tick: number, i: number): number {
return 90 + i * 170 + ((tick) % 4) * 3;
}
cloudYA 函数计算大云朵的垂直位置。90 + i * 170 为每个云朵设置了不同的垂直基准位置,第一个云朵在 y=90 处,第二个在 y=260 处,第三个在 y=430 处,依次向下排列。((tick) % 4) * 3 则为云朵添加了轻微的上下浮动效果,波动幅度为 9 像素(3 * 3),波动周期为 4 个 tick。这种轻微的浮动模拟了云朵在空气中随风飘动的感觉。
6.4 小云反向漂移特效函数:smallXA / smallA / smallYA
小云的漂移特效与大云朵类似,但方向相反——小云是从右向左漂移的,而且尺寸更小、数量更多。
function smallXA(tick: number, i: number): number {
return 680 - ((tick * 3 + i * 191) % 700);
}
smallXA 函数计算小云的水平位置。与大云朵不同的是,小云的位置计算使用了 680 - ... 的形式,这使得移动方向变为从右向左。速度是每帧 3 个单位(tick * 3),比大云朵更快。i * 191 为每个小云设置不同的初始偏移。小云总共有 4 个,比大云朵多一个。
function smallA(tick: number, i: number): number {
return 0.2 + ((tick + i * 2) % 4) * 0.07;
}
smallA 函数计算小云的透明度。透明度在 0.2 到 0.41 之间波动,整体比大云朵更淡。波动周期为 4 个 tick,波动步长为 0.07。i * 2 使得不同小云的相位差更大,变化更不同步。
function smallYA(tick: number, i: number): number {
return 150 + i * 190 + ((tick + 1) % 4) * 2;
}
smallYA 函数计算小云的垂直位置。基准位置从 y=150 开始,每个小云间隔 190 像素。上下浮动的幅度是 6 像素(2 * 3),比大云朵的浮动幅度小。浮动的相位也与大云朵不同(tick + 1),增加了视觉的丰富性。
大云朵和小云的反向漂移设计非常巧妙。它们方向相反、大小不同、速度不同、透明度不同,共同营造出一种层次分明的云层效果。大云朵飘得慢但个头大,像是低空的积云;小云飘得快但个头小,像是高空的碎云。这种层次感极大地增强了界面的深度和动感。
6.5 statusColor:追云状态的颜色映射
function statusColor(s: string): string {
if (s === '已收录') {
return '#7BA884';
} else if (s === '整理中') {
return '#6C9BD1';
} else if (s === '追云中') {
return '#F0A868';
} else if (s === '待鉴定') {
return '#D9A05B';
}
return '#A3B4C4';
}
statusColor 函数根据追云状态返回对应的颜色。五种状态对应五种颜色:
- 已收录 → 成功绿色(#7BA884):表示行动已完成,成果已归档
- 整理中 → 主色蓝色(#6C9BD1):表示正在后期处理中
- 追云中 → 强调橙色(#F0A868):表示正在进行中,是最活跃的状态
- 待鉴定 → 警告黄色(#D9A05B):表示等待专家鉴定
- 其他(如已预约)→ 提示灰色(#A3B4C4):表示尚未开始的状态
这种颜色映射与用户的心理预期高度一致。绿色代表完成和成功,蓝色代表进行中的常态,橙色代表活跃和兴奋,黄色代表待处理和关注,灰色代表未激活。用户可以通过颜色快速判断每条追云记录的状态。
6.6 statusProgress:追云状态的进度映射
function statusProgress(s: string): number {
if (s === '已收录') {
return 100;
} else if (s === '整理中') {
return 74;
} else if (s === '追云中') {
return 56;
} else if (s === '待鉴定') {
return 36;
}
return 14;
}
statusProgress 函数根据追云状态返回对应的进度百分比。它与 statusColor 配合使用,为追云记录提供了进度条可视化。
进度值的设置反映了追云行动的生命周期:预约(14%)→ 待鉴定(36%)→ 追云中(56%)→ 整理中(74%)→ 已收录(100%)。虽然实际的进度可能因行动而异,但这种标准化的进度映射为用户提供了直观的进度感知,也简化了数据模型的设计。
6.7 cloudTagColor:云种名称的颜色映射
function cloudTagColor(g: string): string {
if (g.indexOf('荚状') >= 0 || g.indexOf('乳状') >= 0) {
return '#6C9BD1';
} else if (g.indexOf('积') >= 0) {
return '#F0A868';
} else if (g.indexOf('卷') >= 0) {
return '#7BA884';
}
return '#D9A05B';
}
cloudTagColor 函数根据云种名称返回对应的标签颜色。它通过字符串匹配的方式判断云种的类型:
- 包含"荚状"或"乳状" → 主色蓝色:特殊云种,用主色突出显示
- 包含"积" → 强调橙色:积云族云种
- 包含"卷" → 成功绿色:卷云族云种
- 其他 → 警告黄色:中云族等其他类型
这种基于名称的颜色分类虽然不是非常严谨(比如"积雨云"会被归类为积云族而不是直展云族),但对于视觉区分来说已经足够。它为不同类型的云种提供了颜色编码,增强了图鉴的可读性。
6.8 rarityColor:稀有度的颜色映射
function rarityColor(s: string): string {
if (s === '常见') {
return '#7BA884';
} else if (s === '季节') {
return '#F0A868';
} else if (s === '少见') {
return '#D9A05B';
}
return '#C76B5A';
}
rarityColor 函数根据稀有度返回对应的颜色。四个稀有度等级对应四种颜色:
- 常见 → 成功绿色:最容易见到的云种
- 季节 → 强调橙色:季节性出现的云种
- 少见 → 警告黄色:不太常见的云种
- 稀有 → 危险红色:非常罕见的云种
颜色从绿色到红色的渐变,暗示了稀有度的递增。绿色代表"普遍、容易",红色代表"珍贵、难得"。这种颜色编码与用户的直觉认知高度一致。
七、组件状态管理:从生命周期到交互方法
7.1 组件状态变量的全景
PageCloudSpotting 组件是应用的主页面组件,它包含了大量的 @State 状态变量,用于管理页面的各种状态。
@State mainTab: number = 0
@State subTab: number = 0
@State tick: number = 0
@State glow: number = 0
@State addOpen: boolean = false
@State editOpen: boolean = false
@State delOpen: boolean = false
@State editIdx: number = 0
@State delTarget: string = 'chase'
@State editName: string = ''
@State editStyle: string = ''
@State editNote: string = ''
@State addTitle: string = ''
@State addContent: string = ''
@State postList: PostItem[] = POST_LIST
@State chaseList: ChaseItem[] = CHASE_LIST
@State fxTimer: number = -1
这些状态变量可以分为以下几类:
导航状态:mainTab 和 subTab 分别控制底部主 Tab 和首页子 Tab 的选中状态。初始值都为 0,表示默认选中第一个 Tab。
特效状态:tick 和 glow 是特效动画的时间计数器。tick 每 90 毫秒递增 1,用于驱动云絮漂移特效;glow 在 0 和 1 之间循环,可能用于光晕闪烁效果(虽然在当前代码中未直接使用)。
弹框状态:addOpen、editOpen、delOpen 三个布尔值分别控制新增、编辑和删除三种弹框的显示与隐藏。初始值都为 false,表示弹框默认关闭。
弹框数据:editIdx 记录当前编辑的追云记录索引;delTarget 记录删除操作的目标类型(‘chase’ 或 ‘course’);editName、editStyle、editNote 存储编辑弹框中的输入内容;addTitle、addContent 存储新增弹框中的输入内容。
列表数据:postList 和 chaseList 是两个核心数据列表,分别存储云友动态和追云记录。它们的初始值来自静态数据数组 POST_LIST 和 CHASE_LIST,但因为是 @State 变量,所以可以在运行时被修改(如新增、删除、编辑)。
定时器引用:fxTimer 存储特效定时器的 ID,用于在组件销毁时清除定时器。初始值为 -1,表示定时器尚未启动。
7.2 生命周期回调:aboutToAppear 与 aboutToDisappear
aboutToAppear(): void {
this.fxTimer = setInterval(() => {
this.tick = this.tick + 1;
this.glow = (this.glow + 1) % 2;
}, 90);
}
aboutToDisappear(): void {
if (this.fxTimer > 0) {
clearInterval(this.fxTimer);
this.fxTimer = -1;
}
}
aboutToAppear 和 aboutToDisappear 是 ArkUI 组件的两个生命周期回调函数。
aboutToAppear 在组件即将显示时被调用,这里用于启动特效定时器。定时器每 90 毫秒触发一次,每次将 tick 加 1,并让 glow 在 0 和 1 之间循环。90 毫秒的间隔大约相当于每秒 11 帧,对于云絮漂移这种缓慢的动画来说已经足够流畅,同时不会消耗过多的性能。
aboutToDisappear 在组件即将销毁时被调用,这里用于清除特效定时器,防止内存泄漏。清除之前会先检查 fxTimer 是否大于 0,确保定时器确实在运行。清除后将 fxTimer 重置为 -1,保持状态的一致性。
这种生命周期管理模式是声明式UI开发中的标准做法。开发者只需要关心"组件出现时做什么"和"组件消失时做什么",而不需要手动管理组件的创建和销毁过程。
7.3 交互方法:打开弹框的三组方法
应用定义了三组打开弹框的方法,分别对应新增、编辑和删除三种操作。
打开新增弹框:
openAdd(): void {
this.addTitle = '';
this.addContent = '';
this.addOpen = true;
}
打开新增弹框时,先清空标题和内容输入框,然后将 addOpen 设为 true。清空操作确保了每次打开弹框都是全新的状态,不会残留上次的输入内容。
打开编辑弹框:
openEdit(index: number): void {
if (index >= 0 && index < this.chaseList.length) {
this.editIdx = index;
this.editName = this.chaseList[index].name;
this.editStyle = this.chaseList[index].status;
this.editNote = this.chaseList[index].item;
}
this.editOpen = true;
}
打开编辑弹框时,先根据索引从追云列表中获取对应的记录,将名称、状态和内容分别赋值给编辑表单的对应字段,然后打开弹框。方法中包含了索引有效性检查,确保索引在数组范围内,避免数组越界错误。
打开删除弹框:
应用定义了两个打开删除弹框的方法,分别对应不同的删除目标:
openDelA(): void {
this.delTarget = 'chase';
this.delOpen = true;
}
openDelB(): void {
this.delTarget = 'course';
this.delOpen = true;
}
openDelA 用于取消追云预约(目标为 ‘chase’),openDelB 用于退出气象课堂(目标为 ‘course’)。两个方法都设置了 delTarget 来区分删除类型,然后打开删除确认弹框。这种设计使得同一个删除弹框可以复用于不同的删除场景,减少了代码冗余。
7.4 交互方法:执行操作的三组方法
与打开弹框的方法对应,应用还定义了三组执行操作的方法,分别处理新增、编辑和删除的确认操作。
执行新增:
doAdd(): void {
if (this.addTitle.length > 0) {
this.postList.unshift(new PostItem(999, '我的云语', '☁️', this.addTitle, '刚刚', 0));
}
this.addOpen = false;
}
执行新增操作时,先检查标题是否非空(简单的表单验证),如果不为空则在列表开头插入一条新的动态。新动态使用 id 999 作为特殊标识,昵称为"我的云语",头像为 ☁️,时间为"刚刚",点赞数为 0。插入完成后关闭弹框。
使用 unshift 方法将新动态插入列表开头,确保最新发布的内容显示在最前面,符合社交应用的时间线逻辑。
执行编辑:
doEdit(): void {
if (this.editIdx >= 0 && this.editIdx < this.chaseList.length) {
this.chaseList.splice(this.editIdx, 1, new ChaseItem(this.chaseList[this.editIdx].id, this.editName, this.editNote, this.editStyle, 40));
}
this.editOpen = false;
}
执行编辑操作时,使用 splice 方法替换指定索引位置的追云记录。新记录保留了原来的 id,名称、内容和状态来自编辑表单的输入值,进度被重置为 40。替换完成后关闭弹框。
splice 方法是 JavaScript/TypeScript 中数组操作的常用方法,它可以同时实现删除和插入操作。这里用它来实现"替换"操作——删除 1 个元素,然后在同一位置插入 1 个新元素。
执行删除:
doDel(): void {
if (this.delTarget === 'chase' && this.chaseList.length > 0) {
this.chaseList.splice(0, 1);
} else if (this.delTarget === 'course' && this.postList.length > 0) {
this.postList.splice(0, 1);
}
this.delOpen = false;
}
执行删除操作时,根据 delTarget 的值决定删除哪个列表的首条记录。如果目标是 ‘chase’,则删除追云列表的第一条;如果目标是 ‘course’,则删除动态列表的第一条。删除前会检查列表是否为空,避免对空数组执行删除操作。
删除操作采用"删除首条"的简化策略,这是一种 MVP(最小可行产品)式的实现。在实际产品中,删除操作通常会针对用户选中的特定条目,但在这个演示版本中,删除首条已经足够展示删除功能的交互流程。
八、fxLayer特效层:云絮漂移的视觉魔法
8.1 特效层的整体架构
fxLayer 是应用的特效层构建器,它实现了云絮漂移和小云反向漂移的视觉效果。特效层位于页面的最底层(Stack 布局中位于背景色之上、内容层之下),为整个页面增添了动感和氛围。
@Builder
fxLayer() {
Stack({ alignContent: Alignment.TopStart }) {
ForEach([0, 1, 2], (i: number) => {
// 大云朵(3个)
}, (i: number) => i.toString())
ForEach([0, 1, 2, 3], (i: number) => {
// 小云(4个)
}, (i: number) => i.toString())
}
.width('100%')
.height('100%')
.hitTestBehavior(HitTestMode.None)
}
特效层使用 Stack 布局,将大云朵和小云堆叠在一起。Stack 的对齐方式设为 TopStart,意味着所有子组件都从左上角开始定位。云朵的具体位置由 translate 变换属性控制,而不是由布局系统决定。
特效层最关键的一个属性是 hitTestBehavior(HitTestMode.None)。这个属性设置了特效层的点击测试行为为"不响应",也就是说,特效层上的所有元素都不会拦截触摸事件。用户的点击操作会穿透特效层,直接传递到下方的内容层。这是一个非常重要的设计——如果没有这个设置,漂浮的云朵会挡住下面的按钮和列表,导致用户无法正常操作。
8.2 大云朵的渲染与动画
大云朵由 3 个 Row 组件组成,通过 ForEach([0, 1, 2], ...) 循环生成。
Row()
.width(96)
.height(26)
.borderRadius(14)
.backgroundColor(COLORS.sky)
.opacity(cloudA(this.tick, i))
.translate({ x: cloudXA(this.tick, i), y: cloudYA(this.tick, i) })
每个大云朵都是一个带有圆角的矩形 Row。宽 96 像素、高 26 像素,圆角半径 14 像素(高度的一半以上),形成了两端圆润的长条形状,有点像横放的胶囊。这种简化的云朵形状虽然不够写实,但在视觉上足够表达"云絮"的意象,而且实现成本极低,性能开销也很小。
云朵的颜色使用 COLORS.sky(#BFD7EE),这是一种柔和的天空浅蓝色,与页面背景色非常接近,形成了微妙的层次感。云朵不是纯白色的,而是带有淡淡的蓝色调,与整体的天空主题保持一致。
云朵的动画通过三个属性实现:
- opacity:透明度由
cloudA函数计算,随时间在 0.35 到 0.59 之间波动。透明度的变化模拟了云朵的薄厚变化和远近交替。 - translate.x:水平位移由
cloudXA函数计算,随时间从左向右移动,形成循环漂移效果。 - translate.y:垂直位移由
cloudYA函数计算,有轻微的上下浮动,增加了真实感。
由于 tick 是一个 @State 变量,每次 tick 的变化都会触发 fxLayer 的重新渲染,从而更新所有云朵的位置和透明度。这就是声明式UI的魔力——你只需要描述状态与UI的映射关系,框架会自动处理更新。
8.3 小云的渲染与动画
小云由 4 个 Row 组件组成,通过 ForEach([0, 1, 2, 3], ...) 循环生成。
Row()
.width(44)
.height(14)
.borderRadius(8)
.backgroundColor(COLORS.primaryLight)
.opacity(smallA(this.tick, i))
.translate({ x: smallXA(this.tick, i), y: smallYA(this.tick, i) })
小云的设计与大云朵类似,但有几个关键区别:
- 尺寸更小:宽 44 像素、高 14 像素,大约是大云朵的一半大小。更小的尺寸暗示了更远的距离。
- 颜色更淡:使用
COLORS.primaryLight(#DCE8F5),比大云朵的 sky 色更淡更亮。更淡的颜色进一步强化了"远处的云"的视觉感受。 - 方向相反:小云从右向左漂移,与大云朵的方向相反。反向漂移创造了视差效果,增加了画面的深度感。
- 速度更快:小云的漂移速度比大云朵更快(tick 乘数为 3 对比 2)。更快的速度看似违反直觉(远处的物体应该移动更慢),但这里的小云更多地是作为装饰元素,反向快速漂移营造了"风在高空吹得更快"的意象。
- 数量更多:4 个小云对比 3 个大云朵,数量上的差异也增加了视觉的丰富性。
8.4 特效层的设计哲学
特效层的设计体现了"少即是多"的设计哲学。它没有使用复杂的 Canvas 绘制或 WebGL 动画,而是用最简单的 Row 组件 + translate 变换实现了云絮漂移效果。这种方案的优势在于:
- 实现简单:只需要几个基础组件和纯函数,代码量很少,易于理解和维护。
- 性能优秀:ArkUI 框架对 translate 变换有硬件加速,即使有十几个元素同时动画,也能保持流畅。
- 声明式友好:完全符合声明式UI的编程范式,状态驱动视图更新,没有副作用。
特效层的另一个设计亮点是穿透点击的处理。通过 hitTestBehavior(HitTestMode.None),特效层完全不影响用户交互。这是一个非常重要的细节——很多初学者在做背景特效时会忽略点击穿透的问题,导致特效层挡住了下面的交互元素,影响用户体验。
九、header头部:信息密度与视觉层次的平衡
9.1 头部的整体结构
header 构建器定义了页面顶部的头部区域,它包含四个主要部分:品牌行 + 今日云况、稀有云预警卡、云图鉴挑战横幅、数据四格。这四个部分从上到下依次排列,构成了一个信息丰富但层次分明的头部区域。
@Builder
header() {
Column({ space: 10 }) {
// 品牌行 + 今日云况
Row({ space: 10 }) { ... }
// 稀有云预警卡
Row({ space: 12 }) { ... }
// 云图鉴挑战横幅
Row({ space: 10 }) { ... }
// 数据四格
Row({ space: 8 }) { ... }
}
.width('100%')
.padding({ top: 10, bottom: 4 })
}
整个头部使用 Column 布局,子组件之间的间距为 10 像素。上下内边距分别为 10 像素和 4 像素。
9.2 品牌行:应用标识与今日概览
品牌行是头部的第一行,包含应用 Logo、应用名称 + 今日云况、打卡按钮三个元素。
左侧是应用 Logo(☁️ emoji)和应用信息。Logo 用 24 号字体,醒目而有辨识度。应用信息分为两行:第一行是应用名称"云语观云",加粗的 16 号字体,深主色文字;第二行是今日云况概览"今日云况 · 积云 60% · 降水 10% · 适宜拍摄",10 号小字,次要灰色文字。这种大小对比和颜色对比形成了清晰的信息层级。
右侧是"打卡"按钮。这是一个橙色的圆角按钮,白色加粗文字,左右内边距 14 像素,上下内边距 7 像素,圆角半径 14 像素。橙色按钮在蓝白色调的头部中非常醒目,引导用户点击打卡。点击按钮会打开发云语弹框(openAdd),用户可以发布自己的观云动态。
9.3 稀有云预警卡:核心功能的视觉焦点
稀有云预警卡是头部最引人注目的元素,也是整个应用的核心功能展示。它是一个蓝色背景的卡片,占据了头部的主要视觉空间。
卡片的背景色是主色蓝色(COLORS.primary),圆角 16 像素,内边距 14 像素。蓝色背景上的文字使用白色,确保了高对比度和可读性。
卡片内容分为三个层次:
- 主标题:“今晚或有火烧云 · 拍摄指数 88”,15 号加粗白字。这是卡片的核心信息,直接告诉用户今晚有重要的拍摄机会。
- 副标题:“18:42 日落 · 西天通透度良好 · 抓紧占位”,10 号浅色字(#E3EEF9)。提供了更详细的时间和天气信息。
- 标签行:包含"晚霞预警"和"云图鉴挑战进行中"两个标签。"晚霞预警"标签使用深色文字 + 天色背景,突出显示预警信息;"云图鉴挑战进行中"使用浅色文字,作为辅助信息。
卡片右侧有一个"查看预报"按钮,天色背景 + 深色文字,圆角 14 像素。这个按钮的设计比较特别——它没有使用常见的白色或深色按钮,而是使用了天色背景,与蓝色卡片背景形成柔和的对比。这种设计既保持了按钮的可点击性,又不会过于抢戏。
点击"查看预报"按钮会打开编辑追云单弹框(openEdit(1)),这是一个示例交互,展示了按钮的点击响应。
9.4 云图鉴挑战横幅:游戏化元素的融入
云图鉴挑战横幅是一个白色背景的卡片,展示了云图鉴收集系统的进度。
左侧是奖杯 emoji(🏆),22 号字体,象征着成就和挑战。中间是挑战信息,分为两行:第一行是挑战名称"集齐十云属 · 领限量云图徽章",13 号加粗深字;第二行是收集进度"已收录 8/10 · 再收荚状云和夜光云即达成",10 号次要灰色字。
右侧是"去收集"按钮,橙色背景 + 白色加粗文字,样式与头部的打卡按钮保持一致。橙色按钮在白色卡片上非常醒目,引导用户参与收集挑战。
这个横幅的设计巧妙地将游戏化元素融入了应用。收集云种、解锁徽章、完成挑战——这些游戏化的机制可以有效提高用户的活跃度和留存率。
9.5 数据四格:核心数据的一目了然
数据四格是头部的最后一部分,用四个等宽的小卡片展示了用户的核心数据。
四个数据分别是:
- 打卡天数:208 天,主色蓝色数字
- 收录云种:42 种,强调橙色数字
- 追云里程:310 km,成功绿色数字
- 云币:¥92,警告黄色数字
每个小卡片都是白色背景、圆角 10 像素、内边距 8 像素、居中对齐。卡片内的文字分为两行:上面是数据名称,9 号提示灰色字;下面是数据数值,13 号加粗彩色字。
四个数据使用了四种不同的颜色,分别对应主色、强调色、成功色和警告色。这种颜色编码使得数据不仅有数值信息,还有语义信息——用户可以通过颜色快速识别数据的类型。
数据四格的设计体现了"一目了然"的设计原则。用户打开应用后,只需扫一眼头部,就能了解自己的打卡情况、收集进度、追云里程和云币余额。这些核心数据为用户提供了成就感和持续使用的动力。
十、subNav子导航:序号徽章式的内容分类
10.1 子导航的设计特点
subNav 构建器定义了首页的子导航栏,它采用了序号徽章式的独特设计。
@Builder
subNav() {
Scroll() {
Row({ space: 8 }) {
ForEach(SUB_NAV_LIST, (item: string, idx: number) => {
Row({ space: 5 }) {
Text(`${idx + 1}`)
// 序号徽章样式
Text(item)
// 标签文字样式
}
// Tab 整体样式
.onClick(() => {
this.subTab = idx;
})
}, ...)
}
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.padding({ top: 6, bottom: 10 })
}
子导航使用横向滚动的 Scroll 容器包裹,内部是一个 Row 布局,包含 7 个 Tab 项。每个 Tab 项由序号徽章和标签文字两部分组成。
10.2 序号徽章的视觉设计
序号徽章是子导航的标志性设计元素。每个 Tab 前面都有一个圆形的数字序号,数字从 1 到 7 依次排列。
选中态的序号徽章:
- 背景色:主色蓝色(COLORS.primary)
- 文字颜色:白色
- 字重:加粗
- 尺寸:18x18 像素,圆角 9 像素(完全圆形)
未选中态的序号徽章:
- 背景色:边框色(COLORS.border)
- 文字颜色:提示灰色(COLORS.textHint)
- 字重:加粗
- 尺寸:相同
圆形徽章的设计为导航栏增添了视觉趣味。数字序号不仅是装饰,还具有实际的功能意义——用户可以通过数字快速定位和记忆内容分类。例如,用户可能会说"去第3个 Tab 看我的追云记录",而不需要记住 Tab 的具体名称。
10.3 选中态与未选中态的对比
选中态和未选中态之间的视觉差异是多层次的:
| 属性 | 选中态 | 未选中态 |
|---|---|---|
| 序号背景色 | 主色蓝色 | 边框灰色 |
| 序号文字色 | 白色 | 提示灰色 |
| Tab 背景色 | 浅蓝(primaryLight) | 页面背景色(bg) |
| 标签字号 | 13 号 | 11 号 |
| 标签字重 | 加粗 | 常规 |
| 标签文字色 | 主深色(primaryDark) | 次要灰色(textSecondary) |
六个维度的差异(背景色、文字色、字号、字重等)确保了选中态和未选中态之间有足够的视觉区分度。用户可以清晰地感知当前所在的内容分类。
Tab 的内边距为左右 8-10 像素、上下 6 像素,圆角 14 像素。选中态的浅蓝背景和圆角设计,使得选中的 Tab 看起来像是一个"胶囊"形状的标签,视觉上非常舒适。
10.4 横向滚动与交互
子导航支持横向滚动。当 7 个 Tab 的总宽度超过屏幕宽度时,用户可以左右滑动查看更多内容。滚动条被设置为隐藏状态(BarState.Off),保持了界面的简洁性。
点击某个 Tab 时,会更新 subTab 的值为对应的索引。由于 subTab 是 @State 变量,更新后会触发 UI 的重新渲染——被点击的 Tab 变为选中态,其他 Tab 变为未选中态,同时下方的内容区域会切换到对应的页面。
十一、各页面Builder:七大内容板块的逐页解析
11.1 pageFeatured:精选首页
pageFeatured 是精选页面的构建器,也是首页默认显示的内容。它包含了四个主要模块:玩法四格、周打卡柱状图、云族构成图、人气云种双列、云友动态。
玩法四格:展示应用的四大核心玩法(云种图鉴、追云打卡、观测团、延时课堂),每个玩法都有图标、名称、描述和热门标签。四格使用 Row 布局,每个格子等分宽度,白色背景,圆角 12 像素。
周打卡柱状图:展示本周 7 天的观云打卡次数,使用纯代码实现的柱状图。柱子的高度由 barH 函数计算,颜色由 cloudColor 函数决定。柱状图下方还有"周末追云占比 55%"的统计数据。
云族构成图:展示本季收录的云族构成比例,使用水平进度条的形式。每个云族对应一个进度条,进度条的颜色与云族的主题色一致。进度条右侧显示百分比数值。
人气云种双列:展示热门云种图鉴,采用双列卡片布局。通过 idx % 2 === 0 的判断,在循环中每次处理两个云种,用 Row 将它们并排放置。每张卡片包含云图标、稀有度标签、云种名称、云族分类和目击次数。
云友动态:展示最新的云友动态,只显示前 3 条。每条动态包含头像、昵称、时间、正文和点赞数。右上角有"发云语"入口,点击可以打开发布弹框。
11.2 pageClouds:云图鉴页
pageClouds 是云图鉴页面的构建器。它以列表形式展示所有云种的详细信息。
每个云种卡片包含以下元素:
- 左侧彩色竖条:颜色由
cloudTagColor函数根据云种名称决定,宽 4 像素,高 38 像素,作为视觉标识 - 云种名称 + 稀有度标签:名称为 14 号加粗深字,稀有度为带背景色的小标签
- 云族分类 + 目击次数:11 号次要灰色字,使用 emoji 作为前缀
- 收录进度条:底部有一条进度条,进度为目击百分比,颜色与云种类型对应
- 操作按钮:右下角有"收录进图鉴"的蓝色小按钮
这种列表式的图鉴展示方式信息密度高,用户可以快速浏览所有云种。每个卡片的彩色竖条和进度条提供了直观的视觉反馈。
11.3 pageChases:我的追云页
pageChases 是追云记录页面的构建器。它展示用户的所有追云记录及其进度。
每条追云记录包含:
- 天气图标(⛅):24 号字体,作为视觉点缀
- 追云名称 + 状态标签:名称为 14 号加粗深字,状态为带背景色的小标签(颜色由
statusColor决定) - 追云内容描述:11 号次要灰色字,带有彩虹 emoji 前缀
- 进度条:显示追云进度,进度值由
statusProgress函数决定,颜色由statusColor决定 - 操作入口:"编辑追云单"链接,点击打开编辑弹框
追云记录的状态系统是这个页面的核心。不同的状态用不同的颜色标识,配合进度条的可视化,用户可以一目了然地看到每条追云记录的进展情况。
11.4 pageCamp:观云营地页
pageCamp 是观云营地页面的构建器。它包含营地信息四格、云种人气排行榜和装备速购三个部分。
营地信息四格:展示今日日落时间、热门机位数量、通透度状态和本周出团场次。四个数据使用不同的主题色,与头部的数据四格设计风格一致。
云种人气排行榜:展示人气最高的前 5 个云种,按目击次数排序。每条记录包含排名数字(前三名用主色高亮)、云种名称 + 云族分类 + 稀有度、目击次数。
装备速购:以双列卡片形式展示 8 件装备。每张卡片包含装备图标、名称、描述和"加购"链接。双列布局通过 idx % 2 === 0 的方式实现,与人气云种的布局方式相同。
11.5 pageCourse:气象课堂页
pageCourse 是气象课堂页面的构建器。它以列表形式展示所有课程及其学习进度。
每门课程包含:
- 课程标题 + 周次:标题为 14 号加粗深字,周次为 11 号提示灰色字
- 学习进度条:进度条颜色与课程主题色一致
- 已学百分比:显示具体的学习进度数值
课程列表下方有一个"退课"按钮,红色背景,白色文字。点击按钮会打开删除确认弹框(目标为 ‘course’)。这个按钮使用了一种巧妙的实现方式:先创建一个透明的 Button 组件作为点击区域,然后用一个负边距的 Text 组件覆盖在按钮上方显示文字。这种方式虽然看起来有些"hack",但在某些场景下可以实现更灵活的按钮样式。
11.6 pageCircle:云友圈页
pageCircle 是云友圈页面的构建器。它展示了完整的云友动态列表(8 条全部显示,而不是精选页面的 3 条)。
每条动态的布局与精选页面中的动态类似,但尺寸稍大:
- 头像:26 号字体(精选页面是 22 号)
- 昵称:14 号加粗深字(精选页面是 13 号)
- 正文:13 号次要灰色字,显示全部内容而不是限制 2 行
- 底部信息:显示点赞数和回复入口
右上角有"发云语"入口,点击打开发布弹框。
云友圈是社区应用的核心内容消费场景。完整的动态列表让用户可以浏览更多的社区内容,参与互动讨论。
11.7 pageGear:装备库页
pageGear 是装备库页面的构建器。它包含装备分类三格和装备列表两个部分。
装备分类三格:将装备分为三大类——镜头区(📷)、稳定区(🎯)、知识区(📖)。每个分类有图标、名称和描述,白色卡片背景,圆角 12 像素。三格等分排列,形成清晰的分类导航。
装备列表:以列表形式展示所有 8 件装备。每件装备包含图标、名称、描述和"加购"按钮。列表项的布局与云图鉴列表类似,左侧图标,中间文字信息,右侧操作按钮。
列表下方有一个"取消追云"按钮,红色背景,白色文字。点击按钮打开删除确认弹框(目标为 ‘chase’)。这个按钮的实现方式与课堂页面的"退课"按钮相同。
11.8 pageMine:我的页
pageMine 是"我的"页面的构建器。它包含用户信息卡、追云档案和我的云语三个部分。
用户信息卡:展示用户头像、昵称和会员信息。头像用 40 号的云朵 emoji,昵称为 18 号加粗深字,下面一行小字展示会员等级、打卡天数、收录云种数和追云里程。
追云档案:展示用户的追云记录摘要,只显示前 3 条。卡片标题右侧显示追云单总数。每条记录显示名称和状态,状态用对应颜色的文字显示。
我的云语:展示用户发布的云语摘要,只显示前 3 条。卡片标题右侧显示云语总条数。每条记录显示昵称和发布时间。
"我的"页面是用户的个人中心,集中展示了用户的个人信息和核心数据。它的设计简洁明了,重点突出。
十二、弹框组件:新增、编辑与删除的模态交互
12.1 弹框架构的整体设计
应用包含三种弹框:新增弹框(发布云语)、编辑弹框(编辑追云单)和删除弹框(取消预约/退课)。三种弹框共享相同的架构模式:
- 遮罩层:半透明黑色背景,点击可关闭弹框
- 内容层:白色背景的弹框内容,包含标题、表单/内容、操作按钮
- 状态控制:由对应的布尔变量控制显示与隐藏
弹框的遮罩层由 modalOverlay 构建器统一实现,内容层则由各自的构建器(addModalBody、editModalBody、delModalBody)实现。这种共享遮罩 + 差异化内容的设计,既保证了交互的一致性,又减少了代码重复。
12.2 modalOverlay:遮罩层
@Builder
modalOverlay() {
Stack() {
Column()
.width('100%')
.height('100%')
.backgroundColor('#000000')
.opacity(0.6)
.onClick(() => {
this.addOpen = false;
this.editOpen = false;
this.delOpen = false;
})
}
.width('100%')
.height('100%')
}
遮罩层是一个全屏的半透明黑色层,不透明度为 0.6。它的作用是变暗背景内容,突出弹框,同时提供点击关闭的功能。
点击遮罩层会同时关闭三种弹框(将三个布尔变量都设为 false)。这种简化的设计在实际中不会有问题,因为三种弹框不会同时显示——每次只会有一种弹框处于打开状态。
12.3 addModalBody:发布云语弹框
发布云语弹框用于让用户发布新的观云动态。弹框内容包含:
- 标题:“发布云语”,17 号加粗深字
- 标题输入框:TextInput 组件,提示文字"一句话观云日常…",高度 40 像素
- 内容输入框:TextArea 组件,提示文字"补充细节:云种、时间、拍摄参数…",高度 90 像素
- 操作按钮:取消按钮(浅灰背景 + 深灰文字)和发布按钮(蓝色背景 + 白色加粗文字)
输入框的背景色是页面背景色(COLORS.bg),而不是纯白色,这使得输入区域与白色的弹框背景形成了微妙的区分。输入框的圆角为 8 像素,与整体的圆角风格保持一致。
输入框的内容通过 onChange 回调实时同步到对应的状态变量(addTitle 和 addContent)。这种双向绑定的机制确保了输入内容和状态的同步。
底部的两个按钮使用 Row 布局,等分宽度。按钮之间的间距为 10 像素。取消按钮点击后直接关闭弹框,发布按钮点击后执行 doAdd 方法添加新动态并关闭弹框。
按钮文字的实现方式比较特别——先创建一个透明的 Button 组件作为点击区域,然后用负边距的 Text 组件覆盖在按钮上方。这种方式虽然不那么"优雅",但可以更灵活地控制按钮文字的样式和位置。
12.4 editModalBody:编辑追云单弹框
编辑追云单弹框用于编辑现有的追云记录。它的结构与发布云语弹框类似,但表单字段不同:
- 标题:“编辑追云单”,17 号加粗深字
- 名称输入框:追云名称,TextInput 组件
- 状态输入框:追云状态,TextInput 组件(提示"如 追云中")
- 备注输入框:追云内容与备注说明,TextArea 组件(高度 70 像素)
- 操作按钮:取消按钮和保存按钮
打开编辑弹框时,会将选中的追云记录的现有数据填充到输入框中,用户可以在此基础上修改。保存后,新的数据会替换原来的记录。
编辑弹框的交互模式是一种典型的"获取-修改-保存"模式,它是 CRUD(创建、读取、更新、删除)操作中的 Update 部分。
12.5 delModalBody:删除确认弹框
删除确认弹框用于确认删除操作。它是三种弹框中最简单的一个,只包含标题、说明文字和两个操作按钮。
弹框的内容会根据删除目标动态变化:
- 如果删除目标是 ‘chase’(取消追云预约):标题为"取消追云预约",说明为"将取消首条追云预约并归档照片,确认执行?"
- 如果删除目标是 ‘course’(退出气象课堂):标题为"退出气象课堂",说明为"将退出首门进行中的课程,确认执行?"
这种基于 delTarget 的动态内容设计,使得同一个弹框可以复用于两种不同的删除场景,减少了代码冗余。
确认按钮使用红色背景(COLORS.danger),白色文字。红色在 UI 设计中通常表示危险或不可逆的操作,用在这里非常恰当——删除操作是破坏性的,需要用户特别注意。
删除确认弹框是一种重要的防错设计。它为破坏性操作增加了一道确认屏障,防止用户因误触而造成数据丢失。
十三、bottomBar与mainContent:布局骨架的构建
13.1 bottomBar:底部导航栏
bottomBar 构建器定义了应用的底部导航栏。
@Builder
bottomBar() {
Row() {
ForEach(NAV_LIST, (it: NavItem, idx: number) => {
Column({ space: 2 }) {
Text(it.icon)
.fontSize(20)
.opacity(this.mainTab === idx ? 1 : 0.55)
Text(it.label)
.fontSize(10)
.fontWeight(this.mainTab === idx ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.mainTab === idx ? COLORS.primary : COLORS.textHint)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.onClick(() => {
this.mainTab = idx;
})
}, ...)
}
.width('100%')
.height(56)
.backgroundColor(COLORS.cardBg)
.borderRadius({ topLeft: 16, topRight: 16 })
}
底部导航栏使用 Row 布局,包含 4 个导航项,每个导航项使用 layoutWeight(1) 实现等分宽度。每个导航项是一个 Column 布局,包含图标(emoji)和标签文字两部分。
选中态和未选中态的视觉差异通过以下方式体现:
- 图标:选中态不透明度为 1.0,未选中态为 0.55
- 标签:选中态为主色蓝色且加粗,未选中态为提示灰色且常规字重
导航栏的高度为 56 像素,这是移动端底部导航栏的标准高度。背景色为白色(卡片背景色),顶部左右两角有 16 像素的圆角,形成了"浮起"的视觉效果。
点击导航项会更新 mainTab 的值,从而切换主内容区域的显示内容。
13.2 mainContent:主内容区域
mainContent 构建器定义了应用的主内容区域。它是整个应用布局的核心骨架,根据当前的导航状态渲染不同的页面内容。
@Builder
mainContent() {
Column() {
this.header()
if (this.mainTab === 0) {
// 首页:header + subNav + 内容页
this.subNav()
Scroll() {
Column() {
// 根据 subTab 渲染不同的子页面
if (this.subTab === 0) {
this.pageFeatured()
} else if (this.subTab === 1) {
this.pageClouds()
} else if (this.subTab === 2) {
this.pageChases()
} else if (this.subTab === 3) {
this.pageCamp()
} else if (this.subTab === 4) {
this.pageCourse()
} else if (this.subTab === 5) {
this.pageCircle()
} else {
this.pageGear()
}
}
}
.layoutWeight(1)
.padding({ left: 14, right: 14, bottom: 20 })
} else if (this.mainTab === 1) {
// 图鉴页
Scroll() { Column() { this.pageClouds() } }
.layoutWeight(1)
.padding(...)
} else if (this.mainTab === 2) {
// 课堂页
Scroll() { Column() { this.pageCourse() } }
.layoutWeight(1)
.padding(...)
} else {
// 我的页
Scroll() { Column() { this.pageMine() } }
.layoutWeight(1)
.padding(...)
}
}
.width('100%')
.height('100%')
}
主内容区域使用 Column 布局,从上到下依次排列头部和内容区。内容区使用 Scroll 包裹,支持垂直滚动,确保内容超出屏幕时可以滚动查看。
layoutWeight(1) 是一个关键属性。它使得 Scroll 组件占据 Column 中剩余的所有空间——头部占据固定高度,Scroll 占据剩下的全部空间。这样既保证了头部固定在顶部,又让内容区域可以自由滚动。
当 mainTab 为 0(首页)时,内容结构是:头部 → 子导航 → 滚动内容区。子导航位于头部下方、滚动内容区上方,它是固定在顶部的,不会随内容滚动。
当 mainTab 为其他值时,内容结构是:头部 → 滚动内容区。没有子导航,直接显示对应页面的内容。
13.3 布局架构的层次总结
整个应用的布局架构可以总结为以下层次(从下到上):
- 背景层:纯色背景 Column,填充整个页面
- 特效层:fxLayer,云絮漂移特效,穿透点击
- 内容层:Column 包裹,包含 mainContent(头部 + 内容区)和 bottomBar(底部导航)
- 弹框层:条件渲染,包含遮罩层和弹框内容
这种层次结构通过 Stack 布局实现。Stack 的子组件按照顺序从上到下排列(在代码中的顺序),后渲染的组件在上层。
弹框层使用条件渲染(if (this.addOpen) 等),只有当弹框打开时才会被渲染到界面上。这种方式确保了未使用的弹框不会占用渲染资源。
弹框的 zIndex 被设为 999,确保它在最上层显示,不会被其他内容遮挡。
十四、build()函数:页面的最终组装
14.1 build函数的结构
build() 函数是组件的入口函数,它定义了组件的整体 UI 结构。
build() {
Stack() {
// 背景层
Column()
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
// 特效层
this.fxLayer()
// 内容层
Column() {
this.mainContent()
this.bottomBar()
}
.width('100%')
.height('100%')
// 新增弹框
if (this.addOpen) { ... }
// 编辑弹框
if (this.editOpen) { ... }
// 删除弹框
if (this.delOpen) { ... }
}
.width('100%')
.height('100%')
}
最外层是一个 Stack 布局,宽高都是 100%,填充整个屏幕。Stack 中包含了背景层、特效层、内容层和三个弹框层。
build() 函数的设计非常简洁,它只负责组装各个模块,而具体的 UI 细节都封装在各自的 @Builder 方法中。这种模块化的设计使得 build() 函数保持了良好的可读性——开发者可以快速了解页面的整体结构,而不需要陷入细节。
14.2 弹框的条件渲染
三个弹框都使用条件渲染(if 语句),只有当对应的状态变量为 true 时才会被渲染。每个弹框内部的结构是相同的:
if (this.addOpen) {
Stack() {
this.modalOverlay()
Column() {
this.addModalBody()
}
.width('88%')
.constraintSize({ maxHeight: '80%' })
.borderRadius(16)
.zIndex(999)
}
.width('100%')
.height('100%')
}
最外层是一个全屏的 Stack,用于居中显示弹框。Stack 中包含遮罩层和弹框内容两部分。
弹框内容使用 Column 包裹,宽度为屏幕宽度的 88%,最大高度为屏幕高度的 80%。圆角 16 像素,zIndex 为 999 确保弹框在最上层。
弹框的居中对齐是通过 Stack 的默认居中对齐行为实现的。因为弹框内容比 Stack 小,所以会自动居中显示。
十五、应用架构流程图
十六、数据模型对比表
| 数据模型 | 装饰器 | 核心字段 | 主要用途 | 数据条数 | 颜色映射函数 | 所在页面 |
|---|---|---|---|---|---|---|
| PostItem 云友动态 | @Observed | id, nick, avatar, text, time, likes | 社区内容发布与展示 | 8 条 | — | 精选页、云友圈页、我的页 |
| CloudItem 云种图鉴 | @Observed | id, name, family, rarity, seen | 云种分类收集与图鉴 | 8 种 | cloudTagColor / rarityColor | 精选页、图鉴页、营地页 |
| ChaseItem 追云记录 | @Observed | id, name, item, status, progress | 追云行动追踪与管理 | 8 条 | statusColor / statusProgress | 追云页、我的页 |
| GearItem 装备 | @Observed | id, name, desc, icon | 摄影装备展示与加购 | 8 件 | — | 营地页、装备库页 |
| WeekChartItem 周打卡 | interface | label, value | 周打卡统计可视化 | 7 天 | cloudColor / barH | 精选页 |
| CloudChartItem 云族构成 | interface | label, value, color | 云族占比统计可视化 | 5 族 | — | 精选页 |
| CourseItem 气象课程 | interface | title, week, progress, color | 在线课程学习管理 | 5 门 | — | 课堂页 |
| SkyTypeItem 玩法分类 | interface | name, desc, icon, hot | 功能入口导航 | 4 类 | — | 精选页 |
十七、结尾总结:从代码看设计思想的落地
17.1 声明式UI范式的全面实践
纵观整个应用的代码实现,我们可以看到声明式UI开发范式在各个层面的全面实践。从最顶层的 build() 函数到每一个 @Builder 方法,从状态变量的定义到用户交互的处理,全部遵循"状态驱动视图"的核心思想。
开发者不需要手动操作 DOM 或视图树,只需要定义好状态与UI的映射关系,框架会自动处理状态变化到UI更新的转换。这种开发模式不仅提高了开发效率,也降低了出错的概率——因为状态和UI的同步是由框架保证的,而不是由开发者手动维护的。
在"云语"应用中,这种范式的优势体现得尤为明显。特效层的动画是通过 tick 状态变量驱动的,导航切换是通过 mainTab 和 subTab 状态变量驱动的,弹框的显示与隐藏是通过布尔状态变量驱动的,列表数据的增删改是通过数组状态变量驱动的。一切UI变化都可以追溯到状态的变化,代码逻辑清晰透明。
17.2 色彩系统的系统化设计
应用的色彩系统是一个非常值得学习的案例。它不仅仅是选了几个好看的颜色,而是构建了一个完整的、有层次的、有语义的色彩体系。
主色系的三层渐变(primary / primaryLight / primaryDark)提供了主色调的明暗变化,适用于各种不同的场景。强调色系的两层渐变(accent / accentLight)为界面增添了温暖的点缀。文字色的三级梯度(textPrimary / textSecondary / textHint)构建了清晰的信息层级。功能色(success / warning / danger)提供了标准化的状态反馈。特殊色 sky 则为特效和装饰元素提供了专属的颜色。
这种系统化的色彩设计有多重好处。首先,它保证了应用视觉风格的一致性——所有颜色都来自同一个调色板,不会出现杂乱无章的配色。其次,它降低了设计和开发的沟通成本——设计师和开发者使用同一套颜色命名体系,不需要反复确认色值。最后,它为主题切换打下了基础——如果将来需要支持深色模式或其他主题,只需要修改 COLORS 对象中的颜色值即可。
17.3 数据模型的分层设计
应用的数据模型设计也体现了清晰的分层思想。最核心的四个业务模型(PostItem、CloudItem、ChaseItem、GearItem)使用 @Observed 装饰器,支持细粒度的状态追踪。辅助的数据结构(WeekChartItem、CloudChartItem、CourseItem、SkyTypeItem)使用 interface 定义,只用于数据描述,不需要状态追踪。
这种分层设计非常合理。核心业务模型需要支持增删改查,属性变化需要实时反映到UI上,所以使用 @Observed 装饰器。辅助数据结构是静态的展示数据,不需要动态变化,所以使用 interface 就足够了。
静态数据数组的设计也值得称道。所有的预置数据都被提取为顶层的常量数组,与组件逻辑分离。这种数据与视图分离的设计,使得数据的修改和维护变得简单——如果需要修改内容,只需要修改对应的数组,不需要改动UI代码。
17.4 组件化与代码复用
应用大量使用 @Builder 装饰器来组织代码,将UI模块划分为一个个独立的构建函数。这种模块化的组织方式有很多优点:
- 可读性好:每个 @Builder 方法都有明确的职责和命名,开发者可以通过方法名快速了解该模块的功能。
- 可维护性高:修改某个模块时,只需要找到对应的 @Builder 方法,不会影响其他模块的代码。
- 复用性强:同一个 @Builder 方法可以在多处被调用,减少代码重复。
- 测试友好:每个模块相对独立,便于进行单元测试和UI测试。
在"云语"应用中,我们可以看到这种模块化设计的效果。build() 函数非常简洁,只负责组装各个模块。每个模块内部的细节被封装在各自的 @Builder 方法中。代码的组织结构与界面的视觉结构一一对应,开发者可以很容易地在代码和界面之间建立映射关系。
17.5 特效与交互的平衡
特效层的设计是应用的一大亮点,它在视觉效果和交互体验之间找到了很好的平衡。云絮漂移特效为应用增添了动感和氛围,让用户仿佛置身于天空之下。同时,通过 hitTestBehavior(HitTestMode.None) 的设置,特效层完全不影响用户交互,确保了操作的流畅性。
这种"视觉上存在,交互上透明"的设计思路,是做背景特效时的重要原则。很多开发者在做背景特效时,容易陷入"为了特效而特效"的误区,忽略了特效对交互的影响。而"云语"应用的处理方式,为我们提供了一个优秀的参考范例。
17.6 纯函数工具集的设计思想
应用中的纯函数工具集也是一个值得关注的设计亮点。barH、cloudColor、cloudXA、cloudYA、cloudA、smallXA、smallYA、smallA、statusColor、statusProgress、cloudTagColor、rarityColor——这些函数都是纯函数,它们的输出完全由输入决定,没有副作用,不依赖外部状态。
纯函数具有很多优秀的特性:可测试性好(相同的输入总是产生相同的输出)、可复用性强(不依赖特定的上下文)、易于推理(逻辑清晰透明)。在声明式UI开发中,纯函数是连接数据与视图的桥梁——它们将原始数据转换为UI可以直接使用的格式(颜色、坐标、尺寸等)。
将计算逻辑抽离为纯函数,也是一种重要的架构设计思想。它使得UI描述更加纯粹(只负责"是什么",不负责"怎么算"),计算逻辑更加独立(可以单独测试和优化)。这种关注点分离的设计,提高了代码的质量和可维护性。
17.7 对HarmonyOS开发者的启示
通过对"云语"应用的深度剖析,我们可以得到一些对 HarmonyOS 开发者有价值的启示:
第一,善用 @Builder 装饰器组织代码。将复杂的UI拆分为多个小的构建函数,每个函数职责单一,命名清晰,可以大幅提高代码的可读性和可维护性。
第二,建立系统化的色彩体系。不要在代码中散落各种硬编码的颜色值,而应该定义统一的颜色对象,并在整个应用中引用。这不仅保证了视觉一致性,也为将来的主题扩展留下了空间。
第三,合理使用 @Observed 装饰器。对于需要动态变化的核心业务模型,使用 @Observed 装饰器可以实现细粒度的状态追踪。对于静态的展示数据,使用 interface 就足够了,不要过度使用 @Observed,以免造成不必要的性能开销。
第四,重视生命周期管理。在 aboutToAppear 中启动定时器和事件监听,在 aboutToDisappear 中清除它们,避免内存泄漏。这是每个 ArkUI 开发者都应该养成的习惯。
第五,背景特效要注意点击穿透。使用 hitTestBehavior(HitTestMode.None) 让特效层不拦截触摸事件,确保用户可以正常操作下层的内容。
第六,将计算逻辑抽离为纯函数。纯函数易于测试、易于复用、易于理解,是连接数据与视图的理想桥梁。
安装DevEco Studio程序

选择目标安装目录:

设置环境变量,但是需要重启一下:

新建一个空白模板:

设置API为24的模板项目:
初始化项目,自动下载相关依赖:

完整代码:
// 场景:云种图鉴收集 / 追云打卡 / 气象课堂 / 观云爱好者社区
// 底部4主tab(首页/图鉴/课堂/我的)+ 首页7内容tab(序号徽章式)
// 特效:云絮漂移 + 小云反向漂移(特效层位于底层,不遮挡点击)
// 弹框:新增(发云语) / 编辑(编辑追云单) / 删除(取消预约·退课)
interface ColorPalette {
primary: string;
primaryLight: string;
primaryDark: string;
accent: string;
accentLight: string;
bg: string;
cardBg: string;
textPrimary: string;
textSecondary: string;
textHint: string;
border: string;
success: string;
warning: string;
danger: string;
white: string;
sky: string;
}
const COLORS: ColorPalette = {
primary: '#6C9BD1',
primaryLight: '#DCE8F5',
primaryDark: '#43689A',
accent: '#F0A868',
accentLight: '#FBEEDD',
bg: '#EDF4FA',
cardBg: '#FFFFFF',
textPrimary: '#27384C',
textSecondary: '#5F7386',
textHint: '#A3B4C4',
border: '#E0EAF3',
success: '#7BA884',
warning: '#D9A05B',
danger: '#C76B5A',
white: '#FFFFFF',
sky: '#BFD7EE'
};
// ============ 云友动态数据模型 ============
@Observed
export class PostItem {
id: number = 0
nick: string = ''
avatar: string = ''
text: string = ''
time: string = ''
likes: number = 0
constructor(id: number, nick: string, avatar: string, text: string, time: string, likes: number) {
this.id = id; this.nick = nick; this.avatar = avatar; this.text = text
this.time = time; this.likes = likes
}
}
// ============ 云种图鉴数据模型 ============
@Observed
export class CloudItem {
id: number = 0
name: string = ''
family: string = ''
rarity: string = ''
seen: number = 0
constructor(id: number, name: string, family: string, rarity: string, seen: number) {
this.id = id; this.name = name; this.family = family; this.rarity = rarity
this.seen = seen
}
}
// ============ 追云记录数据模型 ============
@Observed
export class ChaseItem {
id: number = 0
name: string = ''
item: string = ''
status: string = ''
progress: number = 0
constructor(id: number, name: string, item: string, status: string, progress: number) {
this.id = id; this.name = name; this.item = item; this.status = status
this.progress = progress
}
}
// ============ 装备数据模型 ============
@Observed
export class GearItem {
id: number = 0
name: string = ''
desc: string = ''
icon: string = ''
constructor(id: number, name: string, desc: string, icon: string) {
this.id = id; this.name = name; this.desc = desc; this.icon = icon
}
}
// ============ 图表与课程接口 ============
interface WeekChartItem {
label: string;
value: number;
}
interface CloudChartItem {
label: string;
value: number;
color: string;
}
interface CourseItem {
title: string;
week: string;
progress: number;
color: string;
}
interface SkyTypeItem {
name: string;
desc: string;
icon: string;
hot: boolean;
}
// ============ 顶层静态数据 ============
const POST_LIST: PostItem[] = [
new PostItem(1, '积云收藏家', '☁️', '今午的淡积云一朵朵像刚弹好的棉花糖。', '5分钟前', 136),
new PostItem(2, '火烧云蹲守人', '🌅', '日落前 20 分钟开始烧,整个西天都是橘子色。', '18分钟前', 121),
new PostItem(3, '高积云爱好者', '🌤️', '鱼鳞天排得整整齐齐,明早大概率晴。', '34分钟前', 99),
new PostItem(4, '云语主理人', '🌥️', '周六雷暴云观测团招募,带间隔拍摄教学。', '1小时前', 92),
new PostItem(5, '新手观云人', '📖', '第一次分清卷云和卷层云,成就感爆棚。', '2小时前', 83),
new PostItem(6, '延时摄影党', '🎥', '把两小时的云压缩成 15 秒,越看越上头。', '3小时前', 75),
new PostItem(7, '雷暴追逐者', '⛈️', '城南 30 公里外拍到了完整的不透明雨幕。', '5小时前', 68),
new PostItem(8, '夜空云观察', '🌙', '月光下的碎积云,像撒了一地银屑。', '昨天', 61)
];
const CLOUD_LIST: CloudItem[] = [
new CloudItem(1, '淡积云', '积云族 · 低云', '常见', 97),
new CloudItem(2, '浓积云', '积云族 · 直展', '常见', 93),
new CloudItem(3, '积雨云', '直展云族', '常见', 90),
new CloudItem(4, '卷云', '卷云族 · 高云', '常见', 86),
new CloudItem(5, '堡状高积云', '中云族', '少见', 81),
new CloudItem(6, '荚状云', '中云族 · 特殊', '稀有', 77),
new CloudItem(7, '乳状云', '积雨云附属', '稀有', 72),
new CloudItem(8, '航迹云', '人为云族', '季节', 66)
];
const CHASE_LIST: ChaseItem[] = [
new ChaseItem(1, '昨日 · 西郊日落火烧云', '晚霞 · 延时 40 分钟', '已收录', 100),
new ChaseItem(2, '今晨 · 河湾辐射雾', '雾景 · 慢门拍摄', '整理中', 76),
new ChaseItem(3, '周三 · 雷暴单体追踪', '降水概率 80%', '追云中', 58),
new ChaseItem(4, '上周 · 荚状云守候', '山顶 · 等待 3 小时', '待鉴定', 42),
new ChaseItem(5, '周六 · 雷暴云观测团', '团体 · 带教学', '已预约', 34),
new ChaseItem(6, '月底 · 高积云鱼鳞天', '广角 · 拼接拍摄', '待鉴定', 22),
new ChaseItem(7, '昨日 · 夜云月光记录', '长曝 · ISO 800', '整理中', 16),
new ChaseItem(8, '下周 · 云海日出机位', '山顶 · 4:30 出发', '已预约', 10)
];
const GEAR_LIST: GearItem[] = [
new GearItem(1, '偏振镜 CPL', '压蓝天空', '🔵'),
new GearItem(2, '广角镜头 16-35', '大场景云海', '📷'),
new GearItem(3, '渐变灰滤镜', '平衡日出日落', '🌗'),
new GearItem(4, '三脚架 · 碳纤维', '延时必备', '🎯'),
new GearItem(5, '云图鉴手册', '十云属图版', '📖'),
new GearItem(6, '气象雷达 App', '降水临近预报', '📡'),
new GearItem(7, '间隔快门线', '延时摄影', '⏱️'),
new GearItem(8, '防雨罩', '雷暴拍摄', '🌧️')
];
const WEEK_CHART: WeekChartItem[] = [
{ label: '周一', value: 4 },
{ label: '周二', value: 6 },
{ label: '周三', value: 3 },
{ label: '周四', value: 7 },
{ label: '周五', value: 9 },
{ label: '周六', value: 15 },
{ label: '周日', value: 11 }
];
const CLOUD_CHART: CloudChartItem[] = [
{ label: '积云族', value: 31, color: '#6C9BD1' },
{ label: '卷云族', value: 25, color: '#F0A868' },
{ label: '层云族', value: 19, color: '#7BA884' },
{ label: '中云族', value: 15, color: '#D9A05B' },
{ label: '特殊云', value: 10, color: '#C76B5A' }
];
const TYPE_LIST: SkyTypeItem[] = [
{ name: '云种图鉴', desc: '收录 · 鉴定 · 升级', icon: '📖', hot: true },
{ name: '追云打卡', desc: '火烧云 · 雷暴 · 云海', icon: '🏃', hot: true },
{ name: '观测团', desc: '领队带追 · 教学', icon: '🌥️', hot: false },
{ name: '延时课堂', desc: '机位 · 参数 · 后期', icon: '🎥', hot: false }
];
const COURSE_LIST: CourseItem[] = [
{ title: '云属识别入门十云属', week: '第 1 周', progress: 100, color: '#6C9BD1' },
{ title: '天气图与云的预报', week: '第 2 周', progress: 82, color: '#F0A868' },
{ title: '火烧云预测与机位', week: '第 3 周', progress: 58, color: '#7BA884' },
{ title: '云海形成条件分析', week: '第 4 周', progress: 32, color: '#D9A05B' },
{ title: '雷暴安全与拍摄', week: '第 5 周', progress: 7, color: '#C76B5A' }
];
// ============ 导航配置 ============
interface NavItem {
icon: string;
label: string;
}
const NAV_LIST: NavItem[] = [
{ icon: '☁️', label: '首页' },
{ icon: '📖', label: '图鉴' },
{ icon: '📚', label: '课堂' },
{ icon: '👤', label: '我的' }
];
const SUB_NAV_LIST: string[] = ['精选', '云图鉴', '我的追云', '观云营地', '气象课堂', '云友圈', '装备库'];
// ============ 顶层纯函数 ============
function barH(v: number, max: number): number {
return Math.round(118 * v / max);
}
function cloudColor(v: number): string {
if (v > 9) {
return '#6C9BD1';
} else if (v > 6) {
return '#F0A868';
}
return '#D9A05B';
}
function cloudXA(tick: number, i: number): number {
return ((tick * 2 + i * 233) % 700) - 60;
}
function cloudA(tick: number, i: number): number {
return 0.35 + ((tick + i) % 5) * 0.06;
}
function cloudYA(tick: number, i: number): number {
return 90 + i * 170 + ((tick) % 4) * 3;
}
function smallXA(tick: number, i: number): number {
return 680 - ((tick * 3 + i * 191) % 700);
}
function smallA(tick: number, i: number): number {
return 0.2 + ((tick + i * 2) % 4) * 0.07;
}
function smallYA(tick: number, i: number): number {
return 150 + i * 190 + ((tick + 1) % 4) * 2;
}
function statusColor(s: string): string {
if (s === '已收录') {
return '#7BA884';
} else if (s === '整理中') {
return '#6C9BD1';
} else if (s === '追云中') {
return '#F0A868';
} else if (s === '待鉴定') {
return '#D9A05B';
}
return '#A3B4C4';
}
function statusProgress(s: string): number {
if (s === '已收录') {
return 100;
} else if (s === '整理中') {
return 74;
} else if (s === '追云中') {
return 56;
} else if (s === '待鉴定') {
return 36;
}
return 14;
}
function cloudTagColor(g: string): string {
if (g.indexOf('荚状') >= 0 || g.indexOf('乳状') >= 0) {
return '#6C9BD1';
} else if (g.indexOf('积') >= 0) {
return '#F0A868';
} else if (g.indexOf('卷') >= 0) {
return '#7BA884';
}
return '#D9A05B';
}
function rarityColor(s: string): string {
if (s === '常见') {
return '#7BA884';
} else if (s === '季节') {
return '#F0A868';
} else if (s === '少见') {
return '#D9A05B';
}
return '#C76B5A';
}
@Entry
@Component
struct PageCloudSpotting {
@State mainTab: number = 0
@State subTab: number = 0
@State tick: number = 0
@State glow: number = 0
@State addOpen: boolean = false
@State editOpen: boolean = false
@State delOpen: boolean = false
@State editIdx: number = 0
@State delTarget: string = 'chase'
@State editName: string = ''
@State editStyle: string = ''
@State editNote: string = ''
@State addTitle: string = ''
@State addContent: string = ''
@State postList: PostItem[] = POST_LIST
@State chaseList: ChaseItem[] = CHASE_LIST
@State fxTimer: number = -1
aboutToAppear(): void {
this.fxTimer = setInterval(() => {
this.tick = this.tick + 1;
this.glow = (this.glow + 1) % 2;
}, 90);
}
aboutToDisappear(): void {
if (this.fxTimer > 0) {
clearInterval(this.fxTimer);
this.fxTimer = -1;
}
}
openAdd(): void {
this.addTitle = '';
this.addContent = '';
this.addOpen = true;
}
openEdit(index: number): void {
if (index >= 0 && index < this.chaseList.length) {
this.editIdx = index;
this.editName = this.chaseList[index].name;
this.editStyle = this.chaseList[index].status;
this.editNote = this.chaseList[index].item;
}
this.editOpen = true;
}
openDelA(): void {
this.delTarget = 'chase';
this.delOpen = true;
}
openDelB(): void {
this.delTarget = 'course';
this.delOpen = true;
}
doAdd(): void {
if (this.addTitle.length > 0) {
this.postList.unshift(new PostItem(999, '我的云语', '☁️', this.addTitle, '刚刚', 0));
}
this.addOpen = false;
}
doEdit(): void {
if (this.editIdx >= 0 && this.editIdx < this.chaseList.length) {
this.chaseList.splice(this.editIdx, 1, new ChaseItem(this.chaseList[this.editIdx].id, this.editName, this.editNote, this.editStyle, 40));
}
this.editOpen = false;
}
doDel(): void {
if (this.delTarget === 'chase' && this.chaseList.length > 0) {
this.chaseList.splice(0, 1);
} else if (this.delTarget === 'course' && this.postList.length > 0) {
this.postList.splice(0, 1);
}
this.delOpen = false;
}
@Builder
fxLayer() {
Stack({ alignContent: Alignment.TopStart }) {
ForEach([0, 1, 2], (i: number) => {
Row()
.width(96)
.height(26)
.borderRadius(14)
.backgroundColor(COLORS.sky)
.opacity(cloudA(this.tick, i))
.translate({ x: cloudXA(this.tick, i), y: cloudYA(this.tick, i) })
}, (i: number) => i.toString())
ForEach([0, 1, 2, 3], (i: number) => {
Row()
.width(44)
.height(14)
.borderRadius(8)
.backgroundColor(COLORS.primaryLight)
.opacity(smallA(this.tick, i))
.translate({ x: smallXA(this.tick, i), y: smallYA(this.tick, i) })
}, (i: number) => i.toString())
}
.width('100%')
.height('100%')
.hitTestBehavior(HitTestMode.None)
}
@Builder
header() {
Column({ space: 10 }) {
// 品牌行 + 今日云况
Row({ space: 10 }) {
Text('☁️')
.fontSize(24)
Column({ space: 2 }) {
Text('云语观云')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('今日云况 · 积云 60% · 降水 10% · 适宜拍摄')
.fontSize(10)
.fontColor(COLORS.textSecondary)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('打卡')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 14, right: 14, top: 7, bottom: 7 })
.backgroundColor(COLORS.accent)
.borderRadius(14)
.onClick(() => {
this.openAdd();
})
}
.width('100%')
// 稀有云预警卡
Row({ space: 12 }) {
Column({ space: 4 }) {
Text('今晚或有火烧云 · 拍摄指数 88')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Text('18:42 日落 · 西天通透度良好 · 抓紧占位')
.fontSize(10)
.fontColor('#E3EEF9')
Row({ space: 6 }) {
Text('晚霞预警')
.fontSize(9)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primaryDark)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(COLORS.sky)
.borderRadius(6)
Text('云图鉴挑战进行中')
.fontSize(9)
.fontColor('#E3EEF9')
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('查看预报')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primaryDark)
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.backgroundColor(COLORS.sky)
.borderRadius(14)
.onClick(() => {
this.openEdit(1);
})
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.primary)
.borderRadius(16)
// 云图鉴挑战横幅
Row({ space: 10 }) {
Text('🏆')
.fontSize(22)
Column({ space: 2 }) {
Text('集齐十云属 · 领限量云图徽章')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('已收录 8/10 · 再收荚状云和夜光云即达成')
.fontSize(10)
.fontColor(COLORS.textSecondary)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('去收集')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 14, right: 14, top: 7, bottom: 7 })
.backgroundColor(COLORS.accent)
.borderRadius(14)
.onClick(() => {
this.openAdd();
})
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
// 数据四格
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('打卡天数')
.fontSize(9)
.fontColor(COLORS.textHint)
Text('208 天')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
}
.layoutWeight(1)
.padding(8)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('收录云种')
.fontSize(9)
.fontColor(COLORS.textHint)
Text('42 种')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
}
.layoutWeight(1)
.padding(8)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('追云里程')
.fontSize(9)
.fontColor(COLORS.textHint)
Text('310 km')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.success)
}
.layoutWeight(1)
.padding(8)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('云币')
.fontSize(9)
.fontColor(COLORS.textHint)
Text('¥92')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.warning)
}
.layoutWeight(1)
.padding(8)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.alignItems(HorizontalAlign.Center)
}
.width('100%')
}
.width('100%')
.padding({ top: 10, bottom: 4 })
}
@Builder
subNav() {
Scroll() {
Row({ space: 8 }) {
ForEach(SUB_NAV_LIST, (item: string, idx: number) => {
Row({ space: 5 }) {
Text(`${idx + 1}`)
.fontSize(9)
.fontWeight(FontWeight.Bold)
.fontColor(this.subTab === idx ? COLORS.white : COLORS.textHint)
.textAlign(TextAlign.Center)
.width(18)
.height(18)
.borderRadius(9)
.backgroundColor(this.subTab === idx ? COLORS.primary : COLORS.border)
Text(item)
.fontSize(this.subTab === idx ? 13 : 11)
.fontWeight(this.subTab === idx ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.subTab === idx ? COLORS.primaryDark : COLORS.textSecondary)
}
.padding({ left: 8, right: 10, top: 6, bottom: 6 })
.backgroundColor(this.subTab === idx ? COLORS.primaryLight : COLORS.bg)
.borderRadius(14)
.onClick(() => {
this.subTab = idx;
})
}, (item: string) => item)
}
.width('100%')
.alignItems(VerticalAlign.Center)
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.padding({ top: 6, bottom: 10 })
}
@Builder
pageFeatured() {
Column({ space: 12 }) {
// 玩法四格
Column() {
Text('在云语能玩什么')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
.margin({ bottom: 10 })
Row({ space: 8 }) {
ForEach(TYPE_LIST, (it: SkyTypeItem) => {
Column({ space: 4 }) {
Text(it.icon)
.fontSize(24)
Text(it.name)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(it.desc)
.fontSize(9)
.fontColor(COLORS.textHint)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(it.hot ? '热门' : ' ')
.fontSize(9)
.fontWeight(FontWeight.Bold)
.fontColor(it.hot ? COLORS.danger : COLORS.border)
}
.layoutWeight(1)
.padding({ top: 10, bottom: 8, left: 4, right: 4 })
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.alignItems(HorizontalAlign.Center)
}, (it: SkyTypeItem) => it.name)
}
.width('100%')
}
.width('100%')
// 周打卡柱状图
Column() {
Row() {
Text('本周观云打卡次数')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text('单位 次')
.fontSize(10)
.fontColor(COLORS.textHint)
}
.width('100%')
Row({ space: 10 }) {
ForEach(WEEK_CHART, (it: WeekChartItem) => {
Column({ space: 4 }) {
Column()
.width(20)
.height(barH(it.value, 15))
.borderRadius(5)
.backgroundColor(cloudColor(it.value))
Text(`${it.value}`)
.fontSize(9)
.fontColor(COLORS.textSecondary)
Text(it.label)
.fontSize(9)
.fontColor(COLORS.textHint)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}, (it: WeekChartItem) => it.label + it.value.toString())
}
.width('100%')
.alignItems(VerticalAlign.Bottom)
.height(140)
.margin({ top: 12 })
Row({ space: 6 }) {
Text('周末追云占比')
.fontSize(11)
.fontColor(COLORS.textSecondary)
Text('55%')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
}
.width('100%')
.margin({ top: 10 })
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
// 云族构成
Column() {
Text('本季收录云族构成')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
ForEach(CLOUD_CHART, (it: CloudChartItem) => {
Row({ space: 8 }) {
Text(it.label)
.fontSize(12)
.fontColor(COLORS.textSecondary)
.width(70)
Stack({ alignContent: Alignment.Start }) {
Row()
.width('100%')
.height(8)
.borderRadius(4)
.backgroundColor(COLORS.border)
Row()
.width(`${it.value}%`)
.height(8)
.borderRadius(4)
.backgroundColor(it.color)
}
.layoutWeight(1)
Text(`${it.value}%`)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width(44)
.textAlign(TextAlign.End)
}
.width('100%')
.margin({ top: 8 })
}, (it: CloudChartItem) => it.label)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
// 热门云种双列
Column() {
Row() {
Text('人气云种图鉴')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text('查看全部')
.fontSize(11)
.fontColor(COLORS.primary)
}
.width('100%')
.margin({ bottom: 10 })
ForEach(CLOUD_LIST, (it: CloudItem, idx: number) => {
if (idx % 2 === 0) {
Row({ space: 10 }) {
if (idx < CLOUD_LIST.length) {
Column({ space: 6 }) {
Row() {
Text('☁️')
.fontSize(22)
.layoutWeight(1)
Text(CLOUD_LIST[idx].rarity)
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(rarityColor(CLOUD_LIST[idx].rarity))
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(COLORS.accentLight)
.borderRadius(6)
}
.width('100%')
Text(CLOUD_LIST[idx].name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(`${CLOUD_LIST[idx].family} · 目击 ${CLOUD_LIST[idx].seen}`)
.fontSize(11)
.fontColor(COLORS.textSecondary)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}
if (idx + 1 < CLOUD_LIST.length) {
Column({ space: 6 }) {
Row() {
Text('🌤️')
.fontSize(22)
.layoutWeight(1)
Text(CLOUD_LIST[idx + 1].rarity)
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(rarityColor(CLOUD_LIST[idx + 1].rarity))
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(COLORS.accentLight)
.borderRadius(6)
}
.width('100%')
Text(CLOUD_LIST[idx + 1].name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(`${CLOUD_LIST[idx + 1].family} · 目击 ${CLOUD_LIST[idx + 1].seen}`)
.fontSize(11)
.fontColor(COLORS.textSecondary)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}
}
.width('100%')
}
}, (it: CloudItem) => it.id.toString())
}
.width('100%')
// 云友动态
Column({ space: 10 }) {
Row() {
Text('云友动态')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text('发云语')
.fontSize(12)
.fontColor(COLORS.primary)
.onClick(() => {
this.openAdd();
})
}
.width('100%')
ForEach(this.postList.slice(0, 3), (it: PostItem) => {
Row({ space: 10 }) {
Text(it.avatar)
.fontSize(22)
Column({ space: 3 }) {
Row() {
Text(it.nick)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text(it.time)
.fontSize(10)
.fontColor(COLORS.textHint)
}
.width('100%')
Text(it.text)
.fontSize(12)
.fontColor(COLORS.textSecondary)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(`☁️ ${it.likes}`)
.fontSize(11)
.fontColor(COLORS.textHint)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}, (it: PostItem) => it.id.toString())
}
.width('100%')
}
.width('100%')
}
@Builder
pageClouds() {
Column({ space: 10 }) {
Row() {
Text('云图鉴')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text('上传照片鉴定')
.fontSize(12)
.fontColor(COLORS.primary)
.onClick(() => {
this.openAdd();
})
}
.width('100%')
ForEach(CLOUD_LIST, (it: CloudItem) => {
Column({ space: 6 }) {
Row() {
Column()
.width(4)
.height(38)
.borderRadius(2)
.backgroundColor(cloudTagColor(it.name))
Column({ space: 4 }) {
Row() {
Text(it.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text(it.rarity)
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(rarityColor(it.rarity))
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(COLORS.bg)
.borderRadius(6)
}
.width('100%')
Row({ space: 8 }) {
Text(`🌤️ ${it.family}`)
.fontSize(11)
.fontColor(COLORS.textSecondary)
Text(`👀 目击 ${it.seen}`)
.fontSize(11)
.fontColor(COLORS.textSecondary)
}
.width('100%')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
Stack({ alignContent: Alignment.Start }) {
Row()
.width('100%')
.height(6)
.borderRadius(3)
.backgroundColor(COLORS.border)
Row()
.width(`${it.seen}%`)
.height(6)
.borderRadius(3)
.backgroundColor(cloudTagColor(it.name))
}
.width('100%')
Row({ space: 10 }) {
Text(`人气值 ${it.seen}`)
.fontSize(10)
.fontColor(COLORS.textHint)
.layoutWeight(1)
Text('收录进图鉴')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor(COLORS.primary)
.borderRadius(8)
}
.width('100%')
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}, (it: CloudItem) => it.id.toString())
}
.width('100%')
}
@Builder
pageChases() {
Column({ space: 10 }) {
Text('我的追云')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
ForEach(this.chaseList, (it: ChaseItem, idx: number) => {
Column({ space: 8 }) {
Row({ space: 10 }) {
Text('⛅')
.fontSize(24)
Column({ space: 4 }) {
Row() {
Text(it.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text(it.status)
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(statusColor(it.status))
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(COLORS.accentLight)
.borderRadius(6)
}
.width('100%')
Text(`🌈 ${it.item}`)
.fontSize(11)
.fontColor(COLORS.textSecondary)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
Stack({ alignContent: Alignment.Start }) {
Row()
.width('100%')
.height(6)
.borderRadius(3)
.backgroundColor(COLORS.border)
Row()
.width(`${statusProgress(it.status)}%`)
.height(6)
.borderRadius(3)
.backgroundColor(statusColor(it.status))
}
.width('100%')
Row() {
Text(`追云进度 ${statusProgress(it.status)}%`)
.fontSize(10)
.fontColor(COLORS.textHint)
.layoutWeight(1)
Text('编辑追云单')
.fontSize(11)
.fontColor(COLORS.primary)
.onClick(() => {
this.openEdit(idx);
})
}
.width('100%')
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}, (it: ChaseItem) => it.id.toString())
}
.width('100%')
}
@Builder
pageCamp() {
Column({ space: 10 }) {
Text('观云营地')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('今日日落')
.fontSize(10)
.fontColor(COLORS.textHint)
Text('18:42')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
}
.layoutWeight(1)
.padding(8)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('热门机位')
.fontSize(10)
.fontColor(COLORS.textHint)
Text('西郊 5 处')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
}
.layoutWeight(1)
.padding(8)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('通透度')
.fontSize(10)
.fontColor(COLORS.textHint)
Text('良好')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.warning)
}
.layoutWeight(1)
.padding(8)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('本周出团')
.fontSize(10)
.fontColor(COLORS.textHint)
Text('2 场')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.success)
}
.layoutWeight(1)
.padding(8)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.alignItems(HorizontalAlign.Center)
}
.width('100%')
ForEach(CLOUD_LIST.slice(0, 5), (it: CloudItem, idx: number) => {
Row({ space: 10 }) {
Text(`${idx + 1}`)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(idx < 3 ? COLORS.primary : COLORS.textHint)
.width(20)
Column({ space: 3 }) {
Text(it.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(`${it.family} · 稀有度 ${it.rarity}`)
.fontSize(10)
.fontColor(COLORS.textSecondary)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text(`目击 ${it.seen}`)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(cloudTagColor(it.name))
}
.width('100%')
.padding(10)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
}, (it: CloudItem) => it.id.toString())
Text('装备速购')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
.margin({ top: 4 })
ForEach(GEAR_LIST, (it: GearItem, idx: number) => {
if (idx % 2 === 0) {
Row({ space: 10 }) {
if (idx < GEAR_LIST.length) {
Column({ space: 6 }) {
Text(GEAR_LIST[idx].icon)
.fontSize(26)
Text(GEAR_LIST[idx].name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(GEAR_LIST[idx].desc)
.fontSize(11)
.fontColor(COLORS.textSecondary)
Text('加购')
.fontSize(11)
.fontColor(COLORS.primary)
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}
if (idx + 1 < GEAR_LIST.length) {
Column({ space: 6 }) {
Text(GEAR_LIST[idx + 1].icon)
.fontSize(26)
Text(GEAR_LIST[idx + 1].name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(GEAR_LIST[idx + 1].desc)
.fontSize(11)
.fontColor(COLORS.textSecondary)
Text('加购')
.fontSize(11)
.fontColor(COLORS.primary)
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}
}
.width('100%')
}
}, (it: GearItem) => it.id.toString())
}
.width('100%')
}
@Builder
pageCourse() {
Column({ space: 10 }) {
Text('气象课堂')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
ForEach(COURSE_LIST, (it: CourseItem) => {
Column({ space: 8 }) {
Row() {
Text(it.title)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text(it.week)
.fontSize(11)
.fontColor(COLORS.textHint)
}
.width('100%')
Stack({ alignContent: Alignment.Start }) {
Row()
.width('100%')
.height(8)
.borderRadius(4)
.backgroundColor(COLORS.border)
Row()
.width(`${it.progress}%`)
.height(8)
.borderRadius(4)
.backgroundColor(it.color)
}
.width('100%')
Row() {
Text('已学')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.layoutWeight(1)
Text(`${it.progress}%`)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
}
.width('100%')
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}, (it: CourseItem) => it.title)
Button()
.width('100%')
.height(42)
.backgroundColor(COLORS.danger)
.borderRadius(10)
.opacity(0.9)
.onClick(() => {
this.openDelB();
})
Text('退课')
.fontSize(14)
.fontColor(COLORS.white)
.margin({ left: -28, top: -32 })
}
.width('100%')
}
@Builder
pageCircle() {
Column({ space: 10 }) {
Row() {
Text('云友圈')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text('发云语')
.fontSize(12)
.fontColor(COLORS.primary)
.onClick(() => {
this.openAdd();
})
}
.width('100%')
ForEach(this.postList, (it: PostItem) => {
Row({ space: 10 }) {
Text(it.avatar)
.fontSize(26)
Column({ space: 4 }) {
Row() {
Text(it.nick)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text(it.time)
.fontSize(10)
.fontColor(COLORS.textHint)
}
.width('100%')
Text(it.text)
.fontSize(13)
.fontColor(COLORS.textSecondary)
Text(`☁️ ${it.likes} · 💬 回复`)
.fontSize(11)
.fontColor(COLORS.textHint)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}, (it: PostItem) => it.id.toString())
}
.width('100%')
}
@Builder
pageGear() {
Column({ space: 10 }) {
Text('装备库')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
Row({ space: 8 }) {
Column({ space: 4 }) {
Text('📷')
.fontSize(28)
Text('镜头区')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('广角 · 长焦')
.fontSize(10)
.fontColor(COLORS.textSecondary)
}
.layoutWeight(1)
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.alignItems(HorizontalAlign.Center)
Column({ space: 4 }) {
Text('🎯')
.fontSize(28)
Text('稳定区')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('三脚架 · 快门线')
.fontSize(10)
.fontColor(COLORS.textSecondary)
}
.layoutWeight(1)
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.alignItems(HorizontalAlign.Center)
Column({ space: 4 }) {
Text('📖')
.fontSize(28)
Text('知识区')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('图鉴 · 雷达')
.fontSize(10)
.fontColor(COLORS.textSecondary)
}
.layoutWeight(1)
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.alignItems(HorizontalAlign.Center)
}
.width('100%')
ForEach(GEAR_LIST, (it: GearItem) => {
Row({ space: 10 }) {
Text(it.icon)
.fontSize(20)
Column({ space: 3 }) {
Text(it.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(it.desc)
.fontSize(10)
.fontColor(COLORS.textSecondary)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text('加购')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(COLORS.primary)
.borderRadius(8)
}
.width('100%')
.padding(10)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
}, (it: GearItem) => it.id.toString())
Button()
.width('100%')
.height(42)
.backgroundColor(COLORS.danger)
.borderRadius(10)
.opacity(0.9)
.onClick(() => {
this.openDelA();
})
Text('取消追云')
.fontSize(14)
.fontColor(COLORS.white)
.margin({ left: -56, top: -32 })
}
.width('100%')
}
@Builder
pageMine() {
Column({ space: 12 }) {
Row({ space: 12 }) {
Text('☁️')
.fontSize(40)
Column({ space: 4 }) {
Text('云语观云人')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('虹彩会员 · 打卡 208 天 · 云种 42 · 追云 310km')
.fontSize(11)
.fontColor(COLORS.textSecondary)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
Column({ space: 10 }) {
Row() {
Text('我的追云档案')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text(`${this.chaseList.length} 单`)
.fontSize(12)
.fontColor(COLORS.primary)
}
.width('100%')
ForEach(this.chaseList.slice(0, 3), (it: ChaseItem) => {
Row() {
Text(it.name)
.fontSize(13)
.fontColor(COLORS.textSecondary)
.layoutWeight(1)
Text(it.status)
.fontSize(11)
.fontColor(statusColor(it.status))
}
.width('100%')
}, (it: ChaseItem) => it.id.toString())
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
Column({ space: 10 }) {
Row() {
Text('我的云语')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text(`${this.postList.length} 条`)
.fontSize(12)
.fontColor(COLORS.primary)
}
.width('100%')
ForEach(this.postList.slice(0, 3), (it: PostItem) => {
Row() {
Text(it.nick)
.fontSize(13)
.fontColor(COLORS.textSecondary)
.layoutWeight(1)
Text(it.time)
.fontSize(11)
.fontColor(COLORS.textHint)
}
.width('100%')
}, (it: PostItem) => it.id.toString())
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
}
.width('100%')
}
@Builder
modalOverlay() {
Stack() {
Column()
.width('100%')
.height('100%')
.backgroundColor('#000000')
.opacity(0.6)
.onClick(() => {
this.addOpen = false;
this.editOpen = false;
this.delOpen = false;
})
}
.width('100%')
.height('100%')
}
@Builder
addModalBody() {
Column({ space: 12 }) {
Text('发布云语')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
TextInput({ placeholder: '一句话观云日常…', text: this.addTitle })
.fontSize(13)
.fontColor(COLORS.textPrimary)
.backgroundColor(COLORS.bg)
.borderRadius(8)
.height(40)
.onChange((v: string) => {
this.addTitle = v;
})
TextArea({ placeholder: '补充细节:云种、时间、拍摄参数…', text: this.addContent })
.fontSize(13)
.fontColor(COLORS.textPrimary)
.backgroundColor(COLORS.bg)
.borderRadius(8)
.height(90)
.onChange((v: string) => {
this.addContent = v;
})
Row({ space: 10 }) {
Button()
.layoutWeight(1)
.height(38)
.backgroundColor(COLORS.bg)
.borderRadius(8)
.onClick(() => {
this.addOpen = false;
})
Text('取消')
.fontSize(13)
.fontColor(COLORS.textSecondary)
.margin({ left: -52 })
Button()
.layoutWeight(1)
.height(38)
.backgroundColor(COLORS.primary)
.borderRadius(8)
.onClick(() => {
this.doAdd();
})
Text('发布')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.margin({ left: -44 })
}
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.cardBg)
}
@Builder
editModalBody() {
Column({ space: 12 }) {
Text('编辑追云单')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
TextInput({ placeholder: '追云名称', text: this.editName })
.fontSize(13)
.fontColor(COLORS.textPrimary)
.backgroundColor(COLORS.bg)
.borderRadius(8)
.height(40)
.onChange((v: string) => {
this.editName = v;
})
TextInput({ placeholder: '状态(如 追云中)', text: this.editStyle })
.fontSize(13)
.fontColor(COLORS.textPrimary)
.backgroundColor(COLORS.bg)
.borderRadius(8)
.height(40)
.onChange((v: string) => {
this.editStyle = v;
})
TextArea({ placeholder: '追云内容与备注说明', text: this.editNote })
.fontSize(13)
.fontColor(COLORS.textPrimary)
.backgroundColor(COLORS.bg)
.borderRadius(8)
.height(70)
.onChange((v: string) => {
this.editNote = v;
})
Row({ space: 10 }) {
Button()
.layoutWeight(1)
.height(38)
.backgroundColor(COLORS.bg)
.borderRadius(8)
.onClick(() => {
this.editOpen = false;
})
Text('取消')
.fontSize(13)
.fontColor(COLORS.textSecondary)
.margin({ left: -52 })
Button()
.layoutWeight(1)
.height(38)
.backgroundColor(COLORS.primary)
.borderRadius(8)
.onClick(() => {
this.doEdit();
})
Text('保存')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.margin({ left: -44 })
}
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.cardBg)
}
@Builder
delModalBody() {
Column({ space: 12 }) {
Text(this.delTarget === 'chase' ? '取消追云预约' : '退出气象课堂')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(this.delTarget === 'chase' ? '将取消首条追云预约并归档照片,确认执行?' : '将退出首门进行中的课程,确认执行?')
.fontSize(13)
.fontColor(COLORS.textSecondary)
Row({ space: 10 }) {
Button()
.layoutWeight(1)
.height(38)
.backgroundColor(COLORS.bg)
.borderRadius(8)
.onClick(() => {
this.delOpen = false;
})
Text('取消')
.fontSize(13)
.fontColor(COLORS.textSecondary)
.margin({ left: -52 })
Button()
.layoutWeight(1)
.height(38)
.backgroundColor(COLORS.danger)
.borderRadius(8)
.onClick(() => {
this.doDel();
})
Text('确认')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.margin({ left: -44 })
}
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.cardBg)
}
@Builder
bottomBar() {
Row() {
ForEach(NAV_LIST, (it: NavItem, idx: number) => {
Column({ space: 2 }) {
Text(it.icon)
.fontSize(20)
.opacity(this.mainTab === idx ? 1 : 0.55)
Text(it.label)
.fontSize(10)
.fontWeight(this.mainTab === idx ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.mainTab === idx ? COLORS.primary : COLORS.textHint)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.onClick(() => {
this.mainTab = idx;
})
}, (it: NavItem) => it.label)
}
.width('100%')
.height(56)
.backgroundColor(COLORS.cardBg)
.borderRadius({ topLeft: 16, topRight: 16 })
}
@Builder
mainContent() {
Column() {
this.header()
if (this.mainTab === 0) {
this.subNav()
Scroll() {
Column() {
if (this.subTab === 0) {
this.pageFeatured()
} else if (this.subTab === 1) {
this.pageClouds()
} else if (this.subTab === 2) {
this.pageChases()
} else if (this.subTab === 3) {
this.pageCamp()
} else if (this.subTab === 4) {
this.pageCourse()
} else if (this.subTab === 5) {
this.pageCircle()
} else {
this.pageGear()
}
}
.width('100%')
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.layoutWeight(1)
.padding({ left: 14, right: 14, bottom: 20 })
} else if (this.mainTab === 1) {
Scroll() {
Column() {
this.pageClouds()
}
.width('100%')
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.layoutWeight(1)
.padding({ left: 14, right: 14, bottom: 20 })
} else if (this.mainTab === 2) {
Scroll() {
Column() {
this.pageCourse()
}
.width('100%')
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.layoutWeight(1)
.padding({ left: 14, right: 14, bottom: 20 })
} else {
Scroll() {
Column() {
this.pageMine()
}
.width('100%')
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.layoutWeight(1)
.padding({ left: 14, right: 14, bottom: 20 })
}
}
.width('100%')
.height('100%')
}
build() {
Stack() {
Column()
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
this.fxLayer()
Column() {
this.mainContent()
this.bottomBar()
}
.width('100%')
.height('100%')
if (this.addOpen) {
Stack() {
this.modalOverlay()
Column() {
this.addModalBody()
}
.width('88%')
.constraintSize({ maxHeight: '80%' })
.borderRadius(16)
.zIndex(999)
}
.width('100%')
.height('100%')
}
if (this.editOpen) {
Stack() {
this.modalOverlay()
Column() {
this.editModalBody()
}
.width('88%')
.constraintSize({ maxHeight: '80%' })
.borderRadius(16)
.zIndex(999)
}
.width('100%')
.height('100%')
}
if (this.delOpen) {
Stack() {
this.modalOverlay()
Column() {
this.delModalBody()
}
.width('88%')
.constraintSize({ maxHeight: '80%' })
.borderRadius(16)
.zIndex(999)
}
.width('100%')
.height('100%')
}
}
.width('100%')
.height('100%')
}
}
17.8 展望:未来的演进方向

在状态管理层面,可以引入更完善的状态管理方案,如 AppStorage、PersistentStorage 或第三方状态管理库。对于复杂的跨组件状态共享,单一组件内的 @State 变量可能不够用。在组件化层面,可以将一些通用的组件(如卡片、按钮、弹框)抽离为独立的自定义组件(@Component),在多个页面间复用。目前的 @Builder 方法虽然也能实现复用,但它们局限在同一个组件内部。在性能优化层面,可以引入懒加载、虚拟列表等技术,提升大数据量下列表的滚动性能。可以对图片资源进行优化,使用合适的格式和尺寸。
更多推荐

所有评论(0)