鸿蒙 ArkTS 声明式 UI 全解析:摇摆舞会所应用的架构与实现
引言:鸿蒙开发与声明式 UI 范式
鸿蒙操作系统作为华为自主研发的分布式操作系统,其应用开发框架经历了从 Java UI 到声明式 UI 的重大演进。在最新的鸿蒙开发体系中,ArkTS 语言成为了应用开发的核心语言,它基于 TypeScript 进行了深度扩展,在保留 TypeScript 类型系统优势的同时,引入了声明式 UI 编程范式和状态管理装饰器语法。ArkTS 不仅仅是一门语言,更是一套完整的 UI 开发方法论,它将 UI 的描述、状态的管理、组件的复用有机地统一在一起,让开发者能够以更少的代码构建更复杂的界面。
声明式 UI 范式的核心思想在于"状态驱动视图"。在传统的命令式 UI 开发中,开发者需要手动操作 DOM 或视图节点来更新界面;而在 ArkTS 的声明式范式中,开发者只需声明 UI 的结构和它与状态变量的绑定关系,当状态变量发生变化时,框架会自动重新执行 build 方法,计算出 UI 差异并进行高效的最小化更新。这种模式极大地减少了样板代码,让开发者能够将注意力集中在业务逻辑和数据模型上,而不是繁琐的视图操作细节。
ArkUI 组件体系是鸿蒙声明式 UI 的基础支撑。ArkUI 提供了丰富的内置组件,涵盖了容器类(Column、Row、Stack、Flex、Scroll 等)、基础类(Text、Image、TextInput、Button、Toggle、Progress 等)以及特定场景类(WebView、XComponent 等)。每种组件都拥有完善的属性配置方法和事件回调机制,开发者可以通过链式调用对组件进行精细化配置。同时,ArkUI 还提供了多态样式(@Styles)、全局自定义样式(@Extend)、自定义构建器(@Builder)等高级封装能力,使组件的复用性和代码的可维护性得到显著提升。
状态管理是声明式 UI 的灵魂所在。ArkTS 通过一系列装饰器来实现不同层级的状态管理:@State 用于组件内部状态,当状态变化时触发该组件的重新渲染;@Prop 用于父到子的单向数据传递;@Link 用于父子之间的双向数据同步,当任意一方修改时另一方自动更新;@Provide 和 @Consume 用于跨层级的数据共享;@Observed 和 @ObjectLink 用于嵌套对象的可观察化。这些装饰器构成了一个完整的状态管理体系,让数据能够在组件树的各个节点间有序流动,为复杂应用的构建提供了坚实的数据流基础设施。
一、数据模型层:接口定义与类型约束
1.1 数据接口的结构设计
在应用开发的工程实践中,数据模型层的合理设计是整个应用架构的基石。本应用采用了 ArkTS 的 interface 关键字来定义六组核心数据接口,每一组接口都对应着一个业务领域的数据结构。
interface FeedItem {
id: number
title: string
time: string
tag: string
text: string
}
interface LessonItem {
id: number
name: string
level: string
price: number
quota: number
cover: string
note: string
}
interface BallItem {
id: number
name: string
date: string
band: string
ticket: number
quota: number
note: string
}

FeedItem 接口描述了舞厅动态信息,包含唯一标识 id、标题 title、发布时间 time、分类标签 tag 和正文内容 text。这五个字段构成了一个动态信息的完整数据模型。在 ArkTS 中,interface 不仅可以定义对象形状,还能与类型系统深度配合,在编译期捕获类型不匹配的错误。当我们在后续代码中将 FeedItem 数组绑定到 ForEach 列表渲染时,TypeScript 的类型推断机制能够确保每个列表项的属性访问都是类型安全的。
LessonItem 接口则更为丰富,除了 id 和 name 之外,还包含 level(难度等级)、price(价格)、quota(剩余名额)、cover(封面图标)和 note(课程说明)。这里值得注意的设计决策是 cover 字段使用了 string 类型来存储 emoji 字符,而非图片资源路径。这是一种轻量化的数据展示策略,在原型开发和演示场景中能够快速呈现视觉效果,而无需引入图片资源管理模块。
BallItem 接口对应舞会信息,其中 band 字段关联了乐队名称,ticket 字段记录门票价格,quota 字段跟踪剩余席位数量。这种将关联信息内联到当前实体中的做法,在小型应用中简化了数据组织,避免了跨实体的关联查询复杂度。
技术要点: ArkTS 的 interface 与 TypeScript 的 interface 在语法层面高度一致,但 ArkTS 在运行时层面做了额外优化。interface 定义的类型在编译为 ArkTS 运行时表示后,能够被框架的状态观测系统直接识别。当这些接口类型的数组被 @State 或 @Link 装饰器管理时,数组的增删改操作会自动触发关联 UI 的差量更新。
1.2 完整的接口体系
interface BandItem {
id: number
name: string
style: string
hot: number
state: string
note: string
}
interface CupItem {
id: number
name: string
date: string
prize: number
quota: number
state: string
note: string
}
interface MyItem {
id: number
name: string
kind: string
date: string
note: string
}

BandItem 接口描述乐队信息,其中 style 字段表示乐队风格(如"摇摆爵士"、"蓝调"等),hot 字段是一个数值型的人气热度指标,state 字段使用字符串 ‘home’ 或 ‘free’ 来标记乐队是驻场状态还是可邀约状态。这种使用字符串字面量来表示状态的设计在中小型应用中十分常见,它比枚举类型更灵活,但在大型工程中建议使用 enum 来获得更好的类型安全性和代码可读性。
CupItem 接口对应赛事信息,prize 字段记录奖金金额,state 字段区分 ‘open’(报名中)和 ‘closed’(已截止)两种状态。quota 字段在赛事场景中代表剩余名额数,与 BallItem 中的 quota 语义相同但业务含义不同——一个是赛事参赛名额,一个是舞会入场席位。
MyItem 接口是最特殊的一个,它是一个"收藏"实体,通过 kind 字段来区分收藏对象的类型(舞会、课程、乐手、赛事)。这种设计模式在数据建模中被称为"多态实体"或"标签化实体",通过一个类型字段来统一管理不同种类的数据。在本应用中,所有收藏项都被统一存储在一个 MyItem 数组中,通过 kind 字段在 UI 层进行差异化展示。
技术要点: ArkTS 对 interface 的字段类型有严格的要求。所有字段必须在声明时指定类型,不能使用 TypeScript 的隐式 any 推断。这种严格性确保了在编译期就能捕获类型错误,避免运行时因类型不确定导致的异常。同时,interface 的字段不能有可选标记(?)在 ArkTS 的早期版本中,这要求所有数据必须完整初始化。
二、数据初始化层:静态数据与工具函数
2.1 静态数据集的定义
应用的数据来源在当前阶段以静态数组的形式直接定义在代码中。这种做法在应用的原型阶段或演示阶段十分常见,它能够快速搭建起完整的数据层,让开发者专注于 UI 和交互逻辑的实现,而无需等待后端接口的开发和联调。
const FEEDS: FeedItem[] = [
{ id: 1, title: '周六复古摇摆大舞会开票', time: '今天 09:00', tag: '舞会', text: '本周六晚 8 点复古摇摆大舞会,香槟金装点舞池,现场爵士乐队助兴。' },
{ id: 2, title: '零基础摇摆课新增晚班', time: '昨天 23:00', tag: '课程', text: '为上班族新增的晚班零基础课程,19:30-21:00,名额有限速抢。' },
{ id: 3, title: '舞会所全新木地板铺设', time: '昨天 18:00', tag: '动态', text: '全新枫木地板铺设完成,弹性与防滑俱佳,舞步更丝滑。' },
{ id: 4, title: '爵士四重奏驻场官宣', time: '前天 21:00', tag: '演出', text: '阿金爵士四重奏正式驻场,每周三至周日现场演奏经典摇摆曲目。' }
]
FEEDS 数组使用 const 关键字声明,类型标注为 FeedItem[]。在 ArkTS 中,const 声明的数组本身是不可重新赋值的引用,但数组内部的元素是可以被修改的。这意味着我们可以在运行时向 FEEDS 数组中添加新元素或修改现有元素的属性值。不过在本应用中,FEEDS 的初始数据被赋值给 @State 管理的变量后,所有修改操作都是通过 @State 变量来进行的,const 数组仅作为初始数据源。
每个 FeedItem 对象的字段值都遵循了接口定义的类型约束。例如 id 字段是 number 类型,title 是 string 类型。ArkTS 编译器会在编译时检查每个对象字面量的字段是否与接口定义匹配,任何多余字段或缺失字段都会导致编译错误。
技术要点: 在 ArkTS 中,const 声明的常量数组与 @State 管理的状态数组有着本质区别。const 数组是普通的数据引用,不具备状态观察能力;@State 数组则被框架的状态管理系统接管,任何对数组内容的修改(push、splice、unshift 等)都会触发关联 UI 的自动更新。在本应用中,FEEDS 作为初始值赋给 @State feeds,之后所有操作都通过 this.feeds 进行,确保 UI 响应正常工作。
2.2 热度数据与多维数据集
const HEAT: number[] = [64, 78, 70, 86, 80, 92, 84, 89]
HEAT 是一个简单的 number 数组,存储了八期舞会的热度数值。这些数值将在首页被渲染为一个柱状图,每个数值对应一根柱子的高度。这种将纯数值数组用于可视化展示的做法在数据驱动的 UI 开发中非常普遍,它体现了"数据即视图"的设计哲学——UI 的形态完全由数据决定,当数据变化时视图自动更新。
应用中还定义了 LESSONS、BALLS、BANDS、CUPS、MYS 等多组数据集,每组数据集都包含 8 到 10 条记录,覆盖了不同业务场景下的典型数据。这些数据集的设计考虑了多样性:课程数据涵盖了入门、进阶、中级三个难度等级;舞会数据覆盖了不同主题和票价区间;乐队数据包含了不同风格和驻场状态;赛事数据包含了不同奖金规模和报名状态。
技术要点: 在实际工程中,这类静态数据通常会被替换为从网络请求获取的动态数据。ArkTS 的异步数据获取通常使用 Promise 或 async/await 语法,获取到的数据通过 @State 赋值后即可自动刷新 UI。本应用采用的"静态数据先行"策略,使得 UI 开发与后端开发可以并行推进。
2.3 工具函数体系
应用定义了一组工具函数来处理数据展示的格式化和状态判断逻辑。这些函数不依赖于组件上下文,是纯粹的数据转换函数,可以在任意位置调用。
function heatBar(v: number): number {
return Math.floor(28 + v * 0.85)
}
function playsText(v: number): string {
if (v >= 10000) {
return (v / 10000).toFixed(1) + ' 万'
}
return v.toString()
}
function lessonStateText(q: number): string {
if (q > 0) {
return '可约'
}
return '满员'
}
function lessonStateColor(q: number): string {
if (q > 0) {
return '#FCD34D'
}
return '#8A8A96'
}

heatBar 函数将一个热度数值映射为柱状图的高度值。它接受一个数值参数 v,通过公式 28 + v * 0.85 计算出像素高度,并使用 Math.floor 向下取整。这个线性映射函数的设计考虑了视觉范围的下限(28 像素的最小高度保证小数值也有可见的柱子)和增长速率(0.85 的系数让数值增长不会导致柱子过高超出容器)。这种数据到视觉属性的映射函数在数据可视化中十分常见。
lessonStateText 和 lessonStateColor 这两个函数体现了"状态判断与展示分离"的设计理念。lessonStateText 根据剩余名额是否大于零返回"可约"或"满员"的文字标签;lessonStateColor 则返回对应的颜色值——可用状态使用香槟金色 #FCD34D,不可用状态使用灰色 #8A8A96。将文字和颜色的判断逻辑封装在函数中,而不是散落在 UI 代码里,这样做有三个好处:一是提高了代码复用率,同一套判断逻辑可以在列表项和详情页中统一调用;二是确保了展示一致性,不会因为不同位置的硬编码导致状态颜色不统一;三是便于后续修改,若需要调整颜色方案只需修改一处函数实现。
技术要点: ArkTS 的 function 声明与 TypeScript 完全兼容,支持类型注解、泛型、可选参数等特性。在 ArkTS 中,顶层 function(定义在 struct 外部的函数)不依赖于组件上下文,是纯函数,适合用于数据处理和格式化。而定义在 struct 内部的方法则可以访问 this 上下文和组件状态。在架构设计时,应将无状态依赖的逻辑提取为顶层函数,有状态依赖的逻辑保留为组件方法。
2.4 状态映射函数与构建器函数
function bandStateText(s: string): string {
if (s === 'home') {
return '驻场中'
}
return '可邀约'
}
function bandStateColor(s: string): string {
if (s === 'home') {
return '#FCD34D'
}
return '#6EE7B7'
}
function trendText(t: string): string {
if (t === 'up') {
return '↑ 升温'
}
if (t === 'down') {
return '↓ 降温'
}
return '→ 持平'
}
function trendColor(t: string): string {
if (t === 'up') {
return '#FCD34D'
}
if (t === 'down') {
return '#E11D48'
}
return '#8A8A96'
}
bandStateText 和 bandStateColor 函数处理乐队状态的展示映射。当 state 为 ‘home’ 时表示乐队正在驻场演出,返回香槟金色和"驻场中"文字;否则返回薄荷绿色 #6EE7B7 和"可邀约"文字。这里使用两种不同的亮色来区分两种状态——驻场用金色突出其"正在服务"的重要性,可邀约用绿色暗示"可以行动"的信号含义。
trendText 和 trendColor 函数处理趋势数据的展示。趋势上升用金色和上箭头,趋势下降用红色 #E11D48 和下箭头,趋势持平用灰色和右箭头。这种颜色编码遵循了通用的视觉语义:金色代表积极、红色代表警示、灰色代表中性。
技术要点: 在 ArkTS 中,字符串相等比较使用 === 运算符(严格相等)。与 TypeScript 不同的是,ArkTS 不支持 == 运算符的隐式类型转换,强制要求比较双方类型一致。这种设计避免了因类型隐式转换导致的难以追踪的 bug,是 ArkTS 类型严格性的体现之一。
2.5 数据构建器函数
应用还定义了一组"构建器"函数,用于在运行时动态生成数据对象。这些函数封装了对象创建的细节,确保新建的对象符合接口定义的类型约束。
function buildFeed(id: number, title: string): FeedItem {
return { id: id, title: title, time: '刚刚', tag: '动态', text: '这是一条刚刚发布的舞会所动态,欢迎各位舞友围观互动。' }
}
function buildMy(id: number, name: string, kind: string): MyItem {
return { id: id, name: name, kind: kind, date: '08-28', note: '刚刚收藏' }
}
function buildLesson(id: number, name: string): LessonItem {
return { id: id, name: name, level: '入门', price: 128, quota: 12, cover: '🩰', note: '这是一节刚刚开设的舞蹈课程,欢迎报名体验。' }
}
function buildLessonBook(src: LessonItem, quota: number): LessonItem {
return { id: src.id, name: src.name, level: src.level, price: src.price, quota: quota, cover: src.cover, note: src.note }
}
function buildBallBuy(src: BallItem, quota: number): BallItem {
return { id: src.id, name: src.name, date: src.date, band: src.band, ticket: src.ticket, quota: quota, note: src.note }
}

buildFeed 函数接受 id 和 title 参数,生成一个带有默认值的 FeedItem 对象。time 字段固定为"刚刚",tag 固定为"动态",text 使用了一段默认文案。这种设计允许用户在发布动态时只需提供标题,其他字段自动填充默认值,简化了用户操作流程。
buildLessonBook 和 buildBallBuy 是两个特殊的构建器函数,它们接受一个源对象和一个新的 quota 值,返回一个修改了 quota 的副本。这种"不可变更新"模式在状态管理中非常重要——它不直接修改原对象,而是创建一个新对象用于替换数组中的旧元素。在 ArkTS 的状态管理框架中,使用 splice 方法替换数组元素是触发 UI 更新的推荐方式之一。
技术要点: 不可变数据更新(Immutable Update)是声明式 UI 框架中状态管理的核心原则之一。虽然 ArkTS 的 @State 装饰器能够观测数组方法(push、splice、unshift 等)引起的变更,但直接修改对象属性(如 this.picked.quota = 5)不一定能被框架正确观测。通过创建新对象并替换数组元素的方式,可以确保状态变更被框架捕获,从而正确触发 UI 更新。这正是 buildLessonBook 等函数存在的原因。
三、主入口组件:多 Tab 架构与布局体系
3.1 @Entry 与 @Component 装饰器
应用的主入口由 @Entry 和 @Component 装饰器共同标记。这两个装饰器在 ArkTS 的组件体系中承担着不同但互补的角色。
@Entry
@Component
struct Index {
@State currentTab: number = 0
@State feeds: FeedItem[] = FEEDS
@State lessons: LessonItem[] = LESSONS
@State balls: BallItem[] = BALLS
@State bands: BandItem[] = BANDS
@State cups: CupItem[] = CUPS
@State mys: MyItem[] = MYS
@Entry 装饰器标记当前组件为页面的入口组件。在一个 ArkTS 页面文件中,只能有一个 struct 被 @Entry 修饰。被 @Entry 标记的组件会被注册为页面的根组件,框架在加载页面时会实例化这个组件并将其渲染到屏幕上。@Entry 组件的生命周期与页面生命周期绑定,当页面创建时组件实例化,当页面销毁时组件被回收。
@Component 装饰器标记当前 struct 为一个自定义组件。自定义组件是 ArkTS UI 复用的基本单元,它可以被其他组件引用和组合。@Component 装饰器会为 struct 生成组件元数据,包括属性观察器、生命周期钩子、渲染调度器等框架基础设施。每个被 @Component 修饰的 struct 必须实现 build 方法,该方法返回组件的 UI 结构。
技术要点: @Entry 和 @Component 的区别在于:@Component 修饰的组件可以被其他组件引用,也可以作为页面入口;@Entry 则专门用于标记页面入口组件,它在 @Component 的基础上增加了页面级别的生命周期管理(如 onPageShow、onPageHide)和路由注册功能。简单来说,@Entry = @Component + 页面入口能力。
3.2 @State 状态变量的声明
Index 组件声明了七个 @State 状态变量。currentTab 是当前激活的 Tab 索引,初始值为 0,对应首页。其余六个变量分别管理六个业务数据集,初始值来自前面定义的静态常量数组。
@State 装饰器是 ArkTS 中最基础的状态管理装饰器。它为被装饰的变量添加了状态观察能力:当变量值发生变化时,框架会自动重新执行 build 方法(或引用该变量的 @Builder 方法),计算出新的 UI 结构与旧结构的差异,然后执行最小化的 DOM 更新操作。
@State 变量的观察深度取决于数据类型。对于基本类型(number、string、boolean),@State 能精确观测到值的变化。对于数组类型,@State 能观测到数组方法调用(push、pop、splice、unshift、shift、sort 等)引起的变更。对于对象类型,@State 能观测到对象属性的直接赋值,但无法观测嵌套对象属性的深层变更——这是 ArkTS 状态观测的一个限制,需要通过 @Observed 和 @ObjectLink 装饰器来解决。
技术要点: @State 变量的初始值在组件实例化时被设置,之后的所有修改都会触发 UI 更新。在本应用中,feeds、lessons、balls 等数组虽然初始值来自 const 数组,但赋值给 @State 变量后,框架会创建一个新的可观察代理来管理这些数据。因此,对 this.feeds 的操作(如 unshift、splice)会触发 UI 更新,而对原始 FEEDS 常量的修改不会。
3.3 @Builder 自定义构建器与 Tab 项的封装
@Builder
tabItem(icon: string, label: string, tab: number) {
Column({ space: 3 }) {
Text(icon)
.fontSize(22)
.opacity(this.currentTab === tab ? 1 : 0.55)
.scale({ x: this.currentTab === tab ? 1.12 : 1, y: this.currentTab === tab ? 1.12 : 1 })
Text(label)
.fontSize(11)
.fontColor(this.currentTab === tab ? '#FCD34D' : '#7E8A80')
.fontWeight(this.currentTab === tab ? FontWeight.Bold : FontWeight.Normal)
}
.width('16.6%')
.height(58)
.justifyContent(FlexAlign.Center)
.animation({ duration: 200, curve: Curve.EaseOut })
.onClick(() => {
this.currentTab = tab
})
}

@Builder 装饰器是 ArkTS 提供的 UI 片段封装机制。它允许开发者将一段 UI 结构封装为一个可复用的构建方法,通过参数传递实现差异化配置。与 @Component 自定义组件不同,@Builder 不会创建独立的组件实例,它更像是一个"UI 宏"——在被引用处直接展开为内联的 UI 结构。
tabItem 构建器封装了底部 Tab 栏中单个 Tab 项的 UI 结构。它接受三个参数:icon(图标 emoji)、label(文字标签)、tab(Tab 索引值)。通过 this.currentTab === tab 的条件判断,实现了选中态和未选中态的差异化展示:选中时图标完全不透明(opacity 为 1)并放大到 1.12 倍,文字变为香槟金色和粗体;未选中时图标半透明(opacity 为 0.55),文字为灰绿色和正常字重。
Column 组件是一个纵向排列子元素的容器组件。这里使用 Column({ space: 3 }) 创建了一个子元素间距为 3 像素的纵向容器,将图标和文字垂直排列。Column 的 justifyContent 属性设置为 FlexAlign.Center,使子元素在纵向方向上居中对齐。
animation 属性为 Column 内部的属性变化添加了过渡动画。duration: 200 表示动画持续 200 毫秒,curve: Curve.EaseOut 表示使用先快后慢的缓动曲线。当用户点击 Tab 切换时,图标的 opacity 和 scale 变化、文字的 fontColor 和 fontWeight 变化都会以平滑动画过渡,而非生硬地瞬间跳变。
技术要点: @Builder 与 @Component 的核心区别在于组件实例和状态作用域。@Builder 方法在所属组件的上下文中执行,可以直接访问 this(即宿主组件的状态变量和方法),不需要通过参数传递状态。而 @Component 自定义组件拥有独立的状态作用域,父子组件间通过 @Prop、@Link 等装饰器进行数据传递。@Builder 更适合封装简单的、与宿主组件状态紧密关联的 UI 片段;@Component 更适合封装独立的、可复用的复杂组件。
3.4 build 方法与整体布局结构
build() {
Column() {
// Art Deco 放射线头部(无动画)
Column() {
Row() {
Text('◢')
.fontSize(14)
.fontColor('#D97706')
Text('SWING DANCE')
.fontSize(17)
.fontColor('#FCD34D')
.fontWeight(FontWeight.Bold)
.letterSpacing(3)
.backgroundColor('#0D1F14')
.border({ width: 2, color: '#D97706' })
.padding({ left: 12, right: 12, top: 4, bottom: 4 })
Text('◣')
.fontSize(14)
.fontColor('#D97706')
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
build 方法是每个 @Component 组件必须实现的核心方法,它返回组件的 UI 结构。Index 组件的 build 方法构建了一个三段式的垂直布局:顶部头部区域、中间内容区域、底部 Tab 栏。整个页面由一个根 Column 容器包裹,width 和 height 均设为 100%,使页面铺满整个屏幕。
头部区域是一个嵌套的 Column 容器,内部包含三行 Row。第一行是品牌标识行,使用 Row 的 justifyContent(FlexAlign.SpaceBetween) 将左右两个三角形符号推向两端,中间放置"SWING DANCE"品牌文字。品牌文字使用了 letterSpacing(3) 来增加字符间距,营造出 Art Deco 风格的舒展感;backgroundColor 设为深墨绿色 #0D1F14,配合 border 的琥珀金色 #D97706 边框,形成了强烈的复古质感。
Row 是一个横向排列子元素的容器组件。与 Column 类似,Row 也支持 space 参数设置子元素间距,支持 justifyContent 设置主轴对齐方式,支持 alignItems 设置交叉轴对齐方式。FlexAlign.SpaceBetween 表示子元素在主轴方向上均匀分布,首尾两个元素分别贴靠两端,中间元素间距相等。这是导航栏、工具栏等横向布局中最常用的对齐模式。
技术要点: Column 和 Row 是 ArkUI 中最基础的两个线性布局容器。Column 的主轴方向是垂直(从上到下),Row 的主轴方向是水平(从左到右)。它们共享一套对齐 API:justifyContent 控制主轴对齐,alignItems 控制交叉轴对齐。理解主轴和交叉轴的概念是掌握 Flex 布局体系的关键——在 Column 中,主轴是垂直方向、交叉轴是水平方向;在 Row 中恰好相反。
3.5 Art Deco 放射线装饰与 ForEach 渲染
Row() {
ForEach([0, 1, 2, 3, 4, 5, 6, 7, 8], (i: number, index: number) => {
Row()
.layoutWeight(1)
.height(3)
.backgroundColor(index % 2 === 0 ? '#D97706' : '#14532D')
}, (i: number, index: number) => 'line' + index.toString())
}
.width('100%')
.margin({ top: 6 })
Row() {
ForEach([8, 16, 24, 16, 8], (w: number, index: number) => {
Row()
.width(w)
.height(3)
.backgroundColor('#FCD34D')
.margin({ left: 4, right: 4 })
}, (w: number, index: number) => 'dot' + index.toString())
}
.width('100%')
.justifyContent(FlexAlign.Center)
.margin({ top: 4 })
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 10 })
.backgroundColor('#101418')
.border({ width: { bottom: 2 }, color: '#D97706' })
头部区域的第二行和第三行使用 ForEach 渲染了 Art Deco 风格的放射线装饰。第二行通过 ForEach 遍历数字数组 [0,1,2,3,4,5,6,7,8],生成 9 根细长的 Row 条,每根条使用 layoutWeight(1) 平分宽度,高度仅 3 像素。通过 index % 2 === 0 的条件判断,偶数索引的条使用琥珀金色 #D97706,奇数索引使用深墨绿色 #14532D,形成了交替条纹的视觉效果。
layoutWeight 是 ArkUI 中用于弹性布局的权重分配属性。当一个容器内有多个子元素设置了 layoutWeight 时,容器会在分配完固定尺寸的子元素后,将剩余空间按 layoutWeight 值的比例分配给这些子元素。在这里,9 个 Row 都设置了 layoutWeight(1),它们将平分容器的全部宽度(因为没有任何子元素占据固定宽度),每个 Row 获得大约 11.1% 的宽度。
第三行使用 ForEach 遍历宽度数组 [8, 16, 24, 16, 8],生成 5 个不同宽度的金色小条。宽度数组的设计呈对称的"菱形"分布——从两端到中间逐渐增宽(8、16、24 再回到 16、8),模拟了 Art Deco 风格中常见的放射状图案。每个小条使用 margin({ left: 4, right: 4 }) 设置了左右间距,使条与条之间留有视觉呼吸空间。
ForEach 是 ArkTS 中用于列表渲染的核心组件。它接受三个参数:数据源数组、子项生成函数、键值生成函数。子项生成函数为数组中的每个元素生成对应的 UI 结构;键值生成函数为每个元素生成唯一标识,框架使用这个标识来进行差量更新——当数据源变化时,框架通过比较新旧键值集合来确定哪些项需要新增、删除或重排。
技术要点: ForEach 的第三个参数(键值生成函数)虽然可选,但在实际开发中强烈建议提供。如果没有提供键值生成函数,框架默认使用数组索引作为键值,这会导致在数据增删或重排时出现错误的 UI 复用——例如删除列表的第二项后,原来第三项的数据会被错误地绑定到第二项的 UI 上。通过提供稳定的、与数据内容关联的键值(如 ‘line’ + index 或 ‘feed’ + item.id),可以确保数据与 UI 的正确对应关系。
3.6 Stack 层叠布局与 Tab 内容切换
// 内容区
Stack() {
if (this.currentTab === 0) {
HomeContent({ feeds: this.feeds, lessons: this.lessons, mys: this.mys })
}
if (this.currentTab === 1) {
LessonContent({ lessons: this.lessons, mys: this.mys })
}
if (this.currentTab === 2) {
BallContent({ balls: this.balls, mys: this.mys })
}
if (this.currentTab === 3) {
BandContent({ bands: this.bands, mys: this.mys })
}
if (this.currentTab === 4) {
CupContent({ cups: this.cups, mys: this.mys })
}
if (this.currentTab === 5) {
MeContent({ mys: this.mys, lessons: this.lessons, balls: this.balls })
}
}
.layoutWeight(1)
.width('100%')

内容区域使用 Stack 组件作为容器,通过条件渲染(if 语句)根据 currentTab 的值来决定显示哪个子组件。Stack 是一个层叠布局容器,它的子元素会按照声明顺序从底到顶层叠排列。在这里,虽然理论上同一时刻只有一个条件为真(因为 currentTab 只有一个值),但 Stack 的使用确保了内容区域占据剩余空间。
Stack 设置了 layoutWeight(1),在父 Column 中,它会在头部区域和底部 Tab 栏占据固定高度后,获得剩余的所有垂直空间。这种"固定头部 + 弹性内容 + 固定底部"的三段式布局是移动应用最经典的页面结构。
每个 Tab 内容组件在创建时接收来自父组件的数据。例如 HomeContent 接收 feeds、lessons、mys 三个参数,这些参数通过 @Link 装饰器在子组件中声明为双向绑定的状态变量。这意味着当子组件修改这些数据时(如添加新动态、添加新收藏),父组件的 @State 变量也会同步更新,从而确保数据在组件树中的一致性。
技术要点: Stack 组件的层叠特性在弹窗实现中尤为重要。当 Stack 的子元素使用 position 绝对定位或 fillParent 属性时,可以覆盖在底层内容之上。本应用中所有弹窗都是通过 Stack 的层叠特性实现的——弹窗内容作为 Stack 的顶层子元素,使用半透明背景遮盖底层内容,实现模态对话框效果。
3.7 底部 Tab 栏的构建
// 底部 Tab 单排
Row() {
this.tabItem('🏛️', '首页', 0)
this.tabItem('🩰', '课程', 1)
this.tabItem('🪩', '舞会', 2)
this.tabItem('🎷', '乐手', 3)
this.tabItem('🏆', '赛事', 4)
this.tabItem('👤', '我的', 5)
}
.width('100%')
.backgroundColor('#101418')
.border({ width: { top: 1 }, color: '#D97706' })
}
.width('100%')
.height('100%')
.backgroundColor('#0D1114')
}
}
底部 Tab 栏使用一个 Row 容器包裹六个 tabItem 构建器调用。每个 tabItem 占据 16.6% 的宽度(在 tabItem 定义中通过 width(‘16.6%’) 设置),六个加起来约等于 100%,实现了底部 Tab 的均匀分布。Tab 栏的背景色为 #101418(接近黑色的深灰),顶部边框为琥珀金色 #D97706,与头部区域的底边框形成视觉呼应。
@Builder 方法的调用语法是 this.methodName(parameters),它看起来像普通方法调用,但实际上是在当前组件的 UI 上下文中展开 UI 结构。在 tabItem 构建器内部,this.currentTab 的引用会正确指向 Index 组件的状态变量,因为 @Builder 方法在宿主组件的上下文中执行。
技术要点: border 属性支持对象语法来为不同边设置不同的边框。
border({ width: { top: 1 }, color: '#D97706' })表示只设置顶部边框,宽度为 1,颜色为琥珀金色。这种细粒度的边框控制在 UI 设计中非常实用——例如卡片组件通常只需要底部边框来区分上下内容,而不需要完整的四边边框。
四、首页 Tab:动态信息流与数据可视化
4.1 HomeContent 组件的状态声明
@Component
struct HomeContent {
@Link feeds: FeedItem[]
@Link lessons: LessonItem[]
@Link mys: MyItem[]
@State showDetail: boolean = false
@State picked: FeedItem = FEEDS[0]
@State showPost: boolean = false
@State postTitle: string = ''
@State postText: string = ''
@State tip: string = ''
HomeContent 组件声明了三个 @Link 变量和五个 @State 变量。@Link 变量 feeds、lessons、mys 与父组件 Index 中对应的 @State 变量建立双向绑定关系。当 HomeContent 修改 these.feeds(如添加新动态)时,Index 中的 this.feeds 也会同步更新,反之亦然。这种双向绑定确保了数据在组件树中的一致性。
@State 变量管理组件内部的 UI 状态:showDetail 控制动态详情弹窗的显隐,picked 存储当前选中的动态项,showPost 控制发布动态弹窗的显隐,postTitle 和 postText 存储用户在发布弹窗中输入的标题和内容,tip 存储操作提示信息。这些状态变量都是组件私有的,不需要与父组件共享。
@Link 装饰器是 ArkTS 双向数据绑定的核心机制。当父组件将 @State 变量传递给子组件的 @Link 变量时,框架会在两个变量之间建立同步关系——任何一方的修改都会自动反映到另一方。这与 @Prop 的单向传递(父到子)形成对比。@Link 适用于"子组件需要修改父组件数据"的场景,如收藏操作、购买操作等。
技术要点: @Link 变量的初始化方式与 @State 不同。@State 变量在声明时直接赋初始值,而 @Link 变量的初始值来自父组件的传参。在组件创建时,父组件通过参数传递(如
HomeContent({ feeds: this.feeds }))将 @State 变量绑定到子组件的 @Link 变量。框架会建立双向观察通道,确保数据同步。
4.2 动态详情弹窗的 @Builder 实现
@Builder
detailModalOverlay(onClose: () => void) {
Column() {
Column() {
Row() {
Text('📄 动态详情')
.fontSize(17)
.fontColor('#FDF6E3')
.fontWeight(FontWeight.Bold)
Text('✕')
.fontSize(16)
.fontColor('#8A8A96')
.onClick(() => {
onClose()
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 12 })
Row() {
Text('#' + this.picked.tag)
.fontSize(11)
.fontColor('#0D1114')
.backgroundColor('#FCD34D')
.borderRadius(3)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
Text(this.picked.time)
.fontSize(11)
.fontColor('#8A8A96')
}
.width('100%')
.margin({ bottom: 10 })

detailModalOverlay 是一个 @Builder 方法,用于构建动态详情弹窗。它接受一个 onClose 回调函数作为参数,当用户点击关闭按钮或执行某些操作后,调用 onClose() 来关闭弹窗。这种通过回调函数控制弹窗关闭的模式在本应用中被广泛使用——每个弹窗的 @Builder 方法都接受一个 onClose 回调,由调用方决定关闭时的具体行为(通常是设置对应的 showXxx 状态变量为 false)。
弹窗的结构由两层 Column 组成。外层 Column 充当遮罩层,width 和 height 均设为 100%,背景色使用半透明黑色 rgba(6, 9, 11, 0.84),justifyContent 设为 FlexAlign.Center 使弹窗主体在遮罩层中居中显示。内层 Column 是弹窗主体,宽度为 88%,背景色为墨绿色 #16241C,borderRadius 为 10,border 为琥珀金色,padding 为 16,营造出一个浮在半透明遮罩之上的卡片式弹窗。
弹窗标题行使用 Row 容器,左右两端分别放置标题文字和关闭按钮。关闭按钮是一个 Text 组件显示"✕"符号,绑定 onClick 事件调用 onClose() 回调。标签行展示当前选中动态的分类标签和发布时间,标签使用香槟金色背景和深色文字形成"药丸"形态的标签。
技术要点: rgba 颜色格式在 ArkTS 中被完全支持,用于创建半透明颜色效果。rgba(6, 9, 11, 0.84) 表示红色分量 6、绿色分量 9、蓝色分量 11、透明度 0.84(即 84% 不透明)。这种半透明遮罩是模态弹窗的标准实现方式——底层内容隐约可见但不交互,顶层弹窗获得焦点。
4.3 动态详情弹窗的内容与操作按钮
Text(this.picked.title)
.fontSize(16)
.fontColor('#F3ECDD')
.fontWeight(FontWeight.Medium)
.width('100%')
.margin({ bottom: 8 })
Text(this.picked.text)
.fontSize(13)
.fontColor('#B9B9C4')
.width('100%')
.lineHeight(20)
Row() {
Text('👍 点赞')
.fontSize(12)
.fontColor('#FCD34D')
.textAlign(TextAlign.Center)
.width('45%')
.height(36)
.border({ width: 1, color: '#FCD34D' })
.borderRadius(6)
.onClick(() => {
this.tip = '👍 已点赞,感谢支持'
onClose()
})
Text('🩰 分享舞步')
.fontSize(12)
.fontColor('#0D1114')
.textAlign(TextAlign.Center)
.width('45%')
.height(36)
.backgroundColor('#D97706')
.borderRadius(6)
.onClick(() => {
this.tip = '🩰 舞步分享已生成'
onClose()
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ top: 12 })

弹窗内容区展示了选中动态的标题和正文。标题使用 16 像素字号和奶油白色 #F3ECDD,正文使用 13 像素字号和浅灰色 #B9B9C4,lineHeight 设为 20 像素增加行间距提升可读性。这种字号层次的设计遵循了"标题大于正文"的排版原则,同时颜色的深浅差异也帮助用户区分信息层级。
操作按钮区域包含两个按钮,使用 Row 的 SpaceBetween 对齐使两个按钮分别贴靠左右两端。"点赞"按钮使用描边样式(border + 无背景色),"分享舞步"按钮使用实心填充样式(backgroundColor 填充),两种按钮样式的对比形成了视觉层次——实心按钮引导主要操作,描边按钮承载次要操作。
每个按钮的 onClick 事件都会设置 tip 状态变量并调用 onClose() 关闭弹窗。tip 变量被修改后会触发 UI 更新,在列表底部显示一条操作提示信息。这种"操作后即时反馈"的交互模式在移动应用中非常普遍,它替代了传统的 Toast 或 Snackbar 组件,利用 ArkTS 的状态驱动特性实现了轻量级的用户反馈。
技术要点: borderRadius 属性用于设置组件的圆角半径。borderRadius(6) 表示 6 像素的圆角,borderRadius(10) 表示 10 像素的圆角。在 ArkUI 中,较大的圆角值(如 10-16)用于卡片和弹窗主体,中等的圆角值(如 6-8)用于按钮和小型卡片,较小的圆角值(如 3-4)用于标签和徽章。合理的圆角层级能够增强 UI 的层次感和精致度。
4.4 发布动态弹窗与 TextInput 输入框
@Builder
postModalOverlay(onClose: () => void) {
Column() {
Column() {
Row() {
Text('🩰 发布舞会动态')
.fontSize(17)
.fontColor('#FDF6E3')
.fontWeight(FontWeight.Bold)
Text('✕')
.fontSize(16)
.fontColor('#8A8A96')
.onClick(() => {
onClose()
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 12 })
Text('动态标题')
.fontSize(12)
.fontColor('#8A8A96')
.width('100%')
TextInput({ placeholder: '例如:第一次跳摇摆舞的快乐', text: this.postTitle })
.width('100%')
.height(40)
.backgroundColor('#0D1114')
.fontColor('#F3ECDD')
.placeholderColor('#5A5A66')
.borderRadius(6)
.margin({ top: 4, bottom: 10 })
.onChange((v: string) => {
this.postTitle = v
})
postModalOverlay 构建器实现了发布动态的表单弹窗。弹窗包含两个输入字段:动态标题和内容说明。每个字段由一个标签 Text 和一个 TextInput 输入框组成。
TextInput 是 ArkUI 提供的文本输入组件,支持多种配置参数。placeholder 参数设置占位提示文字,当输入框为空时显示,引导用户输入期望的内容。text 参数用于双向绑定输入框的文本值——当用户输入时,onChange 回调被触发,将新值赋给 this.postTitle 状态变量;当 this.postTitle 被外部修改时(如发布后清空),输入框的显示内容也会同步更新。
TextInput 的样式配置体现了深色主题的设计一致性:背景色为深色 #0D1114,文字为奶油白色 #F3ECDD,占位文字为暗灰色 #5A5A66。这些颜色与弹窗整体的墨绿色背景和琥珀金色装饰形成协调的深色系视觉风格。
技术要点: TextInput 的 onChange 回调是处理用户输入的核心机制。回调函数接收一个 string 参数 v,即用户当前输入的文本。通过将 v 赋给 @State 变量,实现了"输入即状态更新"的双向绑定效果。需要注意的是,onChange 在每次按键时都会触发,如果需要进行输入验证或防抖处理,应在回调中实现相应的逻辑。
4.5 发布动态的数据操作
Row() {
Text('取消')
.fontSize(13)
.fontColor('#B9B9C4')
.textAlign(TextAlign.Center)
.width('45%')
.height(38)
.border({ width: 1, color: '#3A3A46' })
.borderRadius(6)
.onClick(() => {
onClose()
})
Text('发布动态')
.fontSize(13)
.fontColor('#0D1114')
.textAlign(TextAlign.Center)
.width('45%')
.height(38)
.backgroundColor('#FCD34D')
.borderRadius(6)
.onClick(() => {
if (this.postTitle.length > 0) {
const nextId = this.feeds.length + 1
this.feeds.unshift(buildFeed(nextId, this.postTitle))
this.tip = '✅ 舞会动态已发布'
} else {
this.tip = '⚠️ 请先填写动态标题'
}
this.postTitle = ''
this.postText = ''
onClose()
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
发布按钮的 onClick 事件处理逻辑包含了输入验证和数据操作两个步骤。首先检查 postTitle 是否非空(length > 0),如果为空则设置提示信息要求填写标题。如果标题有效,则通过 buildFeed 函数构建一个新的 FeedItem 对象,并使用 unshift 方法将其插入到 feeds 数组头部。
unshift 是 JavaScript/TypeScript 数组的原生方法,用于在数组头部插入元素。在 ArkTS 的 @State 管理下,调用 unshift 会触发框架的状态观测机制,自动更新依赖 feeds 数组的 UI——即动态列表会立即在顶部显示新发布的动态。
操作完成后,清空 postTitle 和 postText 状态变量并调用 onClose() 关闭弹窗。状态清空操作确保了下次打开发布弹窗时输入框为空白状态,不会残留上次输入的内容。这种"操作后重置"的模式在表单类弹窗中是标准实践。
技术要点: ArkTS 的 @State 对数组的 unshift、push、splice、shift、pop、sort、reverse 等方法进行了代理(Proxy)包装,使得这些方法的调用能够被框架捕获并触发 UI 更新。这是 ArkTS 状态管理框架实现"数组级"观测的基础。但直接通过索引赋值(如 this.feeds[0] = newItem)不一定能触发更新,建议使用 splice 方法进行数组元素的替换。
4.6 首页主界面的 Stack 布局与 Scroll 滚动容器
build() {
Stack() {
Column() {
Scroll() {
Column() {
// 舞厅横幅
Row() {
Column({ space: 6 }) {
Text('SWING DANCE')
.fontSize(20)
.fontColor('#0D1114')
.fontWeight(FontWeight.Bold)
.letterSpacing(2)
Text('复古舞厅 · 爵士现场 · 优雅摇摆')
.fontSize(11)
.fontColor('#0D1114')
.opacity(0.75)
}
.alignItems(HorizontalAlign.Start)
Column({ space: 4 }) {
Text('● 营业中')
.fontSize(11)
.fontColor('#0D1114')
.backgroundColor('#FDF6E3')
.borderRadius(10)
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
Text('14:00-24:00')
.fontSize(10)
.fontColor('#0D1114')
.opacity(0.7)
}
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding(16)
.backgroundColor('#D97706')
.borderRadius(10)
.margin({ top: 12 })
HomeContent 的 build 方法使用 Stack 作为根容器,实现内容区与弹窗的层叠。Stack 的第一个子元素是一个 Column,包含一个 Scroll 滚动容器,滚动容器内部是一个 Column 内容列。这种 Stack > Column > Scroll > Column 的嵌套结构是滚动列表页面的标准模式:Stack 提供弹窗层叠能力,外层 Column 提供页面结构,Scroll 提供滚动能力,内层 Column 组织内容。
Scroll 组件是 ArkUI 的滚动容器,它为其子元素提供滚动能力。当子元素的总高度超过 Scroll 的高度时,用户可以上下滑动查看被隐藏的内容。Scroll 默认只支持垂直滚动,可以通过 scrollable 属性设置为水平滚动。
舞厅横幅使用 Row 的 SpaceBetween 布局,左侧是品牌信息列,右侧是营业状态列。横幅背景色为琥珀金色 #D97706,文字颜色为深色 #0D1114,形成"深字浅底"的高对比度设计。营业状态标签使用奶油白色 #FDF6E3 背景和深色文字,borderRadius(10) 使其呈现为药丸形态,是典型的状态标签设计模式。
技术要点: Scroll 组件与 Column 的根本区别在于:Column 在内容超出自身高度时会溢出(超出部分不可见),而 Scroll 在内容超出时会提供滚动条和手势滑动能力。在移动应用中,几乎所有内容页都需要 Scroll 包裹,因为屏幕高度有限,而内容可能远超屏幕高度。Scroll 的 layoutWeight(1) 设置使其在父容器中占据剩余空间,作为滚动区域。
4.7 周热度柱状图的 ForEach 渲染
// 周热度柱状图
Row() {
Text('📊 本周舞会热度')
.fontSize(14)
.fontColor('#F3ECDD')
.fontWeight(FontWeight.Bold)
Text('近 8 期')
.fontSize(11)
.fontColor('#8A8A96')
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ top: 14, bottom: 6 })
Row({ space: 5 }) {
ForEach(HEAT, (v: number, index: number) => {
Column() {
Text(v.toString())
.fontSize(9)
.fontColor('#FCD34D')
Row()
.width(16)
.height(heatBar(v))
.backgroundColor(index % 2 === 0 ? '#D97706' : '#FCD34D')
.borderRadius(2)
Text('周' + (index + 1).toString())
.fontSize(9)
.fontColor('#8A8A96')
}
.width('11%')
}, (v: number, index: number) => 'h' + index.toString())
}
.width('100%')
.height(120)
.alignItems(VerticalAlign.Bottom)
.justifyContent(FlexAlign.SpaceBetween)
.padding(8)
.backgroundColor('#16241C')
.borderRadius(8)
.border({ width: 1, color: '#24402C' })
周热度柱状图是首页的一个数据可视化模块。它使用 ForEach 遍历 HEAT 数组 [64, 78, 70, 86, 80, 92, 84, 89],为每个数值生成一个包含三部分的 Column:顶部的数值文字、中间的柱形条(Row 组件)、底部的周次标签。
柱形条的高度由 heatBar 函数计算,传入热度值 v 返回像素高度。通过 index % 2 === 0 的条件判断,偶数柱使用琥珀金色 #D97706,奇数柱使用香槟金色 #FCD34D,形成交替的色彩节奏。每个 Column 的宽度设为 11%,八根柱子加起来约 88%,加上 space 间距,在 100% 宽度的 Row 中均匀分布。
柱状图容器 Row 设置了 alignItems(VerticalAlign.Bottom),使所有柱子从底部对齐——这是柱状图的标准视觉特征。容器高度为 120 像素,背景色为墨绿色 #16241C,边框为深绿色 #24402C,borderRadius(8) 形成圆角卡片容器。
技术要点: alignItems(VerticalAlign.Bottom) 在 Row 中的作用是让所有子元素在交叉轴(垂直方向)上底部对齐。这是实现柱状图视觉效果的关键——如果默认居中对齐,柱子会从中间向上下伸展,不符合柱状图的阅读习惯。在 ArkUI 中,VerticalAlign 枚举提供了 Top(顶部对齐)、Center(居中对齐)、Bottom(底部对齐)三种垂直对齐模式。
4.8 精选速览三宫格与 layoutWeight 分配
// 精选速览
Row({ space: 8 }) {
Column({ space: 4 }) {
Text('🩰')
.fontSize(22)
Text('课程')
.fontSize(11)
.fontColor('#F3ECDD')
Text(this.lessons.length.toString() + ' 节在开')
.fontSize(9)
.fontColor('#8A8A96')
}
.layoutWeight(1)
.height(80)
.justifyContent(FlexAlign.Center)
.backgroundColor('#16241C')
.borderRadius(8)
.onClick(() => {
this.tip = '🩰 课程共 ' + this.lessons.length.toString() + ' 节可报名'
})
Column({ space: 4 }) {
Text('🪩')
.fontSize(22)
Text('舞会')
.fontSize(11)
.fontColor('#F3ECDD')
Text('近期 8 场')
.fontSize(9)
.fontColor('#8A8A96')
}
.layoutWeight(1)
.height(80)
.justifyContent(FlexAlign.Center)
.backgroundColor('#16241C')
.borderRadius(8)
.onClick(() => {
this.tip = '🪩 近期有 8 场舞会可购票'
})
}
.width('100%')
.margin({ top: 12 })
精选速览区域使用 Row 容器配合 layoutWeight 实现三等分布局。三个 Column 子元素都设置了 layoutWeight(1),它们将平分 Row 的可用宽度(减去 space 间距后的剩余空间)。每个 Column 的高度固定为 80 像素,内部使用 Column 的 space: 4 设置子元素间距,justifyContent 设为 FlexAlign.Center 使内容垂直居中。
每个速览卡片展示了图标、标题和数量信息。课程卡片显示当前课程数量 this.lessons.length.toString() + ' 节在开',这里通过 @Link 变量 lessons 的 length 属性获取实时数量,当课程数据变化时(如约课后名额减少但不影响课程总数),显示数量不会变化;但如果添加了新课程,数量会自动更新。
onClick 事件为每个卡片提供了点击反馈,设置 tip 状态变量显示对应的提示信息。这种"点击即提示"的交互模式在信息展示类组件中很常见,它为用户提供了操作的即时确认。
技术要点: layoutWeight 的权重分配机制与 CSS Flexbox 的 flex-grow 属性类似。当一个容器中有多个子元素设置了 layoutWeight 时,容器会在扣除固定宽度子元素和间距后,将剩余宽度按 layoutWeight 比例分配。三个 layoutWeight(1) 的子元素各获得三分之一的剩余宽度,实现了均匀的三等分。如果其中一个改为 layoutWeight(2),则它将获得二分之一的宽度。
4.9 动态列表的 ForEach 渲染与文本溢出处理
// 动态列表标题
Row() {
Text('📡 舞厅动态')
.fontSize(14)
.fontColor('#F3ECDD')
.fontWeight(FontWeight.Bold)
Text('+ 发布')
.fontSize(12)
.fontColor('#FCD34D')
.onClick(() => {
this.showPost = true
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ top: 14, bottom: 6 })
// 动态列表
ForEach(this.feeds, (item: FeedItem, index: number) => {
Row() {
Column({ space: 4 }) {
Text(item.title)
.fontSize(14)
.fontColor('#F3ECDD')
.fontWeight(FontWeight.Medium)
.width('100%')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(item.time + ' · ' + item.tag)
.fontSize(11)
.fontColor('#8A8A96')
.width('100%')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text('详情 ›')
.fontSize(12)
.fontColor('#FCD34D')
.onClick(() => {
this.picked = item
this.showDetail = true
})
}
.width('100%')
.padding(12)
.backgroundColor('#16241C')
.borderRadius(8)
.border({ width: 1, color: '#24402C' })
.margin({ bottom: 8 })
}, (item: FeedItem, index: number) => 'feed' + item.id.toString())
动态列表标题行使用 Row 的 SpaceBetween 布局,左侧是列表标题,右侧是"发布"操作入口。点击"发布"会设置 showPost 为 true,触发发布弹窗的显示。
动态列表使用 ForEach 遍历 this.feeds 数组,为每个 FeedItem 生成一个列表项。每个列表项是一个 Row 容器,左侧是信息列(Column),右侧是"详情"链接(Text)。信息列使用 layoutWeight(1) 占据剩余宽度,"详情"链接占据自然宽度。这种"弹性内容 + 固定操作"的布局模式在列表项设计中非常常见。
列表项标题使用了 maxLines(1) 和 textOverflow({ overflow: TextOverflow.Ellipsis }) 来处理文本溢出。maxLines(1) 限制文本最多显示一行,textOverflow 设置溢出时的处理方式为 Ellipsis(省略号),即当标题过长超出可用宽度时,末尾显示"…"表示被截断。这是列表项标题的标准处理方式,确保长标题不会破坏布局。
点击"详情 ›"链接时,将当前 item 赋值给 picked 状态变量,并设置 showDetail 为 true。picked 变量的值会在详情弹窗中被引用(通过 this.picked),实现"点击列表项打开详情"的标准交互流程。
技术要点: TextOverflow.Ellipsis 是 ArkUI 中文本溢出处理的枚举值之一。除了 Ellipsis(省略号),还有 None(直接截断不显示省略号)和 Clip(按容器边界裁剪)。在实际应用中,Ellipsis 是最常用的溢出处理方式,因为它在截断文本的同时向用户传达了"文本不完整"的视觉信号。配合 maxLines 使用,可以精确控制文本的显示行数和溢出行为。
4.10 操作提示条与条件渲染
if (this.tip.length > 0) {
Text(this.tip)
.fontSize(12)
.fontColor('#FDE68A')
.width('100%')
.padding(8)
.backgroundColor('#3A2A10')
.borderRadius(6)
.margin({ bottom: 8 })
}
操作提示条使用条件渲染(if 语句)来控制其显隐。当 tip 变量为空字符串时,提示条不渲染;当 tip 被设置为非空字符串时(如用户执行了点赞、发布、收藏等操作后),提示条自动出现并显示提示内容。
提示条的样式使用浅金色 #FDE68A 文字和深棕色 #3A2A10 背景,形成"暖色系"的提示视觉。这种颜色选择与整体的墨绿+金色配色方案形成对比,既能引起用户注意,又不会过于突兀。
在 ArkTS 中,条件渲染是控制 UI 元素显隐的推荐方式之一(另一种是 visibility 属性)。条件渲染的优势在于:当条件为 false 时,元素完全不会被创建和渲染,节省了内存和渲染开销;当条件变为 true 时,元素被创建并插入到 UI 树中。这与 visibility 属性的区别在于,visibility: Visibility.Hidden 只是隐藏元素但仍然占据空间,而条件渲染的元素不存在于渲染树中。
技术要点: 在 ArkTS 的声明式 UI 中,if 条件语句不仅可以用于简单的显隐控制,还可以用于实现复杂的分支渲染逻辑。与三元运算符(condition ? A : B)相比,if 语句更适合处理多分支或需要多行代码的场景。需要注意的是,if 语句内部创建的组件在条件为 false 时会被销毁,其内部状态也会丢失;而使用 visibility 属性隐藏的组件会保留状态。在选择显隐策略时需要考虑是否需要保留组件状态。
五、课程 Tab:筛选器与双列卡片布局
5.1 LessonContent 组件与等级横滑筛选器
@Component
struct LessonContent {
@Link lessons: LessonItem[]
@Link mys: MyItem[]
@State pickLevel: string = '全部'
@State showDetail: boolean = false
@State picked: LessonItem = LESSONS[0]
@State showBook: boolean = false
@State bookName: string = ''
@State bookDate: string = '周二晚场'
@State tip: string = ''
LessonContent 组件管理课程列表的展示和交互。pickLevel 状态变量记录当前选中的筛选等级,初始值为"全部"。showDetail 和 showBook 分别控制课程详情弹窗和约课弹窗的显隐。picked 存储当前选中的课程项。bookName 和 bookDate 存储约课表单中用户输入的姓名和选择的场次。
// 等级横滑
Scroll() {
Row({ space: 8 }) {
ForEach(['全部', '入门', '进阶', '中级'], (k: string, index: number) => {
Text(k)
.fontSize(12)
.fontColor(this.pickLevel === k ? '#0D1114' : '#B9B9C4')
.backgroundColor(this.pickLevel === k ? '#FCD34D' : '#16241C')
.borderRadius(14)
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.onClick(() => {
this.pickLevel = k
this.tip = '🔍 已筛选:' + k
})
}, (k: string, index: number) => 'k' + index.toString())
}
.padding({ right: 12 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.margin({ top: 12 })
等级横滑筛选器使用 Scroll 组件的 scrollable(ScrollDirection.Horizontal) 设置为水平滚动模式。内部包含一个 Row 容器,通过 ForEach 渲染四个等级选项:“全部”、“入门”、“进阶”、“中级”。每个选项是一个 Text 组件,通过 pickLevel === k 的条件判断实现选中态和未选中态的差异化展示。
选中态的选项使用香槟金色 #FCD34D 背景和深色文字 #0D1114,未选中态使用墨绿色 #16241C 背景和浅灰色 #B9B9C4 文字。borderRadius(14) 配合 padding 形成药丸形态的筛选标签,是移动应用中标签筛选器的标准设计模式。
scrollBar(BarState.Off) 隐藏了滚动条,使水平滚动更加自然。当筛选选项较多无法在一屏内显示时,用户可以左右滑动查看更多选项。这种"横滑筛选器"在电商应用、内容应用中非常常见,它比下拉选择器更直观,也比垂直排列的筛选器更节省空间。
技术要点: Scroll 组件的 scrollable 属性用于设置滚动方向。默认值为 ScrollDirection.Vertical(垂直滚动),设置为 ScrollDirection.Horizontal 后变为水平滚动。scrollBar 属性控制滚动条的显示状态:BarState.On(始终显示)、BarState.Off(不显示)、BarState.Auto(自动显示/隐藏)。在水平筛选器场景中,隐藏滚动条可以让 UI 更加简洁。
5.2 课程双列卡片的 ForEach 布局
// 课程双列卡片
ForEach([0, 2, 4, 6, 8], (i: number, index: number) => {
Row({ space: 8 }) {
Column() {
Text(this.lessons[i].cover)
.fontSize(30)
Text(this.lessons[i].name)
.fontSize(13)
.fontColor('#F3ECDD')
.fontWeight(FontWeight.Medium)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 6 })
Text(this.lessons[i].level + ' · ¥' + this.lessons[i].price.toString())
.fontSize(11)
.fontColor('#D97706')
.margin({ top: 2 })
Text(lessonStateText(this.lessons[i].quota) + ' · 剩 ' + this.lessons[i].quota.toString() + ' 人')
.fontSize(10)
.fontColor(lessonStateColor(this.lessons[i].quota))
.margin({ top: 4 })
Text('查看 ›')
.fontSize(11)
.fontColor('#FCD34D')
.margin({ top: 6 })
.onClick(() => {
this.picked = this.lessons[i]
this.showDetail = true
})
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.height(168)
.justifyContent(FlexAlign.Center)
.backgroundColor('#16241C')
.borderRadius(8)
.border({ width: 1, color: '#24402C' })
.padding(8)
课程列表采用了双列卡片布局,通过 ForEach 遍历索引数组 [0, 2, 4, 6, 8] 实现每行两个卡片的布局。每次迭代生成一个 Row 容器,内含两个 Column 卡片——第一个使用 this.lessons[i],第二个使用 this.lessons[i+1]。这种"步长为 2"的索引遍历是实现双列网格布局的经典技巧。
每个卡片是一个 Column 容器,layoutWeight(1) 使两列平分宽度,固定高度 168 像素,内容居中对齐。卡片内部从上到下依次展示:封面图标(emoji)、课程名称、等级+价格、状态+剩余名额、"查看"链接。课程名称使用 maxLines(1) 和 Ellipsis 处理溢出,确保长名称不会破坏卡片高度。
状态文字通过 lessonStateText 和 lessonStateColor 函数生成,这两个函数根据 quota 值返回对应的文字和颜色。当 quota > 0 时显示"可约"和金色,quota = 0 时显示"满员"和灰色。这种将业务逻辑封装在工具函数中的做法,使 UI 代码保持简洁和可读。
技术要点: ForEach 遍历索引数组 [0, 2, 4, 6, 8] 来实现双列布局,是一种巧妙但需要谨慎使用的技术。它要求数据源数组的长度必须是偶数(或有额外的边界检查),否则 this.lessons[i+1] 可能访问到 undefined 导致运行时错误。在实际工程中,更安全的做法是在遍历前对数组进行长度检查,或使用取整运算确保索引不越界。
5.3 课程详情弹窗与进度条实现
@Builder
detailModalOverlay(onClose: () => void) {
Column() {
Column() {
Row() {
Text('🩰 舞课详情')
.fontSize(17)
.fontColor('#FDF6E3')
.fontWeight(FontWeight.Bold)
Text('✕')
.fontSize(16)
.fontColor('#8A8A96')
.onClick(() => {
onClose()
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 12 })
Row() {
Text(this.picked.cover)
.fontSize(34)
Column({ space: 4 }) {
Text(this.picked.name)
.fontSize(17)
.fontColor('#F3ECDD')
.fontWeight(FontWeight.Bold)
Text(this.picked.level + ' 课程 · ¥' + this.picked.price.toString())
.fontSize(11)
.fontColor('#8A8A96')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 10 })
Text(lessonStateText(this.picked.quota))
.fontSize(11)
.fontColor(lessonStateColor(this.picked.quota))
.border({ width: 1, color: lessonStateColor(this.picked.quota) })
.borderRadius(10)
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
}
.width('100%')
.padding(12)
.backgroundColor('#0D1114')
.borderRadius(8)
.margin({ bottom: 10 })
课程详情弹窗的头部信息区使用 Row 三段式布局:左侧封面图标、中间名称和等级价格信息、右侧状态标签。状态标签的 border 颜色也通过 lessonStateColor 函数动态生成,确保文字颜色和边框颜色一致。
详情弹窗中有一个进度条组件,用于可视化展示剩余名额比例:
Text('剩余名额:' + this.picked.quota.toString() + ' 人')
.fontSize(12)
.fontColor(this.picked.quota > 0 ? '#FCD34D' : '#E11D48')
.width('100%')
.margin({ bottom: 6 })
Row()
.width('100%')
.height(8)
.backgroundColor('#2A2A36')
.borderRadius(4)
Row()
.width((this.picked.quota / 20 * 100).toString() + '%')
.height(8)
.backgroundColor('#D97706')
.borderRadius(4)
.offset({ x: 0, y: -8 })
这个进度条通过两个 Row 组件的层叠实现:底层 Row 宽度 100%、高度 8 像素、背景色为深灰色 #2A2A36,作为进度条的背景轨道;上层 Row 宽度为名额占比百分比 (this.picked.quota / 20 * 100).toString() + '%'、高度 8 像素、背景色为琥珀金色 #D97706,作为进度条的前景填充。
offset 属性用于设置组件的绝对偏移量。这里设置 offset({ x: 0, y: -8 }) 将前景 Row 向上偏移 8 像素(即自身高度),使其覆盖在背景 Row 之上。这种通过 offset 实现的层叠效果是 ArkUI 中一种轻量级的绝对定位方式。
技术要点: offset 属性与 position 属性的区别在于参考系不同。offset 是相对于元素在正常文档流中的位置进行偏移,元素仍然占据原始位置的空间;position 是相对于父容器进行绝对定位,元素脱离文档流不再占据空间。在进度条场景中,使用 offset 更合适,因为前景条需要在背景条的正上方覆盖,而不影响后续元素的布局。
5.4 约课弹窗与数据不可变更新
@Builder
bookModalOverlay(onClose: () => void) {
Column() {
Column() {
Row() {
Text('📅 预约舞课')
.fontSize(17)
.fontColor('#FDF6E3')
.fontWeight(FontWeight.Bold)
Text('✕')
.fontSize(16)
.fontColor('#8A8A96')
.onClick(() => {
onClose()
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 12 })
Text('课程:' + this.picked.name + '(' + this.picked.level + ')')
.fontSize(12)
.fontColor('#D97706')
.width('100%')
.margin({ bottom: 10 })
Text('你的姓名')
.fontSize(12)
.fontColor('#8A8A96')
.width('100%')
TextInput({ placeholder: '请输入姓名', text: this.bookName })
.width('100%')
.height(40)
.backgroundColor('#0D1114')
.fontColor('#F3ECDD')
.placeholderColor('#5A5A66')
.borderRadius(6)
.margin({ top: 4, bottom: 10 })
.onChange((v: string) => {
this.bookName = v
})
约课弹窗是一个表单弹窗,包含课程信息展示、姓名输入和场次选择。课程信息通过 this.picked.name 和 this.picked.level 动态显示当前选中的课程。姓名输入使用 TextInput 组件,通过 onChange 回调将输入值同步到 bookName 状态变量。
场次选择使用了两个 Text 按钮的"分段选择器"模式:
Text('选择场次')
.fontSize(12)
.fontColor('#8A8A96')
.width('100%')
.margin({ bottom: 6 })
Row({ space: 8 }) {
Text('周二晚场')
.fontSize(12)
.fontColor(this.bookDate === '周二晚场' ? '#0D1114' : '#B9B9C4')
.textAlign(TextAlign.Center)
.layoutWeight(1)
.height(34)
.backgroundColor(this.bookDate === '周二晚场' ? '#FCD34D' : '#0D1114')
.borderRadius(6)
.onClick(() => {
this.bookDate = '周二晚场'
})
Text('周六下午')
.fontSize(12)
.fontColor(this.bookDate === '周六下午' ? '#0D1114' : '#B9B9C4')
.textAlign(TextAlign.Center)
.layoutWeight(1)
.height(34)
.backgroundColor(this.bookDate === '周六下午' ? '#FCD34D' : '#0D1114')
.borderRadius(6)
.onClick(() => {
this.bookDate = '周六下午'
})
}
.width('100%')
.margin({ bottom: 14 })
两个场次选项使用 layoutWeight(1) 平分宽度,通过 bookDate 的值判断当前选中项,选中项使用金色背景深色文字,未选中项使用深色背景浅色文字。点击选项时更新 bookDate 状态变量,触发 UI 刷新。这种"分段选择器"是移动应用中选项选择的常见模式,比下拉选择器更直观,比单选按钮更紧凑。
确认约课的 onClick 事件处理包含了多层验证和数据更新逻辑:
Text('确认约课')
.fontSize(13)
.fontColor('#0D1114')
.textAlign(TextAlign.Center)
.width('45%')
.height(38)
.backgroundColor('#D97706')
.borderRadius(6)
.onClick(() => {
if (this.bookName.length > 0) {
if (this.picked.quota > 0) {
const newQuota = this.picked.quota - 1
const idx = this.lessons.indexOf(this.picked)
if (idx >= 0) {
this.lessons.splice(idx, 1, buildLessonBook(this.picked, newQuota))
}
this.tip = '✅ 约课成功(' + this.bookDate + '),剩余 ' + newQuota.toString() + ' 人'
} else {
this.tip = '⚠️ 该课程已满员'
}
} else {
this.tip = '⚠️ 请先填写姓名'
}
this.bookName = ''
onClose()
})
约课逻辑的执行流程是:首先验证姓名是否填写,其次检查课程名额是否充足,然后通过 indexOf 找到 picked 在 lessons 数组中的索引,使用 splice 方法替换为新对象(通过 buildLessonBook 创建,quota 减一)。这种"查找-替换"的模式确保了数组元素的不可变更新——不直接修改原对象的 quota 属性,而是创建一个新对象替换旧对象。
技术要点: splice 是 JavaScript 数组的方法,
array.splice(start, deleteCount, item1, item2, ...)从 start 位置开始删除 deleteCount 个元素,并插入后续参数提供的元素。this.lessons.splice(idx, 1, newItem)表示从 idx 位置删除 1 个元素并插入 newItem,实现了"原位替换"。在 ArkTS 的 @State 管理下,splice 方法会触发框架的状态观测,自动更新依赖该数组的所有 UI。
六、舞会 Tab:横幅设计与购票步进器
6.1 BallContent 组件与舞会横幅
@Component
struct BallContent {
@Link balls: BallItem[]
@Link mys: MyItem[]
@State showRule: boolean = false
@State showDetail: boolean = false
@State picked: BallItem = BALLS[0]
@State showBuy: boolean = false
@State buyNum: number = 1
@State tip: string = ''
BallContent 组件管理舞会列表和购票流程。showRule 控制舞会规则弹窗,showDetail 控制舞会详情弹窗,showBuy 控制购票弹窗。buyNum 存储用户在购票弹窗中选择的票数,初始值为 1。
// 舞会横幅
Row() {
Column({ space: 4 }) {
Text('下场舞会')
.fontSize(10)
.fontColor('#0D1114')
.opacity(0.75)
Text('周六复古大舞会')
.fontSize(18)
.fontColor('#0D1114')
.fontWeight(FontWeight.Bold)
Text('08-29 20:00 · 阿金四重奏 · ¥68')
.fontSize(10)
.fontColor('#0D1114')
.opacity(0.75)
}
.alignItems(HorizontalAlign.Start)
Text('🪩')
.fontSize(30)
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding(14)
.backgroundColor('#FCD34D')
.borderRadius(10)
.margin({ top: 12 })
舞会横幅使用了与首页横幅不同的配色方案——背景色为香槟金色 #FCD34D(首页为琥珀金色 #D97706),通过颜色的微妙差异来区分不同 Tab 的视觉身份。横幅左侧是三行文字信息(标签、名称、时间乐队票价),右侧是一个大型 emoji 图标。
opacity 属性用于设置元素的透明度。这里对次要信息文字使用 opacity(0.75) 降低了不透明度,使主要信息(舞会名称)更加突出。opacity 取值范围为 0(完全透明)到 1(完全不透明),在 ArkUI 中常用于创建视觉层次——重要信息使用 opacity(1),次要信息使用较低的 opacity 值。
技术要点: opacity 属性与 fontColor 使用 rgba 格式的区别在于作用范围。opacity 影响整个组件(包括文字、背景、边框等所有视觉元素)的透明度,而 rgba 颜色只影响特定属性的颜色透明度。在需要整体降低元素存在感时使用 opacity 更合适,在只需要调整某个颜色属性时使用 rgba 更精确。
6.2 舞会列表的 ForEach 渲染
// 舞会列表
ForEach(this.balls, (item: BallItem, index: number) => {
Row() {
Text('💃')
.fontSize(24)
.width(42)
.textAlign(TextAlign.Center)
Column({ space: 3 }) {
Text(item.name)
.fontSize(14)
.fontColor('#F3ECDD')
.fontWeight(FontWeight.Medium)
.width('100%')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(item.date + ' · ' + item.band)
.fontSize(11)
.fontColor('#8A8A96')
.width('100%')
Text('¥' + item.ticket.toString() + ' · 剩 ' + item.quota.toString() + ' 席')
.fontSize(10)
.fontColor(item.quota > 0 ? '#FCD34D' : '#E11D48')
.width('100%')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.margin({ left: 8 })
Text('购票 ›')
.fontSize(12)
.fontColor('#D97706')
.onClick(() => {
this.picked = item
this.showDetail = true
})
}
.width('100%')
.padding(10)
.backgroundColor('#16241C')
.borderRadius(8)
.border({ width: 1, color: '#24402C' })
.margin({ bottom: 8 })
}, (item: BallItem, index: number) => 'ball' + item.id.toString())
舞会列表项采用三段式 Row 布局:左侧图标、中间信息列、右侧购票链接。与动态列表项的结构相似,但增加了价格和剩余席位信息的展示。票价和剩余席位使用三元运算符动态设置颜色:quota > 0 时为金色(可购买),quota = 0 时为红色 #E11D48(售罄)。
textAlign(TextAlign.Center) 设置文本在自身宽度内的对齐方式。图标 Text 设置了 width(42) 和 textAlign(TextAlign.Center),使 emoji 图标在 42 像素宽度内居中显示。textAlign 支持 Left(左对齐)、Center(居中对齐)、Right(右对齐)和 Start/End(根据文本方向自动对齐)。
技术要点: textAlign 属性作用于 Text 组件自身宽度范围内的文本对齐,与父容器的 alignItems(子元素对齐)不同。当 Text 组件的宽度大于文字实际宽度时(如设置了固定 width 或 layoutWeight),textAlign 决定文字在 Text 组件内的水平位置。如果不设置 width,Text 组件宽度会收缩到文字宽度,此时 textAlign 无效果。
6.3 购票弹窗与步进器交互
@Builder
buyModalOverlay(onClose: () => void) {
Column() {
Column() {
Row() {
Text('🎟️ 购买舞会门票')
.fontSize(17)
.fontColor('#FDF6E3')
.fontWeight(FontWeight.Bold)
Text('✕')
.fontSize(16)
.fontColor('#8A8A96')
.onClick(() => {
onClose()
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 12 })
Text(this.picked.name + ' · ¥' + this.picked.ticket.toString() + '/张')
.fontSize(14)
.fontColor('#F3ECDD')
.fontWeight(FontWeight.Medium)
.width('100%')
.margin({ bottom: 10 })
Text('剩余席位:' + this.picked.quota.toString() + ' 席')
.fontSize(12)
.fontColor('#8A8A96')
.width('100%')
.margin({ bottom: 10 })
购票弹窗展示舞会名称、单价和剩余席位信息。接下来是步进器组件:
Row() {
Text('−')
.fontSize(18)
.fontColor('#F3ECDD')
.textAlign(TextAlign.Center)
.width(40)
.height(36)
.backgroundColor('#0D1114')
.borderRadius(6)
.onClick(() => {
if (this.buyNum > 1) {
this.buyNum = this.buyNum - 1
}
})
Text(this.buyNum.toString() + ' 张')
.fontSize(14)
.fontColor('#F3ECDD')
.textAlign(TextAlign.Center)
.layoutWeight(1)
.height(36)
.backgroundColor('#16241C')
.borderRadius(6)
Text('+')
.fontSize(18)
.fontColor('#F3ECDD')
.textAlign(TextAlign.Center)
.width(40)
.height(36)
.backgroundColor('#0D1114')
.borderRadius(6)
.onClick(() => {
if (this.buyNum < this.picked.quota) {
this.buyNum = this.buyNum + 1
}
})
}
.width('100%')
.margin({ bottom: 6 })
步进器是一个三段式的 Row 组件:左侧减号按钮、中间数量显示、右侧加号按钮。减号按钮的 onClick 事件在 buyNum > 1 时将 buyNum 减一,防止数量降至零或负数;加号按钮的 onClick 事件在 buyNum < picked.quota 时将 buyNum 加一,防止购买数量超过剩余席位。这种边界检查是步进器交互的基本安全保障。
步进器的设计体现了 ArkUI 声明式 UI 的典型特征:buyNum 状态变量的变化自动触发中间 Text 的内容更新和合计金额的计算。开发者无需手动操作 DOM 更新显示,只需修改状态变量,框架自动完成 UI 刷新。
合计金额的计算使用了模板字符串拼接:
Text('合计:¥' + (this.picked.ticket * this.buyNum).toString())
.fontSize(16)
.fontColor('#FCD34D')
.fontWeight(FontWeight.Bold)
.width('100%')
.textAlign(TextAlign.End)
.margin({ bottom: 12 })
合计金额通过 this.picked.ticket * this.buyNum 实时计算,当 buyNum 变化时自动更新。textAlign(TextAlign.End) 使金额右对齐,符合财务数值的阅读习惯。
技术要点: 在 ArkTS 的声明式 UI 中,build 方法会在每次状态变量变化时被重新调用,其中的表达式(如
this.picked.ticket * this.buyNum)会被重新计算。这意味着 UI 显示的值始终与状态变量保持同步,开发者不需要手动触发 UI 更新。这是声明式 UI 与命令式 UI 最本质的区别——开发者描述"UI 应该是什么样子"而非"如何把 UI 变成这个样子"。
6.4 确认购票的数据操作
Text('确认购票')
.fontSize(13)
.fontColor('#0D1114')
.textAlign(TextAlign.Center)
.width('45%')
.height(38)
.backgroundColor('#D97706')
.borderRadius(6)
.onClick(() => {
if (this.picked.quota >= this.buyNum) {
const newQuota = this.picked.quota - this.buyNum
const idx = this.balls.indexOf(this.picked)
if (idx >= 0) {
this.balls.splice(idx, 1, buildBallBuy(this.picked, newQuota))
}
this.tip = '✅ 购票成功 ' + this.buyNum.toString() + ' 张,剩余 ' + newQuota.toString() + ' 席'
} else {
this.tip = '⚠️ 剩余席位不足'
}
onClose()
})
确认购票的逻辑与约课类似:首先检查剩余席位是否足够,然后通过 indexOf 找到 picked 在 balls 数组中的位置,使用 splice 替换为新对象(通过 buildBallBuy 创建,quota 减去 buyNum)。这种模式在应用中被反复使用——约课、购票、报名都遵循"验证-查找-替换-提示"的四步操作流程。
技术要点: indexOf 方法用于在数组中查找指定元素的位置,返回第一个匹配元素的索引(未找到返回 -1)。需要注意的是,indexOf 使用严格相等(===)进行元素比较,对于对象类型,只有同一引用的对象才会匹配。在本应用中,picked 是从 balls 数组中选出的引用,因此 indexOf 能正确找到其位置。如果 picked 是通过 buildBallBuy 等函数创建的新对象,indexOf 将无法找到(因为引用不同)。
七、乐手 Tab:指标横幅与邀约表单
7.1 BandContent 组件与三指标横幅
@Component
struct BandContent {
@Link bands: BandItem[]
@Link mys: MyItem[]
@State showDetail: boolean = false
@State picked: BandItem = BANDS[0]
@State showInvite: boolean = false
@State inviteName: string = ''
@State inviteDate: string = '周六舞会'
@State tip: string = ''
BandContent 组件管理乐队列表和邀约流程。showInvite 控制邀约弹窗,inviteName 和 inviteDate 存储邀约表单数据。
// 3 指标横幅
Row({ space: 8 }) {
Column({ space: 4 }) {
Text(this.bands.length.toString())
.fontSize(20)
.fontColor('#FCD34D')
.fontWeight(FontWeight.Bold)
Text('签约乐队')
.fontSize(10)
.fontColor('#8A8A96')
}
.layoutWeight(1)
.height(64)
.justifyContent(FlexAlign.Center)
.backgroundColor('#16241C')
.borderRadius(8)
Column({ space: 4 }) {
Text('3')
.fontSize(20)
.fontColor('#D97706')
.fontWeight(FontWeight.Bold)
Text('驻场中')
.fontSize(10)
.fontColor('#8A8A96')
}
.layoutWeight(1)
.height(64)
.justifyContent(FlexAlign.Center)
.backgroundColor('#16241C')
.borderRadius(8)
Column({ space: 4 }) {
Text('5')
.fontSize(20)
.fontColor('#6EE7B7')
.fontWeight(FontWeight.Bold)
Text('可邀约')
.fontSize(10)
.fontColor('#8A8A96')
}
.layoutWeight(1)
.height(64)
.justifyContent(FlexAlign.Center)
.backgroundColor('#16241C')
.borderRadius(8)
}
.width('100%')
.margin({ top: 12 })
三指标横幅使用 Row 的三等分布局展示乐队统计信息:签约乐队总数、驻场数量、可邀约数量。三个指标分别使用不同的颜色——香槟金色表示总数、琥珀金色表示驻场中、薄荷绿色表示可邀约。颜色的差异化帮助用户快速区分不同维度的信息。
技术要点: 在数据可视化设计中,颜色的语义化使用非常重要。金色通常用于突出"核心数据",橙色用于表示"活跃/进行中"的状态,绿色用于表示"可用/可操作"的状态。这种颜色编码体系在本应用中被一致使用——课程状态、舞会状态、乐队状态、赛事状态都遵循了类似的颜色语义。
7.2 乐队列表与状态映射
ForEach(this.bands, (item: BandItem, index: number) => {
Row() {
Text('🎺')
.fontSize(22)
.width(40)
.textAlign(TextAlign.Center)
Column({ space: 3 }) {
Text(item.name)
.fontSize(14)
.fontColor('#F3ECDD')
.fontWeight(FontWeight.Medium)
.width('100%')
Text(item.style + ' · 🔥 ' + item.hot.toString())
.fontSize(11)
.fontColor('#8A8A96')
.width('100%')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.margin({ left: 8 })
Column({ space: 2 }) {
Text(bandStateText(item.state))
.fontSize(10)
.fontColor(bandStateColor(item.state))
Text('邀约 ›')
.fontSize(12)
.fontColor('#D97706')
.onClick(() => {
this.picked = item
this.showDetail = true
})
}
}
.width('100%')
.padding(10)
.backgroundColor('#16241C')
.borderRadius(8)
.border({ width: 1, color: '#24402C' })
.margin({ bottom: 8 })
}, (item: BandItem, index: number) => 'band' + item.id.toString())
乐队列表项的布局比舞会列表项更复杂——右侧不是单个操作链接,而是一个包含状态文字和邀约链接的 Column。状态文字通过 bandStateText 和 bandStateColor 函数生成,根据 item.state 的值返回"驻场中"(金色)或"可邀约"(绿色)。这种将状态展示和操作入口组合在同一区域的设计,使列表项的信息密度更高。
7.3 乐队详情弹窗与热度条
@Builder
detailModalOverlay(onClose: () => void) {
Column() {
Column() {
Row() {
Text('🎷 乐队详情')
.fontSize(17)
.fontColor('#FDF6E3')
.fontWeight(FontWeight.Bold)
Text('✕')
.fontSize(16)
.fontColor('#8A8A96')
.onClick(() => {
onClose()
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 12 })
Row() {
Text('🎺')
.fontSize(32)
Column({ space: 4 }) {
Text(this.picked.name)
.fontSize(18)
.fontColor('#F3ECDD')
.fontWeight(FontWeight.Bold)
Text(this.picked.style + ' 风格')
.fontSize(12)
.fontColor('#8A8A96')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 10 })
Text(bandStateText(this.picked.state))
.fontSize(11)
.fontColor(bandStateColor(this.picked.state))
.border({ width: 1, color: bandStateColor(this.picked.state) })
.borderRadius(10)
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
}
.width('100%')
.padding(12)
.backgroundColor('#0D1114')
.borderRadius(8)
.margin({ bottom: 10 })
乐队详情弹窗的头部信息区使用与课程详情弹窗相似的三段式布局。乐队名称使用较大的 18 像素字号,风格信息使用 12 像素字号。状态标签的文字颜色和边框颜色都通过 bandStateColor 函数动态生成。
热度条是乐队详情弹窗的一个特色组件,通过两个 Row 的层叠实现:
Text('人气热度')
.fontSize(12)
.fontColor('#8A8A96')
.width('100%')
.margin({ bottom: 4 })
Row() {
Text('🔥')
.fontSize(12)
Row()
.layoutWeight(1)
.height(8)
.backgroundColor('#2A2A36')
.borderRadius(4)
.margin({ left: 6 })
}
.width('100%')
.margin({ bottom: 4 })
Row() {
Text('🔥')
.fontSize(12)
Row()
.layoutWeight(1)
.height(8)
.backgroundColor('#D97706')
.borderRadius(4)
.margin({ left: 6 })
}
.width('100%')
.offset({ y: -12 })
热度条由两行 Row 组成,每行左侧是火焰 emoji,右侧是进度条。底层 Row 的进度条为深灰色背景,上层 Row 的进度条为琥珀金色填充。上层 Row 使用 offset({ y: -12 }) 向上偏移,使两个进度条在同一位置层叠显示,实现进度条效果。
这里的热度条宽度没有使用百分比计算(如课程详情弹窗中的进度条),而是使用 layoutWeight(1) 自适应填满剩余宽度。实际上,这段代码中热度条的填充宽度与背景宽度相同,没有体现 picked.hot 的值——这是一个设计简化,在实际应用中应使用 width 百分比来反映热度数值。
技术要点: offset 的负值偏移是 ArkUI 中实现元素层叠的一种技巧。当一个元素需要覆盖在另一个元素之上,但两者在文档流中是先后排列时,可以通过给后一个元素设置 offset 的负值(等于前一个元素的高度)来使其回退到前一个元素的位置。这种技巧在进度条、评分条等可视化组件中很常见。
八、赛事 Tab:报名步进与退赛确认
8.1 CupContent 组件与赛事状态函数
function cupStateText(s: string): string {
if (s === 'open') {
return '报名中'
}
return '已截止'
}
function cupStateColor(s: string): string {
if (s === 'open') {
return '#6EE7B7'
}
return '#8A8A96'
}
function buildCupSign(src: CupItem, quota: number): CupItem {
return { id: src.id, name: src.name, date: src.date, prize: src.prize, quota: quota, state: src.state, note: src.note }
}
赛事 Tab 定义了专属的状态映射函数和构建器函数。cupStateText 和 cupStateColor 根据 state 值返回"报名中"(薄荷绿色)或"已截止"(灰色)。buildCupSign 函数创建一个修改了 quota 的 CupItem 副本,用于报名后的不可变更新。
@Component
struct CupContent {
@Link cups: CupItem[]
@Link mys: MyItem[]
@State showRule: boolean = false
@State showDetail: boolean = false
@State picked: CupItem = CUPS[0]
@State showBuy: boolean = false
@State buyNum: number = 1
@State showDel: boolean = false
@State tip: string = ''
CupContent 组件的状态变量与 BallContent 相似,但多了一个 showDel 状态变量,用于控制退赛确认弹窗。这是因为赛事 Tab 除了报名操作外,还提供了退赛功能,需要一个删除确认弹窗来防止误操作。
技术要点: 在涉及删除或不可逆操作时,弹窗确认是移动应用的标准交互模式。ArkUI 没有内置的 AlertDialog 组件(在声明式 UI 中),开发者通过 @Builder 方法自定义弹窗来实现确认对话框。这种做法虽然代码量更大,但提供了完全的 UI 自定义能力——可以自由设计弹窗的样式、颜色、按钮布局和交互行为。
8.2 赛事列表与退赛入口
// 赛事列表
ForEach(this.cups, (item: CupItem, index: number) => {
Row() {
Column({ space: 4 }) {
Text(item.name)
.fontSize(14)
.fontColor('#F3ECDD')
.fontWeight(FontWeight.Medium)
.width('100%')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(item.date + ' · 奖金 ¥' + item.prize.toString())
.fontSize(11)
.fontColor('#8A8A96')
.width('100%')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Column({ space: 2 }) {
Text('剩 ' + item.quota.toString() + ' 席')
.fontSize(10)
.fontColor(item.quota > 0 ? '#6EE7B7' : '#E11D48')
Text(cupStateText(item.state))
.fontSize(10)
.fontColor(cupStateColor(item.state))
}
Text('报名 ›')
.fontSize(12)
.fontColor('#FCD34D')
.margin({ left: 8 })
.onClick(() => {
this.picked = item
this.showDetail = true
})
}
.width('100%')
.padding(10)
.backgroundColor('#16241C')
.borderRadius(8)
.border({ width: 1, color: '#24402C' })
.margin({ bottom: 8 })
Row() {
Text('🗑️ 退赛')
.fontSize(11)
.fontColor('#E11D48')
.onClick(() => {
this.picked = item
this.showDel = true
})
}
.width('100%')
.justifyContent(FlexAlign.End)
.margin({ top: -4, bottom: 8 })
}, (item: CupItem, index: number) => 'cup' + item.id.toString())
赛事列表项比舞会列表项更复杂——每个赛事项包含两行 Row:第一行是赛事信息和操作入口,第二行是退赛链接。退赛链接使用红色 #E11D48 和右对齐(justifyContent(FlexAlign.End)),视觉上与报名操作区分开来,并通过 margin({ top: -4 }) 与上方行紧密贴合,形成一体化的卡片效果。
margin 的负值(top: -4)使当前 Row 向上偏移,与前一行的间距缩小。这种技巧在需要紧贴排列的元素之间很实用,但需要注意负 margin 可能导致元素重叠,应谨慎使用。
技术要点: margin 属性的负值在 ArkUI 中是合法的,它会减少元素与前一元素之间的间距,甚至使元素向上重叠。这种技巧在创建紧密排列的卡片组、列表项的附属操作区等场景中很实用。但负 margin 破坏了正常的文档流间距,过度使用会导致布局混乱,应在明确需要时才使用。
8.3 退赛确认弹窗与数组删除
@Builder
delModalOverlay(onClose: () => void) {
Column() {
Column() {
Text('⚠️ 退赛确认')
.fontSize(17)
.fontColor('#E11D48')
.fontWeight(FontWeight.Bold)
.width('100%')
.textAlign(TextAlign.Center)
.margin({ bottom: 10 })
Text('确定要退出「' + this.picked.name + '」吗?')
.fontSize(13)
.fontColor('#B9B9C4')
.width('100%')
.textAlign(TextAlign.Center)
.margin({ bottom: 14 })
Row() {
Text('再想想')
.fontSize(13)
.fontColor('#B9B9C4')
.textAlign(TextAlign.Center)
.width('45%')
.height(38)
.border({ width: 1, color: '#3A3A46' })
.borderRadius(6)
.onClick(() => {
onClose()
})
Text('确认退赛')
.fontSize(13)
.fontColor('#FFFFFF')
.textAlign(TextAlign.Center)
.width('45%')
.height(38)
.backgroundColor('#E11D48')
.borderRadius(6)
.onClick(() => {
const idx = this.cups.findIndex((c: CupItem) => c.id === this.picked.id)
if (idx >= 0) {
this.cups.splice(idx, 1)
this.tip = '🗑️ 已退赛'
}
onClose()
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
}
.width('88%')
.backgroundColor('#16241C')
.borderRadius(10)
.border({ width: 1, color: '#E11D48' })
.padding(16)
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('rgba(6, 9, 11, 0.84)')
}
退赛确认弹窗是一个典型的"危险操作确认"对话框。弹窗标题使用红色 #E11D48 和"⚠️"图标,明确传达操作的危险性。弹窗内容居中显示,使用 textAlign(TextAlign.Center) 使标题和提示文字水平居中。弹窗的边框也使用红色,与标题颜色呼应,强化了"危险操作"的视觉信号。
确认退赛的 onClick 事件使用 findIndex 方法在 cups 数组中查找 picked 对应的索引,然后使用 splice(idx, 1) 删除该元素。与之前使用 indexOf 不同,这里使用 findIndex 配合箭头函数 (c: CupItem) => c.id === this.picked.id 进行查找——通过 id 属性匹配而非引用匹配。这种做法更加健壮,即使 picked 是一个新创建的对象(引用不同),只要 id 相同就能正确找到并删除。
技术要点: findIndex 是 JavaScript 数组方法,接受一个谓词函数,返回第一个使谓词返回 true 的元素索引。与 indexOf 相比,findIndex 更适合通过属性匹配查找对象——indexOf 使用严格相等比较整个对象引用,findIndex 可以自定义匹配条件。在不可变更新模式中(频繁创建新对象替换旧对象),findIndex 比 indexOf 更可靠,因为新对象的引用与原数组中的对象不同。
九、我的 Tab:个人档案与收藏管理
9.1 MeContent 组件与多重弹窗管理
@Component
struct MeContent {
@Link mys: MyItem[]
@Link lessons: LessonItem[]
@Link balls: BallItem[]
@State nickname: string = '摇摆舞者小艾'
@State showDelMy: boolean = false
@State pickedMy: MyItem = MYS[0]
@State showRename: boolean = false
@State renameVal: string = ''
@State showClear: boolean = false
@State showExit: boolean = false
@State tip: string = ''
MeContent 组件是应用中状态变量最多的组件——四个 @Link 变量和七个 @State 变量。这是因为"我的"页面需要管理多种弹窗:收藏移除确认弹窗(showDelMy)、改昵称弹窗(showRename)、清缓存确认弹窗(showClear)、退出登录确认弹窗(showExit)。
nickname 状态变量存储用户昵称,初始值为"摇摆舞者小艾"。renameVal 存储改昵称弹窗中用户输入的新昵称。pickedMy 存储当前选中要移除的收藏项。这种"每个弹窗对应一个 show 布尔状态变量"的管理模式在本应用中被一致使用,虽然状态变量较多,但每个变量的职责清晰单一,易于理解和维护。
技术要点: 在复杂页面中管理多个弹窗的显隐状态,有多种设计策略。本应用采用"独立布尔变量"策略——每个弹窗对应一个 @State boolean 变量。这种策略的优点是简单直观、互不干扰;缺点是当弹窗数量很多时状态变量会增多。另一种策略是使用一个字符串变量(如 activeModal: string)来记录当前打开的弹窗名称,通过条件渲染控制显隐。两种策略各有优劣,需要根据实际场景选择。
9.2 档案卡与四宫格统计
// 档案卡
Row() {
Text('🕺')
.fontSize(36)
Column({ space: 4 }) {
Text(this.nickname)
.fontSize(18)
.fontColor('#F3ECDD')
.fontWeight(FontWeight.Bold)
Text('Lv.15 摇摆舞者 · 舞龄 3 年')
.fontSize(11)
.fontColor('#8A8A96')
Text('🩰 已上 ' + this.lessons.length.toString() + ' 节 · 参与舞会 ' + this.balls.length.toString() + ' 场')
.fontSize(10)
.fontColor('#D97706')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 12 })
Text('✏️')
.fontSize(18)
.onClick(() => {
this.showRename = true
})
}
.width('100%')
.padding(14)
.backgroundColor('#16241C')
.borderRadius(10)
.border({ width: 1, color: '#FCD34D' })
.margin({ top: 12 })
档案卡使用 Row 三段式布局:左侧头像 emoji、中间用户信息列、右侧编辑昵称按钮。用户信息列展示了昵称、等级和舞龄、课程和舞会参与统计。统计信息通过 @Link 变量的 length 属性动态获取——this.lessons.length.toString() 和 this.balls.length.toString(),当父组件的数据变化时,统计数字会自动更新。
档案卡使用了金色边框 border({ width: 1, color: ‘#FCD34D’ }),与其他卡片的深绿色边框形成区别,突出"个人档案"的特殊地位。
四宫格统计区域使用了与首页精选速览相同的 layoutWeight 布局,但包含四个指标而非三个:
// 四宫格
Row({ space: 8 }) {
Column({ space: 4 }) {
Text('🩰')
.fontSize(20)
Text(this.lessons.length.toString())
.fontSize(15)
.fontColor('#FCD34D')
.fontWeight(FontWeight.Bold)
Text('上过的课')
.fontSize(10)
.fontColor('#8A8A96')
}
.layoutWeight(1)
.height(72)
.justifyContent(FlexAlign.Center)
.backgroundColor('#0D1114')
.borderRadius(8)
Column({ space: 4 }) {
Text('🪩')
.fontSize(20)
Text(this.balls.length.toString())
.fontSize(15)
.fontColor('#D97706')
.fontWeight(FontWeight.Bold)
Text('参与的舞会')
.fontSize(10)
.fontColor('#8A8A96')
}
.layoutWeight(1)
.height(72)
.justifyContent(FlexAlign.Center)
.backgroundColor('#0D1114')
.borderRadius(8)
Column({ space: 4 }) {
Text('🏆')
.fontSize(20)
Text('2')
.fontSize(15)
.fontColor('#FCD34D')
.fontWeight(FontWeight.Bold)
Text('获奖次数')
.fontSize(10)
.fontColor('#8A8A96')
}
.layoutWeight(1)
.height(72)
.justifyContent(FlexAlign.Center)
.backgroundColor('#0D1114')
.borderRadius(8)
Column({ space: 4 }) {
Text('⭐')
.fontSize(20)
Text(this.mys.length.toString())
.fontSize(15)
.fontColor('#D97706')
.fontWeight(FontWeight.Bold)
Text('我的收藏')
.fontSize(10)
.fontColor('#8A8A96')
}
.layoutWeight(1)
.height(72)
.justifyContent(FlexAlign.Center)
.backgroundColor('#0D1114')
.borderRadius(8)
}
.width('100%')
.margin({ top: 10 })
四宫格分别展示上过的课程数、参与的舞会数、获奖次数(固定为 2)和收藏数。前两个和第四个指标通过 @Link 变量的 length 属性动态计算,第三个使用固定值。每个宫格使用不同的强调色——金色和橙色交替,形成视觉节奏。
9.3 收藏列表与多类型图标渲染
ForEach(this.mys, (item: MyItem, index: number) => {
Row() {
Text(item.kind === '舞会' ? '🪩' : (item.kind === '课程' ? '🩰' : (item.kind === '乐手' ? '🎷' : '🏆')))
.fontSize(18)
.width(34)
.textAlign(TextAlign.Center)
Column({ space: 2 }) {
Text(item.name)
.fontSize(13)
.fontColor('#F3ECDD')
.width('100%')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(item.date + ' · ' + item.note)
.fontSize(10)
.fontColor('#8A8A96')
.width('100%')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.margin({ left: 8 })
Text('移除')
.fontSize(11)
.fontColor('#E11D48')
.onClick(() => {
this.pickedMy = item
this.showDelMy = true
})
}
.width('100%')
.padding(10)
.backgroundColor('#0D1114')
.borderRadius(8)
.border({ width: 1, color: '#1A2A20' })
.margin({ bottom: 8 })
}, (item: MyItem, index: number) => 'my' + item.id.toString())
收藏列表的图标选择使用了嵌套三元运算符:item.kind === '舞会' ? '🪩' : (item.kind === '课程' ? '🩰' : (item.kind === '乐手' ? '🎷' : '🏆'))。根据 kind 字段的值选择对应的 emoji 图标——舞会用 disco ball、课程用 ballet shoes、乐手用 saxophone、赛事(else 分支)使用 trophy。这种根据数据字段动态选择展示内容的方式,体现了声明式 UI 中"数据驱动视图"的核心思想。
嵌套三元运算符虽然功能强大,但在可读性上不如 if-else 语句或 switch 语句。在实际工程中,可以考虑将这种逻辑提取为工具函数(如 function myIcon(kind: string): string),使 UI 代码更加简洁。
9.4 改昵称弹窗与状态更新
@Builder
renameModalOverlay(onClose: () => void) {
Column() {
Column() {
Row() {
Text('✏️ 修改昵称')
.fontSize(17)
.fontColor('#FDF6E3')
.fontWeight(FontWeight.Bold)
Text('✕')
.fontSize(16)
.fontColor('#8A8A96')
.onClick(() => {
onClose()
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 12 })
TextInput({ placeholder: '输入新昵称', text: this.renameVal })
.width('100%')
.height(40)
.backgroundColor('#0D1114')
.fontColor('#F3ECDD')
.placeholderColor('#5A5A66')
.borderRadius(6)
.margin({ bottom: 14 })
.onChange((v: string) => {
this.renameVal = v
})
Row() {
Text('取消')
.fontSize(13)
.fontColor('#B9B9C4')
.textAlign(TextAlign.Center)
.width('45%')
.height(38)
.border({ width: 1, color: '#3A3A46' })
.borderRadius(6)
.onClick(() => {
onClose()
})
Text('保存昵称')
.fontSize(13)
.fontColor('#0D1114')
.textAlign(TextAlign.Center)
.width('45%')
.height(38)
.backgroundColor('#FCD34D')
.borderRadius(6)
.onClick(() => {
if (this.renameVal.length > 0) {
this.nickname = this.renameVal
this.tip = '✅ 昵称已更新为 ' + this.nickname
} else {
this.tip = '⚠️ 昵称不能为空'
}
this.renameVal = ''
onClose()
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
}
.width('88%')
.backgroundColor('#16241C')
.borderRadius(10)
.border({ width: 1, color: '#FCD34D' })
.padding(16)
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('rgba(6, 9, 11, 0.84)')
}
改昵称弹窗是一个简单的单输入表单弹窗。保存昵称的 onClick 事件首先验证输入非空,然后将 renameVal 赋值给 nickname 状态变量,触发档案卡中昵称的自动更新。操作完成后清空 renameVal 并关闭弹窗。
这个弹窗体现了 @State 变量更新的直接性——修改 nickname 后,build 方法中引用 this.nickname 的 Text 组件会自动刷新显示新值。开发者不需要调用任何"刷新"或"更新"方法,框架自动完成了状态到视图的同步。
9.5 清缓存与退出登录确认弹窗
@Builder
clearModalOverlay(onClose: () => void) {
Column() {
Column() {
Text('🧹 清空缓存')
.fontSize(17)
.fontColor('#FCD34D')
.fontWeight(FontWeight.Bold)
.width('100%')
.textAlign(TextAlign.Center)
.margin({ bottom: 10 })
Text('将清除 72MB 本地缓存数据,是否继续?')
.fontSize(13)
.fontColor('#B9B9C4')
.width('100%')
.textAlign(TextAlign.Center)
.margin({ bottom: 14 })
Row() {
Text('暂不')
.fontSize(13)
.fontColor('#B9B9C4')
.textAlign(TextAlign.Center)
.width('45%')
.height(38)
.border({ width: 1, color: '#3A3A46' })
.borderRadius(6)
.onClick(() => {
onClose()
})
Text('立即清空')
.fontSize(13)
.fontColor('#0D1114')
.textAlign(TextAlign.Center)
.width('45%')
.height(38)
.backgroundColor('#FCD34D')
.borderRadius(6)
.onClick(() => {
this.tip = '🧹 缓存已清空(72MB 释放)'
onClose()
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
}
.width('88%')
.backgroundColor('#16241C')
.borderRadius(10)
.border({ width: 1, color: '#FCD34D' })
.padding(16)
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('rgba(6, 9, 11, 0.84)')
}
清缓存弹窗使用金色标题和金色边框,表示这是一个常规操作(非危险操作)。弹窗内容显示了缓存大小"72MB",让用户对操作的影响有具体认知。确认操作后设置 tip 提示信息并关闭弹窗。
退出登录弹窗则使用红色标题和红色边框,表示这是一个需要谨慎的操作:
@Builder
exitModalOverlay(onClose: () => void) {
Column() {
Column() {
Text('🚪 退出登录')
.fontSize(17)
.fontColor('#E11D48')
.fontWeight(FontWeight.Bold)
.width('100%')
.textAlign(TextAlign.Center)
.margin({ bottom: 10 })
Text('退出后需重新验证身份,确定退出吗?')
.fontSize(13)
.fontColor('#B9B9C4')
.width('100%')
.textAlign(TextAlign.Center)
.margin({ bottom: 14 })
Row() {
Text('留在舞池')
.fontSize(13)
.fontColor('#B9B9C4')
.textAlign(TextAlign.Center)
.width('45%')
.height(38)
.border({ width: 1, color: '#3A3A46' })
.borderRadius(6)
.onClick(() => {
onClose()
})
Text('确认退出')
.fontSize(13)
.fontColor('#FFFFFF')
.textAlign(TextAlign.Center)
.width('45%')
.height(38)
.backgroundColor('#E11D48')
.borderRadius(6)
.onClick(() => {
this.tip = '🚪 已退出登录,期待你的下次摇摆'
onClose()
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
}
.width('88%')
.backgroundColor('#16241C')
.borderRadius(10)
.border({ width: 1, color: '#E11D48' })
.padding(16)
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('rgba(6, 9, 11, 0.84)')
}
退出登录弹窗的取消按钮文字是"留在舞池"而非通用的"取消",这种与应用主题相关的文案设计增强了用户体验的沉浸感。确认按钮使用白色文字配红色背景 #E11D48,与退赛弹窗的确认按钮样式一致,形成了"危险操作确认"的统一视觉语言。
技术要点: 在 UI 设计中,操作的"危险等级"应该通过视觉语言明确传达。本应用使用了三套弹窗配色:金色边框用于常规确认(清缓存)、红色边框用于危险确认(退赛、退出登录)、无特殊边框用于信息展示(规则弹窗)。这种基于颜色编码的危险等级区分,帮助用户在操作前快速判断操作的严重性,减少误操作的可能性。
9.6 功能入口与底部布局
// 功能入口
Row({ space: 8 }) {
Text('🧹 清空缓存')
.fontSize(12)
.fontColor('#FCD34D')
.textAlign(TextAlign.Center)
.layoutWeight(1)
.height(40)
.backgroundColor('#16241C')
.borderRadius(8)
.onClick(() => {
this.showClear = true
})
Text('🚪 退出登录')
.fontSize(12)
.fontColor('#E11D48')
.textAlign(TextAlign.Center)
.layoutWeight(1)
.height(40)
.backgroundColor('#16241C')
.borderRadius(8)
.onClick(() => {
this.showExit = true
})
}
.width('100%')
.margin({ bottom: 16 })
功能入口区域使用 Row 的二等分布局,放置清空缓存和退出登录两个功能按钮。两个按钮使用 layoutWeight(1) 平分宽度,文字颜色分别使用金色和红色,与各自弹窗的主题色一致。点击按钮设置对应的 show 状态变量为 true,触发确认弹窗的显示。
9.7 MeContent 的完整弹窗层叠
if (this.showDelMy) {
this.delMyModalOverlay(() => {
this.showDelMy = false
})
}
if (this.showRename) {
this.renameModalOverlay(() => {
this.showRename = false
})
}
if (this.showClear) {
this.clearModalOverlay(() => {
this.showClear = false
})
}
if (this.showExit) {
this.exitModalOverlay(() => {
this.showExit = false
})
}
MeContent 的 build 方法末尾,四个条件渲染语句控制四个弹窗的显隐。由于这些条件互斥(用户同一时刻只能打开一个弹窗),所以使用独立的 if 语句而非 if-else 链。每个弹窗的 @Builder 方法都接受一个 onClose 回调,回调内容是将对应的 show 状态变量设为 false。
这种"条件渲染 + @Builder + onClose 回调"的弹窗实现模式在本应用中被所有 Tab 组件一致使用。它的优点是:弹窗的 UI 结构完全自定义、显隐控制简单直观、回调机制灵活。缺点是代码量较大——每个弹窗都需要定义一个 @Builder 方法和一组状态变量。
技术要点: @Builder 方法接受函数参数(如 onClose: () => void)是 ArkTS 的一个重要特性。这允许在调用 @Builder 方法时传入闭包,闭包可以捕获调用方的上下文(如 this.showDetail = false)。这种"参数化 UI 片段"的模式提高了 @Builder 的复用性——同一个 @Builder 方法可以被不同位置的调用方使用,每个调用方提供不同的 onClose 行为。
十、应用整体架构与数据流分析
10.1 组件层级与数据流图
本应用采用了清晰的两层组件架构:Index 作为根组件管理全局状态和 Tab 路由,六个 Tab 内容组件(HomeContent、LessonContent、BallContent、BandContent、CupContent、MeContent)各自管理对应 Tab 的 UI 和交互。数据通过 @Link 装饰器从 Index 流向各 Tab 组件,修改双向同步。
10.2 弹窗管理与用户交互流程
应用中的十六个弹窗分布在六个 Tab 组件中,每个弹窗通过 @Builder 方法定义 UI 结构,通过 @State 布尔变量控制显隐,通过 onClose 回调函数处理关闭逻辑。这种统一的弹窗管理模式使得代码结构一致、易于维护。
10.3 状态管理体系的整体设计
应用的状态管理体系可以归纳为三个层次:
第一层是全局状态层,由 Index 组件的七个 @State 变量构成。这些变量管理六个业务数据集和当前 Tab 索引,是整个应用的数据中枢。所有 Tab 组件通过 @Link 与这些变量建立双向绑定,确保数据在组件树中的一致性。
第二层是组件内部状态层,由各 Tab 组件的 @State 变量构成。这些变量管理组件内部的 UI 状态——弹窗显隐、当前选中项、表单输入值、操作提示信息等。这些状态不需要与父组件共享,是组件私有的。
第三层是即时状态层,由 tip 变量构成。tip 是一个特殊的 @State 变量,存在于每个 Tab 组件中,用于显示操作后的即时反馈信息。当用户执行操作(约课、购票、报名、收藏等)后,tip 被设置为对应的提示文字,在列表底部显示一条提示条。这种"即时反馈"机制为用户提供了操作确认的视觉信号。
技术要点: 在 ArkTS 应用开发中,@State 和 @Link 是最常用的两个状态装饰器。@State 用于"拥有者"——变量所属的组件拥有对该变量的完全控制权;@Link 用于"使用者"——变量所属的组件与父组件共享该变量,修改会双向同步。正确区分"哪些状态是组件私有的(用 @State)“和"哪些状态需要与父组件共享(用 @Link)”,是设计组件状态管理体系的关键决策。
十一、核心技术概念对比与总结
以下表格对本应用中涉及的核心数据结构、组件、状态变量、装饰器和工具函数进行系统性对比:
| 类别 | 名称 | 作用说明 | 使用位置 | 关键特征 |
|---|---|---|---|---|
| 装饰器 | @Entry | 标记页面入口组件 | Index | 页面级生命周期管理、路由注册 |
| 装饰器 | @Component | 标记自定义组件 | 所有 struct | 生成组件元数据、渲染调度 |
| 装饰器 | @State | 组件内部状态管理 | 所有 struct 的状态变量 | 值变化触发 build 重新执行 |
| 装饰器 | @Link | 父子双向数据绑定 | Tab 组件的数据变量 | 与父组件 @State 双向同步 |
| 装饰器 | @Builder | UI 片段封装 | tabItem、各弹窗方法 | 内联展开、访问宿主 this |
| 数据接口 | FeedItem | 动态信息数据模型 | 首页动态列表 | id/title/time/tag/text 五字段 |
| 数据接口 | LessonItem | 课程信息数据模型 | 课程 Tab | 含 quota 名额追踪 |
| 数据接口 | BallItem | 舞会信息数据模型 | 舞会 Tab | 含 ticket 票价和 quota 席位 |
| 数据接口 | BandItem | 乐队信息数据模型 | 乐手 Tab | 含 hot 热度和 state 状态 |
| 数据接口 | CupItem | 赛事信息数据模型 | 赛事 Tab | 含 prize 奖金和 state 状态 |
| 数据接口 | MyItem | 收藏信息数据模型 | 我的 Tab | 含 kind 多类型标签 |
| 容器组件 | Column | 垂直线性布局 | 所有页面结构 | 主轴垂直、space 设置间距 |
| 容器组件 | Row | 水平线性布局 | 所有横向排列 | 主轴水平、SpaceBetween 对齐 |
| 容器组件 | Stack | 层叠布局 | 所有 Tab 根容器 | 子元素从底到顶层叠 |
| 容器组件 | Scroll | 滚动容器 | 所有内容列表页 | 支持垂直/水平滚动 |
| 基础组件 | Text | 文本显示 | 所有文字展示 | 支持 fontSize/fontColor/fontWeight |
| 基础组件 | TextInput | 文本输入 | 所有表单弹窗 | 支持 placeholder/onChange |
| 属性方法 | layoutWeight | 弹性权重分配 | 三宫格/四宫格/双列卡片 | 按比例分配剩余空间 |
| 属性方法 | justifyContent | 主轴对齐 | 所有容器组件 | SpaceBetween/Center/Start |
| 属性方法 | alignItems | 交叉轴对齐 | 所有容器组件 | Start/Center/Bottom |
| 属性方法 | borderRadius | 圆角半径 | 所有卡片和弹窗 | 6-10 像素圆角 |
| 属性方法 | backgroundColor | 背景色 | 所有组件 | 十六进制或 rgba 格式 |
| 属性方法 | border | 边框设置 | 卡片和弹窗 | 支持单边设置 |
| 属性方法 | padding | 内边距 | 所有容器组件 | 支持四边独立设置 |
| 属性方法 | margin | 外边距 | 所有组件 | 支持负值 |
| 属性方法 | offset | 相对偏移 | 进度条/热度条 | 不脱离文档流 |
| 属性方法 | opacity | 透明度 | 次要信息文字 | 0-1 范围 |
| 属性方法 | maxLines | 最大行数 | 列表项标题 | 配合 textOverflow 使用 |
| 属性方法 | textOverflow | 溢出处理 | 列表项标题 | Ellipsis 省略号截断 |
| 属性方法 | animation | 动画过渡 | Tab 切换 | duration + curve |
| 属性方法 | scale | 缩放变换 | Tab 选中态 | x/y 轴独立缩放 |
| 属性方法 | letterSpacing | 字符间距 | 品牌文字 | 像素值 |
| 属性方法 | scrollable | 滚动方向 | 横滑筛选器 | Horizontal/Vertical |
| 属性方法 | scrollBar | 滚动条显隐 | 横滑筛选器 | On/Off/Auto |
| 渲染组件 | ForEach | 列表渲染 | 所有列表和柱状图 | 需提供键值生成函数 |
| 控制语句 | if | 条件渲染 | 弹窗显隐控制 | false 时元素不创建 |
| 三元运算 | ? : | 条件表达式 | 颜色/文字动态选择 | 嵌套使用需注意可读性 |
| 数组方法 | unshift | 头部插入 | 发布动态 | 触发 @State 观测 |
| 数组方法 | splice | 替换/删除 | 约课/购票/退赛 | 触发 @State 观测 |
| 数组方法 | indexOf | 引用查找 | 约课/购票定位 | 严格相等比较 |
| 数组方法 | findIndex | 谓词查找 | 退赛定位 | 支持属性匹配 |
| 枚举类型 | FlexAlign | 对齐枚举 | 所有容器 | SpaceBetween/Center/Start |
| 枚举类型 | FontWeight | 字重枚举 | 所有文字 | Bold/Medium/Normal |
| 枚举类型 | TextAlign | 文本对齐 | Text 组件 | Center/Start/End |
| 枚举类型 | TextOverflow | 溢出枚举 | 列表项标题 | Ellipsis/Clip/None |
| 枚举类型 | Curve | 缓动曲线 | Tab 动画 | EaseOut 等多种曲线 |
| 枚举类型 | ScrollDirection | 滚动方向 | 横滑筛选器 | Horizontal/Vertical |
| 枚举类型 | BarState | 滚动条状态 | 横滑筛选器 | On/Off/Auto |
| 枚举类型 | HorizontalAlign | 水平对齐 | Column 子元素 | Start/Center/End |
| 枚举类型 | VerticalAlign | 垂直对齐 | Row 子元素 | Top/Center/Bottom |
十二、总结
架构设计的整体评价
本应用展示了一个完整的鸿蒙 ArkTS 声明式 UI 应用的架构设计。从数据模型定义到工具函数封装,从主入口组件的 Tab 路由到各 Tab 组件的独立实现,从列表渲染到弹窗交互,应用覆盖了移动应用开发中绝大多数常见的技术场景和交互模式。整体架构清晰、层次分明——数据层(interface + const 数组 + 工具函数)、组件层(@Component struct)、状态层(@State + @Link)、UI 层(build 方法 + @Builder)各司其职,相互配合。
状态管理的实践价值
应用中 @State 和 @Link 的配合使用是状态管理的核心实践。@State 负责组件内部状态的独立管理,@Link 负责父子组件间的双向数据同步。这种设计确保了数据的"单一数据源"原则——所有业务数据由 Index 组件持有,各 Tab 组件通过 @Link 共享访问,任何修改都会双向传播。这种模式虽然简单,但对于中小型应用而言已经足够。在更复杂的应用中,可以考虑使用 @Provide/@Consume 进行跨层级共享,或引入全局状态管理方案。
弹窗实现的模式总结
应用中的十六个弹窗采用了统一的实现模式:@Builder 方法定义 UI 结构、@State 布尔变量控制显隐、onClose 回调处理关闭、条件渲染(if 语句)决定是否挂载。这种模式的优势在于完全自定义——弹窗的样式、布局、交互行为完全由开发者控制,不受框架内置弹窗组件的限制。所有弹窗使用一致的视觉语言(半透明遮罩、居中卡片、金色/红色边框区分操作等级),形成了统一的设计系统。@Builder 方法接受函数参数的能力,使弹窗的关闭逻辑可以由调用方灵活定制,提高了代码的复用性。
数据不可变更新的重要性
应用在数据更新操作中一致采用了"不可变更新"模式——通过构建器函数(buildLessonBook、buildBallBuy、buildCupSign)创建新对象,使用 splice 方法替换数组中的旧元素。这种模式确保了每次状态变更都是一个"全新的对象引用",ArkTS 的状态观测系统能够可靠地捕获这种引用变化并触发 UI 更新。与之对比,直接修改对象属性(如 this.picked.quota -= 1)在 ArkTS 中可能无法被框架正确观测,因为 @State 对对象属性的深层变更观测存在局限性。不可变更新虽然代码量稍多(需要构建器函数),但它在状态管理的可靠性和可预测性上具有显著优势,是声明式 UI 框架中推荐的数据更新范式。
声明式 UI 的核心价值
从本应用的完整实现中,我们可以深刻体会到声明式 UI 范式的核心价值。开发者只需在 build 方法中描述"UI 应该长什么样"以及"UI 与状态变量的绑定关系",而不需要编写任何"如何将 UI 更新为目标状态"的命令式代码。当用户点击 Tab 切换时,currentTab 变化触发条件渲染重新评估;当用户发布动态时,unshift 操作触发 ForEach 重新渲染列表;当用户约课时,splice 替换触发卡片信息自动刷新——所有 UI 更新都由框架自动完成,开发者只需关注"状态如何变化",框架负责"UI 如何响应"。这种关注点分离极大地提升了开发效率和代码可维护性。
安装DevEco Studio程序

选择目标安装目录:

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

新建一个空白模板:

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

完整代码:
// =====================================================================
// 应用名: SWING DANCE 摇摆舞会所 · 复古舞厅与爵士驻场
// 场景: 舞厅动态、舞蹈课程、舞会排期、驻场乐手、赛事、我的收藏
// 风格: 墨绿丝绒 + 琥珀金 Art Deco —— 墨绿、香槟金、奶油白、金色放射线
// Tab: 首页 / 课程 / 舞会 / 乐手 / 赛事 / 我的(6 Tab 单排)
// 弹窗: 动态详情、发布动态(新增)、舞课详情、约课新增(新增)、舞会规则、
// 舞会详情、报名编辑(编辑)、乐手详情、邀约新增(新增)、赛事规则、
// 报名(步进)、退赛(删除警告)、收藏移除(删除)、改昵称(编辑)、
// 清缓存、退出(共 16 个)
// =====================================================================
// ---------------------------- 数据结构 ----------------------------
interface FeedItem {
id: number
title: string
time: string
tag: string
text: string
}
interface LessonItem {
id: number
name: string
level: string
price: number
quota: number
cover: string
note: string
}
interface BallItem {
id: number
name: string
date: string
band: string
ticket: number
quota: number
note: string
}
interface BandItem {
id: number
name: string
style: string
hot: number
state: string
note: string
}
interface CupItem {
id: number
name: string
date: string
prize: number
quota: number
state: string
note: string
}
interface MyItem {
id: number
name: string
kind: string
date: string
note: string
}
// ---------------------------- 写死数据 ----------------------------
const FEEDS: FeedItem[] = [
{ id: 1, title: '周六复古摇摆大舞会开票', time: '今天 09:00', tag: '舞会', text: '本周六晚 8 点复古摇摆大舞会,香槟金装点舞池,现场爵士乐队助兴。' },
{ id: 2, title: '零基础摇摆课新增晚班', time: '昨天 23:00', tag: '课程', text: '为上班族新增的晚班零基础课程,19:30-21:00,名额有限速抢。' },
{ id: 3, title: '舞会所全新木地板铺设', time: '昨天 18:00', tag: '动态', text: '全新枫木地板铺设完成,弹性与防滑俱佳,舞步更丝滑。' },
{ id: 4, title: '爵士四重奏驻场官宣', time: '前天 21:00', tag: '演出', text: '阿金爵士四重奏正式驻场,每周三至周日现场演奏经典摇摆曲目。' },
{ id: 5, title: '复古着装周活动开启', time: '前天 15:00', tag: '活动', text: '本周穿复古风格着装入场,可获免费饮品一杯。' },
{ id: 6, title: '舞会摄影服务上线', time: '3 天前', tag: '功能', text: '舞会现场提供专业摄影服务,定格你的优雅舞姿。' },
{ id: 7, title: 'Lindy Hop 公开课预告', time: '4 天前', tag: '课程', text: '下周二晚 Lindy Hop 公开课,资深导师亲授,欢迎零基础体验。' },
{ id: 8, title: '征集舞会主题曲', time: '5 天前', tag: '互动', text: '评论区留言最想在舞池听到的爵士金曲,点赞最高的将加入歌单。' }
]
const LESSONS: LessonItem[] = [
{ id: 1, name: '零基础摇摆入门', level: '入门', price: 128, quota: 12, cover: '👠', note: '从基础舞步到双人连接,两节课带你走进摇摆舞的大门。' },
{ id: 2, name: 'Lindy Hop 进阶', level: '进阶', price: 188, quota: 10, cover: '🎩', note: '深入学习 6 拍与 8 拍切换,掌握即兴连接的技巧。' },
{ id: 3, name: 'Charleston 特训', level: '进阶', price: 168, quota: 14, cover: '🦵', note: '复古 Charleston 踢腿步法特训,舞池最亮眼的仔。' },
{ id: 4, name: 'Balboa 初体验', level: '入门', price: 148, quota: 10, cover: '💃', note: '近距离贴身舞步 Balboa,适合空间受限的舞池。' },
{ id: 5, name: 'Solo Jazz 独舞课', level: '中级', price: 158, quota: 16, cover: '🕺', note: '单人爵士舞步组合,不依赖舞伴也能尽情摇摆。' },
{ id: 6, name: '舞会礼仪与着装', level: '入门', price: 88, quota: 20, cover: '🎩', note: '复古着装搭配与舞会礼仪,让你优雅亮相每场舞会。' },
{ id: 7, name: '双人旋转技巧', level: '中级', price: 198, quota: 8, cover: '🌀', note: '高难度旋转与空中换位技巧,需要一定基础。' },
{ id: 8, name: '爵士乐赏析课', level: '入门', price: 98, quota: 24, cover: '🎷', note: '听懂爵士乐的节奏与结构,跳舞更有灵魂。' },
{ id: 9, name: '晚班零基础', level: '入门', price: 128, quota: 12, cover: '🌙', note: '专为上班族开设的晚间课程,19:30-21:00 轻松入门。' },
{ id: 10, name: '周末双人速成', level: '进阶', price: 328, quota: 6, cover: '💞', note: '周末两天速成双人组合舞,结课即可登台表演。' }
]
const BALLS: BallItem[] = [
{ id: 1, name: '周六复古大舞会', date: '08-29 20:00', band: '阿金四重奏', ticket: 68, quota: 80, note: '香槟金主题舞会,现场四重奏演奏经典摇摆金曲,附赠欢迎饮品。' },
{ id: 2, name: '爵士蓝调之夜', date: '09-05 21:00', band: '老周蓝调团', ticket: 58, quota: 60, note: '蓝调爵士之夜,慢舞时段更长,适合新手练习。' },
{ id: 3, name: 'Lindy Hop 主题夜', date: '09-12 20:00', band: '林果摇摆组', ticket: 78, quota: 70, note: 'Lindy Hop 主题,全场快速摇摆曲目,跳到大汗淋漓。' },
{ id: 4, name: '复古着装盛典', date: '09-19 19:30', band: '阿金四重奏', ticket: 98, quota: 90, note: '复古着装盛典,现场评选最佳着装奖,奖品丰厚。' },
{ id: 5, name: '中秋月光舞会', date: '09-19 21:30', band: '糖糖乐队', ticket: 68, quota: 75, note: '中秋特别场,月光主题布置,邀月共舞。' },
{ id: 6, name: 'Swing Kids 青年场', date: '09-26 18:00', band: '大飞街乐组', ticket: 45, quota: 50, note: '面向青年舞者的轻松场,欢迎带新朋友来体验。' },
{ id: 7, name: '万圣节奇装舞会', date: '10-31 20:00', band: '林果摇摆组', ticket: 88, quota: 100, note: '奇装异服主题,幽灵爵士曲风,敢来就敢嗨。' },
{ id: 8, name: '圣诞金曲舞会', date: '12-24 20:00', band: '阿金四重奏', ticket: 108, quota: 120, note: '圣诞金曲之夜,全场装饰圣诞灯,氛围感拉满。' }
]
const BANDS: BandItem[] = [
{ id: 1, name: '阿金四重奏', style: '摇摆爵士', hot: 97, state: 'home', note: '萨克斯领衔的四重奏,演奏经典摇摆曲目,舞会所驻场天团。' },
{ id: 2, name: '老周蓝调团', style: '蓝调', hot: 93, state: 'free', note: '口琴加木箱鼓的三人蓝调组合,慢舞最佳拍档。' },
{ id: 3, name: '林果摇摆组', style: 'Swing', hot: 94, state: 'home', note: '清亮主唱加节奏组,年轻活力的摇摆新势力。' },
{ id: 4, name: '糖糖乐队', style: 'R&B', hot: 91, state: 'free', note: '丝滑转音的 R&B 女声乐队,舞池氛围担当。' },
{ id: 5, name: '大飞街乐组', style: '融合', hot: 89, state: 'free', note: '街头音乐人组合,融合爵士与放克,即兴功力一流。' },
{ id: 6, name: 'Swing Sisters', style: '人声合唱', hot: 92, state: 'home', note: '三人女声合唱组,清唱改编经典摇摆曲,耳目一新。' },
{ id: 7, name: '铜管兄弟', style: '大乐队', hot: 90, state: 'free', note: '五人铜管大乐队,气势恢宏,舞会压轴常客。' },
{ id: 8, name: '月光钢琴组', style: '钢琴爵士', hot: 88, state: 'free', note: '钢琴与贝斯的二人组合,适合慢舞与中场休息。' }
]
const CUPS: CupItem[] = [
{ id: 1, name: '春季摇摆舞公开赛', date: '09-06 开赛', prize: 30000, quota: 40, state: 'open', note: '面向全体舞者的公开赛,分入门组与进阶组,现场评委打分。' },
{ id: 2, name: 'Lindy Hop 对抗赛', date: '09-13 开赛', prize: 20000, quota: 32, state: 'open', note: '双人 Lindy Hop 对抗赛,随机配对即兴对决。' },
{ id: 3, name: 'Solo Jazz 独舞赛', date: '09-20 开赛', prize: 15000, quota: 36, state: 'open', note: '单人爵士独舞赛,比拼编排创意与舞台表现。' },
{ id: 4, name: '新人摇摆挑战赛', date: '09-27 开赛', prize: 8000, quota: 24, state: 'open', note: '专为学舞不足一年的新人设立,友谊第一比赛第二。' },
{ id: 5, name: '复古着装评选赛', date: '10-04 开赛', prize: 5000, quota: 50, state: 'open', note: '比拼复古着装搭配,评委与观众共同投票。' },
{ id: 6, name: '秋季舞王争霸赛', date: '10-11 开赛', prize: 50000, quota: 20, state: 'closed', note: '全年积分最高者争夺舞王称号,已截止报名。' },
{ id: 7, name: '双人即兴接龙赛', date: '10-18 开赛', prize: 10000, quota: 30, state: 'open', note: '随机换伴即兴接龙,考验临场应变。' },
{ id: 8, name: '年终盛典总决赛', date: '12-20 开赛', prize: 100000, quota: 12, state: 'closed', note: '年度总决赛,仅限积分前 12 名,荣誉之战。' }
]
const MYS: MyItem[] = [
{ id: 1, name: '周六复古大舞会', kind: '舞会', date: '08-20', note: '已购票' },
{ id: 2, name: '零基础摇摆入门', kind: '课程', date: '08-19', note: '已报名' },
{ id: 3, name: '阿金四重奏', kind: '乐手', date: '08-18', note: '已关注' },
{ id: 4, name: 'Lindy Hop 进阶', kind: '课程', date: '08-15', note: '已报名' },
{ id: 5, name: '爵士蓝调之夜', kind: '舞会', date: '08-12', note: '已购票' },
{ id: 6, name: 'Swing Sisters', kind: '乐手', date: '08-10', note: '已关注' },
{ id: 7, name: '春季摇摆舞公开赛', kind: '赛事', date: '08-08', note: '已报名' },
{ id: 8, name: 'Solo Jazz 独舞课', kind: '课程', date: '08-05', note: '已报名' }
]
const HEAT: number[] = [64, 78, 70, 86, 80, 92, 84, 89]
// ---------------------------- 工具函数 ----------------------------
function heatBar(v: number): number {
return Math.floor(28 + v * 0.85)
}
function playsText(v: number): string {
if (v >= 10000) {
return (v / 10000).toFixed(1) + ' 万'
}
return v.toString()
}
function lessonStateText(q: number): string {
if (q > 0) {
return '可约'
}
return '满员'
}
function lessonStateColor(q: number): string {
if (q > 0) {
return '#FCD34D'
}
return '#8A8A96'
}
function bandStateText(s: string): string {
if (s === 'home') {
return '驻场中'
}
return '可邀约'
}
function bandStateColor(s: string): string {
if (s === 'home') {
return '#FCD34D'
}
return '#6EE7B7'
}
function trendText(t: string): string {
if (t === 'up') {
return '↑ 升温'
}
if (t === 'down') {
return '↓ 降温'
}
return '→ 持平'
}
function trendColor(t: string): string {
if (t === 'up') {
return '#FCD34D'
}
if (t === 'down') {
return '#E11D48'
}
return '#8A8A96'
}
function buildFeed(id: number, title: string): FeedItem {
return { id: id, title: title, time: '刚刚', tag: '动态', text: '这是一条刚刚发布的舞会所动态,欢迎各位舞友围观互动。' }
}
function buildMy(id: number, name: string, kind: string): MyItem {
return { id: id, name: name, kind: kind, date: '08-28', note: '刚刚收藏' }
}
function buildLesson(id: number, name: string): LessonItem {
return { id: id, name: name, level: '入门', price: 128, quota: 12, cover: '🩰', note: '这是一节刚刚开设的舞蹈课程,欢迎报名体验。' }
}
// =====================================================================
// Index 主入口:Art Deco 放射线头部(无动画)+ 6 Tab + 底部单排
// =====================================================================
@Entry
@Component
struct Index {
@State currentTab: number = 0
@State feeds: FeedItem[] = FEEDS
@State lessons: LessonItem[] = LESSONS
@State balls: BallItem[] = BALLS
@State bands: BandItem[] = BANDS
@State cups: CupItem[] = CUPS
@State mys: MyItem[] = MYS
@Builder
tabItem(icon: string, label: string, tab: number) {
Column({ space: 3 }) {
Text(icon)
.fontSize(22)
.opacity(this.currentTab === tab ? 1 : 0.55)
.scale({ x: this.currentTab === tab ? 1.12 : 1, y: this.currentTab === tab ? 1.12 : 1 })
Text(label)
.fontSize(11)
.fontColor(this.currentTab === tab ? '#FCD34D' : '#7E8A80')
.fontWeight(this.currentTab === tab ? FontWeight.Bold : FontWeight.Normal)
}
.width('16.6%')
.height(58)
.justifyContent(FlexAlign.Center)
.animation({ duration: 200, curve: Curve.EaseOut })
.onClick(() => {
this.currentTab = tab
})
}
build() {
Column() {
// Art Deco 放射线头部(无动画)
Column() {
Row() {
Text('◢')
.fontSize(14)
.fontColor('#D97706')
Text('SWING DANCE')
.fontSize(17)
.fontColor('#FCD34D')
.fontWeight(FontWeight.Bold)
.letterSpacing(3)
.backgroundColor('#0D1F14')
.border({ width: 2, color: '#D97706' })
.padding({ left: 12, right: 12, top: 4, bottom: 4 })
Text('◣')
.fontSize(14)
.fontColor('#D97706')
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
Row() {
ForEach([0, 1, 2, 3, 4, 5, 6, 7, 8], (i: number, index: number) => {
Row()
.layoutWeight(1)
.height(3)
.backgroundColor(index % 2 === 0 ? '#D97706' : '#14532D')
}, (i: number, index: number) => 'line' + index.toString())
}
.width('100%')
})
}
}
.width('100%')
.height('100%')
}
}
// ===== 2176.ets END =====

工具函数与 UI 代码的分离
应用将状态判断逻辑(lessonStateText、lessonStateColor、bandStateText、bandStateColor 等)和数据构建逻辑(buildFeed、buildMy、buildLessonBook 等)提取为顶层工具函数,使 UI 代码保持简洁和可读。这种"逻辑与视图分离"的实践在工程中具有重要价值:状态判断逻辑在列表项和详情弹窗中被反复调用,如果内联在 UI 代码中会导致大量重复代码;工具函数的统一调用确保了状态展示的一致性——同一个状态在列表和详情中显示相同的颜色和文字;当业务规则变化时(如调整状态颜色方案),只需修改工具函数即可,无需在多处 UI 代码中逐一调整。
颜色系统与视觉设计的一致性
应用建立了一套完整的颜色系统,以墨绿色、琥珀金色和香槟金色为核心色调,搭配奶油白、薄荷绿和警告红作为辅助色。这套颜色系统在所有 Tab 和弹窗中被一致使用——墨绿色 #16241C 作为卡片背景、琥珀金色 #D97706 作为主要操作色和横幅背景、香槟金色 #FCD34D 作为强调色和选中态、薄荷绿色 #6EE7B7 作为"可用"状态色、红色 #E11D48 作为"危险/售罄"状态色。颜色的一致性使用不仅提升了视觉美感,更重要的是建立了一套视觉语义系统——用户通过颜色就能快速判断信息的性质和操作的危险等级,无需阅读文字内容。
布局技术的综合运用
应用综合运用了 ArkUI 提供的多种布局技术和属性方法。Column 和 Row 的线性布局构建了页面的基本结构;Stack 的层叠布局实现了弹窗的覆盖效果;Scroll 的滚动容器为长内容列表提供了滚动能力;layoutWeight 的权重分配实现了多列等分布局;offset 的相对偏移实现了进度条的层叠效果;ForEach 的列表渲染处理了动态数据到 UI 的映射;条件渲染(if 语句)控制了弹窗和提示信息的显隐。这些技术的组合使用,展现了 ArkUI 声明式 UI 框架在构建复杂移动应用界面时的表达能力和灵活性。通过对这些技术点的深入理解和灵活组合,开发者能够高效地构建出结构清晰、交互丰富、视觉统一的鸿蒙应用。
更多推荐




所有评论(0)