鸿蒙ArkTS鬼畜工坊应用深度解析:从数据模型到组件架构的全链路技术剖析
引言:鸿蒙生态下的声明式UI开发范式
在移动应用开发领域,鸿蒙操作系统(HarmonyOS)的出现为开发者带来了全新的技术视野和开发体验。作为鸿蒙生态的核心开发语言,ArkTS基于TypeScript进行了深度扩展,融合了声明式UI开发范式、状态管理机制、组件化架构等先进理念,构建了一套完整而高效的UI开发体系。与传统的命令式UI开发不同,声明式开发让开发者只需描述"界面应该是什么样子",而无需关心"如何一步步绘制界面",这种思维方式的转变极大地提升了开发效率和代码可维护性。
ArkTS语言的核心特性体现在多个维度。首先是声明式UI语法,开发者可以通过结构化的代码描述来定义界面层次,每一个组件都是一个可组合的UI单元。其次是装饰器机制,通过@Component、@Entry、@State、@Prop、@Builder等装饰器,开发者可以精确控制组件的角色、状态和生命周期。再次是状态驱动的渲染机制,当状态发生变化时,框架会自动计算差异并更新受影响的UI部分,这种响应式编程模型大大简化了数据与视图的同步逻辑。最后是组件化开发思想,整个应用由多个独立的组件构成,每个组件封装了自己的UI结构、状态逻辑和交互行为,组件之间通过属性传递和事件回调进行通信,形成了松耦合、高内聚的架构体系。
鸿蒙开发的核心理念:声明式UI、状态驱动、组件化架构、一次开发多端部署,这四大支柱共同构成了鸿蒙应用开发的方法论基础。理解这些理念,是掌握ArkTS开发的第一步。
在这样的技术背景下,我们今天要深入剖析的是一个典型的鸿蒙ArkTS应用——鬼畜工坊(REMIX LAB)。这是一个功能丰富的视频混剪社区应用,采用了荧光绿黑的赛博朋克视觉风格,包含七个主要功能标签页(素材库、工程、模板、榜单、活动、收藏、我的)以及八个不同类型的弹框组件。应用涵盖了数据模型定义、工具函数封装、主组件架构、子组件拆分、状态管理、动画效果、事件处理等多个技术层面,是一个非常适合深度学习的完整案例。
本文将从最基础的数据接口定义开始,逐层深入到工具函数、主组件结构、各个子组件的实现细节,再到弹框系统的设计模式,全方位地剖析这个应用的技术实现。在分析过程中,我们不仅会解释代码"做了什么",更会深入探讨"为什么这么做"、“这样做的技术优势是什么”,以及"在实际项目中如何应用这些模式"。希望通过本文的深度解析,读者能够全面掌握ArkTS开发的核心技术点,并将其灵活运用到自己的项目实践中。
一、数据模型层:接口定义与类型系统
1.1 MaterialT接口——素材数据模型
interface MaterialT {
id: number
title: string
cat: string
emoji: string
color: string
dur: number
uses: number
}
这是应用中定义的第一个数据接口,代表素材库中的每一条素材。让我们逐一分析每个字段的含义和设计考量。
id字段是素材的唯一标识符,类型为number。在任何数据集合中,唯一标识符都是不可或缺的,它用于数据的查找、更新、删除以及列表渲染时的key绑定。在ArkTS的ForEach循环渲染中,第三个参数(key生成函数)会用到这个id,以确保列表项的正确复用和高效更新。
title字段存储素材的标题文本,类型为string。标题是用户识别素材的主要信息,在UI上通常以较大的字号和较粗的字重展示。设计为字符串类型使得它可以承载任意长度的文本内容,当然在实际渲染时会通过maxLines和textOverflow属性来控制显示行数和溢出效果。
cat字段表示素材的分类,类型为string。分类是素材组织和筛选的重要依据,在素材库页面中,用户可以通过顶部的分类标签来筛选不同类别的素材。这里使用字符串而非枚举类型,体现了一定的灵活性,可以方便地新增分类而无需修改类型定义。
emoji字段存储素材的表情符号图标,类型为string。在移动应用中,使用emoji作为图标是一种轻量级且跨平台的方案,无需引入额外的图标资源文件,同时能够提供丰富的视觉表达。每个素材对应一个特色emoji,增强了视觉识别度。
color字段定义素材的主题色,类型为string,存储十六进制颜色值。主题色在UI中被广泛应用于边框、标签背景、按钮背景等位置,为不同类别的素材提供了视觉区分。这种设计方式使得每个素材都有自己的视觉标识,提升了界面的层次感和美观度。
dur字段表示素材的时长,单位为秒,类型为number。时长是视频素材的核心属性之一,在工程创建时会根据所选素材的数量和时长来预估工程总时长。将时长存储为数字而非格式化字符串,便于进行数学计算和逻辑判断。
uses字段记录素材被使用的次数,类型为number。这个数据具有社交属性和推荐价值,使用次数高的素材通常更受欢迎,在UI上会展示"被用XX次"的信息,为用户选择素材提供参考。
接口设计的基本原则:一个良好的数据接口应该具备单一职责、语义清晰、类型安全三大特征。MaterialT接口完美体现了这些原则,每个字段都有明确的含义,类型定义精确,为整个应用的数据流转奠定了坚实的类型基础。
1.2 ProjectT接口——工程数据模型
interface ProjectT {
id: number
name: string
prog: number
mats: number
dur: number
bgm: string
hot: number
}
ProjectT接口代表用户创建的鬼畜工程,是应用的核心数据模型之一。工程是用户进行视频编辑的基本单位,包含了工程名称、进度、素材数量、时长、背景音乐难度、热度等关键信息。
id字段同样是唯一标识符,用于工程的唯一标识和操作定位。在工程列表中,每个工程卡片都有独立的操作按钮(导出、改名、删除),需要通过id来确定操作的目标对象。
name字段存储工程名称,是用户识别工程的主要依据。工程名称由用户在新建工程时输入,支持自定义命名,同时系统也提供了默认名称"未命名工程"作为兜底。
prog字段表示工程的完成进度,取值范围为0到100,代表百分比。进度信息在UI上通过多种形式展示,包括环形进度条、横向进度条以及文字百分比,为用户提供直观的工程状态反馈。
mats字段记录工程中使用的素材数量,类型为number。素材数量是工程复杂度的一个指标,也影响着工程的预估时长。在新建工程时,系统会根据用户选择的素材数量自动计算这个值。
dur字段表示工程的总时长,单位为秒。总时长是素材时长与基础时长的组合,在新建工程时通过公式20 + this.newMats.length * 8动态计算,体现了数据驱动的动态计算模式。
bgm字段表示背景音乐的难度等级,取值为’EZ’、‘HD’、'EX’三个字符串之一。这个字段不仅影响工程的难度标识,还通过bgmColor函数映射为不同的主题颜色,在UI上为不同难度的工程提供视觉区分。
hot字段表示工程的热度值,类型为number。热度是一个社交属性指标,反映了工程的受欢迎程度,在工程卡片上以火焰emoji和格式化数字的形式展示。
1.3 TemplateT接口——模板数据模型
interface TemplateT {
id: number
name: string
desc: string
star: number
uses: number
emoji: string
}

TemplateT接口代表鬼畜模板,是用户可以一键套用的预设方案。模板功能降低了创作门槛,让新手用户也能快速产出高质量的鬼畜作品。
desc字段存储模板的详细描述,相比title提供了更多信息,帮助用户了解模板的特点和适用场景。在UI上,描述文字通常以较小的字号和较浅的颜色展示,最多显示两行,超出部分以省略号截断。
star字段表示模板的星级评分,类型为number,取值范围为1到5。星级是模板质量的直观体现,通过stars函数转换为对应的星星字符串,在模板卡片上以金色星星的形式展示。
与MaterialT类似,TemplateT也包含emoji和uses字段,分别用于视觉标识和使用次数统计。模板的使用次数反映了模板的受欢迎程度,为用户选择模板提供了数据参考。
1.4 RankT接口——榜单数据模型
interface RankT {
rank: number
name: string
ghost: number
plays: string
works: number
}
RankT接口代表鬼畜值排行榜中的每一条记录,是社区竞技属性的核心数据结构。排行榜能够激发用户的创作热情,提升用户粘性。
rank字段表示排名,类型为number。排名是榜单的核心信息,前三名(第1、2、3名)会有特殊的视觉处理,使用金、银、铜牌emoji代替数字排名,并且文字颜色和边框颜色也会有所不同,突出显示头部用户。
ghost字段表示鬼畜值,是用户在平台上的核心积分指标,类型为number。鬼畜值的高低决定了用户在排行榜上的位置,是用户创作能力和活跃度的综合体现。在UI上,鬼畜值通过横向条形图的形式可视化展示,条形的长度与鬼畜值成正比。
plays字段表示总播放量,类型为string。播放量使用字符串类型是因为它包含了单位(如"2860万"),这种格式化后的字符串更适合直接展示,无需在渲染时进行额外的格式转换。
works字段表示作品数量,类型为number。作品数量是用户创作产出的直接体现,与播放量、鬼畜值共同构成了用户的综合数据画像。
1.5 EventT接口——活动数据模型
interface EventT {
id: number
name: string
desc: string
prog: number
target: number
people: number
joined: boolean
}
EventT接口代表平台活动,是运营层面的重要功能模块。活动能够提升用户活跃度,促进内容创作,增强社区氛围。
prog和target字段共同构成了活动的进度体系。prog表示当前已完成的作品数量,target表示目标数量,两者的比值即为活动完成进度。在UI上,进度以横向进度条的形式展示,让用户直观了解活动的完成情况。
people字段表示参与活动的人数,类型为number。参与人数是活动热度的直接体现,人数越多说明活动越受欢迎,也能吸引更多用户参与,形成正向循环。
joined字段是一个布尔值,表示当前用户是否已加入该活动。这个状态直接影响UI的展示效果:已加入的活动按钮显示为"已加入"并填充主题色,未加入的则显示"加入"并使用描边样式。同时,已加入的活动边框颜色也会有所不同,提供额外的视觉反馈。
1.6 FolderT接口——收藏夹数据模型
interface FolderT {
id: number
name: string
count: number
emoji: string
color: string
}

FolderT接口代表用户的素材收藏夹,是个人内容管理的重要组成部分。收藏夹功能帮助用户组织和管理自己喜欢的素材,提升素材查找效率。
count字段表示收藏夹中的素材数量,类型为number。素材数量是收藏夹的基本属性,在收藏夹列表中以"收藏XX条素材"的形式展示,让用户快速了解每个收藏夹的内容规模。
与其他数据模型类似,FolderT也包含emoji和color字段,用于视觉标识和主题色定制。每个收藏夹可以有独特的emoji图标和主题颜色,增强了个性化和视觉辨识度。
类型系统的价值:在大型应用中,完善的类型系统是代码质量的重要保障。通过接口定义,我们为每个数据实体建立了清晰的契约,不仅提升了代码的可读性和可维护性,还能在编译阶段捕获类型错误,减少运行时bug的产生。ArkTS继承了TypeScript的类型系统,为鸿蒙应用开发提供了强大的类型安全保障。
二、数据层:常量数据与模拟数据源
2.1 素材数据数组
const MATERIALS: Array<MaterialT> = [
{ id: 1, title: '金坷垃喷雾', cat: '金坷垃', emoji: '🌾', color: '#FFD93D', dur: 8, uses: 12800 },
{ id: 2, title: '诸葛亮怒斥', cat: '诸葛', emoji: '📜', color: '#00FFCC', dur: 15, uses: 9600 },
// ... 更多素材
]
MATERIALS常量数组是素材库的模拟数据源,包含了12条不同类别的素材数据。每条数据都严格遵循MaterialT接口的定义,确保了类型一致性。
数据的组织方式体现了分类设计的思想,素材被划分为金坷垃、诸葛、电音、鬼畜、经典五个主要类别。每个类别下有多条具体素材,这种分类体系既符合鬼畜文化的实际内容生态,也为用户提供了清晰的内容导航。
在实际生产环境中,这些数据通常来自后端API接口,但在开发阶段使用静态常量数据具有多重优势。首先,它使得前端开发可以独立于后端进行,不受接口开发进度的影响。其次,静态数据加载速度快,无需网络请求,提升了开发调试效率。再次,固定的测试数据便于UI还原和一致性测试。最后,通过@State装饰器将常量数据赋值给状态变量,实现了静态数据到响应式数据的转换,为后续的动态操作(如新增、删除、筛选)提供了基础。
2.2 工程数据数组
const PROJECTS: Array<ProjectT> = [
{ id: 1, name: '坤坤打篮球 REMIX', prog: 62, mats: 8, dur: 87, bgm: 'EZ', hot: 3200 },
{ id: 2, name: '金坷垃速度翻倍', prog: 100, mats: 12, dur: 42, bgm: 'EX', hot: 5100 },
// ... 更多工程
]
PROJECTS数组存储了用户的工程列表数据,共8条记录。这些数据覆盖了不同完成进度、不同难度等级、不同热度的工程场景,为UI展示提供了丰富的测试用例。
值得注意的是,这些模拟数据的设计非常讲究,每个字段的值都经过了合理的设定。例如,进度值有高有低(22%到100%),背景音乐难度分布均衡(EZ、HD、EX各有若干),热度值与进度和难度大致正相关,这些细节使得模拟数据更加真实可信,也为各种边界条件的测试提供了覆盖。
2.3 其他常量数据
除了主要的业务数据数组外,代码中还定义了大量辅助性的常量数组,包括模板数据TEMPLATES、榜单数据RANKS、活动数据EVENTS、收藏夹数据FOLDERS、周产出数据WEEK_WORKS和WEEK_LABELS、标签页名称TABS、分类名称CATS、标签名称TAGS、素材选项MAT_PICKS、背景音乐难度BGMS、清晰度选项QUALS和对应的文件大小QSIZES、轨道块索引BLOCKS、头像颜色AVATAR_COLORS等。
这些常量数据共同构成了应用的完整数据层,为各个功能模块提供了数据支撑。它们的命名都采用了全大写加下划线的命名约定,这是常量命名的通用规范,便于在代码中快速识别常量引用。
数据驱动UI的思想:在声明式UI开发中,UI是数据的映射。有什么样的数据,就会渲染出什么样的界面。因此,数据层的设计质量直接影响着整个应用的架构质量。一个设计良好的数据层应该具备结构清晰、类型安全、易于扩展、便于测试等特点。
三、工具函数层:纯函数的复用智慧
3.1 数字格式化函数
function fmtNum(n: number): string {
if (n >= 10000) {
return (n / 10000).toFixed(1) + '万'
}
return n.toString()
}
fmtNum函数用于将大数字格式化为更易读的"万"单位表示。当数字大于等于10000时,将其转换为以"万"为单位的小数形式,保留一位小数;否则直接返回数字的字符串形式。
这个函数的应用场景非常广泛,在素材使用次数、工程热度值、金币数量、播放量等多个地方都有使用。将格式化逻辑抽离为独立函数,避免了在多处重复编写相同的格式化代码,体现了DRY(Don’t Repeat Yourself)原则。
函数的实现采用了简单的条件判断和数学计算。toFixed(1)方法确保了小数部分始终保留一位,即使是整数也会显示为"x.0"的形式,这种统一的格式保证了UI的一致性。需要注意的是,toFixed返回的是字符串类型,这正好符合我们的需求,因为最终的展示结果就是字符串。
从函数式编程的角度来看,fmtNum是一个纯函数——相同的输入永远产生相同的输出,没有任何副作用。纯函数具有可预测性、可测试性、可缓存性等诸多优点,是工具函数的理想形态。
3.2 补零函数
function pad2(n: number): string {
if (n < 10) {
return '0' + n.toString()
}
return n.toString()
}
pad2函数用于将数字格式化为两位数,不足两位时在前面补零。这是一个非常经典的工具函数,在时间格式化场景中尤其常用。
函数的实现逻辑很简单:如果数字小于10,就在前面拼接一个’0’;否则直接返回数字的字符串形式。虽然逻辑简单,但将其封装为独立函数仍然很有价值,因为它提高了代码的语义化程度——调用pad2(s)比写一个if判断更能清晰地表达"将秒数格式化为两位"的意图。
3.3 秒数格式化函数
function secFmt(s: number): string {
return pad2(Math.floor(s / 60)) + '分' + pad2(s % 60) + '秒'
}
secFmt函数将秒数格式化为"XX分XX秒"的中文时间表示。它是pad2函数的上层调用者,通过数学运算将总秒数拆分为分钟和秒两部分,然后分别格式化后拼接成最终的字符串。
Math.floor(s / 60)计算分钟数,使用向下取整确保分钟数是整数。s % 60计算剩余的秒数,取模运算得到的是0到59之间的整数。两部分都通过pad2函数进行两位数格式化,确保了时间显示的对齐和美观。
这个函数在应用中被广泛使用,包括素材时长显示、工程时长显示、上传素材时长显示等场景。统一的时间格式提升了用户体验的一致性。
3.4 星级生成函数
function stars(n: number): string {
let s: string = ''
for (let i = 0; i < n; i++) {
s += '★'
}
return s
}
stars函数根据输入的数字生成对应数量的星星字符串。这是一个典型的循环字符串拼接函数,通过for循环将星星字符累加起来。
虽然逻辑简单,但这个函数体现了一个重要的设计思想:将数据转换逻辑与UI渲染分离。星级数据本质上是一个数字(n颗星),但在UI上需要展示为n个星星符号。通过stars函数,我们将数字到星星字符串的转换逻辑封装起来,调用方只需关心"我需要显示几颗星",而无需关心星星是如何生成的。
在应用中,星级显示出现在模板卡片、鬼畜详情弹框、个人中心等多个位置。通过统一的函数生成星星,确保了所有位置的星级显示方式一致,也便于后续修改星星的样式或字符。
3.5 分类颜色映射函数
function catColor(c: string): string {
if (c === '金坷垃') {
return '#FFD93D'
}
if (c === '诸葛') {
return '#00FFCC'
}
if (c === '电音') {
return '#7CFF00'
}
if (c === '鬼畜') {
return '#FF7A45'
}
return '#4DE1C1'
}
catColor函数根据分类名称返回对应的主题色值。这是一个典型的映射函数,建立了分类名称到颜色值的映射关系。
函数采用了多个if条件判断的实现方式,依次检查分类名称并返回对应的颜色。如果都不匹配,则返回默认颜色’#4DE1C1’(经典类的青色)。这种实现方式简单直接,适合分类数量不多的场景。
颜色映射在UI设计中扮演着重要角色。不同的分类使用不同的主题色,不仅增强了视觉美感,更重要的是提供了快速的视觉识别——用户只需扫一眼颜色就能大致判断素材属于哪个类别,提升了信息获取效率。
工具函数的设计哲学:优秀的工具函数应该具备三个特质——单一职责、纯函数、语义化命名。单一职责确保函数只做一件事,便于理解和测试;纯函数保证了可预测性和可复用性;语义化命名让函数名本身就是最好的文档。本文中的这些工具函数都很好地实践了这些设计原则。
3.6 BGM颜色映射函数
function bgmColor(b: string): string {
if (b === 'EZ') {
return '#7CFF00'
}
if (b === 'HD') {
return '#00FFCC'
}
return '#FFD93D'
}

bgmColor函数根据背景音乐难度等级返回对应的颜色。EZ(简单)对应荧光绿色,HD(标准)对应青蓝色,EX(地狱)对应金黄色。颜色的选择与难度等级的感知相匹配,绿色代表简单安全,蓝色代表标准中等,金色代表高难度高奖励。
这个函数在工程页面中被广泛使用,包括工程卡片的环形进度条颜色、BGM标签背景色、时间轴轨道块颜色等。通过统一的颜色映射,确保了同一难度等级在不同UI元素上的颜色表现一致。
3.7 轨道块颜色函数
function blockColor(i: number): string {
if (i % 3 === 0) {
return '#7CFF00'
}
if (i % 3 === 1) {
return '#00FFCC'
}
return '#FFD93D'
}

blockColor函数根据轨道块的索引返回颜色,实现了三种颜色的循环交替效果。索引对3取模的结果决定了颜色:0对应绿色,1对应青色,2对应金色。
在时间轴轨道的可视化中,不同颜色的轨道块交替排列,形成了丰富的视觉节奏感。这种交替配色方案不仅美观,还有助于区分不同的轨道片段,提升了时间轴的可读性。
四、主组件架构:Index组件深度解析
4.1 组件声明与状态定义
@Entry
@Component
struct Index {
@State currentTab: number = 0
@State coins: number = 6680
@State nickname: string = '雷军刀法'
@State sign: string = '鬼畜是一种态度'
@State avatarIdx: number = 0
Index是应用的入口组件,通过@Entry装饰器标记为页面入口,通过@Component装饰器标记为组件。struct关键字定义了组件的结构体,这是ArkTS中声明组件的标准语法。
@State装饰器是ArkTS状态管理的核心。被@State装饰的变量是组件的内部状态,当状态值发生变化时,框架会自动触发UI的重新渲染,更新所有依赖该状态的视图部分。这种响应式的状态管理机制大大简化了数据与视图的同步逻辑。
让我们逐一分析这些状态变量的作用。
currentTab记录当前选中的标签页索引,初始值为0(素材库)。这是整个应用导航系统的核心状态,决定了主内容区域显示哪个Tab页面。当用户点击底部Tab栏的不同标签时,这个状态会更新,进而触发内容区域的切换。
coins存储用户的金币余额,初始值为6680。金币是平台的虚拟货币,在多种场景中会发生变化:新建工程奖励20金币,上传素材奖励10金币,导出成品根据清晰度奖励不同数量的金币。金币数量显示在顶部导航栏的右侧,是用户资产的重要组成部分。
nickname存储用户昵称,初始值为"雷军刀法"。昵称显示在个人中心页面和顶部导航栏的头像中,是用户身份的基本标识。
sign存储用户的个性签名,初始值为"鬼畜是一种态度"。个性签名展示在个人中心的资料卡中,体现用户的个性表达。
avatarIdx存储头像颜色的索引,初始值为0。头像使用纯色背景加文字的形式,颜色从AVATAR_COLORS数组中根据索引选取。用户可以在编辑资料弹框中更换头像颜色。
@State装饰器的工作原理:@State装饰的变量会被ArkUI框架监听,当变量被赋值时,框架会检测到变化并标记所有使用了该变量的UI节点为"脏",然后在下一帧重新渲染这些节点。需要注意的是,只有对变量的整体赋值才会触发更新,如果是数组或对象的内部变化,需要重新赋值整个对象或数组才能触发响应式更新。
4.2 数据状态变量
@State materials: Array<MaterialT> = MATERIALS
@State projects: Array<ProjectT> = PROJECTS
@State folders: Array<FolderT> = FOLDERS
@State events: Array<EventT> = EVENTS
这四个状态变量分别存储素材列表、工程列表、收藏夹列表和活动列表的数据,初始值来自前面定义的常量数组。
将常量数据赋值给@State状态变量是一个重要的设计决策。这样做的好处是,虽然初始数据来自静态常量,但后续可以通过用户操作动态修改这些数据(如新增工程、删除收藏夹、加入活动等),而不会影响原始常量数据。这体现了数据的"可变副本"模式——常量是不可变的数据源,状态变量是可变的工作副本。
需要特别注意的是,在ArkTS中,数组的响应式更新需要重新赋值整个数组。例如,要新增一个工程,不能直接调用this.projects.push(item),而应该使用this.projects = [...this.projects, item]的方式,创建一个新数组并赋值给状态变量。这是因为@State监听的是变量的引用变化,而不是数组内部的变化。代码中所有对数组的修改操作都遵循了这一模式,体现了对ArkTS响应式机制的深刻理解。
4.3 弹框状态变量
// 弹框状态
@State showNew: boolean = false
@State showExport: boolean = false
@State showDelete: boolean = false
@State showRename: boolean = false
@State showUpload: boolean = false
@State showGhost: boolean = false
@State showFolder: boolean = false
@State showProfile: boolean = false
这八个布尔状态变量分别控制八个弹框的显示与隐藏。每个弹框对应一个状态变量,初始值都为false(不显示)。当需要显示某个弹框时,将对应的状态设为true;关闭弹框时设为false。
这种"一个弹框对应一个状态变量"的设计模式是弹框管理的经典实现方式。它的优点是逻辑简单直观,每个弹框的显示状态独立控制,互不影响。缺点是当弹框数量较多时,状态变量会比较多,但在本应用的八个弹框规模下,这种模式是完全可接受的。
在build方法中,每个弹框的渲染都通过if (this.showXxx) { this.xxxOverlay() }的条件渲染来控制。当状态为true时,弹框组件被渲染到页面上;状态为false时,弹框组件不被渲染。这种条件渲染的方式确保了未显示的弹框不会占用渲染资源,提升了性能。
4.4 弹框数据状态变量
// 新建工程
@State newName: string = ''
@State newMats: Array<number> = []
@State newBgm: number = 1
// 导出
@State exportQ: number = 1
@State exportWm: boolean = true
// 删除(工程 / 收藏夹 / 账号 三种目标)
@State delTarget: string = 'proj'
@State delProject: ProjectT = PROJECTS[0]
@State delFolder: FolderT = FOLDERS[0]
除了弹框的显示状态外,每个弹框还需要维护自己的表单数据。这些状态变量存储了弹框中的用户输入和选择结果。
新建工程弹框包含三个状态:newName存储工程名称输入框的内容,newMats存储已选中的素材索引数组(支持多选),newBgm存储选中的BGM难度索引。这些状态在弹框打开时被重置(清空名称、清空素材选择、BGM设为默认值1),在弹框关闭时保留最后一次的值,但下次打开时会被重新重置。
导出弹框包含两个状态:exportQ存储选中的清晰度索引,exportWm存储是否添加水印的开关状态。清晰度索引对应QUALS数组中的选项,从480P到4K共四个等级。水印开关是一个布尔值,通过Toggle组件进行切换。
删除弹框是一个多用途弹框,可以用于删除工程、删除收藏夹或注销账号。delTarget字段标识当前删除的目标类型,delProject和delFolder分别存储待删除的工程和收藏夹对象。这种设计模式提高了代码复用性——同一个删除弹框组件可以服务于三种不同的删除场景,只需根据目标类型显示不同的内容和执行不同的删除逻辑。
弹框数据的生命周期管理:弹框状态数据的生命周期与弹框的显示状态紧密相关。一个良好的实践是在弹框打开时初始化数据(重置表单、设置默认值),在弹框关闭时不主动清理(保留用户的输入痕迹)。这样既保证了每次打开弹框时的初始状态正确,又避免了用户意外关闭弹框后输入内容全部丢失的问题。
4.5 更多弹框数据状态
// 重命名工程
@State renameProject: ProjectT = PROJECTS[0]
@State renameText: string = ''
// 上传素材
@State uploadTitle: string = ''
@State uploadTags: Array<number> = [0]
@State uploadDur: number = 12
// 鬼畜详情
@State ghostRank: RankT = RANKS[0]
```
重命名弹框的状态包括待重命名的工程对象`renameProject`和新名称输入`renameText`。在打开重命名弹框时,会将当前工程的名称赋值给renameText,作为输入框的初始值,用户可以在此基础上修改。
上传素材弹框的状态包括素材标题`uploadTitle`、选中的标签索引数组`uploadTags`、素材时长`uploadDur`。标签支持多选,时长通过Slider滑块组件进行调节,范围从3秒到30秒。
鬼畜详情弹框的状态只有一个`ghostRank`,存储当前查看的榜单用户数据。当用户点击榜单中的某一项时,将对应的RankT对象赋值给这个状态,然后打开详情弹框展示详细信息。
### 4.6 收藏夹与资料编辑状态
```typescript
// 收藏夹新增 / 编辑
@State folderIsNew: boolean = false
@State folderEdit: FolderT = FOLDERS[0]
@State folderName: string = ''
// 编辑资料
@State editName: string = ''
@State editSign: string = ''
@State editAvatar: number = 0
收藏夹弹框也是一个多用途弹框,既可以用于新建收藏夹,也可以用于编辑已有收藏夹。folderIsNew布尔值标识当前是新建模式还是编辑模式,不同模式下弹框的标题、按钮文字、保存逻辑都有所不同。folderEdit存储当前编辑的收藏夹对象(编辑模式下使用),folderName存储输入的收藏夹名称。
编辑资料弹框的状态包括昵称editName、个性签名editSign、头像索引editAvatar。打开弹框时,会将当前用户的资料数据赋值给这些状态作为初始值,用户修改后点击保存即可更新个人资料。
4.7 动画状态与生命周期
// 特效:鬼畜值脉冲
@State ghostOp: number = 1
aboutToAppear(): void {
this.getUIContext().animateTo({ duration: 900, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut }, () => {
this.ghostOp = 0.4
})
}
ghostOp是一个特殊的状态变量,用于实现"鬼畜值脉冲"动画效果。它存储的是不透明度值,初始值为1(完全不透明)。
aboutToAppear是组件的生命周期函数,在组件即将出现时被调用。在这个函数中,通过animateTo方法启动了一个无限循环的透明度动画。让我们详细分析这个动画的参数。
duration: 900表示动画单次时长为900毫秒。iterations: -1表示动画无限循环播放,这是脉冲效果的关键——动画会一直持续下去,直到组件被销毁。playMode: PlayMode.Alternate表示动画交替播放,即先正向播放(从1变到0.4),再反向播放(从0.4变到1),如此循环往复,形成呼吸般的脉冲效果。curve: Curve.EaseInOut表示使用缓入缓出的动画曲线,让动画开始和结束时更平缓,中间过程更快,产生自然流畅的视觉效果。
动画的目标状态是this.ghostOp = 0.4,即不透明度变为0.4。结合交替播放模式,实际效果是不透明度在1和0.4之间来回变化,形成闪烁脉冲的视觉效果。
这个动画效果被应用在多个地方:活动列表的"火热进行中"标签、活动进度条顶部的装饰线、鬼畜详情中的鬼畜值数字等。通过一个全局的动画状态变量来驱动多处UI的脉冲效果,确保了动画的同步性和一致性。
ArkTS动画机制详解:ArkUI框架提供了丰富的动画能力,包括属性动画、显式动画、转场动画等。
animateTo是显式动画的核心API,它接受两个参数:动画配置对象和动画闭包。动画闭包中修改的状态变量会被框架自动捕获,并以动画的方式过渡到新值。这种声明式的动画API大大简化了动画开发,开发者只需关心"要变成什么样",而无需关心"如何一步步变化"。
五、@Builder装饰器:UI片段的复用艺术
5.1 顶部导航栏headerBar
@Builder
headerBar() {
Row() {
Column() {
Text('鬼畜工坊')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#7CFF00')
Text('REMIX LAB')
.fontSize(9)
.fontColor('#00FFCC')
.fontWeight(FontWeight.Bold)
.letterSpacing(2)
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 14 })

@Builder装饰器是ArkTS中用于定义可复用UI片段的装饰器。被@Builder装饰的方法可以返回一段UI结构,在build方法或其他@Builder方法中通过this.xxx()的方式调用。@Builder的主要作用是将复杂的UI结构拆分为多个可复用的片段,提高代码的可读性和可维护性。
headerBar方法构建了应用的顶部导航栏。整个导航栏是一个横向排列的Row组件,包含四个部分:左侧的Logo区域、中间的搜索框、右侧的金币显示和用户头像。
左侧Logo区域是一个Column组件,垂直排列两行文字。第一行"鬼畜工坊"使用较大的字号(20px)、粗体和荧光绿色,是应用的主标题。第二行"REMIX LAB"使用很小的字号(9px)、粗体、青蓝色,并设置了2px的字间距,是应用的英文副标题。这种主副标题的组合既传达了品牌信息,又增强了设计感。Column通过alignItems(HorizontalAlign.Start)设置子元素左对齐,并添加了左侧14px的外边距。
@Builder装饰器的价值:@Builder是ArkTS组件化开发的重要工具。它允许开发者将一个大的UI结构拆分为多个语义化的UI片段,每个片段有自己的职责和名称。这种拆分使得代码结构更加清晰,阅读代码时可以通过方法名快速了解每个部分的功能,而无需阅读具体的UI实现细节。同时,@Builder方法可以在多处调用,实现UI的复用。
接下来我们继续分析搜索框部分的代码。
Row() {
Text('🔍')
.fontSize(13)
Text('搜索素材 / 模板 / UP 主')
.fontSize(11)
.fontColor('#6B8A6B')
.margin({ left: 6 })
}
.layoutWeight(1)
.height(32)
.borderRadius(16)
.backgroundColor('#12200F')
.margin({ left: 14, right: 10 })
.justifyContent(FlexAlign.Center)
搜索框是一个Row组件,内部包含一个放大镜emoji和一段提示文字。它的样式设计非常典型:高度32px,圆角16px(即高度的一半,形成胶囊形状),深绿色背景,内部内容居中对齐。
layoutWeight(1)是一个非常重要的属性,它让搜索框占据Row中剩余的所有空间。在Row布局中,设置了layoutWeight的子组件会按照权重比例分配剩余空间。这里搜索框的权重为1,而其他子组件没有设置权重(默认为0),因此搜索框会占据除了其他组件固有宽度之外的全部空间。这种布局模式在移动端导航栏中非常常见——左侧和右侧的元素宽度固定,中间的搜索栏自适应填充剩余空间。
搜索框内部的文字颜色使用了’#6B8A6B’,这是一种较暗的灰绿色,在深绿色背景上有足够的对比度但又不会过于显眼,符合占位提示文字的视觉层级。
Row() {
Text('🪙')
.fontSize(12)
Text(fmtNum(this.coins))
.fontSize(11)
.fontColor('#FFD93D')
.fontWeight(FontWeight.Bold)
.margin({ left: 3 })
}
.padding({ left: 9, right: 9, top: 5, bottom: 5 })
.borderRadius(12)
.backgroundColor('#1E2E14')
.margin({ right: 8 })
金币显示区域是一个Row组件,包含金币emoji和格式化后的金币数字。它的样式设计为胶囊形按钮:左右内边距9px,上下内边距5px,圆角12px,深橄榄绿色背景。
金币数字使用金黄色(‘#FFD93D’)和粗体,突出显示金币的价值感。数字通过fmtNum函数格式化,当金币数量超过1万时会以"万"为单位显示。
这里值得注意的是状态绑定。this.coins是一个@State状态变量,当金币数量发生变化时(如新建工程、上传素材、导出成品等操作),这里显示的数字会自动更新,无需手动操作DOM。这就是声明式UI和状态管理的强大之处。
Text('鬼畜')
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor('#0E1A0E')
.width(30)
.height(30)
.textAlign(TextAlign.Center)
.borderRadius(15)
.backgroundColor(AVATAR_COLORS[this.avatarIdx])
}
.width('100%')
.height(54)
.alignItems(VerticalAlign.Center)
.padding({ left: 4, right: 12 })
}

最右侧是用户头像,使用一个圆形的Text组件实现。头像的背景色从AVATAR_COLORS数组中根据avatarIdx索引选取,文字颜色使用深暗色以保证在亮色背景上的可读性。头像尺寸为30x30px,圆角15px形成完美的圆形。
整个headerBar的外层Row设置了宽度100%、高度54px、垂直居中对齐,以及左右内边距。这是一个标准的移动端导航栏尺寸和布局。
5.2 Tab标签项tabItem
@Builder
tabItem(title: string, idx: number) {
Column() {
Text(title)
.fontSize(this.currentTab === idx ? 13 : 12)
.fontWeight(this.currentTab === idx ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.currentTab === idx ? '#7CFF00' : '#6B8A6B')
if (this.currentTab === idx) {
Column()
.width(16)
.height(3)
.borderRadius(2)
.backgroundColor('#7CFF00')
.margin({ top: 4 })
}
}
.padding({ left: 12, right: 12, top: 8, bottom: 4 })
.onClick(() => {
this.currentTab = idx
})
}
tabItem方法构建了底部Tab栏中的单个标签项。它接受两个参数:title是标签的文字标题,idx是标签的索引。
这个方法充分体现了声明式UI的核心思想——UI是状态的函数。标签项的外观完全由currentTab === idx这个条件决定:
- 选中状态:字号13px、粗体、荧光绿色、底部有指示条
- 未选中状态:字号12px、常规字重、灰绿色、底部无指示条
通过三元表达式,我们可以在属性设置中直接根据状态条件返回不同的值,这种写法简洁而表达力强。
底部的指示条通过条件渲染实现。if (this.currentTab === idx)判断当前标签是否被选中,如果选中则渲染一个16x3px的圆角矩形作为指示条。指示条使用与文字相同的荧光绿色,形成视觉呼应。
整个标签项的点击事件通过onClick方法绑定,点击时将currentTab设置为当前标签的索引。由于currentTab是@State状态变量,修改后所有依赖它的UI都会自动更新——被点击的标签变为选中样式,之前选中的标签变为未选中样式,主内容区域也会切换到对应的Tab页面。
条件渲染的性能考量:在ArkTS中,if条件渲染会完全添加或移除组件树中的节点。对于简单的指示条这样的小型UI元素,条件渲染的开销可以忽略不计。但对于大型的复杂组件,频繁的条件切换可能会有一定的性能开销。在本应用中,弹框的显示隐藏也使用了条件渲染模式,这是合理的,因为弹框在大部分时间都是隐藏的,不渲染可以节省资源。
5.3 Tab栏tabBar
@Builder
tabBar() {
Column() {
Column()
.width('100%')
.height(1)
.backgroundColor('#1E3324')
Scroll() {
Row() {
ForEach(TABS, (t: string, i: number) => {
this.tabItem(t, i)
}, (t: string) => t)
}
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
}
.width('100%')
.backgroundColor('#0C140C')
}
tabBar方法构建了整个底部Tab栏。它的结构是一个Column组件,顶部有一条1px高的分隔线,下面是可横向滚动的Tab标签列表。
分隔线是一个简单的Column组件(也可以用Row或其他容器,这里只是作为视觉元素),宽度100%、高度1px、深绿色背景。这条细线在视觉上将Tab栏与主内容区分隔开,提升了界面的层次感。
Tab标签列表被包裹在一个Scroll组件中,设置为横向滚动模式。scrollable(ScrollDirection.Horizontal)指定滚动方向为水平方向,scrollBar(BarState.Off)隐藏滚动条,让界面更简洁。
Scroll内部是一个Row组件,通过ForEach循环遍历TABS数组生成所有标签项。ForEach是ArkTS中的列表渲染组件,它接受三个参数:数据源数组、项目生成函数、key生成函数。
- 第一个参数
TABS是数据源,包含7个标签名称 - 第二个参数
(t: string, i: number) => { this.tabItem(t, i) }是项目生成函数,对每个数据项调用tabItem方法生成对应的标签UI - 第三个参数
(t: string) => t是key生成函数,使用标签名称作为唯一标识
ForEach的key生成函数是一个非常重要的参数,它直接影响列表渲染的性能和正确性。框架通过key来识别每个列表项,当数据发生变化时,框架会根据key的变化来决定是新增、删除还是更新列表项。一个好的key应该是唯一且稳定的——每个列表项有不同的key,且同一个列表项的key不会随其他因素变化。
ForEach vs 普通循环:在ArkTS中,渲染列表应该始终使用ForEach组件,而不是在build方法中使用JavaScript的for循环或map方法手动生成组件数组。ForEach组件内部做了大量的性能优化,包括虚拟滚动、diff算法、组件复用等。而手动生成的组件数组无法享受到这些优化,在数据量大时性能差距会非常明显。
六、活动页面eventList深度解析
6.1 活动页面整体结构
@Builder
eventList() {
Scroll() {
Column() {
// 标题区域
// 装饰线
// 活动列表
}
.padding({ left: 14, right: 14, bottom: 20 })
}
.scrollBar(BarState.Off)
.layoutWeight(1)
}
eventList方法构建了活动Tab页的内容。整体结构是一个Scroll组件包裹一个Column组件,内容可以纵向滚动。Scroll隐藏了滚动条,设置了layoutWeight(1)以占据父容器中剩余的所有空间。
Column组件设置了左右各14px的内边距和底部20px的内边距,为内容提供呼吸空间。这种边缘留白的设计在移动端UI中非常重要,它避免了内容紧贴屏幕边缘,提升了阅读舒适度。
6.2 活动标题区域
Row() {
Text('鬼畜活动')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#E6FFE6')
Text('🔥 全站火热进行中')
.fontSize(10)
.fontColor('#FF7A45')
.opacity(this.ghostOp)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.borderRadius(10)
.backgroundColor('#2A1A0E')
.margin({ left: 10 })
}
.width('100%')
.margin({ top: 14, bottom: 12 })
标题区域是一个Row组件,左侧是页面主标题"鬼畜活动",右侧是一个"火热进行中"的状态标签。
主标题使用18px字号、粗体、浅绿白色。18px是页面标题的标准字号,在移动端既醒目又不会过于突兀。
状态标签的设计非常有特色:橙色文字、深棕色背景、圆角胶囊形状,前面还有一个火焰emoji。最特别的是.opacity(this.ghostOp)——它的不透明度绑定了全局的脉冲动画状态变量。因此,这个标签会产生呼吸般的闪烁效果,吸引用户的注意力,非常贴切地表达了"火热进行中"的状态。
这种将动画状态与UI属性直接绑定的方式是声明式动画的典型用法。开发者不需要手动管理动画帧,只需要将属性绑定到一个会变化的状态变量上,然后通过animateTo来驱动状态变化即可。
6.3 装饰线设计
Column() {
Column()
.width('100%')
.height(3)
.backgroundColor('#7CFF00')
.opacity(this.ghostOp)
Row() {
Text('◀')
.fontSize(10)
.fontColor('#00FFCC')
Column()
.layoutWeight(1)
.height(1)
.height(1)
.backgroundColor('#2A4232')
.margin({ left: 6, right: 6 })
Text('▶')
.fontSize(10)
.fontColor('#00FFCC')
}
.width('100%')
.margin({ top: 6 })
}
.width('100%')
.margin({ bottom: 14 })
这是一段非常有设计感的装饰线代码。它由两部分组成:上方是一条3px高的荧光绿粗线,下方是带有左右箭头的细线。
上方的粗线同样绑定了this.ghostOp透明度,会产生脉冲动画效果。这条粗线在视觉上起到了强调和分隔的作用,3px的厚度比普通分隔线更有存在感。
下方的细线设计更加精巧:左右各有一个青蓝色的箭头符号(◀和▶),中间是一条细线。中间的细线通过layoutWeight(1)占据剩余空间,左右箭头固定宽度。这种设计带有科技感和"传送"的隐喻,与鬼畜工坊的赛博朋克风格非常契合。
整段装饰线虽然没有实际的交互功能,但在视觉设计上起到了重要作用,提升了界面的精致感和品牌调性。这也是优秀UI设计的体现——不仅功能元素要做好,装饰元素同样需要精心设计。
6.4 活动列表项结构
ForEach(this.events, (e: EventT, i: number) => {
Column() {
Row() {
// 进度数字
// 活动信息
// 加入按钮
}
.width('100%')
.alignItems(VerticalAlign.Center)
Row() {
// 参与人数
// 进行中标识
}
.width('100%')
.margin({ top: 8 })
}
.padding(14)
.borderRadius(16)
.backgroundColor('#16241A')
.border({ width: 1, color: e.joined ? '#3A5A2A' : '#2A4232' })
.margin({ bottom: 12 })
}, (e: EventT) => (e.id + (e.joined ? 'Y' : 'N')).toString())
活动列表通过ForEach渲染,每个活动是一个Column卡片。卡片的样式会根据用户是否已加入而有所不同:已加入的边框颜色更亮更绿(‘#3A5A2A’),未加入的则是较暗的绿色(‘#2A4232’)。这种视觉反馈让用户可以快速识别自己参与的活动。
值得特别关注的是ForEach的key生成函数:(e: EventT) => (e.id + (e.joined ? 'Y' : 'N')).toString()。它不仅使用了活动id,还拼接了加入状态(Y或N)。这意味着当用户点击"加入/取消"按钮时,由于joined状态发生了变化,key也会变化,框架会认为这是一个全新的列表项并重新渲染,从而确保所有UI状态都正确更新。
这种在key中包含影响UI的状态字段的技巧,在某些场景下非常有用。虽然它会牺牲一些性能(每次状态变化都会重新创建组件而不是复用),但对于确保UI的正确性和一致性是有价值的,尤其是在列表项内部有复杂的状态依赖时。
6.5 活动进度显示
Column() {
Text((e.prog >= e.target ? '✓' : e.prog.toString()))
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(e.prog >= e.target ? '#0E1A0E' : '#00FFCC')
Text('/ ' + e.target)
.fontSize(8)
.fontColor('#6B8A6B')
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Center)
.width(52)
.padding({ top: 6, bottom: 6 })
.borderRadius(12)
.backgroundColor(e.prog >= e.target ? '#7CFF00' : '#0C1A0E')
每个活动卡片的左侧是一个进度显示区域,宽度固定为52px。它展示了活动的完成进度(已完成数/目标数)。
这里有一个巧妙的条件设计:当进度达到或超过目标时(e.prog >= e.target),数字会变成对勾符号(✓),文字颜色变成深暗色,背景变成荧光绿色。这种设计直观地传达了"已完成"的状态,比单纯显示数字更有视觉冲击力。
未完成状态下,数字使用青蓝色,背景使用深绿色,形成了清晰的状态区分。下面还有一行"/ 目标数"的小字,使用灰绿色,进一步明确了进度的含义。
6.6 活动信息区域
Column() {
Text(e.name)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#E6FFE6')
Text(e.desc)
.fontSize(10)
.fontColor('#6B8A6B')
.margin({ top: 4 })
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row() {
Column() {
Column()
.width((e.prog / e.target * 100 > 100 ? 100 : e.prog / e.target * 100).toFixed(0) + '%')
.height(5)
.borderRadius(3)
.backgroundColor(e.joined ? '#7CFF00' : '#00FFCC')
}
.layoutWeight(1)
.height(5)
.borderRadius(3)
.backgroundColor('#1E3324')
Text(e.prog + ' / ' + e.target + ' 件')
.fontSize(9)
.fontColor('#6B8A6B')
.margin({ left: 8 })
}
.width('100%')
.margin({ top: 8 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.margin({ left: 12 })
活动信息区域占据了卡片的主要空间(通过layoutWeight(1)实现),包含活动名称、活动描述和进度条三部分。
活动名称使用15px粗体字,是卡片中最醒目的文字信息。活动描述使用较小的字号和较浅的颜色,作为辅助信息。描述文字设置了maxLines(1)和textOverflow({ overflow: TextOverflow.Ellipsis }),确保即使描述文字很长也只会显示一行,超出部分以省略号截断,保持卡片布局的整洁。
进度条是活动信息中最复杂的部分。它由一个底层的灰色轨道和上层的彩色进度条组成。底层轨道高度5px,圆角3px,深绿色背景,通过layoutWeight(1)占据横向剩余空间。
上层进度条的宽度通过百分比设置:(e.prog / e.target * 100 > 100 ? 100 : e.prog / e.target * 100).toFixed(0) + '%'。这个表达式计算了进度百分比,并做了上限保护——即使进度超过了目标,进度条宽度也最多是100%,不会超出容器。toFixed(0)确保百分比是整数,避免了小数精度问题。
进度条的颜色根据用户是否已加入而不同:已加入是荧光绿色,未加入是青蓝色。这种颜色区分再次强化了参与状态的视觉反馈。
进度条右侧是进度数字的文字显示(“X / Y 件”),使用很小的字号和灰绿色,作为进度条的补充说明。
6.7 加入按钮与交互逻辑
Text(e.joined ? '已加入' : '加入')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(e.joined ? '#0E1A0E' : '#E6FFE6')
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(14)
.backgroundColor(e.joined ? '#7CFF00' : '#24361E')
.border({ width: 1, color: e.joined ? '#7CFF00' : '#2A4232' })
.margin({ left: 10 })
.onClick(() => {
const next: Array<EventT> = this.events.map((x: EventT, xi: number) => {
if (xi === i) {
return {
id: x.id, name: x.name, desc: x.desc, prog: x.prog, target: x.target,
people: x.people + (x.joined ? -1 : 1), joined: !x.joined
} as EventT
}
return x
})
this.events = next
})
加入按钮是活动卡片的交互核心。按钮的样式根据加入状态有很大差异:
- 已加入:填充样式,荧光绿背景,深色文字,绿色边框
- 未加入:描边样式,深绿色背景,浅色文字,深绿色边框
按钮的点击事件处理函数是这段代码的重点。让我们详细分析它的实现逻辑。
点击后,通过map方法遍历活动数组,创建一个新的数组。对于索引等于当前活动索引的项,创建一个新的EventT对象,修改两个字段:
people: x.people + (x.joined ? -1 : 1):如果已加入,则参与人数减1;如果未加入,则加1joined: !x.joined:加入状态取反
对于其他活动项,直接返回原对象。最后将新数组赋值给this.events,触发UI更新。
这种使用map创建新数组的方式是ArkTS中更新数组状态的标准模式。由于@State监听的是数组引用的变化,直接修改数组元素不会触发UI更新,必须创建一个新数组并重新赋值。这种 immutable 的数据更新方式虽然看起来稍微繁琐,但它保证了状态变化的可追踪性,也符合函数式编程的思想。
数据immutability原则:在响应式状态管理中,保持数据的不可变性是一个重要原则。每次状态变化都创建新的数据副本,而不是在原数据上修改,这样可以确保状态变化的可预测性和可追踪性。虽然这会带来一些额外的内存开销,但在大多数应用场景下,这种开销是可以忽略的,而带来的代码质量提升是巨大的。
6.8 参与人数与状态标识
Row() {
Text('👥 ' + fmtNum(e.people) + ' 人参与')
.fontSize(9)
.fontColor('#6B8A6B')
if (e.joined) {
Text('进行中')
.fontSize(9)
.fontColor('#7CFF00')
.margin({ left: 10 })
}
}
.width('100%')
.margin({ top: 8 })
活动卡片的底部是参与人数信息和状态标识。参与人数使用人形emoji和格式化的数字表示,使用很小的字号和灰绿色,作为卡片的辅助信息。
如果用户已加入活动,还会显示一个"进行中"的标识,使用荧光绿色。这个标识通过条件渲染实现,只有已加入的活动才会显示。它为用户提供了额外的视觉确认,让用户可以快速浏览列表找到自己正在参与的活动。
七、弹框系统:八种弹框的设计模式与实现
7.1 弹框系统架构概览
本应用共实现了八种不同类型的弹框,按照样式和用途可以分为几大类:
- 居中大卡弹框:新建工程弹框、鬼畜详情弹框
- 居中确认弹框:导出确认弹框
- 危险操作弹框:删除确认弹框
- 底部抽屉弹框:重命名弹框、上传素材弹框、收藏夹编辑弹框、编辑资料弹框
不同类型的弹框采用了不同的视觉设计和位置布局,以匹配其功能特性。居中大卡弹框用于信息较多的表单类操作,底部抽屉弹框用于轻量级的编辑操作,危险操作弹框使用红色主题色来警示用户。
7.2 新建工程弹框newOverlay
@Builder
newOverlay() {
Column() {
// 遮罩
}
.width('100%')
.height('100%')
.backgroundColor('rgba(4,10,4,0.72)')
.position({ x: 0, y: 0 })
.zIndex(999)
.onClick(() => {
this.showNew = false
})
每个弹框都由两部分组成:半透明遮罩层和弹框内容卡片。遮罩层是一个全屏的Column组件,使用position({ x: 0, y: 0 })定位到屏幕左上角,覆盖整个屏幕。背景色使用带透明度的rgba颜色,rgba(4,10,4,0.72)表示深绿色,透明度72%。
zIndex(999)设置了很高的层级,确保遮罩层和弹框内容显示在所有其他UI元素之上。zIndex是控制元素堆叠顺序的属性,值越大越靠上显示。
遮罩层的点击事件用于关闭弹框——用户点击弹框外部的遮罩区域时,弹框关闭。这是一个符合用户预期的交互设计,提供了便捷的关闭方式。
Column() {
Row() {
Text('🎬 新建鬼畜工程')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#7CFF00')
Text('NEW PROJECT')
.fontSize(8)
.fontColor('#00FFCC')
.letterSpacing(1)
.margin({ left: 8 })
}
.width('100%')
弹框内容卡片是一个Column组件,同样使用绝对定位放置在屏幕中央偏上的位置。卡片标题采用了主副标题的设计,与顶部导航栏的Logo风格一致,保持了品牌视觉的统一性。
Text('工程名')
.fontSize(11)
.fontColor('#6B8A6B')
.width('100%')
.margin({ top: 14 })
TextInput({ text: this.newName, placeholder: '给这个鬼畜起个响亮的名字' })
.fontSize(13)
.fontColor('#E6FFE6')
.placeholderColor('#4A6A4A')
.placeholderFont({ size: 12 })
.height(42)
.borderRadius(12)
.backgroundColor('#0C140C')
.width('100%')
.margin({ top: 6 })
.onChange((v: string) => {
this.newName = v
})
表单输入区域包含一个标签文字和一个TextInput输入框。标签文字使用较小的字号和灰绿色,起到提示和说明的作用。
TextInput组件是ArkTS中的文本输入组件,支持多种配置。text参数绑定了输入框的当前值,placeholder设置了占位提示文字。输入框的样式经过精心设计:高度42px,圆角12px,深绿色背景,浅色文字,较暗的占位符颜色。
onChange回调函数在输入框内容变化时被调用,将新的值赋值给状态变量this.newName。由于newName是@State状态变量,所有依赖它的UI都会自动更新。这是受控组件的典型实现方式——输入框的值由状态控制,用户输入通过事件回调更新状态。
TextInput的受控与非受控模式:TextInput支持两种使用模式。受控模式下,输入框的值由外部状态控制,通过text参数绑定和onChange回调更新。非受控模式下,输入框自己维护内部状态,开发者通过defaultValue设置初始值。在大多数业务场景下,受控模式是更好的选择,因为它让状态管理更加集中和可预测。
7.3 多选素材与Flex布局
Text('选择素材(可多选)')
.fontSize(11)
.fontColor('#6B8A6B')
.width('100%')
.margin({ top: 12 })
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(MAT_PICKS, (m: string, mi: number) => {
Text(m)
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(this.newMats.indexOf(mi) >= 0 ? '#0E1A0E' : '#E6FFE6')
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.borderRadius(12)
.backgroundColor(this.newMats.indexOf(mi) >= 0 ? '#7CFF00' : '#12200F')
.border({ width: 1, color: this.newMats.indexOf(mi) >= 0 ? '#7CFF00' : '#2A4232' })
.margin({ right: 8, top: 6 })
.onClick(() => {
if (this.newMats.indexOf(mi) >= 0) {
this.newMats = this.newMats.filter((x: number) => x !== mi)
} else {
this.newMats = [...this.newMats, mi]
}
})
}, (m: string) => m)
}
.width('100%')
素材选择区域使用了Flex布局,这是本应用中大量使用的一种布局方式。Flex({ wrap: FlexWrap.Wrap })创建了一个支持换行的弹性布局容器,当子元素的总宽度超过容器宽度时,会自动换行到下一行。
每个素材选项是一个Text组件,样式根据是否被选中而变化:
- 选中状态:荧光绿背景,深色文字,绿色边框
- 未选中状态:深绿色背景,浅色文字,深绿色边框
选中状态的判断通过this.newMats.indexOf(mi) >= 0实现——检查素材索引是否在已选数组中。这种方式简单直接,但在数据量较大时性能可能不是最优。对于只有8个素材选项的场景,完全没有问题。
点击事件的处理逻辑也很清晰:如果已选中则移除(通过filter过滤),如果未选中则添加(通过数组扩展运算符创建新数组)。注意这里同样遵循了immutable原则——不直接修改原数组,而是创建新数组并重新赋值。
Flex布局详解:Flex(弹性盒)是CSS中最强大的一维布局方案,ArkTS也完整实现了Flex布局能力。Flex容器可以设置主轴方向(row/column)、换行方式(wrap/nowrap)、对齐方式(justifyContent、alignItems)等属性。在本应用中,Flex被广泛用于标签流、卡片网格、按钮组等场景。与Column和Row相比,Flex最大的优势是支持wrap换行,可以自动处理多行布局。
7.4 BGM难度单选与动态信息
Text('BGM 难度(单选)')
.fontSize(11)
.fontColor('#6B8A6B')
.width('100%')
.margin({ top: 12 })
Row() {
ForEach(BGMS, (b: string, bi: number) => {
Column() {
Text(b)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(this.newBgm === bi ? '#0E1A0E' : bgmColor(b))
Text(bi === 0 ? '轻松鬼畜' : (bi === 1 ? '标准节奏' : '地狱变速'))
.fontSize(8)
.fontColor(this.newBgm === bi ? '#1E3324' : '#6B8A6B')
.margin({ top: 3 })
}
.padding({ left: 14, right: 14, top: 8, bottom: 8 })
.borderRadius(12)
.backgroundColor(this.newBgm === bi ? bgmColor(b) : '#12200F')
.margin({ right: 8 })
.onClick(() => {
this.newBgm = bi
})
}, (b: string) => b)
}
.width('100%')
.margin({ top: 8 })
BGM难度选择是一组单选按钮,使用Row横向排列。每个选项是一个Column组件,包含难度等级文字和描述文字两行内容。
选中状态的样式设计很有特色:选中时背景填充对应的难度颜色(EZ绿色、HD青色、EX金色),文字变成深色;未选中时背景是深绿色,文字使用对应的难度颜色。这种"填充反色"的设计让选中状态非常醒目。
每个难度等级下方还有一行小字描述,帮助用户理解不同难度的含义。选中时描述文字颜色也会变深,与整体风格保持一致。
Text('预计时长 ' + secFmt(20 + this.newMats.length * 8) + ' · 已选 ' + this.newMats.length + ' 个素材')
.fontSize(10)
.fontColor('#00FFCC')
.width('100%')
.margin({ top: 12 })
这是一行动态计算的信息提示文字,显示预计工程时长和已选素材数量。时长的计算公式是20 + this.newMats.length * 8——基础时长20秒加上每个素材8秒。这个计算结果通过secFmt函数格式化为"XX分XX秒"的形式。
由于this.newMats是状态变量,当用户选择或取消选择素材时,这段文字会自动更新,实时反馈选择结果。这种动态的信息反馈提升了用户体验,让用户在操作过程中能够看到即时的效果。
7.5 操作按钮组与创建逻辑
Row() {
Text('取消')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#6B8A6B')
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.borderRadius(14)
.backgroundColor('#12200F')
.onClick(() => {
this.showNew = false
})
Text('创建工程 ▸')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#0E1A0E')
.layoutWeight(1.4)
.textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.borderRadius(14)
.backgroundColor('#7CFF00')
.margin({ left: 10 })
.onClick(() => {
const item: ProjectT = {
id: this.projects.length + 1,
name: this.newName.length > 0 ? this.newName : '未命名工程',
prog: 0,
mats: this.newMats.length,
dur: 20 + this.newMats.length * 8,
bgm: BGMS[this.newBgm],
hot: 0
} as ProjectT
this.projects = [...this.projects, item]
this.coins = this.coins + 20
this.showNew = false
})
}
.width('100%')
.margin({ top: 16 })
底部的操作按钮组包含"取消"和"创建工程"两个按钮,使用Row布局和layoutWeight分配宽度比例。取消按钮权重为1,创建按钮权重为1.4,主操作按钮更宽更醒目,符合视觉权重原则。
"创建工程"按钮的点击事件是核心业务逻辑。它做了三件事:
-
创建新工程对象:根据用户输入的数据构造一个新的ProjectT对象。id设为当前工程数+1(简单的自增id策略),名称使用用户输入的名称(为空时使用默认值"未命名工程"),进度初始为0,素材数和时长根据选择的素材数量计算,BGM使用选中的难度等级,热度初始为0。
-
更新工程列表:通过数组扩展运算符
[...this.projects, item]创建新数组并赋值,将新工程添加到列表末尾。 -
奖励金币:创建工程奖励20金币,直接在原金币数上加20。
-
关闭弹框:将showNew设为false,关闭新建工程弹框。
这一系列操作展示了典型的表单提交流程:收集数据 → 构造对象 → 更新状态 → 关闭界面。整个流程简洁清晰,状态变更都通过赋值触发响应式更新。
八、其他弹框的设计亮点
8.1 导出确认弹框
@Builder
exportOverlay() {
// ... 遮罩层
Column() {
Text('📤 导出鬼畜成品')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#00FFCC')
.width('100%')
导出确认弹框使用青蓝色作为主题色,与新建工程弹框的绿色主题形成区分。不同功能的弹框使用不同的主题色,有助于用户快速识别弹框类型。
Row() {
ForEach(QUALS, (q: string, qi: number) => {
Column() {
Text(q)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(this.exportQ === qi ? '#0E1A0E' : '#00FFCC')
Text(QSIZES[qi])
.fontSize(8)
.fontColor(this.exportQ === qi ? '#1E3324' : '#6B8A6B')
.margin({ top: 3 })
}
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.borderRadius(12)
.backgroundColor(this.exportQ === qi ? '#00FFCC' : '#12200F')
.margin({ right: 8 })
.onClick(() => {
this.exportQ = qi
})
}, (q: string) => q)
}
清晰度选项的设计与BGM难度选择类似,都是横向排列的单选按钮组。每个选项显示清晰度等级和对应的文件大小。文件大小从86MB到1.2GB不等,为用户提供了清晰的体积预期。
Row() {
Text('添加工坊水印')
.fontSize(12)
.fontColor('#E6FFE6')
.layoutWeight(1)
Toggle({ type: ToggleType.Switch, isOn: this.exportWm })
.selectedColor('#7CFF00')
.onChange((on: boolean) => {
this.exportWm = on
})
}
水印开关使用了Toggle组件,类型为Switch(开关样式)。Toggle是ArkTS中的切换组件,支持Switch、Checkbox、Button三种类型。Switch类型是最常见的开关样式,常用于设置项的开启/关闭操作。
selectedColor设置了开关打开时的颜色,这里使用荧光绿色。onChange回调在开关状态变化时触发,更新状态变量。
Row() {
Column() {
Text(QSIZES[this.exportQ])
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FFD93D')
Text('预计文件大小')
.fontSize(9)
.fontColor('#6B8A6B')
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
Column()
.width(1)
.height(30)
.backgroundColor('#2A4232')
Column() {
Text(this.exportWm ? '带水印' : '无水印')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#00FFCC')
Text('导出标识')
.fontSize(9)
.fontColor('#6B8A6B')
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
}
信息概览区域以两栏的形式展示导出设置的摘要:左侧是预计文件大小,右侧是水印状态。中间用一条1px的竖线分隔。
这种信息概览设计非常实用,用户在确认导出前可以快速查看最终的导出参数。文件大小使用金黄色突出显示,因为文件大小是用户最关心的信息之一。
8.2 删除确认弹框
@Builder
deleteOverlay() {
Column() {
// 遮罩
}
.width('100%')
.height('100%')
.backgroundColor('rgba(16,6,4,0.8)')
.position({ x: 0, y: 0 })
.zIndex(999)
.onClick(() => {
this.showDelete = false
})
删除弹框的遮罩层颜色与其他弹框不同,使用了偏红的深色调rgba(16,6,4,0.8),透明度也更高(80%)。这种视觉差异营造了危险、警示的氛围,与删除操作的性质相匹配。
Column() {
Text('⚠️')
.fontSize(34)
// ... 条件渲染不同内容
}
.width('74%')
.padding(20)
.borderRadius(20)
.backgroundColor('#1C1410')
.border({ width: 1, color: '#FF5252' })
.alignItems(HorizontalAlign.Center)
.position({ x: '13%', y: '30%' })
.zIndex(1000)
删除弹框的卡片也使用了独特的配色:红棕色背景、红色边框、居中的警告图标。这些设计元素共同强化了"危险操作"的视觉暗示,提醒用户谨慎操作。
删除弹框是一个多用途弹框,根据delTarget的不同显示三种不同的内容:删除工程、删除收藏夹、注销账号。每种场景有不同的标题、描述和警告信息,但共享相同的布局结构和按钮样式。
if (this.delTarget === 'proj') {
Text('永久删除工程?')
// ...
Text(this.delProject.name)
// ...
Text('删除后工程轨道、已剪片段与草稿缓存将一并清除,无法恢复。')
// ...
Text('存档价值:' + this.delProject.mats + ' 个素材 · ' + secFmt(this.delProject.dur) + ' 时长')
// ...
} else if (this.delTarget === 'folder') {
// ... 收藏夹删除内容
} else {
// ... 注销账号内容
}
每种删除场景都包含以下信息元素:
- 警告标题(红色粗体)
- 目标名称(白色粗体)
- 详细说明(灰绿色,居中,多行)
- 存档价值/统计信息(金黄色,胶囊背景)
这种结构化的设计确保了用户在执行危险操作前能够充分了解操作后果,符合"防错设计"的原则——通过清晰的信息展示和确认步骤,防止用户因误操作而造成不可逆的损失。
Text('我再想想')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#E6FFE6')
.width('100%')
.textAlign(TextAlign.Center)
.padding({ top: 11, bottom: 11 })
.borderRadius(14)
.backgroundColor('#12200F')
.margin({ top: 16 })
.onClick(() => {
this.showDelete = false
})
Text('永久删除')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.width('100%')
.textAlign(TextAlign.Center)
.padding({ top: 11, bottom: 11 })
.borderRadius(14)
.backgroundColor('#FF5252')
.margin({ top: 10 })
.onClick(() => {
if (this.delTarget === 'proj') {
this.projects = this.projects.filter((x: ProjectT) => x.id !== this.delProject.id)
} else if (this.delTarget === 'folder') {
this.folders = this.folders.filter((x: FolderT) => x.id !== this.delFolder.id)
}
this.showDelete = false
})
按钮设计也很有特点。与其他弹框的横向双按钮不同,删除弹框使用了纵向双按钮布局:上方是"我再想想"(取消),下方是"永久删除"(确认)。取消按钮使用深色背景浅色文字,而确认按钮使用鲜红色背景白色文字,视觉冲击力很强。
"我再想想"这个文案也比简单的"取消"更有人情味,带有温和劝阻的意味,符合危险操作确认的设计理念——鼓励用户三思而后行。
删除操作的核心逻辑使用filter方法过滤掉要删除的项目,创建新数组并赋值。这是immutable数据更新的标准做法。
危险操作的设计原则:对于删除、注销等不可逆的危险操作,设计上应该遵循"三重确认"原则:视觉警示(红色调、警告图标)、文字说明(明确告知后果)、操作确认(需要用户主动点击确认按钮)。本应用的删除弹框很好地实践了这些原则,既保证了操作的安全性,又不会过度打扰用户。
8.3 底部抽屉弹框的通用模式
@Builder
renameOverlay() {
Column() {
// 遮罩
}
// ...
Column() {
Column()
.width(40)
.height(4)
.borderRadius(2)
.backgroundColor('#2A4232')
.margin({ top: 10 })
// ... 弹框内容
}
.width('100%')
.padding({ left: 20, right: 20 })
.borderRadius({ topLeft: 24, topRight: 24 })
.backgroundColor('#16241A')
.position({ x: 0, y: '40%' })
.zIndex(1000)
}
底部抽屉弹框是移动端应用中非常常见的弹框样式,它从屏幕底部滑出,占据屏幕的下半部分。本应用中有四种底部抽屉弹框,它们共享相同的布局模式:
-
顶部抓手:一个40x4px的圆角矩形,位于顶部中央,视觉上暗示这是一个可拖拽的抽屉。这是底部抽屉的标志性设计元素。
-
顶部圆角:只有左上角和右上角有圆角(24px),底部是直角,与屏幕底部边缘贴合。
-
全屏宽度:抽屉宽度与屏幕等宽,左右有内边距。
-
位置定位:使用
position({ x: 0, y: '40%' })定位,顶部位于屏幕高度40%的位置,向下延伸到底部。
不同的抽屉弹框位置略有不同:重命名弹框在y:40%,上传素材在y:38%,收藏夹编辑在y:42%,编辑资料在y:36%。位置的差异反映了弹框内容的多少——内容越多,位置越靠上(y值越小),以确保有足够的空间展示内容。
底部抽屉弹框适合承载表单类的轻量级编辑操作,用户操作完成后可以快速关闭。与居中弹框相比,底部抽屉的操作距离更近(拇指热区),单手操作更方便。
九、子组件架构:七大Tab页面详解
9.1 组件通信模式
在深入各个子组件之前,我们先来了解一下主组件与子组件之间的通信模式。
主组件Index与子组件之间采用"数据向下传递,事件向上回调"的通信模式,这是组件化开发的经典架构。
-
数据向下:主组件通过
@Prop装饰的属性将数据传递给子组件。子组件只能读取这些数据,不能直接修改。这保证了数据流的单向性,让状态变化更加可控。 -
事件向上:子组件通过回调函数(如
onNew、onExport、onDelete等)通知主组件执行相应的操作。子组件不关心操作的具体实现,只负责触发事件。具体的业务逻辑由主组件处理。
这种单向数据流的架构模式有很多优势:
- 状态集中管理:所有状态都集中在主组件中,子组件是纯展示型的,状态变化的追踪更容易。
- 组件复用性高:子组件不依赖具体的业务逻辑,只通过属性和回调与外部交互,可以在不同场景下复用。
- 可测试性好:子组件的行为完全由输入属性决定,给定输入就有确定的输出,便于单元测试。
- 代码结构清晰:数据流向明确,阅读代码时可以清楚地知道数据从哪里来、到哪里去。
单向数据流原则:在组件化架构中,单向数据流是一个核心原则。数据只能从父组件流向子组件(通过props),子组件不能直接修改父组件的数据。如果子组件需要修改数据,必须通过事件回调通知父组件,由父组件来修改数据,然后新的数据再通过props流回子组件。这种单向流动的模式大大降低了系统的复杂度。
9.2 素材库页面MaterialTab
@Component
struct MaterialTab {
@Prop materials: Array<MaterialT> = MATERIALS
@State cat: number = 0
@State pickId: number = 0
onUpload: () => void = () => {}
MaterialTab是素材库页面的组件。它接收三个属性:
@Prop materials:素材列表数据,由父组件传入@State cat:当前选中的分类索引,组件内部状态@State pickId:当前选中/点击的素材id,组件内部状态onUpload:上传/取用素材的回调函数,由父组件传入
@Prop装饰器用于接收父组件传递的数据。与@State不同,@Prop装饰的变量是只读的,子组件不能修改它。当父组件的数据发生变化时,子组件中的@Prop变量会自动同步更新。
cat状态控制分类筛选,初始值为0(全部)。pickId状态记录用户点击的素材id,用于高亮显示选中的素材。
build() {
Column() {
Scroll() {
Row() {
ForEach(CATS, (c: string, ci: number) => {
Text(c)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(this.cat === ci ? '#0E1A0E' : '#6B8A6B')
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.borderRadius(14)
.borderRadius(14)
.backgroundColor(this.cat === ci ? '#7CFF00' : '#12200F')
.margin({ right: 8 })
.onClick(() => {
this.cat = ci
})
}, (c: string) => c)
}
.padding({ left: 14, right: 14 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.margin({ top: 10 })
页面顶部是横向滚动的分类标签栏。分类标签的选中状态通过this.cat === ci判断,选中的标签使用填充样式(荧光绿背景、深色文字),未选中的使用描边样式(深绿背景、灰绿文字)。
点击分类标签时,更新cat状态变量。由于cat是@State状态,下面的素材列表会自动重新筛选和渲染。
Scroll() {
Column() {
Row() {
Text('素材池')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#E6FFE6')
Text('共 ' + this.materials.length + ' 条 · 点击上传同款')
.fontSize(11)
.fontColor('#6B8A6B')
.margin({ left: 8 })
}
.width('100%')
.margin({ top: 14, bottom: 10 })
素材池标题区域显示大标题和统计信息。统计信息中显示素材总数,这个数字来自this.materials.length。由于materials是@Prop属性,当父组件的素材列表变化时(比如用户上传了新素材),这里的数字会自动更新。
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
ForEach(this.materials, (m: MaterialT) => {
if (this.cat === 0 || m.cat === CATS[this.cat]) {
// ... 素材卡片
}
}, (m: MaterialT) => (m.id.toString() + this.cat.toString() + (this.pickId === m.id ? 'Y' : 'N')))
}
.width('100%')
素材列表使用Flex布局,wrap: FlexWrap.Wrap支持自动换行,justifyContent: FlexAlign.SpaceBetween让每行的两个卡片左右两端对齐,形成两列网格布局。
ForEach内部通过if (this.cat === 0 || m.cat === CATS[this.cat])进行分类筛选——如果是"全部"分类(cat=0),显示所有素材;否则只显示分类匹配的素材。
ForEach的key生成函数包含了三个部分:素材id、当前分类索引、选中状态。这确保了在分类切换或选中状态变化时,列表项能够正确地重新渲染。
Column() {
Column() {
Text(m.emoji)
.fontSize(30)
}
.width('100%')
.height(80)
.borderRadius(12)
.backgroundColor('#0C140C')
.border({ width: 1, color: m.color })
.justifyContent(FlexAlign.Center)
.onClick(() => {
this.pickId = m.id
this.onUpload()
})
每个素材卡片的顶部是一个80px高的预览区域,居中显示素材的emoji图标。预览区域有1px的边框,颜色使用素材的主题色。
点击预览区域会触发两个操作:设置pickId为当前素材id(高亮显示),然后调用onUpload回调(打开上传弹框)。
Text(m.title)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(this.pickId === m.id ? '#7CFF00' : '#E6FFE6')
.margin({ top: 8 })
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row() {
Text('#' + m.cat)
.fontSize(8)
.fontWeight(FontWeight.Bold)
.fontColor(m.color)
Text(secFmt(m.dur))
.fontSize(8)
.fontColor('#6B8A6B')
.margin({ left: 6 })
}
.margin({ top: 4 })
素材标题使用13px粗体字,如果是当前选中的素材,标题颜色会变成荧光绿色,提供选中状态的视觉反馈。标题最多显示一行,超出部分省略。
标题下方是分类标签和时长信息。分类标签使用素材的主题色,前面加#号,类似社交媒体的话题标签样式。时长使用灰绿色小字体。
Row() {
Text('🔁 ' + fmtNum(m.uses))
.fontSize(9)
.fontColor('#6B8A6B')
Text('取用')
.fontSize(9)
.fontWeight(FontWeight.Bold)
.fontColor('#0E1A0E')
.padding({ left: 10, right: 10, top: 3, bottom: 3 })
.borderRadius(10)
.backgroundColor(m.color)
.margin({ left: 6 })
.onClick(() => {
this.pickId = m.id
this.onUpload()
})
}
.margin({ top: 6, bottom: 4 })
卡片底部左侧显示使用次数(带循环箭头emoji),右侧是"取用"按钮。取用按钮使用素材的主题色作为背景,深色文字,形成鲜明的视觉对比。
点击取用按钮的效果与点击预览区域相同——设置选中状态并触发上传回调。在卡片中提供多个可点击区域(预览图和取用按钮),提升了操作的便捷性。
整个素材卡片的宽度为’48.5%',在两列布局中,两个卡片之间留出了大约3%的间距。这个宽度比例是Flex + SpaceBetween布局下实现两列网格的常用技巧。
9.3 工程页面ProjectTab
@Component
struct ProjectTab {
@Prop projects: Array<ProjectT> = PROJECTS
@State beatH: number = 8
onNew: () => void = () => {}
onExport: (p: ProjectT) => void = () => {}
onRename: (p: ProjectT) => void = () => {}
onDelete: (p: ProjectT) => void = () => {}
ProjectTab是工程页面的组件,包含四个回调函数:新建、导出、重命名、删除。这些回调都接受一个ProjectT类型的参数(除了新建),用于标识操作的目标工程。
beatH状态变量用于轨道块跳动动画,初始值为8。这是一个动画驱动的状态变量。
aboutToAppear(): void {
this.getUIContext().animateTo({ duration: 300, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseOut }, () => {
this.beatH = 22
})
}
在aboutToAppear生命周期中启动了一个高度跳动动画。动画时长300ms,无限循环,交替播放,缓出曲线。beatH的值在8和22之间来回变化,驱动轨道块的高度产生跳动效果。
这个动画的速度比全局的鬼畜值脉冲动画快很多(300ms vs 900ms),营造出更快的节奏感觉,与"时间轴轨道跳动"的概念非常契合。
// 时间轴轨道条形图(块高度跳动特效)
Column() {
Text('时间轴轨道')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#E6FFE6')
.width('100%')
ForEach(this.projects, (p: ProjectT, pi: number) => {
if (pi < 5) {
Row() {
Column() {
Text(p.name)
.fontSize(8)
.fontColor('#6B8A6B')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(p.bgm)
.fontSize(8)
.fontWeight(FontWeight.Bold)
.fontColor(bgmColor(p.bgm))
.margin({ top: 2 })
}
.width(58)
.alignItems(HorizontalAlign.Start)
时间轴轨道区域是工程页面的一个特色模块,它以可视化的方式展示前5个工程的轨道状态。每个工程占一行,左侧是工程名称和BGM难度,中间是轨道块,右侧是进度百分比。
工程名称使用极小的字号(8px),因为这只是辅助标识,不是主要信息。BGM难度使用对应的颜色,提供快速的视觉识别。
Row() {
ForEach(BLOCKS, (b: number, bi: number) => {
if (bi < 3 + p.id % 2) {
Column() {
Column()
.width(12)
.height(this.beatH + bi * 2)
.borderRadius(3)
.backgroundColor(blockColor(bi))
}
.margin({ left: 6, right: 6 })
}
}, (b: number) => b.toString())
}
.layoutWeight(1)
.height(30)
.alignItems(VerticalAlign.Bottom)
.justifyContent(FlexAlign.Center)
中间的轨道区域是动画的核心。轨道块的数量根据工程id动态变化(3或4个块),每个块的高度是this.beatH + bi * 2——基础高度加上索引偏移,让不同位置的块高度略有差异,形成错落有致的视觉效果。
由于beatH是动画状态变量,它的值在8和22之间不断变化,因此轨道块的高度也会随之上下跳动,产生音频波形般的动感效果。
轨道块的颜色通过blockColor(bi)函数获取,三种颜色循环交替。轨道区域底部对齐(alignItems(VerticalAlign.Bottom)),因此高度变化时轨道块是向上增长的,更符合柱状图的视觉习惯。
Text(p.prog + '%')
.fontSize(9)
.fontWeight(FontWeight.Bold)
.fontColor(p.prog >= 100 ? '#7CFF00' : '#00FFCC')
.width(34)
.textAlign(TextAlign.End)
}
.width('100%')
.alignItems(VerticalAlign.Center)
.padding({ top: 5, bottom: 5 })
}
}, (p: ProjectT) => (p.id.toString() + p.prog.toString()))
}
右侧显示工程进度百分比。已完成的工程(进度>=100%)使用荧光绿色,进行中的使用青蓝色。文字右对齐,与左侧的工程名称形成对称布局。
这个时间轴轨道模块虽然没有实际的交互功能,但作为视觉元素极大地提升了页面的动感和科技感,与"鬼畜工坊"的产品定位非常契合。这也是优秀UI设计的体现——不只是功能性的堆砌,还要有情绪和氛围的营造。
9.4 工程列表卡片
// 工程列表卡
ForEach(this.projects, (p: ProjectT) => {
Column() {
Row() {
Progress({ value: p.prog, total: 100 })
.style({ strokeWidth: 6 })
.color(bgmColor(p.bgm))
.width(56)
.height(56)
工程列表中的每个工程卡片左上角都有一个环形进度条。Progress组件用于展示进度,通过style({ strokeWidth: 6 })设置环形线宽为6px,color设置进度颜色(根据BGM难度动态获取)。
环形进度条是一种非常高效的信息展示方式,它在有限的空间内同时传达了进度比例和视觉美感。56x56px的尺寸在移动端卡片中恰到好处,既醒目又不会占据过多空间。
Column() {
Text(p.name)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#E6FFE6')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row() {
Text(p.bgm)
.fontSize(8)
.fontWeight(FontWeight.Bold)
.fontColor('#0E1A0E')
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(6)
.backgroundColor(bgmColor(p.bgm))
Text('素材 ' + p.mats + ' 段')
.fontSize(9)
.fontColor('#6B8A6B')
.margin({ left: 6 })
Text('时长 ' + secFmt(p.dur))
.fontSize(9)
.fontColor('#6B8A6B')
.margin({ left: 6 })
}
.margin({ top: 5 })
工程信息区域包含工程名称和一行元数据。工程名称使用15px粗体,是卡片中最主要的文字信息。
元数据行包含三个信息点:BGM难度标签、素材数量、时长。BGM标签使用填充样式(对应难度的颜色背景,深色文字),素材数量和时长使用灰绿色小字。三个信息点通过左外边距分隔,形成清晰的信息层次。
Row() {
Column() {
Column()
.width(p.prog + '%')
.height(4)
.borderRadius(2)
.backgroundColor(p.prog >= 100 ? '#7CFF00' : '#00FFCC')
}
.layoutWeight(1)
.height(4)
.borderRadius(2)
.backgroundColor('#1E3324')
Text(p.prog >= 100 ? '已完成' : '剪辑中')
.fontSize(8)
.fontColor(p.prog >= 100 ? '#7CFF00' : '#6B8A6B')
.margin({ left: 8 })
}
.width('100%')
.margin({ top: 6 })
进度条是工程状态的另一种展示方式。与左上角的环形进度条相比,这条横向进度条提供了更精确的进度可视化,同时旁边还有文字状态说明(“已完成"或"剪辑中”)。
一个卡片中同时使用环形进度和横向进度两种进度展示方式,看起来似乎有些重复,但实际上它们承担着不同的角色:环形进度是视觉焦点,快速传达整体完成度;横向进度是细节补充,配合文字状态提供更精确的信息。这种"概览+细节"的信息展示方式在UI设计中很常见。
Text('🔥 ' + fmtNum(p.hot))
.fontSize(9)
.fontColor('#FF7A45')
}
.width('100%')
.alignItems(VerticalAlign.Center)
热度值显示在卡片右上角,使用橙色和火焰emoji,视觉上很醒目。热度是社交属性的体现,高热度的工程更受关注。
Row() {
Text('导出')
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor('#0E1A0E')
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
.borderRadius(12)
.backgroundColor('#00FFCC')
.onClick(() => {
this.onExport(p)
})
Text('改名')
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor('#00FFCC')
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
.borderRadius(12)
.backgroundColor('#12200F')
.border({ width: 1, color: '#00FFCC' })
.margin({ left: 8 })
.onClick(() => {
this.onRename(p)
})
Text('删除')
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor('#FF5252')
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
.borderRadius(12)
.backgroundColor('#12200F')
.border({ width: 1, color: '#FF5252' })
.margin({ left: 8 })
.onClick(() => {
this.onDelete(p)
})
Text('鬼畜值 ' + (p.prog * 9))
.fontSize(9)
.fontColor('#FFD93D')
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.End)
.margin({ top: 10 })
卡片底部是操作按钮组,包含导出、改名、删除三个操作按钮和鬼畜值显示。按钮组右对齐,符合移动端卡片操作的常见布局。
三个按钮的样式各有不同:
- 导出按钮:填充样式,青蓝色背景,深色文字——主操作按钮
- 改名按钮:描边样式,深绿背景,青蓝色文字和边框——次要操作
- 删除按钮:描边样式,深绿背景,红色文字和边框——危险操作
三种不同的按钮样式对应三种不同性质的操作,通过视觉差异引导用户的操作预期。主操作最醒目,次要操作较柔和,危险操作用红色警示。
鬼畜值显示在按钮组的最右侧,使用金黄色。鬼畜值的计算公式是p.prog * 9,即进度值乘以9,这是一个简化的计算模型。
每个按钮的点击事件都调用对应的回调函数,并将当前工程对象p作为参数传入。这是"子组件触发事件,父组件处理逻辑"模式的典型应用。
十、模板、榜单、收藏、我的页面解析
10.1 模板页面TemplateTab
@Component
struct TemplateTab {
@Prop templates: Array<TemplateT> = TEMPLATES
@State selId: number = 0
模板页面结构相对简单,展示了可一键套用的鬼畜模板卡片。selId状态记录当前选中的模板id。
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
ForEach(this.templates, (t: TemplateT) => {
Column() {
Row() {
Text(t.emoji)
.fontSize(24)
Text(t.star + ' 鬼畜值')
.fontSize(8)
.fontWeight(FontWeight.Bold)
.fontColor('#0E1A0E')
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(6)
.backgroundColor('#FFD93D')
.margin({ left: 6 })
}
.width('100%')
模板卡片顶部是一个Row,左侧是emoji图标(24px),右侧是星级标签。星级标签使用金黄色背景和深色文字,显示"X 鬼畜值",突出模板的等级和价值感。
Text(t.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#E6FFE6')
.margin({ top: 8 })
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(t.desc)
.fontSize(9)
.fontColor('#6B8A6B')
.margin({ top: 4 })
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
模板名称和描述是卡片的核心文字内容。名称使用14px粗体,描述使用9px灰绿色,最多显示两行。两行的描述限制是一个合理的设计——既能展示足够的信息,又不会因为文字过长而破坏卡片布局。
Text(stars(t.star))
.fontSize(10)
.fontColor('#FFD93D')
.margin({ top: 6 })
Text('🔁 ' + fmtNum(t.uses) + ' 人使用')
.fontSize(9)
.fontColor('#6B8A6B')
.margin({ top: 4 })
星级显示和使用次数是模板的社交属性信息。星星通过stars函数生成,使用金黄色。使用次数展示了模板的受欢迎程度,为用户选择模板提供参考。
Text(this.selId === t.id ? '已选用' : '使用模板')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(this.selId === t.id ? '#0E1A0E' : '#E6FFE6')
.padding({ left: 18, right: 18, top: 6, bottom: 6 })
.borderRadius(14)
.backgroundColor(this.selId === t.id ? '#7CFF00' : '#12200F')
.border({ width: 1, color: this.selId === t.id ? '#7CFF00' : '#2A4232' })
.margin({ top: 10, bottom: 8 })
.onClick(() => {
this.selId = t.id
})
底部的"使用模板"按钮是卡片的主操作按钮。选中状态(已选用)使用填充样式,未选中状态使用描边样式。点击按钮时切换选中状态。
值得注意的是,这里的"使用模板"操作只是切换了本地的选中状态,并没有触发外部回调。在实际的完整应用中,这里应该会触发模板套用的业务逻辑,比如跳转到编辑页面或打开确认弹框。
10.2 榜单页面RankTab
@Component
struct RankTab {
@Prop ranks: Array<RankT> = RANKS
onDetail: (r: RankT) => void = (r: RankT) => {}
榜单页面展示鬼畜值排行榜,点击榜单项可以查看详情。
ForEach(this.ranks, (r: RankT) => {
Column() {
Row() {
Text(r.rank === 1 ? '🥇' : (r.rank === 2 ? '🥈' : (r.rank === 3 ? '🥉' : r.rank.toString())))
.fontSize(r.rank <= 3 ? 20 : 15)
.fontWeight(FontWeight.Bold)
.fontColor(r.rank <= 3 ? '#FFD93D' : '#2A4232')
.width(34)
.textAlign(TextAlign.Center)
排行榜的排名显示设计得很细致。前三名使用奖牌emoji(金、银、铜),字号更大(20px),颜色为金黄色。第四名及以后使用数字排名,字号较小(15px),颜色为暗绿色。
这种差异化设计突出了头部用户,符合排行榜的竞技属性——前三名是最受关注的,需要更强的视觉强调。
Column() {
Text(r.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(r.rank <= 3 ? '#7CFF00' : '#E6FFE6')
Row() {
Text('播放 ' + r.plays)
.fontSize(10)
.fontColor('#6B8A6B')
Text('作品 ' + r.works + ' 件')
.fontSize(10)
.fontColor('#6B8A6B')
.margin({ left: 8 })
}
.margin({ top: 4 })
用户名称的颜色也有差异化:前三名使用荧光绿色,其他使用白色。这进一步强化了头部用户的视觉权重。名称下方是播放量和作品数的统计信息,使用较小的字号和灰绿色。
// 鬼畜值横向条形
Column() {
Column()
.width((r.ghost / 100).toFixed(0) + '%')
.height(5)
.borderRadius(3)
.backgroundColor(r.rank <= 3 ? '#7CFF00' : '#00FFCC')
}
.width('100%')
.height(5)
.borderRadius(3)
.backgroundColor('#1E3324')
.margin({ top: 6 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.margin({ left: 6 })
鬼畜值条形图是排行榜的核心可视化元素。条形的宽度与鬼畜值成正比,公式(r.ghost / 100).toFixed(0) + '%'将鬼畜值(满分10000)转换为百分比宽度(除以100即得到百分比)。
条形的颜色同样有差异化:前三名使用荧光绿色,其他使用青蓝色。颜色的差异与排名、名称的差异保持一致,形成了统一的视觉语言。
Text(r.ghost.toString())
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#FFD93D')
最右侧是鬼畜值的具体数字,使用金黄色粗体字。数字是排行榜的核心指标,用醒目的颜色突出显示。
整个榜单项的布局从左到右依次是:排名 → 用户信息(名称+统计+进度条)→ 鬼畜值数字。这种布局符合信息的重要性排序和阅读习惯,用户可以快速扫描获取关键信息。
10.3 收藏夹页面FolderTab
@Component
struct FolderTab {
@Prop folders: Array<FolderT> = FOLDERS
onAdd: () => void = () => {}
onEdit: (f: FolderT) => void = (f: FolderT) => {}
onDelete: (f: FolderT) => void = (f: FolderT) => {}
收藏夹页面管理用户的素材收藏夹,支持新增、编辑、删除操作。
ForEach(this.folders, (f: FolderT) => {
Row() {
Column() {
Text(f.emoji)
.fontSize(20)
}
.width(44)
.height(44)
.borderRadius(12)
.backgroundColor('#0C140C')
.border({ width: 1, color: f.color })
.justifyContent(FlexAlign.Center)
每个收藏夹是一行,左侧是一个44x44px的图标区域,显示收藏夹的emoji。图标区域有1px的边框,颜色使用收藏夹的主题色。
Column() {
Text(f.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#E6FFE6')
Text('收藏 ' + f.count + ' 条素材')
.fontSize(9)
.fontColor('#6B8A6B')
.margin({ top: 3 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.margin({ left: 12 })
中间是收藏夹的名称和素材数量。名称使用14px粗体,素材数量使用9px灰绿色。
Text('编辑')
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor('#00FFCC')
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.borderRadius(12)
.backgroundColor('#12200F')
.border({ width: 1, color: '#00FFCC' })
.onClick(() => {
this.onEdit(f)
})
Text('删除')
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor('#FF5252')
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.borderRadius(12)
.backgroundColor('#12200F')
.border({ width: 1, color: '#FF5252' })
.margin({ left: 8 })
.onClick(() => {
this.onDelete(f)
})
右侧是编辑和删除两个操作按钮,都是描边样式。编辑按钮使用青蓝色,删除按钮使用红色。按钮尺寸较小(10px字号,5px上下内边距),不会过度抢夺视觉注意力。
收藏夹列表的设计简洁实用,每个收藏夹一行,信息清晰,操作便捷。
10.4 我的页面MineTab
我的页面是信息密度最高的页面,包含了资料卡、数据总览、鬼畜值环形图、本周产出柱状图、设置项等多个模块。
// 资料卡
Row() {
Text(this.nickname.length > 2 ? this.nickname.substring(0, 2) : this.nickname)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#0E1A0E')
.width(56)
.height(56)
.textAlign(TextAlign.Center)
.borderRadius(28)
.backgroundColor(AVATAR_COLORS[this.avatarIdx])
资料卡左侧是用户头像,使用圆形的Text组件实现。头像中显示昵称的前两个字,背景色根据avatarIdx从颜色数组中选取。56x56px的尺寸在个人中心页面中是标准的头像大小。
昵称截取前两个字的逻辑很有意思:this.nickname.length > 2 ? this.nickname.substring(0, 2) : this.nickname。如果昵称长度大于2,就截取前两个字;否则显示完整昵称。这样可以确保头像中的文字不会太长,保持圆形头像的美观。
Column() {
Text(this.nickname)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#E6FFE6')
Text(this.sign)
.fontSize(10)
.fontColor('#6B8A6B')
.margin({ top: 4 })
Row() {
Text('Lv.42')
.fontSize(9)
.fontWeight(FontWeight.Bold)
.fontColor('#7CFF00')
Text(' 鬼畜值 872')
.fontSize(9)
.fontColor('#FFD93D')
}
.margin({ top: 5 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.margin({ left: 14 })
头像右侧是用户信息,包括昵称、个性签名、等级和鬼畜值。昵称使用18px粗体,是资料卡中最醒目的文字。个性签名使用较小的灰绿色文字。
等级和鬼畜值在同一行,等级使用荧光绿色,鬼畜值使用金黄色。两种颜色形成对比,信息层次分明。
Text('编辑')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor('#7CFF00')
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.borderRadius(14)
.border({ width: 1, color: '#7CFF00' })
.onClick(() => {
this.onEdit()
})
最右侧是"编辑"按钮,使用描边样式,荧光绿色。点击后打开编辑资料弹框。
// 数据总览
Row() {
Column() {
Text('156')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#7CFF00')
Text('作品数')
.fontSize(9)
.fontColor('#6B8A6B')
.margin({ top: 3 })
}
.layoutWeight(1)
Column()
.width(1)
.height(26)
.backgroundColor('#2A4232')
Column() {
Text('2860万')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#00FFCC')
Text('总播放')
.fontSize(9)
.fontColor('#6B8A6B')
.margin({ top: 3 })
}
.layoutWeight(1)
Column()
.width(1)
.height(26)
.backgroundColor('#2A4232')
Column() {
Text('🪙 ' + fmtNum(this.coins))
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#FFD93D')
Text('金币余额')
.fontSize(9)
.fontColor('#6B8A6B')
.margin({ top: 3 })
}
.layoutWeight(1)
}
数据总览模块以三栏的形式展示三个核心数据:作品数、总播放、金币余额。每栏占相同的宽度(layoutWeight均为1),栏与栏之间用1px的竖线分隔。
三个数据分别使用不同的主题色:作品数是荧光绿、总播放是青蓝、金币是金黄。颜色的差异化让用户可以快速识别不同类型的数据。
这种"数据总览卡片"的设计模式在个人中心页面中非常常见,它将用户最关心的核心指标集中展示在一个卡片中,一目了然。
// 鬼畜值环形图
Column() {
Text('我的鬼畜值')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#E6FFE6')
.width('100%')
Row() {
Progress({ value: 87.2, total: 100 })
.style({ strokeWidth: 10 })
.color('#7CFF00')
.width(92)
.height(92)
Column() {
Text('872')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#7CFF00')
Text('全站排名 #24')
.fontSize(9)
.fontColor('#00FFCC')
.margin({ top: 4 })
Text('较上周 +36')
.fontSize(9)
.fontColor('#7CFF00')
.margin({ top: 2 })
Text(stars(4))
.fontSize(10)
.fontColor('#FFD93D')
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 20 })
}
.alignItems(VerticalAlign.Center)
.margin({ top: 14 })
}
鬼畜值环形图模块是个人中心的一个亮点。左侧是一个92x92px的大环形进度条,线宽10px,展示鬼畜值完成度(872/10000 = 8.72%?不对,这里value是87.2,total是100,所以是87.2%)。
右侧是详细的文字信息,包括鬼畜值数字、全站排名、较上周变化、星级。这些信息从上到下排列,使用不同的颜色区分:
- 鬼畜值:荧光绿(主数据)
- 排名:青蓝(次要信息)
- 变化:荧光绿(上升用绿色表示好的变化)
- 星级:金黄(评级信息)
这种"图形+文字"的组合展示方式既直观又详细,用户既能通过环形图快速感知整体进度,又能通过文字获取精确信息。
// 本周产出柱状图
Column() {
Text('本周产出作品数')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#E6FFE6')
.width('100%')
Row() {
ForEach(WEEK_WORKS, (v: number, i: number) => {
Column() {
Column()
.width(20)
.height(v / 15 * 100)
.borderRadius(4)
.backgroundColor(i === 6 ? '#7CFF00' : '#00FFCC')
Text(WEEK_LABELS[i])
.fontSize(9)
.fontColor('#6B8A6B')
.margin({ top: 5 })
}
.margin({ left: 5, right: 5 })
}, (v: number, i: number) => (v.toString() + i.toString()))
}
.width('100%')
.alignItems(VerticalAlign.Bottom)
.justifyContent(FlexAlign.Center)
.margin({ top: 16 })
.padding({ bottom: 6 })
Text('周日产出 15 件创新高 🎉')
.fontSize(9)
.fontColor('#7CFF00')
.margin({ top: 6 })
}
本周产出柱状图模块展示了最近一周每天的作品产出数量。柱状图完全通过Column组件的高度来实现——每个柱子是一个Column,高度通过v / 15 * 100计算(以最大值15为100%基准)。
柱状图的颜色也有差异:周日(i===6)使用荧光绿色,其他天使用青蓝色。周日的数据最高(15件),用不同的颜色突出显示最大值,这是图表设计的常用技巧。
柱状图使用Row布局,底部对齐(alignItems(VerticalAlign.Bottom)),居中排列。底部对齐是柱状图的关键——所有柱子从底部向上生长,符合常规的柱状图视觉习惯。
柱子下方是星期标签(一到日),使用灰绿色小字。
底部还有一行总结文字,高亮显示周日创新高的成就,并用庆祝emoji增强正面情绪。这种数据+鼓励的设计可以提升用户的成就感和创作动力。
// 设置项
Column() {
Row() {
Text('🔔')
.fontSize(15)
Text('投稿提醒')
.fontSize(13)
.fontColor('#E6FFE6')
.margin({ left: 10 })
Text('▸')
.fontSize(13)
.fontColor('#4A6A4A')
.margin({ left: 10 })
}
.width('100%')
.padding(14)
.onClick(() => {
this.cacheCleaned = false
})
设置列表是移动端应用中非常经典的列表样式。每一行左侧是图标和文字,右侧是箭头指示器,整行可点击。
设置项之间用1px的分隔线隔开。分隔线使用#1E3324的暗绿色,在深色背景上若隐若现,既起到了分隔作用,又不会过于突兀。
三个设置项分别是:投稿提醒、清除缓存、注销账号。注销账号使用红色文字,标识为危险操作。
清除缓存项有一个特殊的状态——点击后会显示"已清理"的绿色文字提示,给用户操作反馈。这个状态由cacheCleaned变量控制。
十一、build方法与整体布局架构
11.1 Stack布局与弹框层叠
build() {
Stack() {
Column() {
this.headerBar()
// ... 主内容区域
this.tabBar()
}
.width('100%')
.height('100%')
.backgroundColor('#0E1A0E')
// 弹框层
if (this.showNew) {
this.newOverlay()
}
if (this.showExport) {
this.exportOverlay()
}
// ... 更多弹框
}
.width('100%')
.height('100%')
}
整个页面的根布局使用Stack组件。Stack是层叠布局容器,它的子组件会按照顺序一层一层堆叠起来,后添加的子组件显示在上面。
Stack的第一个子组件是主界面Column,包含顶部导航栏、主内容区域和底部Tab栏。这是页面的基础层,始终可见。
Stack的后续子组件是各个弹框,通过条件渲染控制显示与否。由于弹框在Stack中位于主界面之后,所以当弹框显示时,会覆盖在主界面之上。弹框内部又有自己的遮罩层和内容卡片,遮罩层覆盖全屏挡住下面的主界面,内容卡片浮在遮罩之上。
Stack布局的应用场景:Stack布局适用于需要层叠显示的UI场景,比如弹框、浮层、角标、引导蒙层等。与CSS中的position: absolute类似,Stack中的子组件可以通过position属性进行定位。在本应用中,Stack主要用于管理弹框的层叠关系,确保弹框始终显示在主界面之上。
11.2 Tab切换的条件渲染
Column() {
if (this.currentTab === 0) {
MaterialTab({ materials: this.materials, onUpload: () => {...} })
} else if (this.currentTab === 1) {
ProjectTab({ projects: this.projects, onNew: () => {...}, ... })
} else if (this.currentTab === 2) {
TemplateTab({ templates: TEMPLATES })
} else if (this.currentTab === 3) {
RankTab({ ranks: RANKS, onDetail: (r) => {...} })
} else if (this.currentTab === 4) {
this.eventList()
} else if (this.currentTab === 5) {
FolderTab({ folders: this.folders, onAdd: () => {...}, ... })
} else {
MineTab({ nickname: this.nickname, sign: this.sign, ... })
}
}
.layoutWeight(1)
.width('100%')
主内容区域的Tab切换通过if-else条件渲染实现。根据currentTab的值,渲染对应的Tab组件。每次只有一个Tab被渲染,其他Tab的组件树完全不被创建。
这种条件渲染的Tab切换方式有几个特点:
- **内存效
.width(‘100%’)
.height(‘100%’)
}
}
---

在子组件部分,我们逐一分析了七个Tab页面的实现,包括素材库的分类筛选和Flex网格布局、工程页面的时间轴跳动动画和环形进度、模板页面的卡片墙、榜单页面的排行榜条形图、收藏夹的列表管理、个人中心的资料卡、数据总览、环形图、柱状图和设置列表。
在技术深度部分,我们专门讲解了Column、Row、Stack、Flex四大布局组件的特性和适用场景,分析了@Builder、@Component、@State、@Prop、ForEach等核心装饰器和组件的工作原理,探讨了组件化、单向数据流、immutable数据更新等架构原则。
更多推荐




所有评论(0)