鸿蒙 ArkTS 实战解析:迷雾剧本·员工剧本杀馆应用架构与实现
引言:鸿蒙声明式 UI 与剧本杀业务的深度碰撞
在当今移动应用开发领域,华为鸿蒙操作系统凭借其独有的分布式能力和声明式 UI 框架 ArkUI,正在重新定义跨设备应用的开发范式。ArkUI 框架以 ArkTS 语言为核心,融合了 TypeScript 的类型安全特性与声明式编程的直观表达力,为开发者提供了一套从数据建模到界面渲染的完整解决方案。本文将以一个名为"迷雾剧本·员工剧本杀馆"的应用为案例,深入剖析其数据层设计、多页面组件架构、弹窗交互体系以及状态驱动渲染机制。
技术提示:ArkTS 是鸿蒙生态的官方开发语言,它在 TypeScript 的基础上扩展了
@Component、@State、@Builder、@Entry等装饰器语法,将面向对象的数据模型与声明式 UI 描述有机结合。理解这些装饰器的语义边界,是掌握 ArkUI 开发的关键一步。
这个剧本杀馆应用覆盖了完整的业务闭环:从剧本库浏览、组局大厅、角色档案馆,到活动中心、迷雾商城、个人场次记录。它采用典型的"底部多 Tab 导航 + 弹窗详情"的交互架构,通过 @State 状态变量驱动十四个独立弹窗的显示与隐藏,每个弹窗对应一类业务实体的详情或操作表单。整个应用没有使用页面路由跳转,而是通过 Stack 容器叠加遮罩层与弹窗内容,实现轻量级的模态交互,这种设计在 ArkUI 中是一种高效的模式。
从架构层面来看,整个应用遵循"数据层 — 组件层 — 入口层"的三段式结构。数据层通过 interface 定义六类业务实体的类型契约,再通过 const 数组固化模拟数据,辅以一批纯函数完成数据切片与样式映射。组件层由七个独立的 @Component 构成,每个组件负责一个 Tab 页面的 UI 渲染,并通过回调函数向父组件传递用户交互事件。入口层是一个 @Entry 装饰的主组件,它持有全部应用状态,通过条件渲染切换页面内容,并通过 @Builder 方法管理弹窗 UI 的复用。

上图展示了应用从数据到界面再到交互的完整数据流转链路。数据层提供的类型契约和模拟数据,经由工具函数加工后流入组件层;组件层渲染出七大业务页面,再通过回调将用户意图上抛到入口层;入口层根据状态变化决定渲染哪个页面、弹出哪个弹窗。这种单向数据流的设计使得状态可预测、调试可追溯,是 ArkUI 推荐的架构模式。
在技术栈方面,本案例大量运用了 ArkUI 的核心能力:Column 和 Row 作为线性布局容器承载绝大多数排版需求;Stack 作为层叠容器实现弹窗的浮层叠加;Scroll 提供可滚动的内容区域;ForEach 实现列表的声明式渲染与 key 机制;FlexAlign 控制子元素的主轴对齐方式;linearGradient 实现渐变背景营造沉浸氛围;shadow 和 borderRadius 构建卡片质感的视觉层级。此外,@Builder 方法被广泛用于抽取可复用的 UI 片段,十四个弹窗各自封装为一个 Builder 方法,实现了"一处定义、多处调用"的复用策略。
一、数据层:类型契约与模拟数据的基石
1.1 业务实体接口定义
interface MystScript {
id: number;
emoji: string;
name: string;
type: string;
difficulty: number;
mins: number;
players: string;
score: number;
plays: number;
price: number;
color: string;
desc: string;
}
interface MystGroup {
id: number;
emoji: string;
name: string;
script: string;
time: string;
dm: string;
quota: number;
joined: number;
status: string;
color: string;
desc: string;
}
interface MystRole {
id: number;
emoji: string;
name: string;
script: string;
job: string;
age: number;
trait: string;
importance: string;
color: string;
desc: string;
}
interface MystEvent {
id: number;
emoji: string;
name: string;
type: string;
time: string;
quota: number;
joined: number;
reward: number;
status: string;
color: string;
desc: string;
}
interface MystGoods {
id: number;
emoji: string;
name: string;
price: number;
oldPrice: number;
tag: string;
stock: number;
color: string;
desc: string;
}
interface PlayRecord {
id: number;
emoji: string;
name: string;
script: string;
role: string;
result: string;
date: string;
status: string;
color: string;
}

这里是数据层的根基——六个 interface 接口定义了六类业务实体的结构契约。在 ArkTS 中,interface 的作用与 TypeScript 一脉相承,它以纯声明的方式描述对象的数据形状,不包含任何方法实现,也不参与运行时的实例化。每个接口都遵循"标识唯一、字段命名清晰、类型精确"的原则,为后续的数据数组、组件参数传递、状态变量声明提供统一的类型依据。
逐个来看这六个实体的业务含义。MystScript 代表"剧本"这一核心资源,它包含名称、类型、难度系数、游戏时长、适合人数、评分、已开局数、人均价格等维度,是整个应用最复杂的数据结构。MystGroup 代表"组局"——也就是一次具体的开局活动,记录了关联剧本、开本时间、主持 DM、名额与已加入人数、组局状态等信息。MystRole 是角色档案,关联到具体剧本,描述角色的职业、年龄、性格特质和剧本中的重要性等级。
MystEvent 代表馆内举办的各类活动,如竞技赛、表演赛、内测招募等,包含名额、报名人数、奖励和状态。MystGoods 是商城商品,记录价格、原价、标签分类、库存量。PlayRecord 是玩家的场次记录,记录扮演的角色、结局、日期和本场评价。这六个实体共同构成了剧本杀馆的完整业务模型,覆盖了从剧本资源管理到玩家体验追踪的全链路。
值得注意的是,每个接口都包含一个 color 字段。这是一个设计上的巧思——将视觉色彩直接固化在数据层,使得每条记录都自带主题色,组件在渲染时可以直接读取这个字段作为卡片背景或标识色。这种"数据即样式"的做法在原型快速开发阶段非常高效,让 UI 层的逻辑得以简化,无需根据类型再做颜色映射推断。
架构思考:将 interface 放在文件顶部,意味着数据模型在整个文件范围内可见。ArkTS 的 interface 是编译期类型约束,运行时不产生任何开销,这与 class 不同。选择 interface 而非 class,是因为这里的数据是静态的、只读的,不需要实例方法或继承层级,interface 恰好满足"轻量类型契约"的需求。
1.2 模拟数据常量数组
const MYST_SCRIPTS: MystScript[] = [
{ id: 1, emoji: '🔍', name: '雾都夜行', type: '推理', difficulty: 4, mins: 180, players: '5-7人', score: 9.2, plays: 128, price: 68, color: '#4527A0', desc: '民国大都会连环失踪案,线索藏在雨夜的每一个角落' },
{ id: 2, emoji: '🕯️', name: '古宅烛影', type: '恐怖', difficulty: 3, mins: 150, players: '6-8人', score: 8.8, plays: 96, price: 58, color: '#1A237E', desc: '百年古宅闹鬼传闻背后,究竟是人祸还是鬼魅' },
{ id: 3, emoji: '💔', name: '月光邮局', type: '情感', difficulty: 2, mins: 120, players: '4-6人', score: 9.5, plays: 156, price: 48, color: '#AD1457', desc: '一封迟到十年的信,牵起三段尘封的青春往事' },
{ id: 4, emoji: '🎭', name: '十二时辰', type: '机制', difficulty: 4, mins: 200, players: '7-9人', score: 8.6, plays: 88, price: 78, color: '#E65100', desc: '长安城十二时辰内找出下毒真凶,阵营对决步步惊心' },
{ id: 5, emoji: '😂', name: '欢乐动物园', type: '欢乐', difficulty: 1, mins: 90, players: '5-8人', score: 8.4, plays: 142, price: 38, color: '#00838F', desc: '全员动物身份,笑料不断的合家欢本' },
{ id: 6, emoji: '🚢', name: '远洋号谜案', type: '推理', difficulty: 5, mins: 240, players: '6-8人', score: 9.0, plays: 76, price: 88, color: '#37474F', desc: '豪华游轮海上密室,五重不在场证明烧脑对决' }
];

这里展示的是 MYST_SCRIPTS 数组的前六条数据(完整数组有十二条)。使用 const 关键字声明,配合类型标注 MystScript[],确保数组元素的类型在编译期即被严格约束。每条数据是一个对象字面量,字段顺序与 interface 定义保持一致,便于阅读和维护。这种"用 const 数组充当本地数据源"的方式,在原型开发和功能验证阶段非常实用,无需依赖后端接口或数据库即可驱动完整 UI。
数据内容的设计也值得一提。剧本类型涵盖了推理、恐怖、情感、机制、欢乐、科幻六大流派,难度系数从 1 到 5 梯度分布,时长从 90 分钟到 240 分钟不等,适合人数从 4 人到 9 人各有差异。这种丰富的数据多样性,使得 UI 渲染时能充分展示各种边界情况——比如高难度的红色标识、长时长的提示、满员状态的灰色化处理等,让界面信息层次饱满。
每个剧本的 color 字段采用 Material Design 的深色调色板,如 #4527A0(深紫)、#1A237E(靛蓝)、#AD1457(深粉)、#E65100(深橙)、#00838F(深青)、#37474F(蓝灰)。这些颜色饱和度高、明度低,适合作为深色背景或强调色,与白色文字形成良好的对比度。在弹窗的渐变背景中,这些颜色作为起始色,配合 #1A1038(极深紫)作为终止色,营造出剧本杀特有的悬疑暗黑氛围。
数据设计哲学:在真实项目中,这些数据应来自后端 API。但本案例刻意将数据内联,体现了"前端先行"的开发理念——先以 mock 数据完成 UI 与交互的全部逻辑,再对接真实接口。这种模式让前后端可以并行开发,是敏捷团队常用的工程实践。
1.3 数据切片与工具函数
function getScriptRows(): MystScript[] {
return [MYST_SCRIPTS[0], MYST_SCRIPTS[2], MYST_SCRIPTS[4], MYST_SCRIPTS[6], MYST_SCRIPTS[8], MYST_SCRIPTS[10]];
}
function getScriptRows2(): MystScript[] {
return [MYST_SCRIPTS[1], MYST_SCRIPTS[3], MYST_SCRIPTS[5], MYST_SCRIPTS[7], MYST_SCRIPTS[9], MYST_SCRIPTS[11]];
}
function getHomeScripts(): MystScript[] {
return [MYST_SCRIPTS[0], MYST_SCRIPTS[1], MYST_SCRIPTS[2]];
}
function getTypeTags(): string[] {
return ['🔍 推理', '🕯️ 恐怖', '💔 情感', '🎭 机制', '😂 欢乐'];
}
function getPayLevels(): number[] {
return [100, 300, 500, 1000];
}
function getPayGifts(): string[] {
return ['送 20 币', '送 80 币', '送 150 币', '送 400 币'];
}
function getQuotaList(): number[] {
return [4, 5, 6, 7, 8];
}
function getScoreList(): number[] {
return [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
}
function getDmList(): string[] {
return ['老K', '小满', '墨白', '豆豆', '夜枭'];
}

这批函数是数据层与组件层之间的"适配层"。它们的核心职责是对原始数据数组进行切片、重组和筛选,以适配不同页面或弹窗的渲染需求。例如 getScriptRows 和 getScriptRows2 将十二条剧本数据按奇偶索引拆成两组,分别用于剧本页的"主列表"和"更多剧本"区段;getHomeScripts 只取前三条,用于首页的横向滚动卡片。
这种将数据切片逻辑封装为独立函数的做法,带来了三重好处。第一,组件代码无需关心数据来源和筛选逻辑,只需调用函数获取结果数组,降低了组件的复杂度。第二,如果未来数据结构变化或筛选规则调整,只需修改这些函数,组件代码不受影响,符合开闭原则。第三,函数返回的是新的数组引用,而非对原数组的引用,避免了组件对全局数据的意外修改,保障了数据的不可变性。
另外几组函数返回的是配置性的常量数组,如充值档位、赠送奖励、可组人数、评分选项、DM 列表等。这些数据本质上是应用的配置参数,用函数封装而非直接声明 const,保持了调用风格的统一,也方便未来替换为动态配置接口。值得注意的是 getScoreList 返回 1 到 10 的完整评分序列,这在评分弹窗中会作为 ForEach 的数据源,渲染出十个可选的分值按钮。
设计启示:纯函数是 ArkUI 数据流中最安全的一环。它们无副作用、可预测、易测试。在数据层大量使用纯函数做转换和筛选,能让组件保持"只管渲染不管数据来源"的纯粹性,这是声明式 UI 得以高效运作的底层支撑。
1.4 样式映射工具函数
function getBarHeight(v: number): number {
return 26 + v * 0.55;
}
function getDifficultyColor(d: number): string {
if (d >= 5) {
return '#B71C1C';
}
if (d >= 4) {
return '#E65100';
}
if (d >= 3) {
return '#F57C00';
}
return '#2E7D32';
}
function getStatusColor(s: string): string {
if (s === '组局中' || s === '报名中') {
return '#4527A0';
}
if (s === '已满员' || s === '报名截止') {
return '#BDBDBD';
}
return '#F57C00';
}
function getRecordColor(s: string): string {
if (s === 'MVP') {
return '#FFB300';
}
if (s === '跳车') {
return '#BDBDBD';
}
return '#00897B';
}
function formatScore(sc: number): string {
return String(sc);
}

这一组函数负责将业务语义映射为视觉样式,是数据层向 UI 层提供"色彩建议"的桥梁。getDifficultyColor 根据难度系数返回对应的警示色——难度 5 返回深红 #B71C1C,难度 4 返回深橙,难度 3 返回中橙,难度 1-2 返回深绿,形成从绿到红的风险递进色谱。这种"数值到颜色"的映射在仪表盘、状态指示等场景中非常常见,让用户一眼就能感知风险等级。
getStatusColor 将组局和活动的状态文本映射为颜色。进行中的状态返回品牌紫色 #4527A0,表示可操作;已满员或已截止返回灰色 #BDBDBD,表示不可操作;其他中间状态返回橙色 #F57C00,表示需关注。这套色彩语义在组件的标签渲染中被反复调用——状态标签的背景色直接取自这个函数的返回值,确保了全应用范围内状态色彩的一致性。
getRecordColor 专门用于场次记录的评价映射,MVP 返回金色 #FFB300 体现荣誉感,跳车返回灰色体现消极,正常完本返回青绿 #00897B 体现积极。formatScore 虽然只是简单的 String() 转换,但将其封装为独立函数,意味着未来如果需要格式化为"9.2 分"这样的带单位字符串,只需修改这一处即可,体现了对扩展性的预留。
这些函数在组件的 build 方法中被高频调用,体现了 ArkUI 声明式渲染的一个特点:可以在属性链中直接调用函数。例如 .backgroundColor(getDifficultyColor(s.difficulty)),函数的返回值直接作为属性参数参与渲染。这种"函数驱动样式"的模式让 UI 与数据之间建立了动态绑定关系——只要数据变化,样式就会随之更新。
上图展示了三种映射函数的输入输出对应关系。数值型和字符串型数据分别经过各自的映射逻辑,输出为语义化的色彩值,最终注入到 UI 组件的背景色属性中,形成"数据驱动视觉"的完整闭环。
二、首页组件:应用门面的沉浸式设计
2.1 组件声明与回调接口
@Component
struct MystHomeTab {
onScriptClick: (s: MystScript) => void = () => {};
onGroupClick: (g: MystGroup) => void = () => {};
onMoreGroup: () => void = () => {};
build() {
Scroll() {
Column() {
// ... 首页内容
}
.width('100%')
.padding({ left: 14, right: 14, bottom: 20 })
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
}
}

首页组件 MystHomeTab 以 @Component 装饰器声明,这是 ArkUI 中定义自定义组件的标准方式。@Component 装饰器告诉编译器:这个 struct 是一个可复用的 UI 组件,拥有独立的 build 方法描述其视觉结构。每个自定义组件都是一个 struct,而非 class——这是 ArkTS 的设计选择,struct 在内存上更轻量,更契合"数据 + 渲染描述"的组件本质。
组件声明了三个成员属性:onScriptClick、onGroupClick、onMoreGroup,它们的类型都是箭头函数,初始值为空函数 () => {}。这种"以函数属性作为事件回调"的设计,是 ArkUI 父子组件通信的标准模式。父组件在实例化子组件时,通过参数传入具体的回调实现(例如 MystHomeTab({ onScriptClick: (s) => { ... } })),子组件在用户点击时调用这些回调,将用户意图传递给父组件处理。
这种回调模式的精妙之处在于"控制反转"。子组件不知道点击后会发生什么——是打开弹窗、跳转页面还是触发网络请求,它只负责在点击事件中调用回调函数。具体的响应逻辑由父组件决定,子组件与业务逻辑彻底解耦。这使得 MystHomeTab 成为一个纯粹的"展示 + 事件上报"组件,可以在不同上下文中复用而无需修改。
ArkUI 知识点·@Component:被
@Component装饰的 struct 不能独立存在于页面中,它必须被其他组件(包括@Entry主组件)引用才会渲染。@Component组件每次被引用都会创建独立的状态实例,这意味着同一个组件可以在不同位置多次使用且互不干扰。
build 方法的根容器是 Scroll,这是 ArkUI 提供的滚动容器组件。Scroll 包裹一个 Column,Column 内部纵向排列所有首页内容。scrollBar(BarState.Off) 隐藏了滚动条,使界面更干净——这在卡片式布局中是常见的做法,因为卡片本身的阴影和圆角已经提供了足够的视觉边界,额外的滚动条反而显得冗余。
2.2 顶部 Banner 卡片
Column() {
Row() {
Text('🕵️')
.fontSize(36)
Column() {
Text('迷雾之夜 · 开场')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('本季已解锁 12 个剧本 · 侦探等级 Lv.6')
.fontSize(10)
.fontColor('#C5CAE9')
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 10 })
Column() {
Text('1280')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FFD700')
Text('迷雾币')
.fontSize(9)
.fontColor('#C5CAE9')
}
.padding({ left: 14, right: 14, top: 8, bottom: 8 })
.backgroundColor('#33FFFFFF')
.borderRadius(12)
}
.width('100%')
Row() {
Text('🎭 今晚 19:00 雾都夜行首车 · 已满员')
.fontSize(10)
.fontColor('#C5CAE9')
Text('')
.layoutWeight(1)
Text('去组局 ›')
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor('#FFD700')
.onClick(() => {
this.onMoreGroup();
})
}
.width('100%')
.padding({ top: 8, bottom: 8, left: 12, right: 12 })
.backgroundColor('#33FFFFFF')
.borderRadius(10)
.margin({ top: 12 })
}
.width('100%')
.padding({ left: 16, right: 16, top: 16, bottom: 16 })
.linearGradient({
angle: 135,
colorList: ['#1A1038', '#2D1B4E']
})
.borderRadius(16)
.shadow({ radius: 12, color: '#334527A0', offsetY: 4 })

这段代码构建了首页顶部的 Banner 卡片,它是整个应用视觉风格的第一印象。卡片由两层 Column 嵌套构成:外层 Column 定义整体卡片容器,内层先放一行(Row)展示侦探形象和迷雾币余额,再放一行展示今晚的组局提示。这种"Row 内嵌 Column"的嵌套模式是 ArkUI 布局的核心手法——Row 负责水平排布,Column 负责在水平方向上某一区域的纵向堆叠。
Banner 的视觉质感来自三个属性的协同:linearGradient 设置 135 度对角渐变,从极深紫 #1A1038 过渡到稍亮的 #2D1B4E,营造出夜幕低垂的悬疑感;borderRadius(16) 赋予卡片圆润的四角;shadow 添加带紫色色调的投影 #334527A0(30% 透明度的品牌紫),offsetY 为 4 让卡片产生悬浮于背景之上的层次感。这种"渐变 + 圆角 + 阴影"的三件套是 ArkUI 卡片设计的经典配方。
迷雾币的展示用了 backgroundColor('#33FFFFFF')——这是 20% 透明度的白色,在深紫背景上呈现为半透明的浅色区块,既不喧宾夺主,又清晰地隔离出余额信息。这种半透明叠层技巧在深色 UI 中极为常用,能在不引入新色彩的前提下创建信息区块的视觉边界。#FFD700 金色用于余额数字,呼应了"货币=黄金"的通用认知隐喻。
底部的组局提示行使用了 layoutWeight(1) 的空 Text 作为弹性占位符,将"去组局 ›“推向右侧。layoutWeight 是 ArkUI 中分配剩余空间的关键属性——当容器中某元素设置 layoutWeight 后,它会占据父容器在主轴方向上的剩余空间。用一个空的 Text 配合 layoutWeight 实现"两端对齐”,是 ArkUI 布局中的惯用技巧,类似于 CSS 的 flex: 1 或 justify-content: space-between 效果。
ArkUI 知识点·Row 与 Column:Row 是水平线性布局容器,子元素从左到右排列;Column 是垂直线性布局容器,子元素从上到下排列。两者可以无限嵌套,构建出任意复杂的二维布局。
alignItems控制交叉轴对齐(如 Row 中的垂直对齐),justifyContent控制主轴对齐(如 Row 中的水平分布)。
2.3 功能入口与高分剧本横向滚动
Row() {
Column() {
Text('📖')
.fontSize(24)
Text('剧本库')
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.margin({ top: 4 })
}
.layoutWeight(1)
.padding({ top: 12, bottom: 12 })
.justifyContent(FlexAlign.Center)
.backgroundColor('#FFFFFF')
.borderRadius(14)
.margin({ right: 8 })
.shadow({ radius: 6, color: '#14000000', offsetY: 2 })
.onClick(() => {
this.onMoreGroup();
})
Column() {
Text('🎲')
.fontSize(24)
Text('快速组局')
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.margin({ top: 4 })
}
.layoutWeight(1)
.padding({ top: 12, bottom: 12 })
.justifyContent(FlexAlign.Center)
.backgroundColor('#FFFFFF')
.borderRadius(14)
.margin({ right: 8 })
.shadow({ radius: 6, color: '#14000000', offsetY: 2 })
.onClick(() => {
this.onMoreGroup();
})
Column() {
Text('🎭')
.fontSize(24)
Text('角色库')
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.margin({ top: 4 })
}
.layoutWeight(1)
.padding({ top: 12, bottom: 12 })
.justifyContent(FlexAlign.Center)
.backgroundColor('#FFFFFF')
.borderRadius(14)
.shadow({ radius: 6, color: '#14000000', offsetY: 2 })
.onClick(() => {
this.onMoreGroup();
})
}
.width('100%')
.margin({ top: 12 })

功能入口区由三个等宽的白色卡片组成,分别对应"剧本库"“快速组局”"角色库"三大功能。三个卡片放在同一个 Row 中,每个 Column 都设置 layoutWeight(1),使它们平分 Row 的水平空间。这是 ArkUI 实现等分布局的最简方式——无需计算具体宽度,layoutWeight 会自动将剩余空间均分给设置了该属性的元素。
每个功能卡片内部是纵向排列的 emoji 图标和文字标签,通过 justifyContent(FlexAlign.Center) 实现内容在垂直方向的居中。FlexAlign 是 ArkUI 中控制 Flex 布局主轴对齐的枚举类型,FlexAlign.Center 表示子元素在主轴居中排列。对于 Column 而言,主轴是垂直方向,因此 Center 让内容垂直居中,使图标和文字在卡片中视觉平衡。
阴影属性 shadow({ radius: 6, color: '#14000000', offsetY: 2 }) 中的 color 用了 8% 透明度的黑色(#14 前缀 = 十六进制 0x14 = 十进制 20,约 8%),这种轻微的阴影让白色卡片在浅灰背景上产生微妙的悬浮感。三个卡片的阴影参数完全一致,体现了设计规范的一致性——同一层级的元素应具有相同的视觉权重。
点击事件统一调用 this.onMoreGroup(),这是为了在原型阶段简化交互——所有功能入口暂时都跳转到组局页。在真实项目中,每个入口应有各自的回调函数(如 onScriptLib、onQuickGroup、onRoleLib),但这里通过统一的 onMoreGroup 演示了回调传递的基本模式。
Scroll() {
Row() {
ForEach(getHomeScripts(), (s: MystScript) => {
Column() {
Text(s.emoji)
.fontSize(36)
Text(s.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.margin({ top: 8 })
Text(s.type + ' · ' + s.players)
.fontSize(9)
.fontColor('#8E8E8E')
.margin({ top: 3 })
Text('⭐ ' + formatScore(s.score))
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor('#FFB300')
.margin({ top: 4 })
Text(s.difficulty >= 4 ? '高难度' : (s.difficulty >= 3 ? '中难度' : '新手友好'))
.fontSize(9)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(getDifficultyColor(s.difficulty))
.borderRadius(9)
.margin({ top: 6 })
}
.width(118)
.padding({ top: 14, bottom: 14, left: 8, right: 8 })
.backgroundColor('#FFFFFF')
.borderRadius(14)
.margin({ right: 10 })
.shadow({ radius: 6, color: '#14000000', offsetY: 2 })
.onClick(() => {
this.onScriptClick(s);
})
}, (s: MystScript) => String(s.id))
}
}
.width('100%')
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
高分剧本区采用横向滚动列表,外层 Scroll 设置 scrollable(ScrollDirection.Horizontal) 将滚动方向切换为水平。ScrollDirection 枚举提供 Horizontal(水平)、Vertical(垂直)和 Free(自由)三种模式,默认是垂直滚动。水平滚动是展示推荐内容、精选内容的经典交互模式,用户可以通过左右滑动浏览更多卡片,节省垂直空间。
ForEach 是 ArkUI 中渲染列表数据的核心组件。它接收三个参数:数据数组、item 渲染函数、key 生成函数。这里数据源是 getHomeScripts() 返回的三条剧本数据,item 函数为每条数据渲染一个固定宽度 118 的卡片,key 函数 (s: MystScript) => String(s.id) 以剧本 id 作为唯一键。key 的作用是帮助 ArkUI 的 diff 算法高效识别列表项的变化——当数据增删或重排时,框架通过 key 匹配旧节点与新节点,最小化重新渲染的范围。
每张卡片内部是一个 Column,纵向堆叠 emoji、剧本名、类型与人数、评分、难度标签五个信息层。难度标签的实现尤其值得注意:通过三元表达式 s.difficulty >= 4 ? '高难度' : (s.difficulty >= 3 ? '中难度' : '新手友好') 将数值映射为文案,再通过 getDifficultyColor(s.difficulty) 映射为背景色,形成"数值 → 文案 + 色彩"的双重转换。这种在渲染时即时计算的写法,体现了声明式 UI 的灵活性。
点击卡片调用 this.onScriptClick(s),将当前剧本数据传递给父组件。父组件收到后会设置 selScript 状态并打开剧本详情弹窗。这种"点击列表项 → 打开详情弹窗"的交互模式贯穿整个应用,是用户体验的核心路径之一。
ArkUI 知识点·ForEach:
ForEach(arr, itemGenerator, keyGenerator)是 ArkUI 的列表渲染原语。itemGenerator 定义每个列表项的 UI 结构,keyGenerator 为每项生成唯一标识。key 的质量直接影响渲染性能——使用稳定且唯一的 key(如业务 id)能让 diff 算法高效运作,避免不必要的全量重建。
三、剧本页与组局页:列表展示的两种范式
3.1 剧本页:主次分明的双层列表
@Component
struct ScriptTab {
onScriptClick: (s: MystScript) => void = () => {};
build() {
Scroll() {
Column() {
Column() {
Row() {
Text('📖')
.fontSize(30)
Column() {
Text('剧本库')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('12 个剧本 · 覆盖 5 大类型')
.fontSize(10)
.fontColor('#C5CAE9')
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 10 })
}
.width('100%')
}
.width('100%')
.padding({ left: 16, right: 16, top: 14, bottom: 14 })
.linearGradient({
angle: 90,
colorList: ['#2D1B4E', '#4527A0']
})
.borderRadius(14)
.margin({ bottom: 12 })
Row() {
ForEach(getTypeTags(), (tag: string) => {
Text(tag)
.fontSize(11)
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
.backgroundColor('#EDE7F6')
.fontColor('#4527A0')
.borderRadius(12)
}, (tag: string) => tag)
}
.width('100%')
.margin({ bottom: 12 })
}
.width('100%')
.padding({ left: 14, right: 14, bottom: 20 })
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
}
}
剧本页 ScriptTab 的顶部是一个标题卡片,使用 90 度水平渐变 ['#2D1B4E', '#4527A0'],从左到右由深紫过渡到亮紫。这种水平渐变与首页 Banner 的 135 度对角渐变形成视觉区分,让用户在 Tab 切换时能感受到不同页面的"色彩个性"。标题区下方紧跟一行类型标签,用 ForEach(getTypeTags(), ...) 渲染五个流派标签,每个标签是浅紫底深紫字的圆角小方块,构成一个水平排列的筛选条。
类型标签的样式设计遵循"品牌色弱化版"原则:背景 #EDE7F6 是品牌紫 #4527A0 的极浅版本(约 10% 饱和度),文字用 #4527A0 原色,形成"同色系浅底深字"的协调配色。这种标签在 ArkUI 中通常用 Text 配合 padding 和 borderRadius 实现,无需引入 Badge 或 Chip 等额外组件,保持了实现的轻量。
剧本页的核心是两层列表结构。第一层用 getScriptRows() 返回六条数据,渲染为详细的卡片列表——每张卡片展示 emoji、名称、评分、类型与人数与时长、开局数与难度、难度标签和价格,信息密度高。第二层用 getScriptRows2() 返回另外六条数据,渲染为精简的列表项——只展示 emoji、名称、类型摘要和价格,信息密度低。中间用一条 Text('— 更多剧本 —') 作为视觉分隔,引导用户理解列表的层次。
这种"主列表 + 分隔符 + 次列表"的双层结构,是处理长列表的常见策略。主列表提供核心信息的充分展示,满足用户的深度浏览需求;次列表以紧凑形式呈现剩余内容,满足快速扫读需求。两层之间通过视觉密度的差异建立信息层级,避免了"一刀切"的等密度列表带来的阅读疲劳。
ArkUI 知识点·FlexAlign:
FlexAlign是 Flex 布局的主轴对齐枚举,包含Start(首端对齐)、Center(居中)、End(尾端对齐)、SpaceBetween(两端对齐无间距)、SpaceAround(等间距含两端)、SpaceEvenly(完全均布)等值。在 Row 和 Column 中通过justifyContent属性设置,决定子元素在主轴方向上的排布方式。
3.2 组局页:功能操作型列表
@Component
struct GroupTab {
onGroupClick: (g: MystGroup) => void = () => {};
onAddGroup: () => void = () => {};
onEditGroup: (g: MystGroup) => void = () => {};
onDeleteGroup: (g: MystGroup) => void = () => {};
build() {
Scroll() {
Column() {
Column() {
Row() {
Text('🎲')
.fontSize(30)
Column() {
Text('组局大厅')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('10 个车在组 · 6 车还差人')
.fontSize(10)
.fontColor('#C5CAE9')
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 10 })
Text('+ 发起组局')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor('#212121')
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor('#FFD700')
.borderRadius(12)
.onClick(() => {
this.onAddGroup();
})
}
.width('100%')
}
.width('100%')
.padding({ left: 16, right: 16, top: 14, bottom: 14 })
.linearGradient({
angle: 180,
colorList: ['#1A1038', '#2D1B4E']
})
.borderRadius(14)
.margin({ bottom: 12 })
组局页 GroupTab 的声明相比剧本页多了三个回调:onAddGroup、onEditGroup、onDeleteGroup,分别对应发起组局、编辑组局、解散组局三个操作。这说明组局页是一个"功能操作型"页面,除了浏览,还承担创建和管理的职责。回调数量直接反映了页面的功能复杂度。
顶部标题卡的右上角嵌入了"+ 发起组局"按钮,使用金色 #FFD700 背景、深色文字 #212121,与深紫标题背景形成强对比,吸引用户注意。按钮的 padding 和 borderRadius 构造了一个胶囊形状的可点击区域,onClick 调用 this.onAddGroup() 将意图上报给父组件。这种在标题栏内嵌操作按钮的设计,节省了页面空间,让标题与操作在同一视觉层内完成。
标题卡片使用了 180 度垂直渐变 ['#1A1038', '#2D1B4E'],从上到下由更深过渡到稍浅。垂直渐变在标题栏中能营造"从天而降"的纵深感,配合顶部到下方的圆角裁切,让标题栏看起来像一块从顶部垂落的帷幕。
ForEach(getGroupRows(), (g: MystGroup, gi: number) => {
Row() {
Column() {
Text(String(gi + 1))
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
}
.width(30)
.height(30)
.justifyContent(FlexAlign.Center)
.backgroundColor(g.color)
.borderRadius(15)
Column() {
Row() {
Text(g.emoji)
.fontSize(16)
Text(g.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.margin({ left: 4 })
Text('')
.layoutWeight(1)
Text(g.status)
.fontSize(9)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(getStatusColor(g.status))
.borderRadius(9)
}
.width('100%')
Text(g.script + ' · ' + g.time + ' · DM ' + g.dm)
.fontSize(10)
.fontColor('#8E8E8E')
.margin({ top: 4 })
Row() {
Text('席位 ' + String(g.joined) + '/' + String(g.quota))
.fontSize(9)
.fontColor('#4527A0')
Text('')
.layoutWeight(1)
Text(g.status === '组局中' ? '加入 ›' : '已满员')
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(g.status === '组局中' ? '#4527A0' : '#BDBDBD')
}
.width('100%')
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 10 })
}
.width('100%')
.padding({ left: 12, right: 12, top: 12, bottom: 12 })
.backgroundColor('#FFFFFF')
.borderRadius(12)
.margin({ bottom: 10 })
.shadow({ radius: 4, color: '#0F000000', offsetY: 1 })
.onClick(() => {
this.onGroupClick(g);
})
}, (g: MystGroup) => String(g.id))
组局列表项的渲染展示了一个"序号圆 + 信息区"的经典列表布局。ForEach 的第二个参数是 (g: MystGroup, gi: number),这里额外取了索引 gi,用于显示序号。序号通过一个 30x30 的圆形 Column(width 和 height 各 30,borderRadius 15 即为圆)展示,背景色取自数据自身的 g.color 字段,文字为白色。这种"以数据自带颜色作为序号背景"的做法让每条列表项都有独特的色彩标识,在长列表中帮助用户快速定位。
信息区是一个 Column,内部嵌套两层 Row。第一层 Row 展示组局名称、emoji 和状态标签,状态标签通过 getStatusColor(g.status) 获取背景色——组局中显示品牌紫,已满员显示灰色。第二层 Row 展示席位进度和操作提示,操作提示文字"加入 ›"或"已满员"通过三元表达式根据状态动态切换文案和颜色,这种"状态驱动文案与样式"的声明式写法让 UI 始终与数据保持同步。
ArkUI 知识点·ForEach 索引:ForEach 的 item 生成函数可以接收第二个参数 index(索引),语法为
(item: T, index: number) => { ... }。index 从 0 开始递增,常用于序号显示、奇偶行差异化样式等场景。但需注意,index 在列表重排后会变化,不应将其作为 key 使用。
组局页的第二层列表"我组的局"使用了不同的背景色 #EDE7F6(浅紫底),与第一层的白色卡片形成区分,让用户一眼看出这是"我创建的"而非"所有的"。每个列表项内嵌"编辑"和"删除"两个文字按钮,分别用橙色和红色文字,直接调用 onEditGroup 和 onDeleteGroup 回调。这种内联操作按钮的设计避免了长按菜单或滑动删除的复杂交互,在信息密度和操作便捷性之间取得了平衡。
四、角色页、活动页、商城页:列表模式的变体
4.1 角色档案馆
@Component
struct RoleTab {
onRoleClick: (r: MystRole) => void = () => {};
build() {
Scroll() {
Column() {
Column() {
Row() {
Text('🎭')
.fontSize(30)
Column() {
Text('角色档案馆')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('12 位经典角色 · 等你来演绎')
.fontSize(10)
.fontColor('#C5CAE9')
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 10 })
}
.width('100%')
}
.width('100%')
.padding({ left: 16, right: 16, top: 14, bottom: 14 })
.linearGradient({
angle: 135,
colorList: ['#4A148C', '#2D1B4E']
})
.borderRadius(14)
.margin({ bottom: 12 })
ForEach(getRoleRows(), (r: MystRole) => {
Row() {
Text(r.emoji)
.fontSize(34)
Column() {
Row() {
Text(r.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Text(' ' + r.importance)
.fontSize(9)
.fontWeight(FontWeight.Bold)
.fontColor(r.importance === '主角' ? '#FFB300' : '#8E8E8E')
.margin({ left: 4 })
}
Text(r.job + ' · ' + String(r.age) + ' 岁 · ' + r.trait)
.fontSize(9)
.fontColor('#8E8E8E')
.margin({ top: 3 })
Text('出自《' + r.script + '》')
.fontSize(9)
.fontColor('#4527A0')
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 10 })
}
.width('100%')
.padding({ left: 12, right: 12, top: 12, bottom: 12 })
.backgroundColor('#FFFFFF')
.borderRadius(12)
.margin({ bottom: 10 })
.shadow({ radius: 4, color: '#0F000000', offsetY: 1 })
.onClick(() => {
this.onRoleClick(r);
})
}, (r: MystRole) => String(r.id))
}
}
}
}
角色页 RoleTab 的标题渐变使用了 ['#4A148C', '#2D1B4E']——从深品紫到深紫蓝,135 度对角方向。这与首页 Banner 的 ['#1A1038', '#2D1B4E'] 和剧本页的 ['#2D1B4E', '#4527A0'] 色调不同,为角色页赋予了独特的视觉签名。在整个应用中,七个 Tab 页面各有一个独特的标题渐变配色,形成"一页一色"的设计规范,帮助用户通过色彩快速识别当前所在页面。
角色列表项的核心信息包括:名称、重要性(主角/重要)、职业、年龄、性格特质、出自剧本。重要性标签的色彩处理值得注意——主角显示金色 #FFB300,其他显示灰色 #8E8E8E,这种"主角金色、配角灰色"的对比让用户一眼识别核心角色。色彩语义在此被用于传达业务层级,而非纯粹的装饰。
"出自《剧本名》"这一行用品牌紫 #4527A0 文字,与上方灰色信息行形成色彩跳变,引导用户关注角色的来源关联。这种在信息流中通过色彩突显关键字段的手法,是信息层级设计的常见技巧——并非所有信息都同等重要,通过颜色为重要信息赋予更高的视觉权重。
角色页同样采用双层列表结构:getRoleRows() 返回六个主角色渲染为详细卡片,getRoleRows2() 返回六个次角色渲染为精简列表项。次列表项的背景使用 #F9F9FB(极浅灰),与主列表的白色卡片形成微妙的层次差异。这种"白卡 + 浅灰项"的双层配色模式在剧本页、角色页、商城页中重复出现,构成了应用列表设计的一致规范。
4.2 活动中心
@Component
struct MystEventTab {
onEventClick: (e: MystEvent) => void = () => {};
onDeleteEvent: (e: MystEvent) => void = () => {};
build() {
Scroll() {
Column() {
Column() {
Row() {
Text('🏆')
.fontSize(30)
Column() {
Text('活动中心')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('本月 8 场活动 · 6 场可报名')
.fontSize(10)
.fontColor('#C5CAE9')
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 10 })
}
.width('100%')
}
.width('100%')
.padding({ left: 16, right: 16, top: 14, bottom: 14 })
.linearGradient({
angle: 90,
colorList: ['#4527A0', '#6A1B9A']
})
.borderRadius(14)
.margin({ bottom: 12 })
ForEach(getEventRows(), (e: MystEvent, ei: number) => {
Row() {
Column() {
Text(String(ei + 1))
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
}
.width(30)
.height(30)
.justifyContent(FlexAlign.Center)
.backgroundColor(e.color)
.borderRadius(15)
Column() {
Row() {
Text(e.emoji)
.fontSize(16)
Text(e.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.margin({ left: 4 })
Text('')
.layoutWeight(1)
Text(e.status)
.fontSize(9)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(getStatusColor(e.status))
.borderRadius(9)
}
.width('100%')
Text(e.type + ' · ' + e.time)
.fontSize(10)
.fontColor('#8E8E8E')
.margin({ top: 4 })
Row() {
Text('报名 ' + String(e.joined) + '/' + String(e.quota))
.fontSize(9)
.fontColor('#4527A0')
Text('')
.layoutWeight(1)
Text('奖励 ' + String(e.reward) + ' 币')
.fontSize(9)
.fontWeight(FontWeight.Bold)
.fontColor('#F57C00')
}
.width('100%')
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 10 })
}
.width('100%')
.padding({ left: 12, right: 12, top: 12, bottom: 12 })
.backgroundColor('#FFFFFF')
.borderRadius(12)
.margin({ bottom: 10 })
.shadow({ radius: 4, color: '#0F000000', offsetY: 1 })
.onClick(() => {
this.onEventClick(e);
})
}, (e: MystEvent) => String(e.id))
}
}
}
}
活动页 MystEventTab 的列表结构与组局页高度相似——都采用"序号圆 + 信息区"的布局,序号圆的背景色取自数据自身的 color 字段。这种结构复用体现了 ArkUI 组件设计的一个理念:相似的数据结构可以复用相似的 UI 模式,减少用户的学习成本。组局和活动都是"有时间、有名额、有状态"的实体,自然适合相同的列表样式。
活动列表项的信息行展示了类型与时间、报名进度与奖励。奖励金额用橙色 #F57C00 加粗显示,与报名进度的紫色形成色彩对比,让"利益点"在视觉上突出。在运营型应用中,将奖励、价格等利益相关字段用暖色(橙/红)突出,是引导用户行为的有效手段。
第二层列表"已结束活动"的每个列表项内嵌"删除"文字按钮,红色文字 #D32F2F,调用 onDeleteEvent 回调。这种对"过期内容"提供清理入口的设计,体现了应用对内容生命周期管理的考量——已结束的活动不应永久占用列表空间,用户应能主动清理。
4.3 迷雾商城
ForEach(getGoodsRows(), (g: MystGoods) => {
Column() {
Row() {
Text(g.emoji)
.fontSize(32)
Column() {
Text(g.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Text(g.tag + ' · 库存 ' + String(g.stock) + ' 件')
.fontSize(9)
.fontColor('#8E8E8E')
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 10 })
Text(g.tag)
.fontSize(9)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(g.color)
.borderRadius(9)
}
.width('100%')
Row() {
Text('¥' + String(g.price))
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#D32F2F')
Text(' ¥' + String(g.oldPrice))
.fontSize(10)
.fontColor('#BDBDBD')
.decoration({ type: TextDecorationType.LineThrough })
.margin({ left: 6 })
Text('')
.layoutWeight(1)
Text('兑换 ›')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor('#4527A0')
}
.width('100%')
.margin({ top: 8 })
}
.width('100%')
.padding({ left: 12, right: 12, top: 12, bottom: 12 })
.backgroundColor('#FFFFFF')
.borderRadius(12)
.margin({ bottom: 10 })
.shadow({ radius: 4, color: '#0F000000', offsetY: 1 })
.onClick(() => {
this.onGoodsClick(g);
})
}, (g: MystGoods) => String(g.id))
商城页的商品列表项展示了一个电商场景的经典布局:商品图标 + 名称 + 标签信息 + 分类标签 + 现价 + 原价(划线)+ 操作入口。最值得关注的是原价的划线效果,通过 decoration({ type: TextDecorationType.LineThrough }) 实现。TextDecorationType 是 ArkUI 提供的文本装饰枚举,LineThrough 表示删除线,另有 Underline(下划线)和 None(无装饰)。划线原价配合灰色文字 #BDBDBD,与红色的现价 #D32F2F 形成对比,是电商 UI 中传递"降价感"的通用手法。
商城页的标题栏右侧嵌入了迷雾币余额展示,点击触发 onRecharge 回调打开充值弹窗。这与组局页标题栏嵌入"发起组局"按钮的模式一致——将全局性的高频操作入口嵌入标题栏,让用户在任何时候都能快速触达核心功能。余额数字用金色 #FFD700 显示,呼应了 Banner 中的余额配色,保持了跨页面的色彩一致性。
ArkUI 知识点·decoration:
Text组件的decoration属性用于添加文本装饰线,参数为{ type: TextDecorationType, color?: ResourceColor }。除了删除线,还可以添加下划线并自定义颜色。这是 ArkUI 对文本视觉细节控制的体现,让开发者能在不引入额外组件的情况下实现丰富的文本样式。
五、我的页面:个人数据档案的呈现
5.1 个人信息卡片与统计区
@Component
struct MystMineTab {
onRecordClick: (r: PlayRecord) => void = () => {};
onDeleteRecord: (r: PlayRecord) => void = () => {};
build() {
Scroll() {
Column() {
Column() {
Row() {
Text('🕵️')
.fontSize(40)
Column() {
Text('推理玩家 · 阿雾')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('侦探等级 Lv.6 · 累计 12 本')
.fontSize(10)
.fontColor('#C5CAE9')
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 12 })
Text('🎖️')
.fontSize(28)
}
.width('100%')
Row() {
Column() {
Text('8')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FFD700')
Text('剧本数')
.fontSize(9)
.fontColor('#C5CAE9')
.margin({ top: 2 })
}
.layoutWeight(1)
Column() {
Text('3')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FFD700')
Text('MVP 场次')
.fontSize(9)
.fontColor('#C5CAE9')
.margin({ top: 2 })
}
.layoutWeight(1)
Column() {
Text('1280')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FFD700')
Text('迷雾币')
.fontSize(9)
.fontColor('#C5CAE9')
.margin({ top: 2 })
}
.layoutWeight(1)
}
.width('100%')
.padding({ top: 14, bottom: 4 })
}
.width('100%')
.padding({ left: 16, right: 16, top: 18, bottom: 16 })
.linearGradient({
angle: 135,
colorList: ['#1A1038', '#4527A0']
})
.borderRadius(16)
.shadow({ radius: 12, color: '#334527A0', offsetY: 4 })
“我的"页 MystMineTab 的顶部是一个用户信息卡片,采用了与首页 Banner 完全一致的渐变配色 ['#1A1038', '#4527A0'] 和 135 度对角方向。这种视觉呼应是有意为之的——首页是应用的"门面”,我的页是用户的"门面",两者使用相同的视觉语言传达了"同等重要"的设计意图。卡片内部的 shadow 参数也与首页 Banner 一致(radius 12, color #334527A0, offsetY 4),确保了悬浮感的一致性。
用户头像使用了一个大号 emoji 🕵️(fontSize 40),右侧紧跟用户名和等级信息。最右侧的 🎖️ 勋章图标作为装饰,暗示用户的成就身份。这种"大头像 + 信息 + 装饰"的三段式头部布局在个人中心页极为常见,ArkUI 用一个 Row 即可完成,无需复杂的自定义组件。
统计区是三个等宽的 Column,通过 layoutWeight(1) 平分宽度。每个 Column 展示一个数字(金色加粗)和一行标签(浅色小字)。数字使用 #FFD700 金色,与卡片整体的深紫背景形成强对比,让核心数据成为视觉焦点。三个数据——剧本数、MVP 场次、迷雾币——分别代表了用户的"广度(玩了多少)"“深度(玩得多好)”"资产(积累了多少)"三个维度,构成了用户画像的完整描述。
5.2 场次记录列表
ForEach(getRecordRows(), (r: PlayRecord) => {
Column() {
Row() {
Text(r.emoji)
.fontSize(26)
Column() {
Text(r.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Text(r.script + ' · ' + r.date)
.fontSize(9)
.fontColor('#8E8E8E')
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 10 })
Text(r.status)
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor(getRecordColor(r.status))
.borderRadius(10)
}
.width('100%')
Row() {
Text('扮演 ' + r.role + ' · ' + r.result)
.fontSize(10)
.fontColor('#4527A0')
Text('')
.layoutWeight(1)
Text('删除')
.fontSize(10)
.fontColor('#D32F2F')
.onClick(() => {
this.onDeleteRecord(r);
})
}
.width('100%')
.margin({ top: 8 })
.padding({ top: 8 })
}
.width('100%')
.padding({ left: 12, right: 12, top: 12, bottom: 12 })
.backgroundColor('#FFFFFF')
.borderRadius(12)
.margin({ bottom: 10 })
.shadow({ radius: 4, color: '#0F000000', offsetY: 1 })
.onClick(() => {
this.onRecordClick(r);
})
}, (r: PlayRecord) => String(r.id))
场次记录列表项采用 Column 嵌套两层 Row 的结构。第一层 Row 展示剧本 emoji、名称、类型与日期、以及状态标签。状态标签的背景色通过 getRecordColor(r.status) 获取——MVP 金色、完本青绿、跳车灰色,这三种色彩分别传达了"荣誉"“完成”"遗憾"三种情感语义。在游戏类应用中,将玩家的体验结果用色彩编码,能增强成就感和反馈感。
第二层 Row 展示扮演的角色和结局,用品牌紫色文字,与上方的灰色信息行形成色彩跳变。"删除"按钮红色文字,点击调用 onDeleteRecord 回调。这个删除按钮与外层卡片的 onClick(打开记录详情)是两个独立的点击区域——ArkUI 的事件处理会正确处理嵌套点击:内层 Text 的 onClick 会触发自身回调,同时事件不会冒泡到外层(因为在 ArkUI 中,消费了事件的组件会阻止冒泡)。
历史场次列表 getRecordRows2() 渲染为精简列表项,只展示 emoji、名称、日期与类型、以及状态文字(不是标签,而是彩色文字)。状态文字的颜色通过 getRecordColor(r.status) 获取,但直接作为 fontColor 而非 backgroundColor,形成"深色背景标签 vs 彩色文字"的两种状态展示风格,在视觉上区分了"近期场次"和"历史场次"的信息密度。
上图展示了用户在"我的"页的完整交互路径。从页面渲染到点击记录项或删除按钮,再到父组件响应并打开对应弹窗,整条链路通过回调函数串联,体现了 ArkUI"事件上抛、状态下沉"的数据流模式。
六、主入口组件:状态枢纽与渲染调度
6.1 Tab 枚举与状态声明
enum MystTab {
Home = 0,
Script = 1,
Group = 2,
Role = 3,
Event = 4,
Shop = 5,
Mine = 6
}
@Entry
@Component
struct MystApp {
@State curTab: number = MystTab.Home;
@State showScript: boolean = false;
@State selScript: MystScript | null = null;
@State showVote: boolean = false;
@State voteScore: number = 9;
@State showGroup: boolean = false;
@State selGroup: MystGroup | null = null;
@State showAddGroup: boolean = false;
@State showEditGroup: boolean = false;
@State showDelGroup: boolean = false;
@State delGroup: MystGroup | null = null;
@State showAssign: boolean = false;
@State assignScript: MystScript | null = null;
@State assignIdx: number = 0;
@State showRole: boolean = false;
@State selRole: MystRole | null = null;
@State showEvent: boolean = false;
@State selEvent: MystEvent | null = null;
@State showDelEvent: boolean = false;
@State delEvent: MystEvent | null = null;
@State showGoods: boolean = false;
@State selGoods: MystGoods | null = null;
@State showPay: boolean = false;
@State showRecord: boolean = false;
@State selRecord: PlayRecord | null = null;
@State showDelRecord: boolean = false;
@State delRecord: PlayRecord | null = null;
@State toast: string = '';
@State fSlot: string = '10:00-13:00';
@State fPayIdx: number = 0;
@State fName: string = '';
@State fTime: string = '';
@State fQuota: number = 5;
MystTab 枚举定义了七个 Tab 的索引值,从 0 到 6 分别对应首页、剧本、组局、角色、活动、商城、我的。使用枚举而非魔法数字(如直接写 0、1、2)能大幅提升代码可读性——MystTab.Home 比 0 更具语义。枚举值还用于条件渲染的判断,如 if (this.curTab === MystTab.Home),让分支逻辑一目了然。
MystApp 是整个应用的入口组件,以 @Entry 装饰。@Entry 的作用是标记该组件为页面入口——每个页面有且仅有一个 @Entry 组件,它是渲染树的根。@Entry 组件会被框架自动实例化并挂载到窗口上,无需在其他地方引用。这与 @Component 不同——@Component 组件需要被显式引用才会渲染。
@State 是 ArkUI 状态管理的核心装饰器。被 @State 装饰的变量会成为"响应式状态"——当其值变化时,框架会自动重新渲染依赖该状态的 UI 部分。这里声明了超过三十个 @State 变量,它们可以分为四类:当前 Tab(curTab)、各类弹窗的显示开关(showScript、showVote、showGroup 等,均为 boolean)、各类弹窗的选中数据(selScript、selGroup、selRole 等,为对象或 null)、表单临时状态(fName、fTime、fQuota、fPayIdx、voteScore、assignIdx 等)。
ArkUI 知识点·@State:
@State装饰的变量是组件内部的私有状态,变化会触发组件的局部重新渲染。对于基本类型(number、string、boolean),值的变化即可触发更新;对于引用类型(对象、数组),需要赋值整个新引用才能触发——修改对象内部属性不会自动触发渲染,除非使用@Observed/@ObjectLink或重新赋值。本案例中selScript: MystScript | null在选中时会被整体赋值新对象,因此能正确触发更新。
selScript: MystScript | null 这种"联合类型"声明是 ArkTS 的类型安全设计。初始值为 null,表示未选中任何剧本;当用户点击剧本卡片时,赋值为对应的 MystScript 对象。在弹窗渲染前会检查 this.selScript !== null,确保不会在空值上访问属性。这种"可空类型 + 空值检查"的模式贯穿整个应用的状态管理,是避免运行时空指针异常的有效策略。
表单状态变量以 f 前缀命名(form 的缩写),如 fName、fTime、fQuota、fPayIdx,用于在创建组局、编辑组局、充值等表单弹窗中临时存储用户输入。这些状态在父组件上声明而非在弹窗组件内部,是因为弹窗 UI 通过 @Builder 方法定义而非独立组件——Builder 方法没有自己的状态,只能依赖宿主组件的状态。
6.2 底部导航数据与 @Builder 方法
private tabs1: string[] = ['首页', '剧本', '组局', '角色'];
private tabs1Icons: string[] = ['🏠', '📖', '🎲', '🎭'];
private tabs2: string[] = ['活动', '商城', '我的'];
private tabs2Icons: string[] = ['🏆', '🛍️', '🕵️'];
@Builder
modalOverlay(onClose: () => void) {
Column()
.width('100%')
.height('100%')
.backgroundColor('rgba(0,0,0,0.55)')
.onClick(onClose)
}
@Builder
contentArea() {
if (this.curTab === MystTab.Home) {
MystHomeTab({
onScriptClick: (s: MystScript) => {
this.selScript = s;
this.showScript = true;
},
onGroupClick: (g: MystGroup) => {
this.selGroup = g;
this.showGroup = true;
},
onMoreGroup: () => {
this.curTab = MystTab.Group;
}
})
} else if (this.curTab === MystTab.Script) {
ScriptTab({
onScriptClick: (s: MystScript) => {
this.selScript = s;
this.showScript = true;
}
})
} else if (this.curTab === MystTab.Role) {
RoleTab({
onRoleClick: (r: MystRole) => {
this.selRole = r;
this.showRole = true;
}
})
}
}
底部导航被拆成两组:tabs1(首页、剧本、组局、角色)和 tabs2(活动、商城、我的),每组配有对应的 emoji 图标数组。这种拆分是为了在底部渲染两行 Tab——第一行四个,第二行三个,共七个。每个 Tab 的索引通过 ti(tabs1)和 ti + 4(tabs2,偏移 4)计算,与 MystTab 枚举值对应。
@Builder 是 ArkUI 中定义可复用 UI 片段的装饰器。与 @Component 不同,@Builder 方法不创建独立组件实例,它只是将一段 UI 描述封装为可调用的方法。Builder 方法可以直接访问宿主组件的 this(包括状态变量和方法),因此适合在同一个组件内抽取重复的 UI 结构。
modalOverlay 是一个极简的 Builder——它只渲染一个全屏的半透明黑色 Column,接收一个 onClose 回调作为点击事件。这个遮罩层是所有弹窗的底座,点击遮罩(即点击弹窗外部区域)关闭弹窗是模态交互的标准行为。rgba(0,0,0,0.55) 表示 55% 不透明度的黑色,既能遮挡背景内容又不完全遮蔽,保持了视觉上下文的连续性。
contentArea Builder 是页面内容的调度中心。它通过一串 if-else if 条件判断 this.curTab 的值,渲染对应的 Tab 组件。每个分支在实例化子组件时传入回调函数,这些回调函数在内部修改 @State 变量——例如 onScriptClick 将 selScript 赋值为点击的剧本对象,并将 showScript 设为 true。状态变化后,框架会自动重新渲染依赖这些状态的弹窗区域。
ArkUI 知识点·@Builder:
@Builder方法用于封装可复用的 UI 描述片段,语法为@Builder methodName(params) { UI内容 }。它与@Component的区别在于:Builder 不产生独立组件实例,不拥有独立状态,它直接引用宿主组件的 this。适合用于抽取同一组件内重复的 UI 结构,如弹窗、卡片模板等。Builder 方法可以接收参数,参数类型在方法签名中声明。
6.3 底部 Tab 项 Builder
@Builder
bottomTabItem(icon: string, label: string, idx: number) {
Column() {
Text(icon)
.fontSize(19)
.opacity(this.curTab === idx ? 1 : 0.55)
Text(label)
.fontSize(9)
.fontColor(this.curTab === idx ? '#4527A0' : '#9E9E9E')
.fontWeight(this.curTab === idx ? FontWeight.Bold : FontWeight.Normal)
.margin({ top: 2 })
}
.layoutWeight(1)
.padding({ top: 5, bottom: 5 })
.justifyContent(FlexAlign.Center)
.onClick(() => {
this.curTab = idx;
})
}
bottomTabItem Builder 接收三个参数:emoji 图标、文字标签、Tab 索引。它渲染一个 Column,内部纵向排列图标和文字。核心在于条件样式——this.curTab === idx 判断当前是否为激活的 Tab,是则用全不透明度(opacity 1)、品牌紫色文字 #4527A0、加粗字重;否则用 55% 不透明度、灰色文字 #9E9E9E、常规字重。
这种"状态驱动样式"的写法是声明式 UI 的精髓。开发者无需手动在状态变化时调用 setStyle 之类的命令式 API,只需声明"样式取决于状态值",框架会在状态变化时自动重新计算样式并更新 UI。opacity 属性用于控制元素的透明度,0.55 让非激活 Tab 呈现"退后"的视觉效果,与激活 Tab 的全不透明形成对比,引导用户聚焦当前页面。
点击事件 this.curTab = idx 是整个应用导航的核心——修改 curTab 状态后,contentArea Builder 会重新执行条件判断,渲染新的 Tab 组件,页面内容随之切换。这种"状态即导航"的模式避免了命令式的页面跳转 API 调用,让导航逻辑可预测、可调试。
layoutWeight(1) 让每个 Tab 项平分底部导航的宽度,FlexAlign.Center 让图标和文字在垂直方向居中。两行 Tab 通过两个 Row 容器分别承载 ForEach 渲染,每个 Row 中的 bottomTabItem 都会设置 layoutWeight(1),确保七个 Tab 项在两行中各自等宽分布。
七、弹窗体系:十四个 Builder 的设计与实现
7.1 剧本详情弹窗
@Builder
scriptModal() {
Column() {
Column() {
Row() {
Text(this.selScript!.emoji)
.fontSize(40)
Column() {
Text(this.selScript!.name)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text(this.selScript!.type + ' · ' + this.selScript!.players)
.fontSize(10)
.fontColor('#C5CAE9')
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 12 })
Text('✕')
.fontSize(16)
.fontColor('#FFFFFF')
.padding(8)
.onClick(() => {
this.showScript = false;
})
}
.width('100%')
}
.width('100%')
.padding({ left: 16, right: 16, top: 16, bottom: 16 })
.linearGradient({
angle: 135,
colorList: ['#1A1038', '#4527A0']
})
Column() {
Row() {
Column() {
Text('⭐ ' + formatScore(this.selScript!.score))
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FFB300')
Text('玩家评分')
.fontSize(9)
.fontColor('#8E8E8E')
.margin({ top: 2 })
}
.layoutWeight(1)
Column() {
Text(String(this.selScript!.mins) + 'min')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#4527A0')
Text('游戏时长')
.fontSize(9)
.fontColor('#8E8E8E')
.margin({ top: 2 })
}
.layoutWeight(1)
Column() {
Text('难度 ' + String(this.selScript!.difficulty))
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(getDifficultyColor(this.selScript!.difficulty))
Text('上手难度')
.fontColor('#8E8E8E')
.fontSize(9)
.margin({ top: 2 })
}
.layoutWeight(1)
}
.width('100%')
.padding({ top: 14, bottom: 14 })
.backgroundColor('#EDE7F6')
.borderRadius(12)
Text(this.selScript!.desc)
.fontSize(11)
.fontColor('#616161')
.lineHeight(18)
.margin({ top: 12 })
Row() {
Text('我要组局')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.width('48%')
.textAlign(TextAlign.Center)
.padding({ top: 12, bottom: 12 })
.backgroundColor('#4527A0')
.borderRadius(24)
.onClick(() => {
this.showScript = false;
this.toast = '组局已发起:' + this.selScript!.name;
})
Text('评分')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.width('48%')
.textAlign(TextAlign.Center)
.padding({ top: 12, bottom: 12 })
.backgroundColor('#FFB300')
.borderRadius(24)
.onClick(() => {
this.voteScore = 9;
this.showScript = false;
this.showVote = true;
})
}
.width('100%')
.margin({ top: 14 })
}
.width('100%')
.padding({ left: 16, right: 16, top: 16, bottom: 18 })
.backgroundColor('#FFFFFF')
}
.width('82%')
.borderRadius(18)
.clip(true)
.margin({ bottom: 40 })
.transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
}
剧本详情弹窗 scriptModal 是十四个弹窗中最具代表性的一个,它展示了弹窗设计的完整范式。弹窗整体是一个 Column,宽度设为 82%(不占满全屏),通过 borderRadius(18) 和 clip(true) 实现圆角裁切——clip(true) 确保内部子元素的渐变背景不会溢出圆角边界,这是 ArkUI 中实现圆角容器内嵌渐变的关键属性。
弹窗分为上下两段:上半段是深紫渐变的标题区(linearGradient 135 度,与首页 Banner 同色),展示剧本 emoji、名称、类型与人数,右上角有关闭按钮"✕";下半段是白色背景的信息区,展示三个核心指标(评分、时长、难度)、剧本描述、以及两个操作按钮。这种"深色头 + 浅色身"的拼接设计是弹窗的经典布局,深色头部承载身份信息,浅色身体承载详情和操作。
三指标区放在一个浅紫底 #EDE7F6 的圆角区块内,三个 Column 通过 layoutWeight(1) 等分宽度。每个指标由大号数值和小号标签组成,数值的颜色因指标而异——评分金色、时长紫色、难度根据函数返回色。这种"每指标独立配色"的设计让信息层次丰富,但又不至于杂乱,因为底部有统一的浅紫底色作为调和。
transition(TransitionEffect.OPACITY.animation({ duration: 200 })) 为弹窗添加了淡入淡出动画。TransitionEffect.OPACITY 是 ArkUI 提供的过渡效果之一(透明度动画),animation({ duration: 200 }) 指定动画时长 200 毫秒。当弹窗通过条件渲染出现或消失时,框架会自动播放这段过渡动画,使弹窗的显隐更平滑、更有质感。这是 ArkUI 声明式动画的体现——无需手动控制动画的 start 和 stop,只需声明过渡效果,框架自动管理。
ArkUI 知识点·transition:
transition属性用于为组件的插入和删除添加过渡动画。参数为TransitionEffect对象,可链式调用.animation()指定动画参数。常见的 TransitionEffect 包括OPACITY(淡入淡出)、SLIDE(滑动)、EXPAND(展开)等。transition 只在组件通过条件渲染(if 判断)出现或消失时触发,是弹窗、抽屉等模态交互的标准动画方案。
两个操作按钮"我要组局"和"评分"各占 48% 宽度,放在一个 Row 中,中间留有间距。按钮使用 Text 组件配合 textAlign(TextAlign.Center) 实现文字居中,borderRadius(24) 形成胶囊形状。"我要组局"用品牌紫背景,"评分"用金色背景,两种颜色对应两种不同性质的操作——组局是主流程操作(品牌色),评分是辅助操作(强调色)。
点击"我要组局"后,回调先设置 this.showScript = false 关闭当前弹窗,再设置 this.toast = '组局已发起:' + this.selScript!.name 显示 Toast 提示。点击"评分"则先关闭剧本弹窗,再设置 this.showVote = true 打开评分弹窗——这种"关闭一个弹窗同时打开另一个弹窗"的链式交互,通过修改多个状态变量实现,体现了状态驱动渲染的灵活性。
7.2 评分弹窗
@Builder
voteModal() {
Column() {
Column() {
Text('⭐')
.fontSize(44)
Text('为《' + this.selScript!.name + '》评分')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.margin({ top: 8 })
Text('你的评价将帮助更多玩家')
.fontSize(10)
.fontColor('#C5CAE9')
.margin({ top: 4 })
}
.width('100%')
.padding({ top: 22, bottom: 20 })
.justifyContent(FlexAlign.Center)
.linearGradient({
angle: 135,
colorList: ['#4527A0', '#1A1038']
})
Column() {
Text(this.voteScore + ' 分')
.fontSize(28)
.fontWeight(FontWeight.Bold)
.fontColor('#FFB300')
.margin({ top: 16 })
Row() {
ForEach(getScoreList(), (sc: number) => {
Text(String(sc))
.fontSize(12)
.width(36)
.height(32)
.textAlign(TextAlign.Center)
.backgroundColor(this.voteScore === sc ? '#FFB300' : '#F5F5F5')
.fontColor(this.voteScore === sc ? '#FFFFFF' : '#616161')
.borderRadius(8)
.margin({ right: 6 })
.onClick(() => {
this.voteScore = sc;
})
}, (sc: number) => String(sc))
}
.width('100%')
.justifyContent(FlexAlign.Center)
.margin({ top: 8 })
Text('提交评分')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.width('100%')
.textAlign(TextAlign.Center)
.padding({ top: 12, bottom: 12 })
.backgroundColor('#4527A0')
.borderRadius(24)
.margin({ top: 16, bottom: 18 })
.onClick(() => {
this.showVote = false;
this.toast = '评分成功:' + this.selScript!.name + ' ' + String(this.voteScore) + ' 分';
})
}
.width('100%')
.backgroundColor('#FFFFFF')
}
.width('78%')
.borderRadius(18)
.clip(true)
.margin({ bottom: 40 })
.transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
}
评分弹窗 voteModal 展示了一个交互表单弹窗的设计。弹窗宽度为 78%,比剧本弹窗的 82% 略窄,因为评分弹窗内容更少,窄一些能让弹窗在屏幕中更显聚焦。弹窗顶部是一个居中的大号星星 emoji 和标题文字,标题中动态插入了 this.selScript!.name,让用户明确知道在为哪个剧本评分——这种"上下文带入"的文案设计能减少用户的认知负担。
评分的核心交互是十个分值按钮,通过 ForEach(getScoreList(), ...) 渲染 1 到 10 的分值。每个按钮是一个固定尺寸(36x32)的 Text,背景色根据 this.voteScore === sc 判断——选中的分值用金色背景 #FFB300 白字,未选中的用浅灰背景 #F5F5F5 深字。点击任意按钮将 this.voteScore 设为对应分值,状态变化后所有按钮的样式会重新计算——选中的变金,未选中的变灰,实现"单选"效果。
这种"状态驱动的单选交互"是 ArkUI 中实现选择器的标准模式。无需维护 selectedIndex 或使用 Radio 组件,只需一个状态变量记录当前选中值,在渲染时用三元表达式判断每个选项的样式即可。这种模式简洁、直观、易维护,适用于分值选择、档位选择、分类选择等各种场景。
ArkUI 知识点·条件样式:在 ArkUI 声明式语法中,属性值可以是任意表达式——包括三元表达式、函数调用、变量引用。例如
.backgroundColor(this.voteScore === sc ? '#FFB300' : '#F5F5F5'),框架会在每次渲染时重新求值这个表达式,确保样式始终与状态同步。这种"属性即表达式"的设计是声明式 UI 的核心特征,让 UI 与状态之间建立自动的响应式绑定。
弹窗顶部的当前分值展示 Text(this.voteScore + ' 分') 是一个实时反馈——用户点击分值按钮后,voteScore 变化,这行文字立即更新为新的分值,配合按钮的选中状态变化,形成"点击 → 按钮变色 + 数字更新"的双重即时反馈。这种即时反馈是优秀交互体验的基础,让用户确信操作已生效。
7.3 组局详情弹窗
@Builder
groupModal() {
Column() {
Column() {
Row() {
Text(this.selGroup!.emoji)
.fontSize(34)
Column() {
Text(this.selGroup!.name)
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text(this.selGroup!.script + ' · ' + this.selGroup!.status)
.fontSize(10)
.fontColor('#C5CAE9')
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 10 })
Text('✕')
.fontSize(16)
.fontColor('#FFFFFF')
.padding(8)
.onClick(() => {
this.showGroup = false;
})
}
.width('100%')
}
.width('100%')
.padding({ left: 16, right: 16, top: 14, bottom: 16 })
.linearGradient({
angle: 90,
colorList: ['#1A1038', '#2D1B4E']
})
Column() {
Row() {
Column() {
Text(this.selGroup!.time)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Text('开本时间')
.fontSize(9)
.fontColor('#8E8E8E')
.margin({ top: 2 })
}
.layoutWeight(1)
Column() {
Text('DM ' + this.selGroup!.dm)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor('#4527A0')
Text('主持 DM')
.fontSize(9)
.fontColor('#8E8E8E')
.margin({ top: 2 })
}
.layoutWeight(1)
Column() {
Text(String(this.selGroup!.joined) + '/' + String(this.selGroup!.quota))
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor('#00897B')
Text('当前席位')
.fontSize(9)
.fontColor('#8E8E8E')
.margin({ top: 2 })
}
.layoutWeight(1)
}
.width('100%')
.padding({ top: 14, bottom: 14 })
.backgroundColor('#EDE7F6')
.borderRadius(12)
Text(this.selGroup!.desc)
.fontSize(11)
.fontColor('#616161')
.lineHeight(18)
.margin({ top: 12 })
Text(this.selGroup!.status === '组局中' ? '加入此局' : '查看详情')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.width('100%')
.textAlign(TextAlign.Center)
.padding({ top: 12, bottom: 12 })
.backgroundColor(this.selGroup!.status === '组局中' ? '#4527A0' : '#9E9E9E')
.borderRadius(24)
.margin({ top: 14 })
.onClick(() => {
this.showGroup = false;
if (this.selGroup!.status === '组局中') {
this.toast = '已加入:' + this.selGroup!.name;
}
})
}
.width('100%')
.padding({ left: 16, right: 16, top: 16, bottom: 18 })
.backgroundColor('#FFFFFF')
}
.width('82%')
.borderRadius(18)
.clip(true)
.margin({ bottom: 40 })
.transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
}
组局详情弹窗 groupModal 的结构与剧本弹窗高度相似——深色头部 + 白色身体 + 三指标区 + 描述 + 操作按钮。但有三处细节差异值得注意。首先,头部的渐变方向改为 90 度水平渐变 ['#1A1038', '#2D1B4E'],与组局页标题卡的渐变方向一致,保持页面与弹窗的视觉统一。其次,三指标分别是时间、DM、席位,每个指标用不同颜色——时间用深灰 #333333、DM 用品牌紫 #4527A0、席位用青绿 #00897B,三种颜色分别传达"中性"“品牌”"正向"的语义。
最值得分析的是操作按钮的动态文案和样式。this.selGroup!.status === '组局中' ? '加入此局' : '查看详情' 根据组局状态动态切换按钮文案——组局中显示"加入此局"(可操作),已满员显示"查看详情"(只读)。背景色也同步变化——组局中用品牌紫 #4527A0(可操作的主色),已满员用灰色 #9E9E9E(不可操作的弱化色)。这种"状态驱动文案 + 状态驱动样式"的双重声明,让按钮始终与业务状态保持一致,避免了"按钮文案与实际行为不符"的体验问题。
点击按钮后,回调先关闭弹窗,再根据状态决定是否显示 Toast——只有 组局中 状态才显示"已加入"提示,已满员状态点击后不显示任何提示(因为只是查看详情,无需反馈)。这种"条件反馈"的设计避免了不必要的打扰,只在用户真正执行了操作时才给予反馈。
7.4 创建组局表单弹窗
@Builder
addGroupModal() {
Column() {
Row() {
Text('+ 发起组局')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#212121')
Text('')
.layoutWeight(1)
Text('✕')
.fontSize(16)
.fontColor('#9E9E9E')
.padding(8)
.onClick(() => {
this.showAddGroup = false;
})
}
.width('100%')
Text('组局名称')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.margin({ top: 14, bottom: 6 })
Text(this.fName.length > 0 ? this.fName : '例如:雾都夜行·下班局')
.fontSize(12)
.fontColor(this.fName.length > 0 ? '#333333' : '#BDBDBD')
.width('100%')
.padding({ top: 10, bottom: 10, left: 12, right: 12 })
.backgroundColor('#F5F5F5')
.borderRadius(10)
Text('可组人数')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.margin({ top: 12, bottom: 6 })
Row() {
ForEach(getQuotaList(), (qo: number) => {
Text(String(qo) + ' 人')
.fontSize(11)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor(this.fQuota === qo ? '#4527A0' : '#F5F5F5')
.fontColor(this.fQuota === qo ? '#FFFFFF' : '#616161')
.borderRadius(10)
.margin({ right: 8 })
.onClick(() => {
this.fQuota = qo;
})
}, (qo: number) => String(qo))
}
.width('100%')
.margin({ top: 2 })
Text('确认发起')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.width('100%')
.textAlign(TextAlign.Center)
.padding({ top: 12, bottom: 12 })
.backgroundColor('#4527A0')
.borderRadius(24)
.margin({ top: 16 })
.onClick(() => {
this.showAddGroup = false;
this.toast = '组局发起成功,等待玩家加入';
})
}
.width('100%')
.padding({ left: 16, right: 16, top: 16, bottom: 20 })
.backgroundColor('#FFFFFF')
.borderRadius({ topLeft: 18, topRight: 18 })
.constraintSize({ maxHeight: '80%' })
.transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
}
创建组局弹窗 addGroupModal 展示了一种不同于居中弹窗的布局——底部弹出式表单。弹窗宽度为 100%(占满全宽),borderRadius 只设置 topLeft 和 topRight(顶部两角圆角),constraintSize({ maxHeight: '80%' }) 限制最大高度为屏幕的 80%。这种"底部弹出、顶部圆角、高度受限"的样式是底部抽屉(Bottom Sheet)的标准设计,在移动端用于表单输入场景。
表单的"组局名称"字段展示了一种简化版的输入框实现。本案例没有使用 TextInput 组件,而是用 Text 配合 placeholder 文案模拟——当 fName.length > 0 时显示用户输入的内容(深色字),否则显示占位提示文案(灰色字)。这种实现虽然不接收真实输入(因为原型阶段 fName 未被赋值),但完整展示了输入框的视觉结构和样式规范。
"可组人数"使用了与评分弹窗相同的单选交互模式——ForEach(getQuotaList(), ...) 渲染 4 到 8 人五个选项,每个选项通过 this.fQuota === qo 判断选中状态,点击修改 fQuota 状态。这种模式在应用中被反复使用:评分弹窗的分值选择、组局弹窗的人数选择、编辑弹窗的 DM 选择、充值弹窗的档位选择,全部采用同一套"状态驱动单选"的实现范式。这种一致性降低了维护成本——修改一处范式即可影响所有同类交互。
设计原则·一致性:在同一个应用中,相同的交互模式应使用相同的视觉表现和操作逻辑。本案例中所有"单选选择器"都采用"圆角小方块 + 选中高亮色 + 未选中浅灰底"的统一样式,让用户形成"这种样式就是让我选一个"的认知习惯,降低了学习成本。这是尼尔森可用性原则中"一致性"的体现。
7.5 编辑组局与删除确认弹窗
@Builder
editGroupModal() {
Column() {
Row() {
Text('✏️ 编辑组局')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#212121')
Text('')
.layoutWeight(1)
Text('✕')
.fontSize(16)
.fontColor('#9E9E9E')
.padding(8)
.onClick(() => {
this.showEditGroup = false;
})
}
.width('100%')
Text(this.selGroup!.emoji + ' ' + this.selGroup!.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.width('100%')
.padding({ top: 10, bottom: 10, left: 12, right: 12 })
.backgroundColor('#EDE7F6')
.borderRadius(10)
.margin({ top: 12 })
Text('调整开本时间')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.margin({ top: 12, bottom: 6 })
Text(this.fTime.length > 0 ? this.fTime : this.selGroup!.time)
.fontSize(12)
.fontColor('#333333')
.width('100%')
.padding({ top: 10, bottom: 10, left: 12, right: 12 })
.backgroundColor('#F5F5F5')
.borderRadius(10)
Text('换一位 DM')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.margin({ top: 12, bottom: 6 })
Row() {
ForEach(getDmList(), (dm: string, di: number) => {
Text(dm)
.fontSize(11)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor(this.fPayIdx === di ? '#4527A0' : '#F5F5F5')
.fontColor(this.fPayIdx === di ? '#FFFFFF' : '#616161')
.borderRadius(10)
.margin({ right: 8 })
.onClick(() => {
this.fPayIdx = di;
})
}, (dm: string) => dm)
}
.width('100%')
.margin({ top: 2 })
Text('保存修改')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.width('100%')
.textAlign(TextAlign.Center)
.padding({ top: 12, bottom: 12 })
.backgroundColor('#F57C00')
.borderRadius(24)
.margin({ top: 16 })
.onClick(() => {
this.showEditGroup = false;
this.toast = '组局信息已更新';
})
}
.width('100%')
.padding({ left: 16, right: 16, top: 16, bottom: 20 })
.backgroundColor('#FFFFFF')
.borderRadius({ topLeft: 18, topRight: 18 })
.constraintSize({ maxHeight: '80%' })
.transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
}
编辑弹窗 editGroupModal 也是底部弹出式表单,结构与创建弹窗类似,但有一个重要差异——时间字段使用了回退值 this.fTime.length > 0 ? this.fTime : this.selGroup!.time。当用户尚未修改时间时,显示原始组局的时间(selGroup.time)而非占位提示文案。这种"有用户输入则显示输入值,无输入则回退到原始数据"的模式,让编辑弹窗天然携带了被编辑对象的当前值,是编辑表单的标准做法。
DM 选择器复用了 fPayIdx 状态变量(注意这个名字,它本意是"充值档位索引",但在编辑弹窗中被复用为 DM 索引)。这是原型开发中的权宜做法——在真实项目中应为每个表单独立维护各自的选中索引状态,避免跨弹窗的状态冲突。但从模式角度看,DM 选择器与人数选择器、档位选择器使用完全相同的实现范式,再次印证了"状态驱动单选"模式的普适性。
“保存修改"按钮使用橙色 #F57C00 背景,与创建弹窗的紫色按钮形成区分。这种"创建用主色、编辑用强调色"的配色策略,帮助用户通过按钮颜色快速识别操作类型——紫色意味着"新建”,橙色意味着"修改"。
@Builder
delGroupModal() {
Column() {
Column() {
Text('🗑️')
.fontSize(40)
Text('解散组局')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.margin({ top: 8 })
Text('解散后已加入玩家将收到通知')
.fontSize(10)
.fontColor('#C5CAE9')
.margin({ top: 4 })
}
.width('100%')
.padding({ top: 20, bottom: 20 })
.justifyContent(FlexAlign.Center)
.linearGradient({
angle: 135,
colorList: ['#4527A0', '#1A1038']
})
Column() {
Text(this.delGroup!.emoji + ' ' + this.delGroup!.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.margin({ top: 14 })
Row() {
Text('取消')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#616161')
.width('48%')
.textAlign(TextAlign.Center)
.padding({ top: 11, bottom: 11 })
.backgroundColor('#F5F5F5')
.borderRadius(22)
.onClick(() => {
this.showDelGroup = false;
})
Text('确认解散')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.width('48%')
.textAlign(TextAlign.Center)
.padding({ top: 11, bottom: 11 })
.backgroundColor('#D32F2F')
.borderRadius(22)
.onClick(() => {
this.showDelGroup = false;
this.toast = '组局已解散';
})
}
.width('100%')
.margin({ top: 14, bottom: 16 })
}
.width('100%')
.backgroundColor('#FFFFFF')
}
.width('80%')
.borderRadius(18)
.clip(true)
.margin({ bottom: 40 })
.transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
}
删除确认弹窗 delGroupModal 展示了"居中确认弹窗"的设计范式。弹窗宽度 80%,居中显示(在 Stack 中通过默认对齐实现居中),顶部深紫渐变区展示删除图标和标题,底部白色区展示被删除对象的名称和两个操作按钮。这种"居中 + 警示图标 + 双按钮"的结构是危险操作确认弹窗的通用模板。
双按钮"取消"和"确认解散"各占 48% 宽度。"取消"用浅灰底深灰字,“确认解散"用红色 #D32F2F 底白字。红色作为危险操作的警示色,与"取消"的中性灰形成强对比,引导用户慎重操作。按钮文案特意使用"确认解散"而非简单的"确认”——更具体的动词让用户明确操作的后果,这是防误操作设计的文案策略。
这个删除确认弹窗的模式在应用中被复用了三次——删除组局、删除活动、删除记录,三个确认弹窗的结构和样式完全一致,只更换了图标、标题文案和被删除对象的展示。这种模板化复用确保了危险操作确认体验的一致性,用户在任何删除场景下都能识别相同的交互模式。
ArkUI 知识点·Stack:
Stack是层叠布局容器,子元素按声明顺序从底到顶层叠,后声明的子元素覆盖在前面的子元素之上。本案例的主 build 方法用 Stack 作为根容器,第一层是页面内容,后续的弹窗和遮罩依次叠加在上面。Stack 默认让所有子元素居中对齐,因此居中弹窗无需额外的定位属性即可居中显示。底部弹出弹窗则通过position或align属性调整对齐位置。
7.6 角色分配弹窗与角色详情弹窗
@Builder
assignModal() {
Column() {
Row() {
Text('🎭 角色分配')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#212121')
Text('')
.layoutWeight(1)
Text('✕')
.fontSize(16)
.fontColor('#9E9E9E')
.padding(8)
.onClick(() => {
this.showAssign = false;
})
}
.width('100%')
Text('为《' + this.assignScript!.name + '》挑选你的角色')
.fontSize(11)
.fontColor('#8E8E8E')
.margin({ top: 6, bottom: 10 })
ForEach(getAssignRoles(), (r: MystRole, ri: number) => {
Row() {
Text(r.emoji)
.fontSize(26)
Column() {
Text(r.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Text(r.job + ' · ' + r.trait)
.fontSize(9)
.fontColor('#8E8E8E')
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 8 })
Column() {
Text(this.assignIdx === ri ? '已选' : '选择')
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(this.assignIdx === ri ? '#FFFFFF' : '#4527A0')
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor(this.assignIdx === ri ? '#4527A0' : '#EDE7F6')
.borderRadius(10)
}
}
.width('100%')
.padding({ left: 12, right: 12, top: 10, bottom: 10 })
.backgroundColor(this.assignIdx === ri ? '#EDE7F6' : '#FFFFFF')
.borderRadius(10)
.margin({ bottom: 8 })
.onClick(() => {
this.assignIdx = ri;
})
}, (r: MystRole) => String(r.id))
Text('确认分配')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.width('100%')
.textAlign(TextAlign.Center)
.padding({ top: 12, bottom: 12 })
.backgroundColor('#4527A0')
.borderRadius(24)
.margin({ top: 6 })
.onClick(() => {
this.showAssign = false;
this.toast = '角色已分配:' + getAssignRoles()[this.assignIdx].name;
})
}
.width('100%')
.padding({ left: 16, right: 16, top: 16, bottom: 20 })
.backgroundColor('#FFFFFF')
.borderRadius({ topLeft: 18, topRight: 18 })
.constraintSize({ maxHeight: '80%' })
.transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
}
角色分配弹窗 assignModal 是一个"列表式单选"弹窗——用户从三个角色卡片中选择一个。与评分弹窗的"按钮式单选"不同,列表式单选的每个选项是一个卡片 Row,包含更丰富的信息(emoji、名称、职业、特质)和选中状态标识。选中状态通过两重表现:右侧的"已选/选择"标签变色变底,以及整个卡片行背景从白色变为浅紫 #EDE7F6。
这种"整行高亮 + 状态标签"的双重选中反馈,比单纯的标签变色更直观——用户能从整体卡片背景一眼看出哪个被选中,而无需逐个查看标签。this.assignIdx === ri 的判断用索引而非 id,因为这里的选择是"列表中的第几个"而非"哪个角色"——索引与渲染顺序直接对应。
确认分配后,回调通过 getAssignRoles()[this.assignIdx].name 获取选中角色的名称,组装进 Toast 文案。这种"从数据源重新查询"而非"缓存选中对象"的做法,确保了显示的数据始终来自单一数据源,避免了缓存与源不同步的风险。
角色详情弹窗 roleModal 的结构与剧本详情弹窗高度一致——深色头部 + 三指标 + 描述 + 操作按钮。差异在于头部渐变用了 ['#1A1038', '#4A148C'](极深紫到深品紫),与角色页标题的渐变色调呼应。两个指标分别是"性格特质"和"剧本地位",用紫色和金色区分。"预约此角色"按钮调用 onRoleClick 类的回调,显示预约成功 Toast。
7.7 活动详情、商品详情、充值弹窗
@Builder
goodsModal() {
Column() {
Column() {
Text(this.selGoods!.emoji)
.fontSize(56)
Text(this.selGoods!.name)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.margin({ top: 8 })
Text(this.selGoods!.tag + ' · 库存 ' + String(this.selGoods!.stock) + ' 件')
.fontSize(10)
.fontColor('#C5CAE9')
.margin({ top: 4 })
}
.width('100%')
.padding({ top: 22, bottom: 22 })
.justifyContent(FlexAlign.Center)
.linearGradient({
angle: 135,
colorList: [this.selGoods!.color, '#1A1038']
})
Column() {
Row() {
Text('¥' + String(this.selGoods!.price))
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor('#D32F2F')
Text(' ¥' + String(this.selGoods!.oldPrice))
.fontSize(12)
.fontColor('#BDBDBD')
.decoration({ type: TextDecorationType.LineThrough })
.margin({ left: 6 })
Text('')
.layoutWeight(1)
Text('可抵 ' + String(this.selGoods!.price) + ' 迷雾币')
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor('#4527A0')
}
.width('100%')
Text(this.selGoods!.desc)
.fontSize(11)
.fontColor('#616161')
.lineHeight(18)
.margin({ top: 10 })
Text('立即兑换')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.width('100%')
.textAlign(TextAlign.Center)
.padding({ top: 12, bottom: 12 })
.backgroundColor(this.selGoods!.color)
.borderRadius(24)
.margin({ top: 14 })
.onClick(() => {
this.showGoods = false;
this.toast = '兑换成功,请到前台领取';
})
}
.width('100%')
.padding({ left: 16, right: 16, top: 16, bottom: 18 })
.backgroundColor('#FFFFFF')
}
.width('80%')
.borderRadius(18)
.clip(true)
.margin({ bottom: 40 })
.transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
}
商品详情弹窗 goodsModal 有一个独特的设计——头部渐变的起始色使用商品自身的颜色 this.selGoods!.color,而非固定的品牌色。这意味着不同商品的弹窗会有不同的头部色调——骰子套装的弹窗头部是深紫,蜡烛礼盒是靛蓝,骰子是深青…这种"数据驱动弹窗配色"的设计让每个商品都有独特的视觉个性,增强了浏览的丰富感。
头部的商品 emoji 用了极大的字号(fontSize 56),这是所有弹窗中最大的 emoji 尺寸,目的是让商品图标成为视觉焦点,模拟电商应用中"商品主图"的展示效果。下方的价格区展示了现价(红色大字 22px)和原价(灰色小字 12px 带删除线),以及"可抵 X 迷雾币"的换算提示,为用户提供了价格的多维参考。
"立即兑换"按钮的背景色也使用 this.selGoods!.color(商品自身颜色),与头部渐变起始色呼应,让按钮与商品视觉绑定。这种"按钮颜色随商品变化"的设计在电商弹窗中并不常见,但在本案例中增强了"每个商品都有独特身份"的体验感。
@Builder
payModal() {
Column() {
Row() {
Text('💎 迷雾币充值')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#212121')
Text('')
.layoutWeight(1)
Text('✕')
.fontSize(16)
.fontColor('#9E9E9E')
.padding(8)
.onClick(() => {
this.showPay = false;
})
}
.width('100%')
Text('选择充值档位')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.margin({ top: 14, bottom: 10 })
Row() {
ForEach(getPayLevels(), (lv: number, li: number) => {
Column() {
Text(String(lv))
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(this.fPayIdx === li ? '#4527A0' : '#333333')
Text(getPayGifts()[li])
.fontSize(9)
.fontColor(this.fPayIdx === li ? '#4527A0' : '#8E8E8E')
.margin({ top: 2 })
}
.layoutWeight(1)
.padding({ top: 12, bottom: 12 })
.justifyContent(FlexAlign.Center)
.backgroundColor(this.fPayIdx === li ? '#EDE7F6' : '#F5F5F5')
.borderRadius(12)
.margin({ right: 8 })
.onClick(() => {
this.fPayIdx = li;
})
}, (lv: number) => String(lv))
}
.width('100%')
Text('会员充值可获双倍迷雾币')
.fontSize(11)
.fontColor('#F57C00')
.margin({ top: 12 })
.alignSelf(ItemAlign.Start)
Text('确认充值')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.width('100%')
.textAlign(TextAlign.Center)
.padding({ top: 12, bottom: 12 })
.backgroundColor('#4527A0')
.borderRadius(24)
.margin({ top: 14 })
.onClick(() => {
this.showPay = false;
this.toast = '充值成功:+' + String(getPayLevels()[this.fPayIdx] * 2) + ' 迷雾币';
})
}
.width('100%')
.padding({ left: 16, right: 16, top: 16, bottom: 20 })
.backgroundColor('#FFFFFF')
.borderRadius({ topLeft: 18, topRight: 18 })
.constraintSize({ maxHeight: '80%' })
.transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
}
充值弹窗 payModal 是底部弹出式表单,核心交互是四档充值选项的选择。每档选项是一个 Column,通过 layoutWeight(1) 等分宽度,展示档位金额(大字)和赠送奖励(小字)。选中态用浅紫底 #EDE7F6 + 品牌紫字,未选中用浅灰底 #F5F5F5 + 深灰字。这种"等宽卡片单选"的布局在充值、套餐选择等场景中极为常见。
"会员充值可获双倍迷雾币"这行提示用橙色 #F57C00 文字,通过 alignSelf(ItemAlign.Start) 让文字在 Column 中左对齐。ItemAlign 是交叉轴对齐枚举,Start 表示起始端对齐(在 Column 中即左对齐)。这行提示告知用户充值后会翻倍,并在确认按钮的回调中兑现——getPayLevels()[this.fPayIdx] * 2 计算双倍后的迷雾币数量,显示在 Toast 中。这种"提示承诺 → 操作兑现"的闭环让用户的预期与结果一致。
7.8 场次记录详情弹窗
@Builder
recordModal() {
Column() {
Column() {
Row() {
Text(this.selRecord!.emoji)
.fontSize(30)
Column() {
Text(this.selRecord!.name)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text(this.selRecord!.date + ' · ' + this.selRecord!.script)
.fontSize(10)
.fontColor('#C5CAE9')
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 10 })
Text('✕')
.fontSize(16)
.fontColor('#FFFFFF')
.padding(8)
.onClick(() => {
this.showRecord = false;
})
}
.width('100%')
}
.width('100%')
.padding({ left: 16, right: 16, top: 14, bottom: 16 })
.linearGradient({
angle: 90,
colorList: ['#1A1038', '#4527A0']
})
Column() {
Row() {
Column() {
Text(this.selRecord!.role)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#4527A0')
Text('扮演角色')
.fontSize(9)
.fontColor('#8E8E8E')
.margin({ top: 2 })
}
.layoutWeight(1)
Column() {
Text(this.selRecord!.result)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#00897B')
Text('结局')
.fontSize(9)
.fontColor('#8E8E8E')
.margin({ top: 2 })
}
.layoutWeight(1)
Column() {
Text(this.selRecord!.status)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(getRecordColor(this.selRecord!.status))
Text('本场评价')
.fontSize(9)
.fontColor('#8E8E8E')
.margin({ top: 2 })
}
.layoutWeight(1)
}
.width('100%')
.padding({ top: 14, bottom: 14 })
.backgroundColor('#EDE7F6')
.borderRadius(12)
Row() {
Column() {
Text('+150 币')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor('#F57C00')
Text('场次奖励')
.fontSize(9)
.fontColor('#8E8E8E')
.margin({ top: 3 })
}
.layoutWeight(1)
Column() {
Text('已归档')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor('#00897B')
Text('战报状态')
.fontSize(9)
.fontColor('#8E8E8E')
.margin({ top: 3 })
}
.layoutWeight(1)
}
.width('100%')
.padding({ top: 12, bottom: 12 })
.backgroundColor('#F9F9FB')
.borderRadius(12)
.margin({ top: 10 })
Text('查看完整战报')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.width('100%')
.textAlign(TextAlign.Center)
.padding({ top: 12, bottom: 12 })
.backgroundColor('#4527A0')
.borderRadius(24)
.margin({ top: 14 })
.onClick(() => {
this.showRecord = false;
this.toast = '战报已发送到邮箱';
})
}
.width('100%')
.padding({ left: 16, right: 16, top: 16, bottom: 18 })
.backgroundColor('#FFFFFF')
}
.width('82%')
.borderRadius(18)
.clip(true)
.margin({ bottom: 40 })
.transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
}
场次记录详情弹窗 recordModal 被设计成"票根样式"——通过两层指标区(浅紫底 + 浅灰底)模拟纸质票据的多段结构。第一层三指标展示扮演角色(紫)、结局(青绿)、本场评价(动态色),第二层两指标展示场次奖励(橙)和战报状态(青绿)。两层指标区使用不同的背景色(#EDE7F6 和 #F9F9FB)建立视觉分段,模拟票据上的信息区块划分。
"本场评价"指标的颜色通过 getRecordColor(this.selRecord!.status) 动态获取——MVP 记录显示金色,完本显示青绿,跳车显示灰色。这种"数据驱动色彩"让同一个弹窗根据记录类型呈现不同的视觉氛围,MVP 记录的金色评价字带来荣誉感,跳车记录的灰色则带有一丝遗憾。
"查看完整战报"按钮点击后显示"战报已发送到邮箱"的 Toast,模拟了战报邮件发送的功能闭环。这种"操作 → 即时反馈 → 后续处理"的交互链路,虽然在原型中只是 Toast 提示,但完整地展示了功能的设计意图。
设计洞察:十四个弹窗虽然各有差异,但共享一套设计语言——深紫渐变头部、白色信息身体、指标三等分布局、胶囊形操作按钮、200ms 淡入淡出过渡。这种"统一框架 + 局部变化"的设计策略,既保证了应用整体的视觉一致性,又通过头部渐变色、按钮颜色、指标内容的变化赋予了每个弹窗独特的业务个性。
八、主构建方法:Stack 层叠与条件渲染调度
8.1 页面骨架与底部导航
build() {
Stack() {
Column() {
Column() {
Text('🕵️ 迷雾剧本 · 员工剧本杀馆')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Row() {
Text('🎲')
.fontSize(12)
Text(' 今晚 3 车开本 · 快去组队')
.fontSize(9)
.fontColor('#C5CAE9')
.margin({ left: 2 })
}
.margin({ top: 3 })
}
.width('100%')
.padding({ top: 14, bottom: 12 })
.justifyContent(FlexAlign.Center)
.linearGradient({
angle: 90,
colorList: ['#1A1038', '#4527A0']
})
this.contentArea()
Column() {
Row() {
ForEach(this.tabs1, (t: string, ti: number) => {
this.bottomTabItem(this.tabs1Icons[ti], t, ti)
}, (t: string) => t)
}
.width('100%')
Row() {
ForEach(this.tabs2, (t: string, ti: number) => {
this.bottomTabItem(this.tabs2Icons[ti], t, ti + 4)
}, (t: string) => t)
}
.width('100%')
}
.width('100%')
.backgroundColor('#FFFFFF')
.shadow({ radius: 8, color: '#1A000000', offsetY: -2 })
}
.width('100%')
.height('100%')
.backgroundColor('#F5F3FA')
主 build 方法是整个应用的渲染入口,以 Stack 作为根容器。Stack 内的第一层是一个全屏 Column,它构成了应用的页面骨架——顶部标题栏、中间内容区、底部导航栏三段式布局。这三段通过 Column 的垂直排列自然堆叠,无需额外的定位属性。
顶部标题栏是一个水平渐变的 Column,展示应用名称和一条动态提示"今晚 3 车开本"。justifyContent(FlexAlign.Center) 让文字在水平方向居中。这个标题栏是固定不变的——无论用户在哪个 Tab,顶部都显示应用名,提供了持续的品牌标识。
中间内容区通过 this.contentArea() 调用 Builder 方法渲染,如前所述,它根据 curTab 状态条件渲染对应的 Tab 组件。这是整个页面中唯一会随 Tab 切换而变化的区域,上下两端(标题栏和导航栏)保持固定。
底部导航栏是一个白色背景的 Column,内含两行 Row,每行通过 ForEach 渲染一组 Tab 项。shadow({ radius: 8, color: '#1A000000', offsetY: -2 }) 的 offsetY 为 -2,意味着阴影投射方向是向上(负 Y 方向),这让导航栏与上方内容之间产生微妙的分割感——阴影从导航栏顶部向上扩散,模拟了导航栏"浮在内容之上"的视觉效果。
ArkUI 知识点·Stack:
Stack是 ArkUI 的层叠布局容器,所有子元素默认在容器中居中对齐,后声明的子元素覆盖在先声明的子元素之上。Stack 适合实现"基础内容 + 浮层叠加"的场景,如本案例的"页面内容 + 遮罩 + 弹窗 + Toast"。Stack 的alignContent属性可以调整子元素的整体对齐方式,但本案例使用默认居中,配合弹窗自身的宽度百分比实现居中弹窗效果。
8.2 弹窗条件渲染调度
if (this.showScript && this.selScript !== null) {
this.modalOverlay(() => {
this.showScript = false;
})
this.scriptModal()
}
if (this.showVote && this.selScript !== null) {
this.modalOverlay(() => {
this.showVote = false;
})
this.voteModal()
}
if (this.showGroup && this.selGroup !== null) {
this.modalOverlay(() => {
this.showGroup = false;
})
this.groupModal()
}
if (this.showAddGroup) {
this.modalOverlay(() => {
this.showAddGroup = false;
})
this.addGroupModal()
}
if (this.showEditGroup && this.selGroup !== null) {
this.modalOverlay(() => {
this.showEditGroup = false;
})
this.editGroupModal()
}
if (this.showDelGroup && this.delGroup !== null) {
this.modalOverlay(() => {
this.showDelGroup = false;
})
this.delGroupModal()
}
这段代码是弹窗渲染的核心调度逻辑。每个弹窗的渲染由一个 if 条件控制,条件通常是"显示开关为 true 且选中数据不为 null"。例如剧本弹窗的渲染条件是 this.showScript && this.selScript !== null——只有当用户点击了某个剧本(设置了 selScript)并且主动打开了弹窗(showScript 为 true)时,才渲染弹窗。
每个弹窗的渲染包含两个 Builder 调用:先调用 this.modalOverlay(onClose) 渲染遮罩层,再调用具体的弹窗 Builder(如 this.scriptModal())。遮罩层在前、弹窗在后,由于 Stack 的层叠特性,后声明的弹窗会覆盖在遮罩之上。遮罩的 onClick 回调关闭弹窗——点击遮罩区域(弹窗外部)即可关闭弹窗,这是模态交互的标准行为。
十四个弹窗的渲染条件按声明顺序排列在 Stack 中。由于同一时刻通常只有一个弹窗显示,因此实际上只有一个条件为 true。但代码结构允许理论上多个弹窗同时显示——例如先显示剧本弹窗,用户在弹窗内点击"评分"后,剧本弹窗关闭(showScript=false)、评分弹窗打开(showVote=true),同一时刻只有一个弹窗存在。这种设计通过状态互斥确保了单一弹窗的显示。
8.3 Toast 提示的渲染
if (this.toast.length > 0) {
Column() {
Text('✅ ' + this.toast)
.fontSize(13)
.fontColor('#FFFFFF')
.padding({ left: 18, right: 18, top: 10, bottom: 10 })
.backgroundColor('#333333')
.borderRadius(18)
}
.width('100%')
.justifyContent(FlexAlign.Center)
.position({ x: 0, y: '72%' })
}
Toast 提示是整个 Stack 中的最后一层,覆盖在所有内容之上。渲染条件是 this.toast.length > 0——只要 toast 字符串非空就显示。Toast 是一个居中的深色圆角文本条,通过 position({ x: 0, y: '72%' }) 定位在屏幕 72% 高度的位置(偏下方),这是移动端 Toast 的常见位置——不遮挡顶部内容,又在拇指可达区域内。
Toast 的背景色 #333333 是深灰而非纯黑,文字白色,前缀"✅"暗示操作成功。这种"深灰底 + 白字 + 勾号"的样式是轻量提示信息的通用设计。Toast 没有关闭按钮,在真实应用中通常会配合定时器在几秒后自动清空 toast 状态使其消失。本案例中 toast 的清空需要用户进行下一次操作时触发(某些操作的回调中没有清空 toast,因此它会持续显示直到下一次状态变化)。
上图完整描绘了从用户操作到弹窗渲染再到反馈提示的全流程。状态变化是整个流程的驱动力——每一次 setState 都会触发 Stack 的重新渲染,条件判断决定渲染哪些弹窗,过渡动画让显隐更自然。这种"状态 → 渲染 → 动画"的链路是 ArkUI 声明式 UI 的核心运行机制。
九、核心特性对比与技术总结
| 技术维度 | 实现方式 | 作用与价值 | 适用场景 |
|---|---|---|---|
@Component |
装饰 struct 声明自定义组件 | 封装可复用的 UI 单元,拥有独立 build 方法 | 页面级组件、卡片组件、列表项组件 |
@Entry |
装饰入口组件 | 标记页面根组件,框架自动挂载 | 应用主入口、路由页面 |
@State |
装饰组件内部状态变量 | 值变化触发 UI 局部重新渲染 | 当前 Tab、弹窗开关、表单输入、选中数据 |
@Builder |
装饰 UI 描述方法 | 封装可复用 UI 片段,访问宿主 this | 弹窗模板、遮罩层、导航项、内容区 |
Stack |
层叠布局容器 | 子元素从底到顶叠加,后声明者在上 | 弹窗叠加、遮罩层、Toast 浮层 |
Column |
垂直线性布局 | 子元素从上到下排列 | 卡片内部纵向堆叠、页面整体结构 |
Row |
水平线性布局 | 子元素从左到右排列 | 按钮并排、信息行、标签条 |
ForEach |
列表渲染原语 | 根据数据数组渲染列表项,支持 key | 列表、标签组、单选选项组 |
Scroll |
滚动容器 | 包裹可滚动内容,支持横纵向 | 长列表页面、横向推荐卡片 |
FlexAlign |
主轴对齐枚举 | 控制子元素在主轴方向的排布 | 内容居中、两端对齐、等间距分布 |
linearGradient |
渐变背景 | 营造色彩层次和氛围感 | 标题栏、Banner 卡片、弹窗头部 |
transition |
过渡动画 | 组件显隐时播放淡入淡出等动画 | 弹窗、抽屉、模态交互 |
layoutWeight |
弹性空间分配 | 占据父容器主轴剩余空间 | 等分布局、弹性占位、两端对齐 |
clip |
裁切属性 | 防止子元素溢出圆角边界 | 圆角容器内嵌渐变背景 |
interface |
类型契约声明 | 定义数据形状,编译期类型约束,运行时零开销 | 业务实体建模、组件参数类型、回调签名 |
const 数组 |
静态数据源 | 固化模拟数据,驱动 UI 原型 | Mock 数据、配置参数、枚举值列表 |
| 纯函数 | 数据加工与样式映射 | 无副作用的数据切片和颜色转换 | 数据筛选、难度映射、状态着色 |
| 回调属性 | 父子通信桥梁 | 子组件上报事件意图,父组件决定响应 | 列表点击、按钮操作、表单提交 |
| 条件渲染 | if 判断控制组件显隐 | 根据状态动态决定渲染内容 | Tab 切换、弹窗显隐、Toast 显示 |
shadow |
阴影属性 | 营造卡片悬浮感和层级深度 | 卡片容器、标题栏、导航栏 |
borderRadius |
圆角属性 | 赋予元素柔和边缘,提升视觉品质 | 卡片、按钮、标签、弹窗 |
opacity |
透明度属性 | 控制元素视觉权重,弱化非焦点内容 | 非激活 Tab、禁用状态、背景层 |
安装DevEco Studio程序
.position({ x: 0, y: '72%' })
}
}
.width('100%')
.height('100%')
}
}
---

## 十、总结:
通过对这个迷雾剧本杀馆应用的逐段剖析,我们可以清晰地看到鸿蒙 ArkUI 声明式开发范式的完整面貌。整个应用以"数据层—组件层—入口层"三层架构组织代码,数据层用 interface 和 const 数组构建类型安全的模拟数据源,组件层用七个独立的 @Component 构建七大业务页面,入口层用一个 @Entry 主组件统管全局状态和弹窗调度。这种分层设计让职责边界清晰,数据流向单向可追踪,是 ArkUI 推荐的工程化结构。
从状态管理的角度看,本应用展示了 @State 的典型用法。超过三十个状态变量集中声明在入口组件上,分为四类:Tab 导航状态、弹窗开关状态(boolean)、选中数据状态(对象或 null)、表单临时状态。每个状态的变化都会触发 Stack 的重新渲染,通过条件判断决定哪些弹窗或页面内容应该显示。这种"状态即 UI"的模式让界面的当前状态完全由状态变量决定,无需命令式地调用 show/hide API,使得 UI 始终与数据保持同步,调试和预测都更加容易。
从交互设计的角度看,应用采用了"底部多 Tab + 居中弹窗/底部表单 + Toast 反馈"的经典三段式交互架构。七个 Tab 覆盖了浏览、组局、角色、活动、商城、个人中心全部业务场景;十四个弹窗通过 @Builder 方法模板化定义,分为详情展示型(深色头+白底身)、表单操作型(底部弹出)、确认型(居中双按钮)三大类;Toast 作为轻量反馈在操作完成后即时提示。这套交互体系完整地覆盖了从浏览到操作到反馈的用户旅程,每个环节都有对应的 ArkUI 技术实现支撑。
从视觉设计的角度看,应用建立了一套完整的色彩规范——以深紫 #1A1038 和品牌紫 #4527A0 为主色,金色 #FFD700 为强调色,搭配 Material Design 深色调色板作为数据标识色。七个 Tab 页面各有独特的标题渐变配色,十四个弹窗共享"深紫渐变头+白底身"的统一框架但在头部色调上各有变化。阴影、圆角、透明度三个属性被系统性地用于构建卡片的悬浮感、柔和感和层级深度。这种"统一框架+局部变化"的设计策略,既保证了整体一致性,又赋予了每个页面和弹窗独特的视觉个性。
从代码复用的角度看,@Builder 方法是本应用复用策略的核心。modalOverlay 作为通用遮罩被十四个弹窗共享;bottomTabItem 作为通用导航项被七个 Tab 共享;contentArea 作为页面调度器统一管理七个 Tab 的渲染切换。在交互模式层面,"状态驱动单选"的范式被评分弹窗、组局弹窗、编辑弹窗、充值弹窗反复使用,形成了高度一致的交互体验。这种在框架层面和模式层面的双重复用,大幅降低了代码量和维护成本。
从鸿蒙技术生态的角度看,本案例集中运用了 ArkUI 的核心能力:Column 和 Row 的线性布局、Stack 的层叠布局、Scroll 的滚动容器、ForEach 的列表渲染、linearGradient 的渐变背景、transition 的过渡动画、layoutWeight 的弹性分配、FlexAlign 的对齐控制、clip 的裁切约束。这些能力覆盖了声明式 UI 开发中布局、渲染、动画、交互的全部维度,构成了一个完整的技术闭环。掌握这些原语的语义和用法,是进行鸿蒙应用开发的基础功力。
更多推荐





所有评论(0)