引言:鸿蒙生态下的ArkTS组件化开发实践

鸿蒙操作系统与ArkTS语言的技术背景

鸿蒙操作系统(HarmonyOS)是华为推出的面向全场景的分布式操作系统,其核心设计理念是"一次开发,多端部署"。在鸿蒙的开发体系中,ArkTS作为首选的应用开发语言,承载了连接UI描述与业务逻辑的关键使命。ArkTS基于TypeScript进行了扩展,引入了声明式UI范式、状态管理机制、自定义组件等一系列核心特性,使得开发者能够以更加直观、高效的方式构建跨设备的用户界面。

声明式UI是ArkTS最核心的特性之一。与传统的命令式编程不同,声明式UI允许开发者描述"界面应该是什么样子",而不是一步步告诉系统"如何构建界面"。在ArkTS中,开发者通过build()方法描述UI结构,框架会自动负责底层的渲染、更新和优化工作。这种范式的转变极大地提升了开发效率,同时也降低了UI状态不一致的风险。

声明式UI的核心哲学是:UI = f(State)。给定一个状态,UI的呈现就是确定的。当状态发生变化时,框架自动重新计算并更新UI。

组件化开发是鸿蒙ArkTS开发的另一大基石。在ArkTS中,一切皆组件——从最简单的Text文本组件,到复杂的页面结构,都可以抽象为一个@Component装饰的结构体。这种组件化思想带来了诸多优势:代码复用性高、职责单一明确、便于单元测试、易于团队协作。一个设计良好的组件应该是高内聚、低耦合的,它只关注自身的渲染逻辑和状态管理,通过属性参数和回调函数与外部世界交互。

状态管理是ArkTS开发中不可回避的核心话题。鸿蒙提供了多层次的状态管理装饰器:@State用于组件内部状态,@Prop用于父子组件间的单向数据传递,@Link用于双向数据绑定,@Provide/@Consume用于跨层级的状态共享,@Observed/@ObjectLink用于嵌套对象的观察。合理选择和使用这些状态管理机制,直接决定了应用的性能表现和可维护性。

布局系统是UI框架的基础能力。ArkTS提供了丰富的布局容器:Column用于垂直方向的线性布局,Row用于水平方向的线性布局,Stack用于层叠布局,Flex用于弹性布局,List/Grid用于滚动列表和网格布局。每种布局容器都有其特定的使用场景和性能特点,开发者需要根据界面结构灵活选择。

本文将以一个完整的配音社区应用"声临其境 VOICE LAB"为案例,从数据结构、组件设计、状态管理、布局实现、动画效果等多个维度,深入剖析ArkTS开发的技术细节和最佳实践。

应用整体架构概览

"声临其境 VOICE LAB"是一个面向声优和配音爱好者的社区平台,涵盖了试音接单、声优榜单、剧本浏览、作品管理、声音收藏、个人中心等核心功能模块。整个应用采用经典的底部Tab导航架构,共包含6个主功能Tab和6个弹框组件,形成了一个功能完整、交互丰富的移动端应用。

Index 主页面

AuditionTab 试音

RankTab 榜单

ScriptTab 剧本

VoiceTab 作品

FavTab 收藏

MyTab 我的

applyOverlay 试音申请抽屉

scriptOverlay 剧本详情大卡

uploadOverlay 上传配音表单

editOverlay 编辑配音抽屉

delOverlay 删除确认弹框

rewardOverlay 打赏结算卡

从架构图中可以清晰地看到,整个应用以Index组件为核心枢纽,向上管理6个Tab子组件的切换显示,向下管理6个弹框组件的弹出与关闭。这种集中式的状态管理模式使得应用的数据流更加清晰可追踪。


数据层设计:接口定义与静态数据

TypeScript接口在ArkTS中的应用

在开始构建UI之前,首先需要定义清晰的数据模型。TypeScript的接口(Interface)机制为我们提供了强大的类型检查能力,确保数据在传递和使用过程中始终符合预期的结构。

interface AuditT {
  id: number
  role: string
  project: string
  pay: string
  words: string
  deadline: string
  style: string
  color: string
}

在这里插入图片描述

AuditT接口定义了试音项目的数据结构。其中id作为唯一标识符,类型为number,用于在列表操作中精确匹配特定条目。role字段存储角色名称,如"冷淡系男主"、“元气少女"等,直接展示给用户。project字段表示所属项目名称,通常是广播剧、有声书等作品的标题。pay字段以字符串形式存储报酬信息,格式如"¥45/千字”,选择字符串类型而非数值类型是因为这里包含了货币符号和单位,更适合直接展示。words字段描述字数规模,如"约 1.2 万字"。deadline字段记录截止日期,采用月-日格式。style字段描述声音风格标签,如"低音·克制"。color字段则存储十六进制颜色值,用于卡片背景色的个性化展示。

为每个数据实体定义明确的接口是TypeScript/ArkTS开发的最佳实践。它不仅提供了编译时的类型检查,还能作为文档帮助团队成员理解数据结构,减少沟通成本。

interface RankT {
  id: number
  name: string
  emoji: string
  score: number
  delta: number
  works: number
  color: string
}

RankT接口定义了声优榜单条目。name是声优昵称,emoji是头像表情符号,这种用emoji代替真实头像的设计在Demo或原型阶段非常常见,既美观又省事。score是积分,类型为number,用于排序和展示。delta字段非常有意思,它表示排名变化量——正数表示上升,负数表示下降,零表示持平。这个字段驱动了榜单中升降箭头的显示逻辑。works字段记录作品数量。color同样用于头像背景色。

interface ScriptT {
  id: number
  title: string
  genre: string
  chapters: number
  roles: number
  level: number
  hot: string
  color: string
}

在这里插入图片描述

ScriptT接口定义了剧本数据模型。title是剧本标题,genre是类型标签如"都市·悬疑"。chaptersroles都是数值类型,分别表示章节数和角色数。level字段是难度等级,取值为1到5的整数,对应五颗星的星级显示。hot字段以字符串形式存储热度信息,如"8.2 万人在读",选择字符串是因为它包含了单位和描述性文字。

interface VoiceT {
  id: number
  title: string
  role: string
  duration: string
  status: string
  likes: number
  date: string
}

VoiceT接口定义了配音作品的数据结构。duration表示时长,如"04分12秒"。status字段是作品状态,有"已发布"、“审核中”、"草稿"三种取值,不同状态对应不同的显示样式和操作权限。likes是点赞数,数值类型,便于计算和格式化。date是发布日期。

interface SoundT {
  id: number
  title: string
  author: string
  duration: string
  color: string
}

SoundT接口用于收藏的声音条目。相比VoiceT,它的字段更简洁——只有标题、作者、时长和颜色。这是因为收藏页面不需要展示状态、点赞数等管理属性,只需要最核心的展示信息。

接口设计的一个重要原则是:不同场景使用不同的数据模型。不要试图用一个"万能接口"满足所有需求,那样会导致字段冗余和语义模糊。为每个使用场景定义精准的数据接口,能够让代码更加清晰、更加类型安全。

静态数据的组织与设计

const AUDIT_LIST: Array<AuditT> = [
  { id: 1, role: '冷淡系男主', project: '广播剧《雪落无声》', pay: '¥45/千字', words: '约 1.2 万字', deadline: '09-02', style: '低音 · 克制', color: '#F8BBD0' },
  { id: 2, role: '元气少女', project: '动态漫《追风日记》', pay: '¥38/千字', words: '约 0.8 万字', deadline: '08-31', style: '明亮 · 活泼', color: '#FFCDD2' },
  // ... 更多条目
]

在这里插入图片描述

AUDIT_LIST是一个类型为Array<AuditT>的常量数组,包含了8条试音项目数据。每条数据都精心设计了不同的角色类型、项目背景、报酬水平和颜色主题,模拟了真实平台上多样化的试音机会。使用const声明表明这是不可变的静态数据,在运行时不会被修改。

const RANK_LIST: Array<RankT> = [
  { id: 1, name: '清枕', emoji: '🎙️', score: 9862, delta: 2, works: 328, color: '#F8BBD0' },
  { id: 2, name: '苏打气泡音', emoji: '🫧', score: 9415, delta: 0, works: 291, color: '#E1BEE7' },
  // ... 更多条目
]

RANK_LIST包含8名声优的榜单数据。值得注意的是delta字段的设计——有正有负有零,分别对应排名上升、持平、下降三种状态。这种设计使得UI层可以通过简单的条件判断展示不同的箭头图标和颜色。

const SCRIPT_LIST: Array<ScriptT> = [
  { id: 1, title: '雪落无声', genre: '都市 · 悬疑', chapters: 12, roles: 6, level: 4, hot: '8.2 万人在读', color: '#F8BBD0' },
  // ... 更多条目
]

SCRIPT_LIST的8条剧本数据覆盖了都市、校园、科幻、古风、搞笑、美食、赛博等多种题材,level从2到5不等,展示了难度分级的设计思路。

const FANS_WEEK: Array<string> = ['一', '二', '三', '四', '五', '六', '日']
const FANS_WEEK_VAL: Array<number> = [120, 260, 180, 340, 210, 520, 380]

这两个数组用于粉丝增长柱状图。FANS_WEEK存储星期标签,FANS_WEEK_VAL存储对应的粉丝增长数值。两个数组通过索引一一对应,形成了图表的数据源。这种平行数组(Parallel Array)的设计在简单图表场景中非常常见,实现简单且易于理解。

const GENRE_CHIPS: Array<string> = ['全部', '都市', '校园', '科幻', '古风', '搞笑', '美食', '赛博']

GENRE_CHIPS是剧本分类的标签数组,用于横向滚动的筛选栏。"全部"作为第一个选项,是分类筛选的常见设计模式。

const TIP_LIST: Array<string> = [
  '试音前请确保环境安静,避免喷麦',
  '干声上传后甲方可自行后期处理',
  // ... 更多贴士
]

TIP_LIST是新手贴士数组,这类提示性内容通常以数组形式存储,便于循环渲染。

纯函数工具:数字格式化

function fmtK(n: number): string {
  if (n >= 10000) {
    return (n / 10000).toFixed(1) + '万'
  }
  return n.toString()
}

在这里插入图片描述

fmtK函数是一个典型的纯函数——给定相同的输入永远产生相同的输出,没有任何副作用。它的功能是将大数字格式化为以"万"为单位的字符串。当数值大于等于10000时,除以10000并保留一位小数,然后追加"万"字;否则直接返回数字的字符串形式。

这个函数在代码中被多次调用,用于展示积分、粉丝数等可能很大的数值。将格式化逻辑抽取为独立的纯函数,是代码复用和关注点分离的经典实践。所有需要展示数字的地方都可以调用这个函数,确保了格式的一致性。

纯函数是函数式编程的核心概念。纯函数具有可预测性、可测试性、可缓存性等诸多优点。在UI开发中,将数据格式化、数据转换等逻辑封装为纯函数,能够显著提升代码质量。


试音Tab:AuditionTab组件深度解析

组件结构与状态定义

@Component
struct AuditionTab {
  @State applied: Array<number> = []
  onApply: (idx: number) => void = (idx: number) => {}

AuditionTab使用@Component装饰器标记为一个自定义组件。结构体名称遵循大驼峰命名法,这是ArkTS组件的命名规范。

@State applied: Array<number> = []定义了一个组件内部状态。applied是一个数字数组,存储了用户已经投递过的试音项目ID。初始值为空数组,表示用户尚未投递任何试音。@State装饰器告诉框架:这个变量是响应式的,当它发生变化时,所有引用它的UI都会自动更新。

onApply: (idx: number) => void = (idx: number) => {}是一个回调函数属性。它的类型是一个接受number参数、返回void的函数。默认值是一个空函数,这是一种防御性编程——即使父组件没有传入这个回调,调用它也不会报错。这种设计模式实现了子组件向父组件的通信:子组件通过调用回调函数,将事件和数据传递给父组件处理。

父子组件通信是组件化开发的核心模式。父传子通过属性参数(Props),子传父通过回调函数(Events)。这种单向数据流的设计使得组件间的依赖关系清晰明确。

外层滚动容器

  build() {
    Scroll() {
      Column() {
        // ... 内容
      }
      .padding({ left: 12, right: 12, top: 12, bottom: 16 })
    }
    .scrollBar(BarState.Off)
    .width('100%')
    .layoutWeight(1)
  }

在这里插入图片描述

build()方法是组件的核心,它返回组件的UI描述。最外层使用Scroll组件包裹Column,使得整个页面内容可以垂直滚动。

Scroll组件的.scrollBar(BarState.Off)方法关闭了滚动条显示,这在移动端应用中很常见——用户通过滑动手势来滚动,不需要显示滚动条。

.width('100%')设置宽度占满父容器。.layoutWeight(1)是一个重要的布局属性,它表示该组件在弹性布局中占据剩余空间的权重。在父容器是Column且存在固定高度的兄弟组件时,layoutWeight(1)会让当前组件填满剩余的垂直空间。

内部的Column设置了四边的内边距(padding),左右各12像素,顶部12像素,底部16像素。这种边距设计为内容区域提供了舒适的呼吸空间,避免内容紧贴屏幕边缘。

横向滚动的"进行中的试音"卡片

        Text('进行中的试音')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor('#3A2430')
          .width('100%')
          .margin({ bottom: 10 })

在这里插入图片描述

首先是一个标题文本。.fontSize(15)设置字号为15像素,.fontWeight(FontWeight.Bold)设置为粗体。.fontColor('#3A2430')设置文字颜色为深棕紫色,这是整个应用的主文字色。.width('100%')让文本占满整行,.margin({ bottom: 10 })在底部留出10像素的外边距,与下方内容分隔。

        Scroll() {
          Row({ space: 10 }) {
            ForEach(AUDIT_LIST, (a: AuditT, i: number) => {
              if (i < 4) {
                Column() {
                  Text(a.role)
                    .fontSize(14)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#FFFFFF')
                  Text(a.project)
                    .fontSize(9)
                    .fontColor('rgba(255,255,255,0.85)')
                    .margin({ top: 5 })
                    .maxLines(1)
                  Text(a.pay + ' · 截止 ' + a.deadline)
                    .fontSize(9)
                    .fontColor('#FFFFFF')
                    .margin({ top: 5 })
                  Text(this.applied.indexOf(a.id) >= 0 ? '已投递' : '急招')
                    .fontSize(9)
                    .fontColor('#E91E63')
                    .fontWeight(FontWeight.Medium)
                    .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                    .borderRadius(8)
                    .backgroundColor('#FFFFFF')
                    .margin({ top: 8 })
                }
                .width(150)
                .padding(12)
                .borderRadius(16)
                .backgroundColor(a.color)
                .alignItems(HorizontalAlign.Start)
              }
            })
          }
          .padding({ left: 2, right: 2 })
        }
        .scrollable(ScrollDirection.Horizontal)
        .scrollBar(BarState.Off)
        .width('100%')
        .margin({ bottom: 16 })

在这里插入图片描述

这是一个横向滚动的卡片列表,是本组件的亮点之一。

外层又是一个Scroll组件,但这次设置了.scrollable(ScrollDirection.Horizontal),使其变为水平滚动方向。.scrollBar(BarState.Off)同样隐藏滚动条。

内部的Row({ space: 10 })使用了space参数,设置子元素之间的间距为10像素。这比为每个子元素单独设置margin更加简洁。

ForEach(AUDIT_LIST, (a: AuditT, i: number) => { ... })是循环渲染的核心语法。它接受两个参数:第一个是要遍历的数组,第二个是渲染函数,函数参数为当前元素a和索引iForEach会为数组中的每个元素执行渲染函数,并将结果依次添加到父容器中。

if (i < 4)这个条件判断非常关键——它只渲染前4个元素。也就是说,虽然AUDIT_LIST有8条数据,但"进行中的试音"这个横向滚动区域只展示前4条。这是一种常见的UI设计模式:在页面顶部展示精选/热门内容,完整列表在下方展示。

每个卡片是一个Column,固定宽度150像素,内边距12像素,圆角16像素。背景色使用a.color,即数据中定义的颜色值,每张卡片有不同的主题色。.alignItems(HorizontalAlign.Start)设置子元素左对齐。

卡片内部有四个文本元素:

  1. 角色名:14号粗体白字,最醒目
  2. 项目名:9号半透明白字(85%不透明度),上方5像素间距,最多显示1行(超出省略)
  3. 报酬和截止日期:9号白字,上方5像素间距
  4. 状态标签:“已投递"或"急招”,9号粉色粗体字,白色背景,圆角8像素,内边距各方向不同

状态标签的文本通过三元表达式动态决定:this.applied.indexOf(a.id) >= 0 ? '已投递' : '急招'。它检查当前项目的ID是否存在于applied数组中——如果存在,说明用户已经投递过,显示"已投递";否则显示"急招"。这是响应式状态的典型应用:当applied数组变化时,所有相关标签的文本会自动更新。

.maxLines(1)是一个重要的文本属性,它限制文本最多显示1行,超出部分会以省略号结尾。在卡片布局中,这个属性可以防止长文本撑破布局。

ForEach是ArkTS中列表渲染的核心机制。它不是简单的循环,而是框架内部做了大量优化——包括虚拟滚动、差异更新等。对于大数据量列表,建议使用List组件配合ForEach以获得更好的性能。

"全部角色"列表头

        Row() {
          Text('全部角色')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor('#3A2430')
            .layoutWeight(1)
          Text('今日新增 12 个')
            .fontSize(10)
            .fontColor('#E91E63')
        }
        .width('100%')
        .margin({ bottom: 10 })

这是完整列表的标题行,使用Row水平排列两个文本。

第一个文本"全部角色"设置了.layoutWeight(1),这意味着它会占据Row中除了第二个文本宽度之外的所有剩余空间。效果就是"全部角色"左对齐,"今日新增 12 个"右对齐。这是Row布局中实现两端对齐的常用技巧。

"今日新增 12 个"使用粉色字体,10号字,作为辅助信息吸引用户注意。

试音列表项详解

        Column() {
          ForEach(AUDIT_LIST, (a: AuditT, i: number) => {
            Column() {
              // ... 列表项内容
            }
            .width('100%')
            .padding(12)
            .borderRadius(14)
            .backgroundColor('#FFFFFF')
            .margin({ bottom: 8 })
          })
        }
        .width('100%')

完整的试音列表使用ForEach遍历全部8条数据。每个列表项是一个白色背景的Column,宽度100%,内边距12像素,圆角14像素,底部外边距8像素。白色卡片加圆角加阴影(这里通过背景色和间距形成视觉层次)是移动端UI设计的经典模式。

              Row() {
                Text(a.role)
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#3A2430')
                Text(a.style)
                  .fontSize(9)
                  .fontColor('#E91E63')
                  .padding({ left: 7, right: 7, top: 2, bottom: 2 })
                  .borderRadius(7)
                  .backgroundColor('#FDE4EF')
                  .margin({ left: 8 })
              }

在这里插入图片描述

列表项第一行是角色名和风格标签。角色名使用14号粗体深色字,是主要信息。风格标签是一个粉色背景(#FDE4EF)的小标签,粉色文字,圆角7像素,内边距精心调整,左边距8像素与角色名分隔。这种标签式设计能够快速传达分类信息。

              Text(a.project)
                .fontSize(11)
                .fontColor('#9A8794')
                .margin({ top: 6 })

第二行是项目名称,11号字,浅紫色(#9A8794),顶部6像素间距。次要信息使用较小字号和较浅颜色是视觉层次设计的基本原则。

              Row() {
                Text('💰 ' + a.pay)
                  .fontSize(11)
                  .fontColor('#E91E63')
                  .fontWeight(FontWeight.Medium)
                Text('📝 ' + a.words)
                  .fontSize(10)
                  .fontColor('#9A8794')
                  .margin({ left: 10 })
                Text('⏰ 截止 ' + a.deadline)
                  .fontSize(10)
                  .fontColor('#FF7043')
                  .margin({ left: 10 })
              }
              .margin({ top: 8 })

第三行是三个关键信息标签,使用emoji图标增强视觉识别度:

  • 报酬信息:钱袋emoji + 报酬金额,粉色粗体,最醒目(因为报酬是声优最关心的信息)
  • 字数信息:纸笔emoji + 字数,浅灰色,次要
  • 截止时间:闹钟emoji + 截止日期,橙色(#FF7043),警示色提醒截止时间

三个标签水平排列,通过左边距分隔。这种信息密集型的行布局需要仔细权衡各元素的视觉权重。

              Row() {
                Text(i < 3 ? '🔥 热门' : '⭐ 推荐')
                  .fontSize(9)
                  .fontColor('#9A8794')
                  .layoutWeight(1)
                Text(this.applied.indexOf(a.id) >= 0 ? '已投递' : '立即试音')
                  .fontSize(11)
                  .fontWeight(FontWeight.Medium)
                  .fontColor(this.applied.indexOf(a.id) >= 0 ? '#9A8794' : '#FFFFFF')
                  .padding({ left: 14, right: 14, top: 6, bottom: 6 })
                  .borderRadius(14)
                  .backgroundColor(this.applied.indexOf(a.id) >= 0 ? '#F3EDF1' : '#E91E63')
                  .onClick(() => {
                    this.onApply(a.id)
                  })
              }
              .width('100%')
              .margin({ top: 10 })

第四行是操作区,左侧是标签,右侧是按钮。

左侧的标签根据索引判断:前3个显示"🔥 热门",其余显示"⭐ 推荐"。这是一种简单的推荐标记策略,通过位置来区分热门程度。.layoutWeight(1)让这个标签占据左侧剩余空间,实现右侧按钮的右对齐。

右侧的"立即试音"/"已投递"按钮是整个列表项的交互核心。按钮的样式完全由this.applied.indexOf(a.id) >= 0这个条件决定——如果已投递,按钮变为灰色背景(#F3EDF1)+ 灰色文字(#9A8794)的禁用样式;如果未投递,则是粉色背景(#E91E63)+ 白色文字的激活样式。

.onClick(() => { this.onApply(a.id) })绑定了点击事件。点击时调用父组件传入的onApply回调函数,并将当前项目的ID作为参数传递出去。这样,投递的具体逻辑由父组件决定,子组件只负责触发事件和传递数据,体现了单一职责原则。

新手贴士区域

        Column() {
          Text('新手贴士')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor('#3A2430')
            .width('100%')
            .margin({ bottom: 8 })
          ForEach(TIP_LIST, (t: string) => {
            Row() {
              Text('·')
                .fontSize(12)
                .fontColor('#E91E63')
                .fontWeight(FontWeight.Bold)
              Text(t)
                .fontSize(10)
                .fontColor('#9A8794')
                .layoutWeight(1)
                .margin({ left: 6 })
            }
            .width('100%')
            .padding({ top: 4, bottom: 4 })
          })
        }
        .width('100%')
        .padding(12)
        .borderRadius(14)
        .backgroundColor('#FFFFFF')
        .margin({ top: 8 })

页面底部是"新手贴士"模块,同样是一个白色卡片。标题使用13号粗体字。

贴士列表通过ForEach(TIP_LIST, (t: string) => { ... })循环渲染。每条贴士是一个Row,左侧是一个粉色的圆点(用·字符模拟),右侧是贴士文本。圆点使用粗体增强视觉效果,文本使用浅灰色10号字。

.layoutWeight(1)让文本占据剩余空间,实现圆点左对齐。每条贴士的上下内边距为4像素,保证行间距舒适。

这种"标题 + 列表项"的卡片式布局在移动端非常普遍。通过统一的卡片样式(白色背景、圆角、内边距),整个页面形成了清晰的视觉节奏和信息层次。

AuditionTab 试音页面结构

Scroll 垂直滚动容器

Column 内容列

进行中的试音标题

Scroll 水平滚动卡片

Row 横向排列

ForEach 前4条数据

彩色卡片 Column

全部角色 标题行

试音列表

ForEach 全部8条数据

白色卡片 Column

角色名 + 风格标签 Row

项目名 Text

报酬/字数/截止 Row

热门标签 + 试音按钮 Row

新手贴士卡片

ForEach 贴士列表

从结构图中可以看出,AuditionTab采用了典型的"头部Banner + 分类列表 + 底部贴士"的信息架构,层次分明,逻辑清晰。每一个模块都是独立的视觉单元,通过间距和背景色进行区分。


榜单Tab:RankTab组件与动画系统

领奖台设计与动画实现

@Component
struct RankTab {
  @State crownScale: number = 1
  onReward: (id: number) => void = (id: number) => {}

  aboutToAppear(): void {
    this.getUIContext().animateTo({ duration: 800, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut }, () => {
      this.crownScale = 1.25
    })
  }

RankTab是榜单页面组件。它有一个@State crownScale: number = 1状态变量,用于控制冠军皇冠的缩放比例,初始值为1(原始大小)。

aboutToAppear()是组件的生命周期函数,在组件即将出现时被调用。这是执行初始化动画的最佳时机。

this.getUIContext().animateTo({...}, () => { ... })是ArkTS的显式动画API。animateTo方法接受两个参数:第一个是动画配置对象,第二个是动画结束状态的闭包函数。

动画配置包含以下关键参数:

  • duration: 800:动画持续时间800毫秒
  • iterations: -1:迭代次数,-1表示无限循环
  • playMode: PlayMode.Alternate:播放模式为交替,即正向播放完后反向播放
  • curve: Curve.EaseInOut:动画曲线为缓入缓出,动画开始和结束时速度较慢,中间较快

闭包中的this.crownScale = 1.25定义了动画的目标状态——皇冠放大到1.25倍。由于设置了PlayMode.Alternate和无限循环,皇冠会在1倍和1.25倍之间来回脉动,形成呼吸灯般的脉冲效果。

animateTo是ArkTS中最核心的动画API。它的工作原理是:框架会自动计算初始状态和目标状态之间的差异,然后在指定的时间内平滑过渡。开发者只需要定义"变成什么样",而不需要关心"怎么变过去"。

领奖台布局结构

  build() {
    Scroll() {
      Column() {
        // 三甲领奖台
        Column() {
          Row({ }) {
            // 第二名
            Column() {
              Text('🥈')
                .fontSize(22)
              Text(RANK_LIST[1].name)
                .fontSize(11)
                .fontWeight(FontWeight.Medium)
                .fontColor('#3A2430')
                .maxLines(1)
              Text(fmtK(RANK_LIST[1].score) + ' 分')
                .fontSize(9)
                .fontColor('#9A8794')
                .margin({ top: 2 })
              Column()
                .width(72)
                .height(64)
                .borderRadius({ topLeft: 10, topRight: 10 })
                .backgroundColor('#E1BEE7')
                .margin({ top: 8 })
                .justifyContent(FlexAlign.Center)
              Text('2')
                .fontSize(22)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
                .position({ x: 26, y: 34 })
            }
            .alignItems(HorizontalAlign.Center)
            .margin({ right: 10 })

领奖台是整个榜单页面最具视觉冲击力的部分。它使用Row水平排列三个名次的展示,第二名在左,第一名在中,第三名在右。

第二名的领奖台由以下元素组成(从上到下):

  1. 银牌emoji 🥈,22号字
  2. 声优名字,11号中等粗细,最多一行
  3. 积分,9号浅灰色,调用fmtK函数格式化
  4. 领奖台柱体:一个72x64的紫色(#E1BEE7)矩形,顶部圆角10像素,顶部外边距8像素,内容居中
  5. 数字"2",22号粗体白字

.position({ x: 26, y: 34 })是绝对定位属性,它将数字"2"定位在领奖台柱体的特定坐标位置。这里的坐标是相对于父容器的,x=26, y=34的位置大约在柱体的中心。使用绝对定位可以精确控制元素的位置,不受正常布局流的影响。

.alignItems(HorizontalAlign.Center)设置整列内容水平居中对齐,这是领奖台对称美感的基础。

            // 第一名
            Column() {
              Text('👑')
                .fontSize(24)
                .scale({ x: this.crownScale, y: this.crownScale })
              Text(RANK_LIST[0].name)
                .fontSize(12)
                .fontWeight(FontWeight.Bold)
                .fontColor('#3A2430')
                .maxLines(1)
              Text(fmtK(RANK_LIST[0].score) + ' 分')
                .fontSize(9)
                .fontColor('#9A8794')
                .margin({ top: 2 })
              Column()
                .width(84)
                .height(96)
                .borderRadius({ topLeft: 10, topRight: 10 })
                .backgroundColor('#E91E63')
                .margin({ top: 8 })
                .justifyContent(FlexAlign.Center)
              Text('1')
                .fontSize(30)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
                .position({ x: 26, y: 56 })
            }
            .alignItems(HorizontalAlign.Center)
            .margin({ right: 10 })

第一名的领奖台是视觉焦点,设计上有几个明显的强化:

  • 皇冠emoji 👑 代替了奖牌,字号24比银牌铜牌的22更大
  • .scale({ x: this.crownScale, y: this.crownScale })应用了缩放变换,缩放值绑定到crownScale状态变量,从而实现脉冲动画
  • 名字字号12号(比第二三名的11号大),且是粗体(Bold)
  • 领奖台柱体更宽(84px)更高(96px),颜色为主色粉色(#E91E63
  • 数字"1"字号30,比第二名的22更大
  • 绝对定位的y坐标为56,对应更高的柱体

这种视觉层级的强化设计,通过尺寸、颜色、动画等多种手段,让第一名在视觉上脱颖而出,符合用户对领奖台的心理预期。

            // 第三名
            Column() {
              Text('🥉')
                .fontSize(22)
              Text(RANK_LIST[2].name)
                .fontSize(11)
                .fontWeight(FontWeight.Medium)
                .fontColor('#3A2430')
                .maxLines(1)
              Text(fmtK(RANK_LIST[2].score) + ' 分')
                .fontSize(9)
                .fontColor('#9A8794')
                .margin({ top: 2 })
              Column()
                .width(72)
                .height(46)
                .borderRadius({ topLeft: 10, topRight: 10 })
                .backgroundColor('#FFE0B2')
                .margin({ top: 8 })
                .justifyContent(FlexAlign.Center)
              Text('3')
                .fontSize(20)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
                .position({ x: 27, y: 24 })
            }
            .alignItems(HorizontalAlign.Center)

第三名的领奖台高度最低(46px),颜色为橘黄色(#FFE0B2),数字字号20。三个领奖台的高度依次递减(96 > 64 > 46),形成阶梯状的视觉效果,完美模拟了现实中领奖台的形态。

领奖台布局结构

Row 水平排列

第二名 Column

第一名 Column

第三名 Column

🥈 银牌emoji

名字 11号 Medium

积分 9号灰色

柱体 72x64 紫色

数字2 绝对定位

👑 皇冠 + 脉冲动画

名字 12号 Bold

积分 9号灰色

柱体 84x96 主色粉色

数字1 绝对定位

🥉 铜牌emoji

名字 11号 Medium

积分 9号灰色

柱体 72x46 橘黄

数字3 绝对定位

.justifyContent(FlexAlign.Center)在领奖台柱体的Column上设置,它控制子元素在主轴(垂直方向)上的对齐方式为居中。虽然柱体内只有一个数字元素通过绝对定位放置,但这个属性为布局提供了灵活性。

.borderRadius({ topLeft: 10, topRight: 10 })这种分角设置圆角的方式在UI设计中非常实用。它允许我们为四个角分别设置不同的圆角半径,创造出各种形状的视觉元素。领奖台柱体只设置顶部圆角,底部保持直角,正是这种技巧的典型应用。

榜单列表详解

        Text('声优总榜')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor('#3A2430')
          .width('100%')
          .margin({ bottom: 10 })

        Column() {
          ForEach(RANK_LIST, (r: RankT, i: number) => {
            Row() {
              Text(i < 3 ? ['🥇', '🥈', '🥉'][i] : (i + 1).toString())
                .fontSize(i < 3 ? 18 : 13)
                .fontWeight(FontWeight.Bold)
                .fontColor(i < 3 ? '#3A2430' : '#9A8794')
                .width(30)

榜单列表的每一行是一个Row,包含四个主要部分:排名、头像、名字信息、升降排名和打赏按钮。

排名显示部分的设计很巧妙:对于前三名(i < 3),显示对应的金银铜牌emoji,使用数组['🥇', '🥈', '🥉'][i]来获取;对于第四名及以后,显示排名数字(i + 1,因为索引从0开始)。同时前三名的字号更大(18)、颜色更深(深紫色),其余的字号较小(13)、颜色较浅(浅灰色)。

.width(30)给排名区域一个固定宽度,确保所有行的排名列对齐,形成整齐的表格视觉效果。

              Text(r.emoji)
                .fontSize(20)
                .width(42)
                .height(42)
                .borderRadius(21)
                .backgroundColor(r.color)
                .textAlign(TextAlign.Center)

头像是一个42x42的圆形区域(通过borderRadius(21)实现,半径为宽度的一半即得正圆),背景色是数据中定义的个人主题色,emoji作为头像内容居中显示。.textAlign(TextAlign.Center)确保文字水平居中。

              Column() {
                Text(r.name)
                  .fontSize(13)
                  .fontWeight(FontWeight.Medium)
                  .fontColor('#3A2430')
                Text(r.works + ' 部作品 · ' + fmtK(r.score) + ' 分')
                  .fontSize(9)
                  .fontColor('#9A8794')
                  .margin({ top: 3 })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              .margin({ left: 10 })

中间是声优信息列,使用Column垂直排列名字和作品/积分信息。.alignItems(HorizontalAlign.Start)设置左对齐。.layoutWeight(1)让这一列占据剩余空间,实现左右两侧的固定宽度、中间弹性填充的布局效果。

名字使用13号中等粗细字体,是行内的主要信息。下方的作品数和积分为9号浅灰色,是辅助信息。积分调用fmtK函数进行格式化,确保大数字以"万"为单位简洁展示。

              Column() {
                if (r.delta > 0) {
                  Text('↑' + r.delta.toString())
                    .fontSize(11)
                    .fontColor('#E5484D')
                    .fontWeight(FontWeight.Medium)
                } else if (r.delta < 0) {
                  Text('↓' + (-r.delta).toString())
                    .fontSize(11)
                    .fontColor('#2E9E5B')
                    .fontWeight(FontWeight.Medium)
                } else {
                  Text('—')
                    .fontSize(11)
                    .fontColor('#C9BCC5')
                }
                Text('打赏')
                  .fontSize(9)
                  .fontColor('#E91E63')
                  .padding({ left: 8, right: 8, top: 2, bottom: 2 })
                  .borderRadius(8)
                  .backgroundColor('#FDE4EF')
                  .margin({ top: 4 })
                  .onClick(() => {
                    this.onReward(r.id)
                  })
              }

右侧是排名变化指示和打赏按钮,使用Column垂直排列。

排名变化通过if/else if/else条件渲染实现三种状态:

  • 上升(delta > 0):显示红色上箭头 + 上升位数
  • 下降(delta < 0):显示绿色下箭头 + 下降位数(取绝对值)
  • 持平(delta = 0):显示灰色横线

这里有个有趣的设计选择:上升用红色,下降用绿色。这与股票市场的"涨红跌绿"一致,但与某些地区的习惯(涨绿跌红)相反。在国内互联网产品中,涨红跌绿是比较常见的配色方案。

下方的"打赏"按钮是一个粉色背景的小标签,点击后调用onReward回调,将声优ID传递给父组件,由父组件弹出打赏弹框。

            }
            .width('100%')
            .padding({ top: 10, bottom: 10 })
            .border({ width: { bottom: 0.5 }, color: '#F6ECF1' })
          })
        }
        .width('100%')
        .padding({ left: 12, right: 12, top: 6, bottom: 6 })
        .borderRadius(16)
        .backgroundColor('#FFFFFF')

每一行的底部有一条0.5像素的浅粉色分隔线(.border({ width: { bottom: 0.5 }, color: '#F6ECF1' })),这是列表设计的经典做法。0.5像素的细线在高清屏幕上显示为1物理像素,非常精致。

整个列表被包裹在一个白色背景、圆角16像素的Column中,左右内边距12像素,上下内边距6像素,形成一个完整的卡片容器。


剧本Tab:ScriptTab组件与分类筛选

分类标签栏(Chips)

@Component
struct ScriptTab {
  @State cate: number = 0
  onOpenScript: (idx: number) => void = (idx: number) => {}

  build() {
    Scroll() {
      Column() {
        Scroll() {
          Row({ space: 8 }) {
            ForEach(GENRE_CHIPS, (c: string, i: number) => {
              Text(c)
                .fontSize(12)
                .fontColor(this.cate === i ? '#FFFFFF' : '#6E5A66')
                .padding({ left: 13, right: 13, top: 6, bottom: 6 })
                .borderRadius(14)
                .backgroundColor(this.cate === i ? '#E91E63' : '#FFFFFF')
                .onClick(() => {
                  this.cate = i
                })
            })
          }
          .padding({ left: 2, right: 2 })
        }
        .scrollable(ScrollDirection.Horizontal)
        .scrollBar(BarState.Off)
        .width('100%')
        .margin({ bottom: 14 })

ScriptTab是剧本浏览页面。@State cate: number = 0维护当前选中的分类索引,初始值为0(对应"全部")。

分类筛选栏是一个横向滚动的标签(Chips)列表。外层Scroll设置水平滚动,内层Row设置8像素间距。

每个标签是一个Text组件,通过this.cate === i判断是否为当前选中状态,动态切换样式:

  • 选中态:白色文字 + 粉色背景(#E91E63
  • 未选中态:深灰色文字(#6E5A66) + 白色背景

标签的内边距和圆角经过精心调校,形成圆润的胶囊形状。.onClick(() => { this.cate = i })在点击时更新选中索引,触发UI重新渲染。

这种"选中状态由索引驱动"的模式是Tab/Chips类组件的标准实现方式。通过一个简单的数值状态变量,即可控制整组标签的选中样式,逻辑清晰且易于维护。

剧本卡片列表

        Text('热门剧本库')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor('#3A2430')
          .width('100%')
          .margin({ bottom: 10 })

        Column() {
          ForEach(SCRIPT_LIST, (s: ScriptT, i: number) => {
            Column() {
              Row() {
                Column()
                  .width(52)
                  .height(72)
                  .borderRadius(12)
                  .backgroundColor(s.color)
                  .justifyContent(FlexAlign.Center)

剧本列表的每张卡片是一个Row,左侧是封面占位,右侧是剧本信息和操作按钮。

左侧封面是一个52x72的矩形色块,圆角12像素,背景色使用剧本的主题色。这个色块模拟了书籍封面的视觉效果,在没有真实封面图的情况下,用纯色块作为占位是一种简洁有效的设计手法。.justifyContent(FlexAlign.Center)让内容垂直居中(虽然当前没有内容,但为后续添加图标或文字预留了位置)。

                Column() {
                  Text('《' + s.title + '》')
                    .fontSize(14)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#3A2430')
                  Text(s.genre + ' · ' + s.chapters + ' 章 · ' + s.roles + ' 个角色')
                    .fontSize(10)
                    .fontColor('#9A8794')
                    .margin({ top: 5 })

中间信息列的第一行是剧本标题,使用书名号包裹,14号粗体字。第二行是剧本的基本信息:类型、章节数、角色数,用中间点(·)分隔,10号浅灰色字。

                  Row() {
                    Text('难度')
                      .fontSize(9)
                      .fontColor('#9A8794')
                    Row({ space: 2 }) {
                      ForEach([1, 2, 3, 4, 5], (n: number) => {
                        Text('★')
                          .fontSize(9)
                          .fontColor(n <= s.level ? '#FFB300' : '#E8DEE4')
                      })
                    }
                    .margin({ left: 4 })
                    Text(s.hot)
                      .fontSize(9)
                      .fontColor('#E91E63')
                      .margin({ left: 8 })
                  }
                  .margin({ top: 6 })

第三行包含难度星级和热度信息,是剧本卡片中最具技术含量的部分。

难度星级通过ForEach([1, 2, 3, 4, 5], (n: number) => { ... })渲染五颗星星。每颗星的颜色由n <= s.level决定:如果星的序号小于等于难度等级,则显示金色(#FFB300),否则显示浅灰色(#E8DEE4)。这种通过循环和条件判断实现星级评分的模式在UI开发中非常经典。

Row({ space: 2 })设置星星之间的间距为2像素,让它们紧密排列但不重叠。

热度信息显示在右侧,粉色字体,如"8.2 万人在读",吸引用户关注热门内容。

                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)
                .margin({ left: 10 })

                Text('详情')
                  .fontSize(11)
                  .fontColor('#E91E63')
                  .padding({ left: 11, right: 11, top: 5, bottom: 5 })
                  .borderRadius(11)
                  .backgroundColor('#FDE4EF')
                  .onClick(() => {
                    this.onOpenScript(i)
                  })
              }
              .width('100%')
            }
            .width('100%')
            .padding(12)
            .borderRadius(14)
            .backgroundColor('#FFFFFF')
            .margin({ bottom: 8 })

最右侧是"详情"按钮,粉色文字配浅粉背景,圆角胶囊形状。点击后调用onOpenScript(i)回调,将当前剧本的索引传递给父组件,由父组件打开剧本详情弹框。

信息列使用.layoutWeight(1)占据中间剩余空间,实现左侧封面固定、中间信息弹性填充、右侧按钮固定的经典三栏布局。


作品Tab:VoiceTab组件与波形动画

组件属性与状态设计

@Component
struct VoiceTab {
  @Prop voices: Array<VoiceT> = VOICE_INIT
  @State playingId: number = -1
  @State waveH: number = 6
  onUpload: () => void = () => {}
  onEdit: (idx: number) => void = (idx: number) => {}
  onDelete: (idx: number) => void = (idx: number) => {}

VoiceTab是作品管理页面,这里引入了新的装饰器@Prop

@Prop voices: Array<VoiceT> = VOICE_INIT定义了一个从父组件传入的属性。@Prop装饰器表示这是一个单向数据绑定——父组件的数据变化会同步到子组件,但子组件不能直接修改这个属性。默认值为VOICE_INIT,在父组件没有传入时使用。

@Prop@State的核心区别在于:@State是组件内部自有状态,组件可以自由修改;@Prop是从父组件传入的属性,子组件只能读取不能修改。如果子组件需要修改@Prop的值,应该通过回调函数通知父组件,由父组件修改后再通过@Prop同步回来。这就是单向数据流的原则。

@State playingId: number = -1记录当前正在播放的作品ID,初始值-1表示没有在播放的作品。这个状态用于控制播放按钮的显示(播放/暂停图标切换)和波形动画的激活状态。

@State waveH: number = 6是波形条的高度,初始值6像素。这个值会在动画中不断变化,驱动波形条的上下起伏。

三个回调函数分别对应上传、编辑、删除三个操作,遵循子组件触发事件、父组件处理逻辑的通信模式。

生命周期与动画

  aboutToAppear(): void {
    this.getUIContext().animateTo({ duration: 420, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut }, () => {
      this.waveH = 16
    })
  }

与榜单页面的皇冠动画类似,作品页面在aboutToAppear生命周期中启动了波形动画。动画参数:

  • 持续时间420毫秒(比皇冠的800毫秒快,模拟波形的快速跳动)
  • 无限循环
  • 交替播放模式
  • 缓入缓出曲线

目标状态是waveH = 16,即波形高度从6像素变化到16像素。由于交替播放,实际效果是波形高度在6和16之间来回变化,形成音频播放时的跳动视觉效果。

@Builder装饰器:波形条组件

  @Builder waveBars(active: boolean) {
    Row({ space: 2 }) {
      Column().width(3).height(active ? this.waveH : 5).borderRadius(2).backgroundColor(active ? '#E91E63' : '#D9C8D2')
      Column().width(3).height(active ? this.waveH * 0.7 : 5).borderRadius(2).backgroundColor(active ? '#E91E63' : '#D9C8D2')
      Column().width(3).height(active ? this.waveH * 1.1 : 5).borderRadius(2).backgroundColor(active ? '#E91E63' : '#D9C8D2')
      Column().width(3).height(active ? this.waveH * 0.8 : 5).borderRadius(2).backgroundColor(active ? '#E91E63' : '#D9C8D2')
      Column().width(3).height(active ? this.waveH : 5).borderRadius(2).backgroundColor(active ? '#E91E63' : '#D9C8D2')
    }
    .height(18)
    .alignItems(VerticalAlign.Center)
  }

@Builder是ArkTS中一个非常实用的装饰器,它用于将可复用的UI片段封装为函数。@Builder函数可以像组件一样调用,但写法更轻量,适合封装那些不需要独立状态、只是纯展示的UI片段。

waveBars函数接受一个active: boolean参数,表示波形是否处于激活状态(即是否正在播放)。

波形由5个竖条组成,每个竖条是一个3像素宽的Column(用作矩形色块),圆角2像素。5个竖条的高度各不相同:

  • 第1条:完整高度 waveH
  • 第2条:0.7倍高度
  • 第3条:1.1倍高度(最高)
  • 第4条:0.8倍高度
  • 第5条:完整高度

这种错落有致的高度设计模拟了真实音频波形的视觉特征——中间高、两边低,富有节奏感。

activetrue时(正在播放):

  • 高度使用this.waveH及其倍数(动态变化,产生动画效果)
  • 颜色为粉色(#E91E63

activefalse时(未播放):

  • 高度固定为5像素(静止的矮条)
  • 颜色为浅灰色(#D9C8D2

外层Row的高度固定为18像素,.alignItems(VerticalAlign.Center)让竖条在垂直方向居中对齐。这样无论波形条怎么变化高度,整体的位置都是居中的。

@Builder函数是ArkTS中代码复用的重要手段。与自定义组件(@Component)相比,@Builder更轻量、更灵活——它可以定义在组件内部、可以访问组件的状态、可以接受参数。对于那些只在一个组件内复用、且不需要独立状态的UI片段,@Builder是比@Component更好的选择。

初始状态

点击播放按钮

点击暂停按钮

未播放

waveH 不参与

5条均为5px高度

灰色 #D9C8D2

播放中

waveH 动画

5条高度随 waveH 变化

粉色 #E91E63
420ms 无限循环交替

这张状态图清晰地展示了波形条的两种状态及其切换条件。播放状态的变化通过playingId控制,而动画的驱动力则来自waveH的持续变化。

作品列表头部

  build() {
    Scroll() {
      Column() {
        Row() {
          Text('我的作品')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor('#3A2430')
            .layoutWeight(1)
          Text('+ 上传')
            .fontSize(11)
            .fontColor('#FFFFFF')
            .padding({ left: 12, right: 12, top: 6, bottom: 6 })
            .borderRadius(12)
            .backgroundColor('#E91E63')
            .onClick(() => {
              this.onUpload()
            })
        }
        .width('100%')
        .margin({ bottom: 10 })

页面头部是标题和上传按钮的组合。“我的作品"标题使用.layoutWeight(1)占据左侧空间,”+ 上传"按钮固定在右侧。

上传按钮是一个粉色背景的圆角矩形,白色文字,点击后触发onUpload回调。按钮前的"+"号是操作按钮的常见设计模式,直观地表达"添加/创建"的含义。

作品列表项详解

        Column() {
          ForEach(this.voices, (v: VoiceT, i: number) => {
            Column() {
              Row() {
                Text(this.playingId === v.id ? '⏸' : '▶')
                  .fontSize(13)
                  .fontColor('#FFFFFF')
                  .width(34)
                  .height(34)
                  .borderRadius(17)
                  .borderRadius(17)
                  .backgroundColor(this.playingId === v.id ? '#C2185B' : '#E91E63')
                  .textAlign(TextAlign.Center)
                  .onClick(() => {
                    if (this.playingId === v.id) {
                      this.playingId = -1
                    } else {
                      this.playingId = v.id
                    }
                  })

每个作品卡片的第一行是播放控制区域,包含播放按钮、作品信息和波形条。

播放按钮是一个34x34的圆形按钮(borderRadius(17)),背景色有两种状态:播放中使用深粉色(#C2185B),未播放使用主色粉色(#E91E63)。按钮上的图标通过三元表达式切换:正在播放显示暂停图标⏸,未播放显示播放图标▶。

点击事件的逻辑是:如果当前作品正在播放,则设置playingId = -1(暂停播放);否则设置playingId = v.id(开始播放当前作品)。由于playingId是全局唯一的,同时只能有一个作品处于播放状态,这符合音频播放器的常见行为。

                Column() {
                  Text(v.title)
                    .fontSize(12)
                    .fontWeight(FontWeight.Medium)
                    .fontColor('#3A2430')
                    .maxLines(1)
                  Row() {
                    Text(v.role)
                      .fontSize(9)
                      .fontColor('#E91E63')
                      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                      .borderRadius(6)
                      .backgroundColor('#FDE4EF')
                    Text(v.date + ' · ' + v.duration)
                      .fontSize(9)
                      .fontColor('#9A8794')
                      .margin({ left: 6 })
                  }
                  .margin({ top: 5 })
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)
                .margin({ left: 10 })

中间是作品信息列,使用.layoutWeight(1)占据弹性空间。

作品标题使用12号中等粗细字体,最多显示1行。下方一行包含角色标签和日期/时长信息。角色标签是一个粉色的小胶囊,日期和时长用中间点分隔,浅灰色字体。

                this.waveBars(this.playingId === v.id)
              }
              .width('100%')

最右侧是波形条,调用之前定义的@Builder waveBars函数。传入的参数this.playingId === v.id表示当前作品是否正在播放——如果是,则波形处于激活状态,显示动画效果。

这里有一个值得注意的设计细节:虽然所有作品的波形条都引用了同一个waveH状态变量,但由于active参数的控制,只有当前播放的作品的波形才会显示动画效果,其他作品的波形保持静止。这种"一个动画源驱动多个显示"的模式既高效又简洁。

              Row() {
                Text(v.status)
                  .fontSize(9)
                  .fontColor(v.status === '已发布' ? '#2E9E5B' : v.status === '审核中' ? '#FF9F43' : '#9A8794')
                  .padding({ left: 7, right: 7, top: 2, bottom: 2 })
                  .borderRadius(7)
                  .backgroundColor(v.status === '已发布' ? '#E4F7F0' : v.status === '审核中' ? '#FFF3E0' : '#F3EDF1')
                if (v.status === '已发布') {
                  Text('❤ ' + fmtK(v.likes))
                    .fontSize(9)
                    .fontColor('#9A8794')
                    .margin({ left: 8 })
                }

第二行是状态标签、点赞数和操作按钮。

状态标签的样式根据状态动态变化,使用嵌套的三元表达式:

  • 已发布:绿色文字(#2E9E5B)+ 浅绿背景(#E4F7F0
  • 审核中:橙色文字(#FF9F43)+ 浅橙背景(#FFF3E0
  • 其他(草稿):灰色文字 + 浅灰背景

不同状态使用不同颜色是信息可视化的重要手段,用户可以一眼识别出作品的状态。

if (v.status === '已发布')这个条件判断很有意思——只有已发布的作品才显示点赞数。审核中和草稿状态的作品没有公开,自然也就没有点赞数据。这种"数据和状态匹配"的逻辑设计体现了对业务场景的深入理解。

                Row() {
                  Text('编辑')
                    .fontSize(10)
                    .fontColor('#E91E63')
                    .padding({ left: 9, right: 9, top: 4, bottom: 4 })
                    .borderRadius(9)
                    .backgroundColor('#FDE4EF')
                    .onClick(() => {
                      this.onEdit(i)
                    })
                  Text('删除')
                    .fontSize(10)
                    .fontColor('#E5484D')
                    .padding({ left: 9, right: 9, top: 4, bottom: 4 })
                    .borderRadius(9)
                    .backgroundColor('#FDECEC')
                    .margin({ left: 6 })
                    .onClick(() => {
                      this.onDelete(i)
                    })
                }
                .layoutWeight(1)
                .justifyContent(FlexAlign.End)
              }
              .width('100%')
              .margin({ top: 10 })

右侧是编辑和删除两个操作按钮,使用Row包裹并设置.justifyContent(FlexAlign.End)靠右对齐。.layoutWeight(1)让按钮组占据右侧剩余空间。

"编辑"按钮使用粉色配色方案,"删除"按钮使用红色配色方案(#E5484D文字 + #FDECEC背景)。删除操作使用红色是UI设计的通用规范,警示用户这是一个危险操作。

两个按钮都调用对应的回调函数,并将当前索引i作为参数传递出去。


收藏Tab:FavTab组件与Flex布局

Flex宫格布局

@Component
struct FavTab {
  @State playingId: number = -1

  build() {
    Scroll() {
      Column() {
        Text('收藏的声音')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor('#3A2430')
          .width('100%')
          .margin({ bottom: 10 })

        Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
          ForEach(FAV_SOUNDS, (s: SoundT) => {
            Column() {
              // ... 卡片内容
            }
            .width('31%')
            .padding(10)
            .borderRadius(14)
            .backgroundColor('#FFFFFF')
            .margin({ bottom: 10 })
            .alignItems(HorizontalAlign.Start)
            .onClick(() => {
              if (this.playingId === s.id) {
                this.playingId = -1
              } else {
                this.playingId = s.id
              }
            })
          })
        }
        .width('100%')

FavTab是收藏页面,最大的技术亮点是使用Flex组件实现了流式宫格布局。

Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween })创建了一个弹性布局容器。两个关键参数:

  • wrap: FlexWrap.Wrap:开启换行。当一行的内容超出容器宽度时,自动换行到下一行。如果不设置这个属性,默认是NoWrap,所有子元素会挤在一行(可能溢出或被压缩)。
  • justifyContent: FlexAlign.SpaceBetween:主轴对齐方式为两端对齐。第一个元素靠左,最后一个元素靠右,中间元素均匀分布。

每个卡片的宽度设置为'31%',即父容器宽度的31%。这样一行可以放下3个卡片(31% × 3 = 93%),剩余的7%空间通过SpaceBetween均匀分配到卡片之间,形成两列间隙。底部外边距10像素,控制行与行之间的垂直间距。

Flex布局是现代UI框架的标配能力。ArkTS的Flex组件完全兼容CSS Flexbox的核心概念——主轴(Main Axis)、交叉轴(Cross Axis)、flex-direction、justify-content、align-items、flex-wrap等。掌握Flex布局是成为熟练ArkTS开发者的必备技能。

Flex宫格布局原理

wrap: Wrap

Flex 容器

第一行

第二行

第三行

卡片 31%

卡片 31%

卡片 31%

卡片 31%

卡片 31%

卡片 31%

卡片 31%

卡片 31%

从原理图可以看到,Flex布局会自动计算每一行能容纳的元素数量,超出的元素自动换行。SpaceBetween的对齐方式使得每行的首尾元素贴边,中间间距均匀。

声音卡片内容

            Column() {
              Row() {
                Text(this.playingId === s.id ? '⏸' : '▶')
                  .fontSize(14)
                  .fontColor('#FFFFFF')
                  .width(30)
                  .height(30)
                  .borderRadius(15)
                  .backgroundColor('rgba(0,0,0,0.25)')
                  .textAlign(TextAlign.Center)
                Text(s.duration)
                  .fontSize(8)
                  .fontColor('#FFFFFF')
                  .padding({ left: 5, right: 5, top: 1, bottom: 1 })
                  .borderRadius(5)
                  .backgroundColor('rgba(0,0,0,0.25)')
              }
              .width('100%')
              .padding(8)
              .justifyContent(FlexAlign.SpaceBetween)

每个声音卡片的顶部是一个操作栏,包含播放按钮和时长标签。

播放按钮是一个30x30的圆形,半透明黑色背景(rgba(0,0,0,0.25)),白色图标。半透明背景的设计让按钮既能被看见,又不会完全遮挡下方的内容。播放/暂停图标的切换逻辑与作品页面一致。

时长标签同样使用半透明黑色背景,8号白色字,圆角5像素,非常精致。

顶部的Row设置了.justifyContent(FlexAlign.SpaceBetween),使得播放按钮在左、时长标签在右,两端对齐。

rgba(red, green, blue, alpha)是CSS/ArkTS中表示颜色的一种方式,前三个参数是红、绿、蓝三原色(0-255),第四个参数是透明度(0-1,0为完全透明,1为完全不透明)。使用半透明颜色可以创造出层次感和玻璃质感的视觉效果。

              Text(s.title)
                .fontSize(11)
                .fontWeight(FontWeight.Medium)
                .fontColor('#3A2430')
                .margin({ top: 8 })
                .maxLines(1)
              Text('@' + s.author)
                .fontSize(9)
                .fontColor('#9A8794')
                .margin({ top: 3 })

卡片下方是标题和作者信息。标题11号中等粗细,最多一行。作者信息9号浅灰色,前面加"@"符号,这是社交媒体中常见的用户标识方式。

.alignItems(HorizontalAlign.Start)设置卡片内容左对齐,符合阅读习惯。

整个卡片的点击事件绑定在外层Column上——点击卡片任意位置都可以切换播放状态。这种扩大点击区域的设计提升了移动端的触控体验。

最近循环列表

        Text('最近循环')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor('#3A2430')
          .width('100%')
          .margin({ top: 10, bottom: 10 })

        Column() {
          ForEach(FAV_SOUNDS, (s: SoundT, i: number) => {
            if (i < 5) {
              Row() {
                Text((i + 1).toString())
                  .fontSize(12)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#E91E63')
                  .width(20)
                Text(s.title)
                  .fontSize(12)
                  .fontColor('#3A2430')
                  .layoutWeight(1)
                  .maxLines(1)
                Text('循环 ' + (i + 3) * 7 + ' 次')
                  .fontSize(9)
                  .fontColor('#9A8794')
              }
              .width('100%')
              .padding({ top: 9, bottom: 9 })
              .border({ width: { bottom: 0.5 }, color: '#F6ECF1' })
            }
          })
        }
        .width('100%')
        .padding({ left: 12, right: 12 })
        .borderRadius(14)
        .backgroundColor('#FFFFFF')

页面下方是"最近循环"排行榜,展示循环播放次数最多的5首声音。

if (i < 5)限制只显示前5条,这是"排行榜"类组件的常见做法——展示前N名而非全部数据。

每一行的结构是:排名序号(左,固定宽度20px)+ 标题(中,弹性宽度)+ 循环次数(右,固定宽度)。排名序号使用粉色粗体,与右侧的灰色循环次数形成对比。

行之间通过0.5像素的底部边框分隔,与之前的列表设计保持一致。整个列表包裹在白色圆角卡片中。

收藏页面展示了两种不同的列表布局方式:上方的宫格布局(Flex + Wrap)和下方的行式布局(Column + ForEach)。在同一个页面中根据内容特点选择合适的布局方式,是良好UI设计的体现。


我的Tab:MyTab组件与数据可视化

用户信息卡片

@Component
struct MyTab {
  build() {
    Scroll() {
      Column() {
        Row() {
          Text('🎙️')
            .fontSize(26)
            .width(58)
            .height(58)
            .borderRadius(29)
            .backgroundColor('#FDE4EF')
            .textAlign(TextAlign.Center)
          Column() {
            Text('青栀未眠')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor('#3A2430')
            Text('Lv.5 进阶声优 · 中标 23 单')
              .fontSize(10)
              .fontColor('#9A8794')
              .margin({ top: 5 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          .margin({ left: 12 })
          Text('认证声优')
            .fontSize(10)
            .fontColor('#FFFFFF')
            .padding({ left: 8, right: 8, top: 4, bottom: 4 })
            .borderRadius(8)
            .backgroundColor('#E91E63')
        }
        .width('100%')
        .padding(14)
        .borderRadius(16)
        .backgroundColor('#FFFFFF')
        .margin({ bottom: 14 })

MyTab是个人中心页面。顶部的用户信息卡片是个人中心的标志性元素。

卡片使用Row水平排列三个部分:头像(左)、用户信息(中)、认证标签(右)。

头像是一个58x58的圆形(borderRadius(29)),浅粉色背景(#FDE4EF),麦克风emoji作为头像内容,26号字,居中显示。

中间信息列使用.layoutWeight(1)弹性填充。用户名16号粗体,等级和中标数10号浅灰色。"Lv.5 进阶声优 · 中标 23 单"这种格式同时展示了用户等级和成就数据,给用户明确的成长反馈。

右侧的"认证声优"标签是一个粉色背景的胶囊标签,白色文字,表明用户的身份认证状态。

个人中心页面的设计核心是"用户画像"——通过头像、昵称、等级、认证等信息,让用户快速感知自己的身份状态和成长进度。良好的个人中心设计能够提升用户的归属感和活跃度。

粉丝增长柱状图

        Column() {
          Text('本周新增粉丝')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor('#3A2430')
            .width('100%')
          Row({ space: 8 }) {
            ForEach(FANS_WEEK, (d: string, i: number) => {
              Column() {
                Text(FANS_WEEK_VAL[i].toString())
                  .fontSize(8)
                  .fontColor('#9A8794')
                Column()
                  .width(16)
                  .width(16)
                  .height(FANS_WEEK_VAL[i] * 0.28)
                  .borderRadius(6)
                  .backgroundColor(i === 5 ? '#E91E63' : '#F3B3CF')
                Text('周' + d)
                  .fontSize(9)
                  .fontColor('#9A8794')
                  .margin({ top: 6 })
              }
            })
          }
          .alignItems(VerticalAlign.Bottom)
          .height(110)
          .margin({ top: 12 })
          Text('周六直播翻牌活动带来 520 个新粉丝')
            .fontSize(10)
            .fontColor('#9A8794')
            .margin({ top: 8 })
        }
        .width('100%')
        .padding(14)
        .borderRadius(16)
        .backgroundColor('#FFFFFF')
        .margin({ bottom: 14 })

粉丝增长柱状图是个人中心的数据可视化模块,展示了一周七天的粉丝增长情况。这是纯代码实现数据可视化的一个优秀案例。

柱状图的实现原理非常巧妙:

  1. 使用Row水平排列7天的数据列
  2. 每个数据列是一个Column,包含数值标签、柱体、星期标签
  3. 柱体的高度由数据值决定:FANS_WEEK_VAL[i] * 0.28,即数值乘以一个比例系数
  4. 整个Row的高度固定为110像素,设置.alignItems(VerticalAlign.Bottom)——底部对齐,这样柱体就会从底部向上生长

.alignItems(VerticalAlign.Bottom)是实现柱状图"从底部升起"效果的关键。默认的交叉轴对齐方式是顶部对齐,如果不修改这个属性,柱子会从上往下悬挂,看起来就像倒过来的柱状图。

高亮效果通过条件判断实现:i === 5 ? '#E91E63' : '#F3B3CF'——周六(索引5)的柱子使用主色粉色高亮,其余使用浅粉色。这是因为周六的数据最高(520),高亮突出最大值是数据可视化的常见手法。

底部的说明文字"周六直播翻牌活动带来 520 个新粉丝"解释了数据峰值的原因,将数据与业务场景关联起来。

纯代码实现的图表虽然功能相对简单,但具有零依赖、加载快、完全可定制的优势。对于简单的数据展示场景,使用基础布局组件手写图表是一种轻量高效的方案。

柱状图实现原理

Row 底部对齐
height: 110px

周一

周二

周三

周四

周五

周六

周日

数值 520

柱体 height: 520*0.28
粉色高亮

周六

钱包模块

        Row() {
          Column() {
            Text('¥1,286.50')
              .fontSize(18)
              .fontWeight(FontWeight.Bold)
              .fontColor('#E91E63')
            Text('可提现余额')
              .fontSize(10)
              .fontColor('#9A8794')
              .margin({ top: 4 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          Text('提现')
            .fontSize(11)
            .fontColor('#FFFFFF')
            .padding({ left: 14, right: 14, top: 7, bottom: 7 })
            .borderRadius(14)
            .backgroundColor('#E91E63')
        }
        .width('100%')
        .padding(14)
        .borderRadius(16)
        .backgroundColor('#FFFFFF')
        .margin({ bottom: 14 })

钱包模块简洁明了:左侧显示余额信息,右侧是提现按钮。

余额数字使用18号粉色粗体字,是整个卡片的视觉焦点。"可提现余额"的标签用浅灰色小字,作为辅助说明。这种"大数字 + 小标签"的信息层级设计在金融类UI中非常典型。

提现按钮是一个粉色的胶囊按钮,白色文字,左右内边距14像素,上下7像素,圆角14像素。按钮尺寸经过精心设计,既保证了可点击区域足够大,又不会显得臃肿。

设备列表

        Column() {
          Text('录音设备')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor('#3A2430')
            .width('100%')
            .margin({ bottom: 8 })
          Row() {
            Text('🎤')
              .fontSize(16)
            Text('电容麦 NT1 · 已连接')
              .fontSize(12)
              .fontColor('#3A2430')
              .layoutWeight(1)
              .margin({ left: 10 })
            Text('●')
              .fontSize(12)
              .fontColor('#2E9E5B')
          }
          .padding({ top: 8, bottom: 8 })
          .width('100%')

设备列表展示了用户的录音设备连接状态。每个设备一行,包含设备图标、设备名称和状态、连接状态指示。

连接状态通过圆点的颜色来表示:

  • 绿色(#2E9E5B)实心圆"●":已连接
  • 灰色(#C9BCC5)空心圆"○":未连接

这种"绿点表示在线、灰点表示离线"的设计是即时通讯和IoT设备UI的通用模式,用户几乎不需要学习就能理解其含义。

三个设备(电容麦、监听耳机、声卡)依次排列,其中前两个已连接(绿色),第三个未连接(灰色)。设备列表的设计体现了配音应用的专业性——关注用户的硬件设备状态。


主页面Index:状态管理与弹框架构

@Entry装饰器与组件状态

@Entry
@Component
struct Index {
  @State currentTab: number = 0
  @State voices: Array<VoiceT> = VOICE_INIT
  @State showApply: boolean = false
  @State applyId: number = 1
  @State applySample: number = 0
  @State applyPrice: number = 45
  @State applyNote: string = ''
  @State showScript: boolean = false
  @State scriptIdx: number = 0
  @State showUpload: boolean = false
  @State uploadTitle: string = ''
  @State uploadRole: string = ''
  @State uploadTag: string = ''
  @State uploadPub: boolean = true
  @State showEdit: boolean = false
  @State editIdx: number = -1
  @State editTitle: string = ''
  @State editRole: string = ''
  @State editIntro: string = ''
  @State showDel: boolean = false
  @State delIdx: number = -1
  @State showReward: boolean = false
  @State rewardId: number = 1
  @State rewardAmount: number = 1
  @State rewardWay: number = 0

Index是应用的主入口组件,使用@Entry装饰器标记。@Entry表示这个组件是页面的根组件,应用启动时会从这里开始渲染。一个页面只能有一个@Entry组件。

Index组件管理着大量的状态变量,可以分为几类:

导航状态

  • currentTab:当前选中的Tab索引,初始值0(试音页)

业务数据

  • voices:作品列表数据,初始值为VOICE_INIT。这个状态由父组件管理,通过@Prop传递给子组件VoiceTab。当需要修改数据时(增删改),在父组件中修改后自动同步到子组件。

弹框显示状态(6个弹框,每个对应一个boolean状态):

  • showApply:试音申请抽屉
  • showScript:剧本详情大卡
  • showUpload:上传配音表单
  • showEdit:编辑配音抽屉
  • showDel:删除确认弹框
  • showReward:打赏结算卡

弹框数据状态(每个弹框维护自己的表单数据):

  • 试音申请:applyIdapplySampleapplyPriceapplyNote
  • 剧本详情:scriptIdx
  • 上传表单:uploadTitleuploadRoleuploadTaguploadPub
  • 编辑表单:editIdxeditTitleeditRoleeditIntro
  • 删除确认:delIdx
  • 打赏结算:rewardIdrewardAmountrewardWay

将所有弹框的状态集中在父组件管理,是一种常见的架构模式。虽然状态变量看起来很多,但这种集中式管理的好处是:数据流清晰、弹框之间的互斥关系易于控制、子组件保持纯粹(只负责展示和触发事件)。

Index组件状态

导航状态

业务数据

弹框显示状态

弹框数据状态

currentTab

voices

showApply

showScript

showUpload

showEdit

showDel

showReward

applyId

applyPrice

uploadTitle

editIdx

rewardAmount

Stack布局与弹框层叠

  build() {
    Stack() {
      Column() {
        // 头部 + 内容区 + 底部tab
        // ...
      }
      .width('100%')
      .height('100%')

      // 弹框层
      if (this.showApply) {
        this.applyOverlay()
      }
      if (this.showScript) {
        this.scriptOverlay()
      }
      if (this.showUpload) {
        this.uploadOverlay()
      }
      if (this.showEdit) {
        this.editOverlay()
      }
      if (this.showDel) {
        this.delOverlay()
      }
      if (this.showReward) {
        this.rewardOverlay()
      }
    }
    .width('100%')
    .height('100%')
  }

Stack是实现弹框效果的核心布局组件。Stack的特点是:子元素按照顺序层叠排列,后写的元素覆盖在先写的元素上面。第一个子元素在最底层,最后一个在最顶层。

在这个应用中,Stack包含两层内容:

  1. 底层:主界面(头部 + 内容区 + 底部Tab),占满整个屏幕
  2. 顶层:弹框层,通过条件渲染决定显示哪个弹框

每个弹框通过if (this.showXxx)条件判断来控制显示和隐藏。当对应的状态变量为true时,弹框被渲染出来,覆盖在主界面上方;为false时,弹框不被渲染。

Stack布局的层叠特性使其成为实现弹框、浮层、遮罩等效果的首选。与CSS中的position: fixedz-index不同,Stack的层级关系完全由子元素的书写顺序决定,更加直观和可预测。

头部区域

      Column() {
        // 头部(玫红影视风,无动画)
        Column() {
          Row() {
            Text('🎙️')
              .fontSize(22)
            Column() {
              Text('声临其境')
                .fontSize(15)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
              Text('VOICE LAB')
                .fontSize(8)
                .fontColor('rgba(255,255,255,0.7)')
            }
            .alignItems(HorizontalAlign.Start)
            .margin({ left: 8 })

            Row() {
              Text('🔍 搜剧本 / 声优')
                .fontSize(11)
                .fontColor('rgba(255,255,255,0.75)')
                .layoutWeight(1)
            }
            .layoutWeight(1)
            .height(32)
            .borderRadius(16)
            .height(32)
            .borderRadius(16)
            .backgroundColor('rgba(255,255,255,0.22)')
            .padding({ left: 12, right: 12 })
            .margin({ left: 14 })

            Text('💬')
              .fontSize(16)
              .margin({ left: 10 })
          }
          .width('100%')

头部区域采用渐变的玫红色背景,营造出"影视风"的视觉氛围。顶部一行包含Logo、搜索框和消息图标。

Logo部分由麦克风emoji和应用名称组成。应用名称"声临其境"是15号粗体白字,英文副标题"VOICE LAB"是8号半透明白字(70%不透明度)。主副标题的大小和透明度差异形成了清晰的视觉层次。

搜索框是一个半透明白色背景(22%不透明度)的圆角矩形,高度32像素,圆角16像素(完全圆角)。内部使用放大镜emoji和占位文字"搜剧本 / 声优",文字也是半透明白色(75%不透明度)。.layoutWeight(1)让搜索框占据中间剩余空间。

消息图标(💬)在最右侧,16号字,左边距10像素。

          Row() {
            Text('🎧 新人首单立减 ¥20 · 声库装备节进行中')
              .fontSize(10)
              .fontColor('#FFFFFF')
              .layoutWeight(1)
            Text('去逛逛 >')
              .fontSize(10)
              .fontColor('rgba(255,255,255,0.9)')
          }
          .width('100%')
          .padding({ left: 10, right: 10, top: 8, bottom: 8 })
          .borderRadius(10)
          .backgroundColor('rgba(255,255,255,0.16)')
          .margin({ top: 10 })
        }
        .width('100%')
        .padding({ left: 14, right: 14, top: 12, bottom: 12 })
        .linearGradient({ angle: 120, colors: [['#E91E63', 0], ['#AD1457', 1]] })

头部下方是一个活动横幅(Banner),同样是半透明白色背景(16%不透明度),圆角10像素。

横幅左侧是活动文案,使用耳机emoji开头,10号白色字,.layoutWeight(1)占据左侧空间。右侧是"去逛逛 >"的引导文字,90%不透明度的白色字。

整个头部的背景使用了线性渐变:.linearGradient({ angle: 120, colors: [['#E91E63', 0], ['#AD1457', 1]] })。渐变角度120度,从亮粉色(#E91E63)过渡到深粉色(#AD1457)。渐变背景比纯色背景更有视觉冲击力和层次感。

linearGradient是ArkTS中实现渐变背景的方法。参数包括渐变角度和颜色停止点数组。每个颜色停止点是一个二元数组,第一个元素是颜色值,第二个是位置比例(0到1)。合理使用渐变可以大幅提升UI的视觉品质。

内容区与Tab切换

        // 内容区
        Column() {
          if (this.currentTab === 0) {
            AuditionTab({
              onApply: (id: number) => {
                this.applyId = id
                this.showApply = true
              }
            })
          }
          if (this.currentTab === 1) {
            RankTab({
              onReward: (id: number) => {
                this.rewardId = id
                this.showReward = true
              }
            })
          }
          if (this.currentTab === 2) {
            ScriptTab({
              onOpenScript: (idx: number) => {
                this.scriptIdx = idx
                this.showScript = true
              }
            })
          }
          if (this.currentTab === 3) {
            VoiceTab({
              voices: this.voices,
              onUpload: () => {
                this.showUpload = true
              },
              onEdit: (idx: number) => {
                this.editIdx = idx
                this.editTitle = this.voices[idx].title
                this.editRole = this.voices[idx].role
                this.editIntro = ''
                this.showEdit = true
              },
              onDelete: (idx: number) => {
                this.delIdx = idx
                this.showDel = true
              }
            })
          }
          if (this.currentTab === 4) {
            FavTab()
          }
          if (this.currentTab === 5) {
            MyTab()
          }
        }
        .width('100%')
        .layoutWeight(1)
        .backgroundColor('#FFF0F5')

内容区通过if条件判断,根据currentTab的值渲染对应的Tab组件。每次只渲染一个Tab组件,其他Tab不被渲染,保证了性能和内存的高效利用。

每个子组件通过属性参数接收回调函数,实现子组件向父组件的事件传递。以AuditionTab为例:

  • onApply回调被触发时,父组件执行两个操作:设置applyId为传入的ID,设置showApply = true显示试音申请弹框

VoiceTab的回调更多,因为它涉及数据增删改操作:

  • onUpload:打开上传弹框
  • onEdit:设置编辑索引,预填编辑表单数据(标题、角色),打开编辑弹框
  • onDelete:设置删除索引,打开删除确认弹框

特别值得注意的是onEdit回调中的数据预填逻辑:

this.editIdx = idx
this.editTitle = this.voices[idx].title
this.editRole = this.voices[idx].role
this.editIntro = ''

在打开编辑弹框之前,父组件从voices数组中取出对应的数据,填充到编辑表单的状态变量中。这样弹框打开时就已经有了原始数据,用户可以在基础上修改。这是编辑功能的标准实现模式。

内容区的背景色是浅粉色(#FFF0F5),比纯白色更有温度感,与应用的整体粉色主题保持一致。.layoutWeight(1)让内容区占据头部和底部Tab之间的所有剩余空间。

底部Tab导航

        // 底部 tab
        Row() {
          this.tabItem('📢', '试音', 0)
          this.tabItem('🏆', '榜单', 1)
          this.tabItem('📖', '剧本', 2)
          this.tabItem('🎧', '作品', 3)
          this.tabItem('💗', '收藏', 4)
          this.tabItem('👤', '我的', 5)
        }
        .width('100%')
        .backgroundColor('#FFFFFF')
        .border({ width: { top: 0.5 }, color: '#FDE4EF' })

底部导航栏是一个白色背景的Row,包含6个Tab项。顶部有一条0.5像素的浅粉色边框,与内容区分隔。

this.tabItem(...)调用了一个@Builder函数来创建每个Tab项,避免了重复代码。

@Builder tabItem函数

  @Builder tabItem(icon: string, label: string, idx: number) {
    Column() {
      Text(icon)
        .fontSize(19)
      Text(label)
        .fontSize(9)
        .fontColor(this.currentTab === idx ? '#E91E63' : '#9A8794')
        .margin({ top: 2 })
    }
    .alignItems(HorizontalAlign.Center)
    .layoutWeight(1)
    .padding({ top: 7, bottom: 6 })
    .onClick(() => {
      this.currentTab = idx
    })
  }

tabItem是一个@Builder函数,接受三个参数:图标emoji、标签文字、索引值。

每个Tab项是一个垂直排列的Column:上方是图标(19号字),下方是文字标签(9号字)。

文字颜色由选中状态决定:当前选中的Tab显示粉色(#E91E63),未选中的显示浅灰色(#9A8794)。判断条件是this.currentTab === idx

.layoutWeight(1)让每个Tab项平均分配宽度,6个Tab各占1/6的宽度。.alignItems(HorizontalAlign.Center)让内容水平居中。

点击事件.onClick(() => { this.currentTab = idx })将当前Tab索引设置为被点击项的索引,触发内容区的切换。

底部Tab导航是移动端应用最经典的导航模式之一。通过@Builder封装Tab项,既减少了代码重复,又保证了所有Tab项的样式一致性。如果需要修改Tab的样式(如字号、间距),只需要修改一处即可。


弹框系统:六大弹框深度解析

弹框的通用架构模式

六个弹框虽然功能各异,但都遵循相同的架构模式:

  1. 遮罩层:全屏半透明黑色背景,点击可关闭弹框
  2. 内容层:白色背景的内容容器,包含标题、表单/内容、操作按钮
  3. zIndex层级控制:确保弹框在最上层

每个弹框都是一个@Builder函数,定义在Index组件内部,可以访问组件的所有状态和方法。

弹框1:试音申请抽屉

  @Builder applyOverlay() {
    Column()
      .width('100%')
      .height('100%')
      .backgroundColor('rgba(40,15,28,0.55)')
      .position({ x: 0, y: 0 })
      .zIndex(999)
      .onClick(() => {
        this.showApply = false
      })

}


在这里插入图片描述

总结:

综观整个应用,我们可以看到ArkTS开发技术体系的各个组成部分是如何协同工作的。TypeScript的类型系统为数据提供了安全保障,接口定义确保了数据结构的一致性。声明式UI的build方法让界面描述更加直观,开发者只需要关注"是什么"而不需要关心"怎么做"。装饰器(@Component、@State、@Prop、@Builder、@Entry)为不同的编程元素提供了明确的身份标记,框架根据这些标记执行相应的处理。布局系统(Column、Row、Stack、Flex、Scroll)提供了丰富的布局能力,几乎可以实现任何设计稿。状态管理机制让数据变化自动驱动UI更新,开发者不需要手动操作DOM或控件。动画系统提供了简洁的animateTo API,几行代码就能实现流畅的过渡效果。事件系统通过onClick等方法让用户交互与业务逻辑关联起来。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐