鸿蒙ArkTS声明式UI实战:听力健康中心场景的深度解析
一、鸿蒙开发背景与ArkTS语言概览
鸿蒙操作系统(HarmonyOS)是华为面向万物互联时代打造的分布式操作系统,其应用开发框架ArkUI提供了一套全新的声明式UI开发范式。在鸿蒙的生态体系中,ArkTS作为主力开发语言,在TypeScript的基础上进行了深度定制与扩展,既保留了TypeScript的静态类型检查优势,又融入了鸿蒙特有的装饰器系统和状态管理机制。ArkTS并非简单的TypeScript超集,它通过@Entry、@Component、@State、@Builder、@Prop、@Link、@Provide、@Consume等一系列装饰器,构建出了一套面向声明式UI的编程模型,让开发者能够以数据驱动的方式描述界面结构,从而大幅提升开发效率和代码可维护性。
声明式UI范式的核心思想是"状态驱动视图"。在传统的命令式UI开发中,开发者需要手动获取控件引用、手动更新控件属性,代码中充斥着大量的findViewById和setText调用。而在ArkTS的声明式范式下,开发者只需描述界面"应该是什么样子",框架会自动跟踪状态变量的变化,并在状态发生改变时智能地重新渲染受影响的部分。这种数据与视图的自动绑定机制,使得复杂的交互逻辑变得清晰可控,尤其在构建多Tab、多弹框、多列表的复杂业务页面时,优势更为明显。本案例中的听力健康中心页面,正是一个集成了6个底部Tab、6个弹框、多种列表渲染和动画效果的典型复杂场景,非常适合用来深入剖析ArkTS的各种高级特性。
ArkUI组件体系是鸿蒙声明式UI的基石。它提供了丰富的基础容器组件(如Column、Row、Stack、Flex、Scroll)、基础显示组件(如Text、Image、Progress、Toggle、TextInput)、以及强大的交互能力(如onClick、onChange事件回调)。Column是垂直线性布局容器,子元素按从上到下顺序排列;Row是水平线性布局容器,子元素按从左到右顺序排列;Stack是层叠布局容器,子元素可以堆叠在一起,通过zIndex控制层级;Flex是弹性布局容器,支持换行等高级特性;Scroll是滚动容器,当内容超出可视区域时提供滚动能力。这些容器组件通过链式调用的方式配置属性,形成了一种高度可读的DSL(领域特定语言)风格,让UI代码既像代码又像设计稿,极大地降低了UI开发的认知负担。
ArkTS的状态管理体系是理解整个声明式UI运作机制的关键。@State是最基础的状态装饰器,被它修饰的变量一旦发生变化,框架会自动触发该组件的重新渲染。当数据结构较为复杂时,ArkTS还提供了@Prop(单向同步)、@Link(双向同步)、@Provide/@Consume(跨层级传递)、@Observed/@ObjectLink(嵌套对象观察)等更高级的状态管理装饰器。在本案例中,页面通过大量的@State变量管理着当前选中的Tab索引、各类业务数据数组、多个弹框的显示开关、各种表单的选中状态以及动画驱动的数值,这些状态共同构成了整个页面的数据模型,驱动着界面的每一次更新。掌握这些状态变量的设计方法,是构建高质量鸿蒙应用的核心技能。
二、数据结构设计:interface接口的定义与应用
2.1 听力频率数据结构
interface FreqT11 {
id: number
hz: string
left: number
right: number
}

这段代码定义了一个名为FreqT11的接口,用于描述纯音测听中每个频率点的听力数据。在ArkTS中,interface是定义数据结构的主要方式,它与TypeScript中的interface语法基本一致,但在鸿蒙的编译器约束下更加严格——所有属性必须显式声明类型,且不支持可选属性在运行时为undefined的情况(除非显式标注)。这里定义了四个属性:id是唯一标识符,hz是频率值(用字符串表示,因为包含’1k’、'2k’这样的缩写),left和right分别代表左耳和右耳在该频率下的听阈值(单位dB HL)。
技术要点:interface与状态管理的关系。 在ArkTS中,interface定义的数据结构本身不参与响应式追踪,但当interface类型的数组被@State修饰时,整个数组的引用变化会触发UI更新。需要注意的是,直接修改数组内某对象的属性不会触发更新,必须通过生成新数组的方式(如map、filter、concat)来实现响应式刷新。这是鸿蒙声明式UI与传统命令式UI在数据处理思维上的重大差异。
听力学中,纯音测听是评估听力损失程度的金标准。测听频率通常覆盖250Hz到8000Hz,这个范围涵盖了人类语音的主要频率成分。正常的听阈值应低于25dB HL,26-40dB为轻度听力损失,41-60dB为中度损失。本数据结构设计巧妙地用left和right两个字段分别记录双耳数据,使得在UI层可以通过双柱状图直观对比双耳听力差异,这种数据结构与可视化呈现的紧密配合,是医疗健康类应用设计的核心原则。
2.2 助听器商品数据结构
interface AidT11 {
id: number
brand: string
model: string
type: string
price: number
score: number
feature: string
stock: boolean
}

AidT11接口定义了助听器商品的数据模型。这里值得关注的几个字段设计:type字段用字符串描述助听器的外形类型(如"耳背式RIC"、“深耳道CIC”、"耳内式ITC"等),这些是听力学和助听器行业的专业术语;score字段是评分,使用number类型保留小数位,在UI层通过toFixed(1)格式化显示;stock字段使用boolean类型表示现货状态,这在电商类场景中非常常见,UI层会根据该值显示"现货"或"预订2周"的不同提示。
技术要点:boolean类型在条件渲染中的价值。 stock字段使用boolean而非字符串,体现了数据建模的严谨性。在ArkTS的条件表达式中,boolean值可以直接用于三元运算符判断,如
a.stock ? '现货' : '预订 2 周',代码简洁且不易出错。如果用字符串"有"/"无"则容易因大小写、空格等问题导致判断失败,这是初学者常犯的错误。
2.3 训练课程与噪声场景数据结构
interface CourseT11 {
id: number
name: string
mins: number
days: number
done: number
icon: string
}
interface NoiseT11 {
id: number
scene: string
db: number
mins: number
safe: boolean
icon: string
}

CourseT11描述的是听觉康复训练课程,其中mins表示每日训练时长(分钟),days表示课程总周期天数,done表示已完成的进度。这三个数值字段配合,可以在UI层计算出完成百分比(done/days*100),并用进度条可视化展示。icon字段使用emoji字符串,这是一种轻量级的图标方案,避免了引入图片资源包的复杂性,同时保持了良好的视觉表现力。
NoiseT11描述的是噪声暴露场景记录。db字段记录分贝值,这是噪声评估的核心指标;safe字段是一个计算属性,表示该次暴露是否在安全上限内。在听力学中,85dB以上的持续暴露超过8小时即可能造成不可逆的感音神经性听力损失,因此safe字段的判定逻辑通常围绕这个阈值展开。mins字段记录暴露时长,与db值共同决定了风险等级。
技术要点:emoji作为图标资源的替代方案。 在鸿蒙ArkUI中,Text组件可以直接渲染emoji字符,这是一种零成本、跨平台的图标方案。相较于Image组件需要引入png/svg资源文件,emoji方案省去了资源管理和路径处理的负担,特别适合快速原型开发和概念验证。但需要注意,不同设备对emoji的渲染样式可能有细微差异,正式生产环境中建议替换为统一的图标字体或矢量资源。
2.4 复查记录与订单数据结构
interface RevT11 {
id: number
date: string
place: string
result: string
avg: number
next: string
}
interface OrdT11 {
id: number
name: string
state: string
date: string
price: number
}

RevT11是复查记录的数据结构,date是检查日期,place是检查地点,result是检查结论描述,avg是平均听阈值,next是下次建议复查时间。这些字段共同构成了一条完整的复查历史记录,在UI层将以时间轴的形式呈现,让用户能够清晰地追踪听力变化趋势。
OrdT11是订单数据结构,state字段使用字符串描述订单状态(如"已完成"、“调试中”、“已发货”、"进行中"等)。这里有一个设计上的取舍:state本可以定义为枚举(enum)或联合字面量类型以获得更好的类型安全,但使用string提供了更大的灵活性,允许后端返回任意状态文本。在UI层,通过ordColor11工具函数将状态字符串映射为颜色值,实现了状态的可视化区分。
技术要点:数据结构的字段命名规范。 本案例采用了简洁的短字段名(如mins、db、avg),这在数据量大的场景下能减少代码体积。但在团队协作的大型项目中,建议采用更具语义性的完整命名(如minutes、decibel、averageThreshold),以提升代码可读性。ArkTS的编译器会在构建时进行tree-shaking优化,长字段名不会影响最终包体积。
三、静态数据初始化与数组操作
3.1 听力频率数据初始化
const FREQS11: Array<FreqT11> = [
{ id: 1, hz: '250', left: 15, right: 15 },
{ id: 2, hz: '500', left: 15, right: 20 },
{ id: 3, hz: '1k', left: 20, right: 25 },
{ id: 4, hz: '2k', left: 30, right: 30 },
{ id: 5, hz: '3k', left: 40, right: 40 },
{ id: 6, hz: '4k', left: 45, right: 50 },
{ id: 7, hz: '6k', left: 40, right: 45 },
{ id: 8, hz: '8k', left: 35, right: 40 }
]

这段代码使用const关键字声明了一个名为FREQS11的常量数组,类型标注为Array<FreqT11>,表示这是一个元素类型为FreqT11的数组。数组中包含8个频率点的双耳听阈数据,从250Hz到8000Hz,覆盖了纯音测听的标准频率范围。可以看到,4kHz处左耳45dB、右耳50dB,属于轻度到中度听力损失的临界区域,这符合噪声性听力损失的典型特征——通常首先在4kHz附近出现"V型"凹陷。
技术要点:const与@State的协作模式。 代码中使用const声明了静态原始数据(FREQS11等),然后在@State变量中通过slice()方法创建副本(
freqs11: Array<FreqT11> = FREQS11.slice())。这种"原始数据不可变 + 状态数据可变副本"的设计模式非常重要:const数组作为数据源保持稳定,@State数组作为可变状态参与UI响应式更新。slice()方法创建的是浅拷贝,对于本案例的对象数组来说足够使用,因为UI更新时总是替换整个对象而非修改对象内部属性。
观察这组数据可以发现,低频段(250Hz-500Hz)双耳听阈均在15-20dB,属于正常范围;中频段(1kHz-2kHz)开始轻微上升;高频段(3kHz-8kHz)明显升高,4kHz达到峰值45-50dB。这种"高频下降"的曲线模式,是噪声性听力损伤的标志性特征。在UI渲染时,每个频率点会渲染为一对柱子(左耳蓝色、右耳黄色),柱子的高度与听阈值成正比,直观地呈现了听力损失的程度和频率分布。
3.2 助听器商品数据
const AIDS11: Array<AidT11> = [
{ id: 1, brand: '峰力', model: 'Lumity L90', type: '耳背式 RIC', price: 32800, score: 4.9, feature: '双耳直连 iPhone · 噪声下言语识别提升 30%', stock: true },
{ id: 2, brand: '奥迪康', model: 'Intent 1', type: '受话器外置', price: 29800, score: 4.8, feature: '4D 传感器场景自动切换', stock: true },
{ id: 3, brand: '瑞声达', model: '浩听 96', type: '耳背式 BTE', price: 26800, score: 4.8, feature: 'AI 言语聚焦 · 风噪抑制', stock: true },
{ id: 4, brand: '西嘉', model: 'Pure 7IX', type: '深耳道 CIC', price: 35800, score: 4.9, feature: '隐形佩戴 · 原声处理芯片', stock: false },
{ id: 5, brand: '斯达克', model: 'Evolve AI 20', type: '耳内式 ITC', price: 21800, score: 4.7, feature: '中文语境优化', stock: true },
{ id: 6, brand: '唯听', model: 'Moment 440', type: '受话器外置', price: 24800, score: 4.8, feature: '双压缩通道零延迟', stock: true },
{ id: 7, brand: '优利康', model: '蓝鲸 900', type: '耳背式', price: 16800, score: 4.6, feature: '高性价比入门首选', stock: true },
{ id: 8, brand: '新声', model: '灵犀 Pro', type: '耳内式', price: 9800, score: 4.5, feature: '国产旗舰 · 自研芯片', stock: true },
{ id: 9, brand: '爱沃福', model: '微风 8C', type: '深耳道', price: 7800, score: 4.4, feature: '轻中度听损适用', stock: false },
{ id: 10, brand: '耳魔王', model: 'AV-300', type: '耳背式', price: 4800, score: 4.2, feature: '老人简易操作款', stock: true }
]

这组助听器数据涵盖了从4800元到35800元的不同价位段,包含了峰力、奥迪康、瑞声达、西嘉、斯达克、唯听等国际一线品牌,以及优利康、新声、爱沃福、耳魔王等高性价比品牌。每条记录的feature字段用简短的一句话概括了该产品的核心卖点,这些卖点反映了当前助听器行业的技术趋势:蓝牙直连手机、AI场景识别、风噪抑制、零延迟处理、中文语境优化等。
技术要点:Array泛型类型的显式标注。 在ArkTS中,数组类型的声明推荐使用
Array<T>泛型形式而非T[]简写形式。虽然两者在语义上等价,但Array<T>形式更加显式,在阅读复杂类型(如Array<Array<FreqT11>>)时更清晰。此外,ArkTS编译器对类型推断有严格限制,不像TypeScript那样支持隐式any,因此所有变量都需要显式类型标注。
3.3 训练课程与噪声场景数据
const COURSES11: Array<CourseT11> = [
{ id: 1, name: '听觉再适应 · 声音辨识', mins: 20, days: 21, done: 12, icon: '👂' },
{ id: 2, name: '嘈杂环境言语训练', mins: 15, days: 14, done: 14, icon: '🎙️' },
{ id: 3, name: '音乐频率感知修复', mins: 25, days: 28, done: 8, icon: '🎵' },
{ id: 4, name: '耳鸣声治疗(白噪声)', mins: 40, days: 30, done: 22, icon: '🌊' },
{ id: 5, name: '方向听觉定位训练', mins: 12, days: 10, done: 10, icon: '🧭' },
{ id: 6, name: '言语速度适应(快语速)', mins: 18, days: 21, done: 5, icon: '⚡' },
{ id: 7, name: '电话语音强化训练', mins: 10, days: 14, done: 9, icon: '📞' },
{ id: 8, name: '安静环境语句复述', mins: 15, days: 12, done: 12, icon: '📝' }
]
训练课程数据体现了听觉康复的科学体系。从"听觉再适应"到"嘈杂环境言语训练",从"音乐频率感知修复"到"耳鸣声治疗",再到"方向听觉定位训练"和"言语速度适应",这些课程覆盖了听力康复的多个维度。每门课程的done字段记录了已完成天数,UI层通过done/days的比值计算完成率,并用不同颜色(蓝色表示进行中、绿色表示已完成)的进度条直观展示。
const NOISES11: Array<NoiseT11> = [
{ id: 1, scene: '地铁通勤(车厢内)', db: 88, mins: 45, safe: false, icon: '🚇' },
{ id: 2, scene: '办公室工位', db: 55, mins: 480, safe: true, icon: '🏢' },
{ id: 3, scene: '健身房团课', db: 92, mins: 60, safe: false, icon: '🏋️' },
{ id: 4, scene: '演唱会内场', db: 105, mins: 150, safe: false, icon: '🎤' },
{ id: 5, scene: '咖啡馆闲聊', db: 62, mins: 90, safe: true, icon: '☕' },
{ id: 6, scene: '马路骑行', db: 78, mins: 35, safe: true, icon: '🚴' },
{ id: 7, scene: '电钻装修(邻居家)', db: 96, mins: 120, safe: false, icon: '🛠️' },
{ id: 8, scene: '影院 IMAX 厅', db: 85, mins: 130, safe: false, icon: '🎬' },
{ id: 9, scene: 'KTV 包厢', db: 98, mins: 120, safe: false, icon: '🎤' },
{ id: 10, scene: '居家夜晚', db: 38, mins: 600, safe: true, icon: '🏠' }
]

噪声场景数据展示了日常生活中常见的噪声暴露源。从数据中可以看到,演唱会内场高达105dB,KTV包厢98dB,健身房团课92dB,这些场景都远超85dB的安全阈值,被标记为safe: false。而办公室工位55dB、居家夜晚38dB虽然时长很长,但因为分贝值低,仍被标记为安全。这组数据为UI层的分贝标尺柱状图提供了可视化素材。
技术要点:safe字段的预计算策略。 噪声数据的safe字段在数据初始化时就已计算好,而非在UI渲染时动态判断。这种"预计算"策略减少了UI层的逻辑复杂度,但也带来了数据一致性的风险——如果后续修改了db值或mins值,safe字段不会自动更新。在更严谨的设计中,safe应该是一个getter计算属性,但在ArkTS的interface中不支持getter,因此需要通过工具函数在运行时计算。本案例中dbColor11和噪声记录弹框中的
n.db < 85 || this.nProtect11判断,正是对这种预计算策略的补充。
3.4 复查记录与订单数据
const REVS11: Array<RevT11> = [
{ id: 1, date: '2026-08-20', place: '爱康听力学中心·望京', result: '高频轻度下降 4kHz/45dB', avg: 28, next: '2027-02-20' },
{ id: 2, date: '2026-02-21', place: '爱康听力学中心·望京', result: '双耳高频临界 4kHz/40dB', avg: 26, next: '2026-08-20' },
{ id: 3, date: '2025-08-18', place: '爱康国宾·国贸分院', result: '正常范围,注意防护', avg: 22, next: '2026-02-21' },
{ id: 4, date: '2025-02-15', place: '爱康国宾·国贸分院', result: '正常范围', avg: 20, next: '2025-08-18' },
{ id: 5, date: '2024-09-02', place: '社区听力筛查车', result: '正常范围', avg: 18, next: '2025-02-15' }
]
复查记录按时间倒序排列(最新在前),记录了从2024年9月到2026年8月的5次听力检查。可以清晰地看到听力变化的趋势:avg从18dB逐步上升到28dB,从"正常范围"演变到"高频轻度下降"。这种时间轴式的数据展示,让用户能够直观感知到听力健康的变化趋势,体现了健康档案类应用的核心价值——“记录历史,发现趋势,预警风险”。
const ORDS11: Array<OrdT11> = [
{ id: 1, name: '纯音测听 + 声导抗复查', state: '已完成', date: '2026-08-20', price: 120 },
{ id: 2, name: '峰力 Lumity L90 双耳验配', state: '调试中', date: '2026-08-25', price: 32800 },
{ id: 3, name: '定制耳模(软耳膜)', state: '已完成', date: '2026-08-23', price: 680 },
{ id: 4, name: '助听器干燥盒', state: '已发货', date: '2026-08-22', price: 89 },
{ id: 5, name: '听觉训练季卡', state: '进行中', date: '2026-07-01', price: 398 },
{ id: 6, name: '耳鸣声治疗评估', state: '已完成', date: '2026-06-12', price: 260 },
{ id: 7, name: '音乐节定制耳塞', state: '已完成', date: '2026-05-20', price: 1280 },
{ id: 8, name: '听力健康年卡', state: '生效中', date: '2026-01-01', price: 680 }
]

订单数据涵盖了服务类(测听、验配、训练)和商品类(耳模、干燥盒、耳塞)两大类型,state字段的值非常多样:已完成、调试中、已发货、进行中、生效中。这种多状态的订单管理,对UI层提出了"状态颜色映射"的需求,后面的ordColor11工具函数正是为此而生。
技术要点:日期字符串的存储格式。 本案例中所有日期均采用’YYYY-MM-DD’的ISO 8601标准格式存储为字符串。这种格式的优势在于字符串排序即等价于时间排序,无需解析为Date对象即可比较大小。但在需要计算日期差、显示相对时间(如"3天前")时,仍需通过new Date(dateStr)解析。在ArkTS中,Date对象的使用与标准JavaScript一致,但需注意时区处理。
3.5 常量选项数组
const SLOT11: Array<string> = ['09:00', '10:15', '11:30', '13:45', '15:00', '16:15']
const EARS11: Array<string> = ['双耳', '左耳', '右耳']
const BUDGET11: Array<string> = ['1万内', '1-2万', '2-3万', '3万以上']
这三个常量数组分别定义了预约时段选项、耳别选项和预算区间选项。它们将被弹框中的ForEach渲染为可点击的标签按钮。这种将选项数据与UI渲染分离的设计,使得选项内容的维护(增删改)只需修改常量数组,而无需触碰UI代码,实现了数据与视图的解耦。
四、工具函数:颜色映射与数值转换
4.1 分贝颜色映射函数
function dbColor11(db: number): string {
if (db >= 95) {
return '#F87171'
}
if (db >= 80) {
return '#F59E0B'
}
return '#34D399'
}
dbColor11是一个顶层函数(不属于任何类或结构体),接收一个分贝数值,返回对应的颜色十六进制字符串。函数采用分段映射策略:95dB及以上返回红色(#F87171,表示危险),80-94dB返回橙色(#F59E0B,表示警告),80dB以下返回绿色(#34D399,表示安全)。这种"分贝-颜色"映射在UI层多处使用:噪声柱状图的柱体颜色、场景列表中的分贝数值颜色等,确保了整个应用中分贝值颜色语义的一致性。
技术要点:顶层函数与组件方法的区别。 在ArkTS中,使用function关键字声明的顶层函数是纯函数,不依赖组件状态,可以在任何地方调用。而使用@Builder修饰的方法虽然也能复用UI结构,但它绑定在特定组件实例上,可以访问this.state。工具类函数(如颜色映射、数值转换)推荐定义为顶层函数,以保证其纯粹性和可测试性。
4.2 分贝转高度函数
function dbToH11(db: number): number {
return Math.max((db - 60) * 1.6, 6)
}
dbToH11函数将分贝值转换为柱状图的高度值(像素)。公式(db - 60) * 1.6的含义是:以60dB为基准(0高度),每增加1dB高度增加1.6像素。Math.max(…, 6)确保最小高度为6像素,避免低分贝场景下柱子完全不可见。例如,88dB对应高度(88-60)*1.6=44.8像素,105dB对应高度(105-60)*1.6=72像素,38dB对应高度6像素(取最小值)。这种线性映射简化了可视化逻辑,让柱子高度直观反映分贝大小。
技术要点:Math对象在ArkTS中的可用性。 ArkTS保留了标准JavaScript的Math对象,包括Math.max、Math.min、Math.round、Math.floor、Math.ceil等常用方法。但需注意,ArkTS对动态特性有限制,Math对象的扩展或重写不被允许。在进行浮点数运算时,建议使用toFixed进行格式化以避免精度问题。
4.3 订单状态颜色映射函数
function ordColor11(s: string): string {
if (s === '调试中' || s === '进行中') {
return '#38BDF8'
}
if (s === '已发货') {
return '#F59E0B'
}
return '#34D399'
}
ordColor11函数将订单状态字符串映射为颜色。调试中和进行中返回蓝色(#38BDF8),表示进行中状态;已发货返回橙色(#F59E0B),表示待收货状态;其他状态(已完成、生效中、待到店、待回电等)返回绿色(#34D399),表示已完成或稳定状态。这种基于字符串相等性判断的映射方式简单直接,但如果状态种类增多,建议改为基于Map或对象的查找表,以获得更好的可维护性。
技术要点:字符串相等性比较。 ArkTS中使用=进行严格相等比较,既比较值也比较类型。对于字符串,=比较的是字符序列是否完全一致。需要注意的是,ArkTS不支持的隐式类型转换,所有相等比较都应使用=。在处理包含中文的字符串时,===能正确处理Unicode字符序列。
五、页面入口与状态变量设计
5.1 @Entry与@Component装饰器
@Entry
@Component
struct Index {
这三行代码是鸿蒙ArkTS页面的标准入口结构。@Entry装饰器标记该组件为页面入口组件,意味着它会被注册到路由系统中,可以通过页面路由直接访问。@Component装饰器声明这是一个自定义组件,编译器会为其生成组件工厂代码。struct关键字定义了一个结构体(在ArkTS中,struct替代了class作为组件的载体),Index是组件名称。
技术要点:@Entry的唯一性。 在一个页面文件中,@Entry修饰的组件有且只能有一个,它是整个页面的根组件。@Component修饰的组件可以有多个(通过子组件方式引入),但@Entry是页面级别的标记。如果不添加@Entry,该struct虽然能被编译,但无法作为独立页面被路由系统识别。
5.2 核心业务状态变量
@State curTab11: number = 0
@State freqs11: Array<FreqT11> = FREQS11.slice()
@State courses11: Array<CourseT11> = COURSES11.slice()
@State noises11: Array<NoiseT11> = NOISES11.slice()
@State revs11: Array<RevT11> = REVS11.slice()
@State orders11: Array<OrdT11> = ORDS11.slice()
这组@State变量构成了页面的核心数据状态。curTab11初始值为0,表示默认显示第一个Tab(听力图)。其余5个变量都是业务数据数组的可变副本,通过slice()方法从对应的const常量数组中复制而来。这些@State变量的任何重新赋值都会触发UI的重新渲染。
技术要点:@State的响应式触发条件。 @State变量的响应式更新并非"深度监听",而是"引用监听"。对于数组类型,只有当整个数组引用被替换时(如
this.freqs11 = newArray),UI才会更新。直接通过this.freqs11[0].left = 20修改数组元素属性不会触发更新。这就是为什么后面的代码中大量使用map、filter、concat等返回新数组的方法来更新状态。
5.3 弹框开关状态
// 弹框开关
@State showTest11: boolean = false
@State showReport11: boolean = false
@State showNoise11: boolean = false
@State showRev11: boolean = false
@State showCourseDel11: boolean = false
@State showFit11: boolean = false
六个boolean类型的@State变量分别控制六个弹框的显示与隐藏。初始值均为false,表示页面加载时所有弹框都是隐藏状态。当用户点击触发按钮时,将对应的变量设为true,弹框便通过条件渲染显示出来。这种"布尔开关 + 条件渲染"是ArkUI中实现弹框/抽屉/模态层的标准模式。
技术要点:boolean状态与条件渲染的配合。 在build方法中,通过
if (this.showTest11) { this.testOverlay11() }的方式控制弹框渲染。当showTest11从false变为true时,框架会执行testOverlay11构建器并挂载到组件树;当从true变为false时,框架会卸载该组件。这种基于条件的挂载/卸载比通过visibility属性控制显示隐藏更高效,因为不可见的组件不占用渲染资源。
5.4 表单状态变量
// 测听预约
@State tSlot11: number = 1
@State tEar11: number = 0
// 噪声记录
@State nScene11: number = 0
@State nMins11: number = 60
@State nProtect11: boolean = true
// 复查计划
@State revMonths11: number = 6
// 课程删除
@State courseId11: number = 0
@State armed11: boolean = false
// 验配咨询
@State fModel11: number = 0
@State fEar11: number = 0
@State fBudget11: number = 2
这些@State变量分别服务于各个弹框中的表单交互。tSlot11和tEar11是预约测听弹框中的选中索引(时段和耳别);nScene11、nMins11、nProtect11是噪声记录弹框中的场景索引、时长和防护开关;revMonths11是复查周期选择;courseId11和armed11是课程删除弹框的目标ID和二次确认开关;fModel11、fEar11、fBudget11是验配咨询弹框中的型号、耳别和预算选中索引。
这种将表单状态集中管理在页面组件中的设计,在小型应用中是合理的。但如果弹框数量继续增加,建议将每个弹框拆分为独立的子组件,通过@Prop/@Link或@Provide/@Consume传递状态,以避免状态变量过多导致的可维护性下降。
技术要点:索引型状态变量的设计。 tSlot11、tEar11等变量使用number类型存储选中项的索引(而非选中项的值),这是一种高效的设计。索引比较(
this.tSlot11 === i)比字符串比较更快,且索引可以直接用于从数组中取值(SLOT11[this.tSlot11])。但需要注意索引越界问题——当选项数组动态变化时,索引可能失效。
5.5 动画驱动状态
// 特效
@State waveX11: number = -20
@State earOp11: number = 0.3
waveX11驱动头部声波装饰条的左右移动动画,初始值-20表示向左偏移20像素。earOp11驱动头部耳机图标的透明度脉动动画,初始值0.3表示30%不透明度。这两个变量将在aboutToAppear生命周期中被animateTo动画API持续修改,从而产生循环往复的视觉效果。
技术要点:动画驱动状态与UI绑定。 动画驱动的@State变量与普通状态变量在机制上完全相同,区别仅在于变化来源不同。普通状态变量由用户交互(点击、输入)触发变化,而动画状态变量由animateTo的回调函数在每一帧自动修改。框架在每一帧检测到@State值变化后,会自动重新渲染依赖该状态的UI部分,形成流畅的动画效果。
六、生命周期与动画初始化
6.1 aboutToAppear生命周期
aboutToAppear(): void {
this.getUIContext().animateTo({ duration: 2400, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut }, () => {
this.waveX11 = 20
})
this.getUIContext().animateTo({ duration: 1300, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut }, () => {
this.earOp11 = 0.9
})
}
aboutToAppear是ArkUI组件的生命周期回调函数,在组件创建后、build方法执行前被调用。它通常用于初始化数据、启动动画、发起网络请求等准备工作。本案例中,aboutToAppear启动了两个无限循环动画。
技术要点:aboutToAppear的执行时机。 aboutToAppear在组件实例创建之后、首次build之前执行。此时组件的@State变量已完成初始化,但UI尚未渲染。因此,在aboutToAppear中修改@State变量不会触发额外的重新渲染(因为首次渲染尚未发生),但启动的动画会在build完成后立即生效。需要注意的是,aboutToAppear只执行一次,组件的后续更新不会再次触发该回调。
6.2 animateTo动画API详解
animateTo是ArkUI提供的显式动画API,它接收两个参数:动画选项对象和闭包函数。动画选项对象包含以下关键字段:
- duration:动画持续时间,单位毫秒。本案例中两个动画分别是2400ms和1300ms。
- iterations:迭代次数,-1表示无限循环。这是实现循环动画的关键参数。
- playMode:播放模式。PlayMode.Alternate表示交替播放,即正向播完后反向播放,形成"来回"的往复效果。其他模式包括PlayMode.Normal(正向一次)、PlayMode.Reverse(反向一次)、PlayMode.Alternate(正反交替)。
- curve:动画曲线。Curve.EaseInOut表示先加速后减速的缓动曲线,让动画看起来更自然。
闭包函数内定义了动画的终止状态。对于waveX11动画,闭包内将waveX11设为20,框架会自动在初始值-20和目标值20之间进行插值,配合PlayMode.Alternate模式,实现声波条在-20到20之间的往复移动。earOp11同理,在0.3到0.9之间往复变化,形成透明度的脉动效果。
技术要点:getUIContext()的必要性。 在较新的ArkUI API版本中,animateTo需要通过getUIContext()获取UI上下文后调用,而非直接调用全局animateTo函数。这种设计将动画与具体的UI上下文绑定,避免了多实例场景下的上下文混淆。getUIContext()返回当前组件所在的UIContext实例,它还提供了getAnimator、getFont、getPromptAction等丰富的UI能力。
这个流程图展示了aboutToAppear生命周期中两个动画的启动过程和最终效果。可以看到,动画在aboutToAppear中声明后,会在build完成后自动生效,持续驱动UI的视觉变化,为页面增添了动态生命力。
七、头部构建器:声波动画与搜索栏
7.1 @Builder装饰器与headerBar11
@Builder headerBar11() {
Column() {
Row() {
Column() {
Text('👂 HEARING LAB').fontSize(17).fontWeight(700).fontColor('#7DD3FC').letterSpacing(1)
Text('纯音测听 · 助听验配 · 听觉训练').fontSize(10).fontColor('#64809C').margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('🎧').fontSize(22).opacity(this.earOp11)
}
.width('100%')
.padding({ left: 16, right: 16, top: 14, bottom: 8 })
@Builder是ArkTS中用于定义UI构建片段的装饰器。被@Builder修饰的方法返回一段UI描述,可以在build方法或其他@Builder方法中通过this.methodName()的方式调用。它的作用类似于其他框架中的"局部组件"或"渲染函数",用于将复杂的UI拆分为可复用的模块。
headerBar11构建了页面的顶部头部区域。整体结构是一个Column容器(垂直布局),内部包含三行内容:第一行是标题行(Row容器),包含应用名称"HEARING LAB"和副标题"纯音测听·助听验配·听觉训练",以及右侧的耳机图标。
技术要点:@Builder与@Component子组件的区别。 @Builder定义的是当前组件内部的UI片段,它共享当前组件的this和状态,不需要参数传递就能访问@State变量。而@Component子组件是独立的组件实例,需要通过@Prop/@Link显式传递数据。@Builder更轻量,适合页面内的UI拆分;@Component更独立,适合跨页面复用。本案例中所有Tab和弹框都用@Builder实现,因为它们紧密依赖页面的@State状态,无需独立复用。
标题行使用Row水平布局,左侧的Column(layoutWeight(1))占据剩余空间,右侧的耳机图标固定大小。耳机图标的opacity绑定到this.earOp11,这是aboutToAppear中启动的透明度脉动动画的目标变量,因此耳机图标会呈现0.3到0.9之间往复变化的透明度效果,形成"呼吸"般的视觉脉动。
技术要点:layoutWeight权重分配。 layoutWeight是ArkUI中Flex/Row/Column容器的权重分配属性。当容器内有多个子元素时,layoutWeight值为正数的子元素会按比例分配容器的剩余空间(总空间减去固定尺寸子元素占用的空间)。本例中左侧Column设置了layoutWeight(1),右侧Text没有设置layoutWeight(默认为0,不参与剩余空间分配),因此左侧Column占据100%的剩余宽度,右侧Text保持内容自适应宽度。
7.2 声波装饰条与ForEach渲染
// 静态声波装饰条
Row() {
ForEach([6, 14, 22, 30, 22, 14, 6, 14, 22, 30, 22, 14, 6, 14, 22, 30, 22, 14], (h: number, i: number) => {
Column() {
}
.width(4)
.height(h)
.borderRadius(2)
.backgroundColor(i % 3 === 0 ? '#38BDF8' : '#1E5A8A')
.margin({ right: 6 })
.translate({ x: i % 2 === 0 ? this.waveX11 : -this.waveX11 })
}, (h: number, i: number) => 'wv' + i.toString() + '_' + h.toString())
}
.width('100%')
.justifyContent(FlexAlign.Center)
.padding({ top: 6, bottom: 10 })
声波装饰条是头部的一个视觉亮点。它通过ForEach渲染了18个不同高度的细长柱子,高度数组[6, 14, 22, 30, 22, 14, ...]呈现出"低-高-中-低"的波峰波谷形态,模拟声波的视觉形象。每个柱子宽4像素,高度从数组中取值,圆角2像素,柱子间右间距6像素。
颜色采用交替策略:i % 3 === 0时为亮蓝色(#38BDF8),其余为深蓝色(#1E5A8A),形成每隔3个柱子一个亮色的节奏感。translate属性绑定了this.waveX11,但通过i % 2 === 0 ? this.waveX11 : -this.waveX11实现了相邻柱子反向移动——偶数索引柱子向右移动,奇数索引柱子向左移动,配合animateTo的往复动画,营造出声波"扩散"的动态效果。
技术要点:ForEach的三个参数。 ForEach是ArkUI的列表渲染组件,接收三个参数:数据源数组、子项生成函数、键值生成函数。子项生成函数
(h: number, i: number) => { ... }接收元素值和索引两个参数。键值生成函数(h: number, i: number) => 'wv' + i.toString() + '_' + h.toString()为每个项生成唯一键,框架通过键值判断哪些项需要新增、删除或重排,从而实现高效的差异化更新。键值的唯一性非常重要,如果键值重复会导致渲染异常。
技术要点:translate位移变换。 translate是ArkUI的图形变换属性,用于在x/y方向平移组件。与position绝对定位不同,translate不影响布局流——组件在布局中仍然占据原始位置,只是视觉上发生了位移。这使得translate非常适合动画场景,因为位移不会触发布局重排,性能优于修改left/top属性。本案例中声波柱子的左右移动动画正是利用translate实现的。
7.3 搜索栏
Row() {
Text('🔎').fontSize(13)
Text('查助听器型号 / 听力术语 / 门店').fontSize(11).fontColor('#64809C').margin({ left: 6 }).layoutWeight(1).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Text('📊').fontSize(16).onClick(() => {
this.showReport11 = true
})
}
.width('100%')
.padding({ left: 12, right: 12, top: 9, bottom: 9 })
.borderRadius(20)
.backgroundColor('#0E2038')
.margin({ left: 16, right: 16, bottom: 12 })
}
.width('100%')
.backgroundColor('#081527')
.borderRadius({ bottomLeft: 20, bottomRight: 20 })
}
搜索栏是一个Row容器,包含搜索图标、占位文本和报告图标。占位文本设置了maxLines(1)和textOverflow({ overflow: TextOverflow.Ellipsis }),确保文本过长时只显示一行并以省略号结尾,避免布局被撑开。报告图标的onClick事件将showReport11设为true,触发完整报告弹框的显示。
技术要点:textOverflow文本溢出处理。 TextOverflow.Ellipsis是ArkUI提供的文本溢出处理策略,当文本超出可用宽度时以省略号"…"结尾。其他策略包括TextOverflow.Clip(直接截断)和TextOverflow.None(不处理)。配合maxLines限制行数,可以有效控制文本组件的尺寸,防止长文本破坏布局。需要注意的是,textOverflow只在maxLines或width约束生效时才有意义。
八、底部Tab栏:多Tab切换架构
8.1 tabItem11构建器
@Builder tabItem11(icon: string, label: string, idx: number) {
Column() {
Text(icon).fontSize(19)
Text(label).fontSize(9).fontColor(this.curTab11 === idx ? '#38BDF8' : '#5A7590').margin({ top: 3 })
}
.alignItems(HorizontalAlign.Center)
.padding({ top: 8, bottom: 6 })
.layoutWeight(1)
.onClick(() => {
this.curTab11 = idx
})
}
tabItem11是一个带参数的@Builder方法,接收三个参数:icon(图标emoji)、label(标签文字)、idx(Tab索引)。这种带参数的@Builder是ArkUI中实现UI复用的重要手段。每个Tab项是一个Column容器,垂直排列图标和文字,文字颜色根据this.curTab11 === idx判断——如果当前选中Tab与该项索引一致,文字为亮蓝色(#38BDF8),否则为灰蓝色(#5A7590),实现了选中态的视觉反馈。
技术要点:@Builder的参数传递。 @Builder方法可以接收参数,参数类型可以是基本类型(string、number、boolean)或对象类型。参数在方法内部作为局部变量使用,与@State变量的区别在于:参数是只读的(在方法内不应修改),而@State变量是响应式的。当@State变量变化时,引用该变量的@Builder会自动重新渲染。
每个Tab项设置了layoutWeight(1),使得6个Tab项在Row容器中均分宽度。onClick事件回调将curTab11设为该Tab的索引,触发状态更新后,所有Tab项的文字颜色会重新计算,选中的Tab变为高亮色,未选中的恢复灰色,同时build方法中的条件渲染逻辑会切换显示对应的Tab内容。
8.2 tabBar11构建器
@Builder tabBar11() {
Row() {
this.tabItem11('📈', '听力图', 0)
this.tabItem11('🎧', '助听器', 1)
this.tabItem11('🎓', '训练', 2)
this.tabItem11('🔊', '防护', 3)
this.tabItem11('🗓️', '复查', 4)
this.tabItem11('👤', '我的', 5)
}
.width('100%')
.backgroundColor('#0B1A2E')
.border({ width: 0.5, color: '#3338BDF8' })
}
tabBar11将6个tabItem11组织在一个Row容器中,形成了底部Tab栏。6个Tab分别是:听力图(📈)、助听器(🎧)、训练(🎓)、防护(🔊)、复查(🗓️)、我的(👤)。Tab栏设置了深色背景(#0B1A2E)和顶部边框(0.5像素、半透明蓝色#3338BDF8),与上方内容区域形成视觉分隔。
技术要点:颜色值的透明度表示。 ArkUI中的颜色值支持8位十六进制格式#RRGGBBAA,其中后两位AA表示Alpha通道(透明度)。例如#3338BDF8表示颜色#38BDF8的约20%透明度(0x33/0xFF ≈ 0.2)。这种格式在需要半透明效果时非常方便,无需使用rgba()函数。但需注意,部分旧版本ArkUI编译器可能不支持8位格式,建议确认目标API级别。
九、Tab0 听力图:双柱状图与Progress组件
9.1 听力概览卡片
@Builder tabAudi11() {
Column() {
Row() {
Stack() {
Progress({ value: 30, total: 100 })
.width(66)
.height(66)
.style({ strokeWidth: 7 })
.color('#38BDF8')
Column() {
Text('28dB').fontSize(13).fontWeight(700).fontColor('#7DD3FC')
Text('平均听阈').fontSize(8).fontColor('#64809C')
}
.alignItems(HorizontalAlign.Center)
}
Column() {
Text('2026-08-20 纯音测听').fontSize(13).fontWeight(700).fontColor('#E2EDF7')
Text('双耳高频轻度下降(4kHz 45dB),语频区正常,建议防护+半年复查。').fontSize(10).fontColor('#64809C').margin({ top: 5 }).lineHeight(14)
Row() {
Text('预约测听').fontSize(10).fontColor('#081527').fontWeight(700).padding({ left: 12, right: 12, top: 5, bottom: 5 }).borderRadius(14).backgroundColor('#38BDF8').onClick(() => {
this.showTest11 = true
})
Text('完整报告').fontSize(10).fontColor('#38BDF8').padding({ left: 12, right: 12, top: 5, bottom: 5 }).borderRadius(14).border({ width: 0.8, color: '#38BDF8' }).margin({ left: 8 }).onClick(() => {
this.showReport11 = true
})
}
.margin({ top: 9 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 14 })
.layoutWeight(1)
}
.width('100%')
.alignItems(VerticalAlign.Center)
.padding(14)
.borderRadius(16)
.backgroundColor('#0E2038')
.margin({ left: 14, right: 14, top: 12 })
听力概览卡片是Tab0的第一块内容,整体是一个Row容器,左右两部分:左侧是圆形进度环,右侧是检查信息和操作按钮。
左侧使用Stack层叠布局,将Progress圆形进度组件和文字内容堆叠在一起。Progress组件设置为环形进度(默认type为ProgressType.Circular),value为30表示30%进度,total为100。style({ strokeWidth: 7 })设置环线宽度为7像素,color(‘#38BDF8’)设置进度色为蓝色。在Progress之上叠叠了一个Column,显示"28dB"(平均听阈值)和"平均听阈"标签,形成"环形进度 + 中心文字"的经典仪表盘视觉效果。
技术要点:Progress进度组件。 Progress是ArkUI的进度展示组件,支持三种类型:ProgressType.Linear(线性进度条)、ProgressType.Circular(圆形进度环)、ProgressType.Ring(圆环进度,较细)。通过构造参数{ value, total, type }初始化。value表示当前值,total表示总值,百分比 = value/total*100。style方法用于设置类型相关的样式,如环形进度的strokeWidth(线宽)。
技术要点:Stack层叠布局。 Stack是ArkUI的层叠布局容器,子元素默认居中堆叠。可以通过alignContent参数设置默认对齐方式。Stack中后添加的子元素覆盖在先添加的子元素之上。本例中Progress先添加(底层),Column后添加(上层),因此文字显示在进度环之上。Stack常用于"背景+前景"的层叠场景,如头像+角标、图片+遮罩+文字等。
右侧Column包含检查日期标题、检查结论描述和两个操作按钮。"预约测听"按钮是实心按钮(深色文字+蓝色背景),点击触发showTest11弹框;"完整报告"按钮是描边按钮(蓝色文字+蓝色边框+透明背景),点击触发showReport11弹框。两种按钮风格的对比,遵循了"主操作实心、次操作描边"的设计规范。
9.2 双耳频率双柱状图
// 双耳频率柱
Column() {
Text('📈 气导听阈(dB HL,柱越低越好)').fontSize(13).fontWeight(700).fontColor('#E2EDF7')
Row() {
ForEach(this.freqs11, (f: FreqT11) => {
Column() {
Text(f.left.toString()).fontSize(7).fontColor('#7DD3FC')
Column() {
Column() {
}
.width(11)
.height(f.left * 0.9)
.borderRadius({ topLeft: 2, topRight: 2 })
.backgroundColor('#38BDF8')
}
.justifyContent(FlexAlign.End)
.height(46)
Text(f.hz).fontSize(7).fontColor('#64809C').margin({ top: 3 })
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
Column() {
Text(f.right.toString()).fontSize(7).fontColor('#FBBF24')
Column() {
Column() {
}
.width(11)
.height(f.right * 0.9)
.borderRadius({ topLeft: 2, topRight: 2 })
.backgroundColor('#FBBF24')
}
.justifyContent(FlexAlign.End)
.height(46)
Text(' ').fontSize(7)
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
}, (f: FreqT11) => 'fq' + f.id.toString())
}
.width('100%')
.margin({ top: 14 })
双耳频率双柱状图是听力图Tab的核心可视化组件。整体结构是一个Row容器,通过ForEach遍历freqs11数组,为每个频率点渲染一对柱子(左耳蓝色+右耳黄色)。
每个柱子的结构值得仔细分析。以左耳柱子为例:最外层是一个Column(layoutWeight(1)均分宽度),内部从上到下包含三部分——听阈数值文本(如"15")、柱体容器、频率标签(如"1k")。柱体容器是一个固定高度46像素的Column,设置了justifyContent(FlexAlign.End)使内部子元素底部对齐。柱体本身是一个空Column,宽度11像素,高度为f.left * 0.9(听阈值乘以0.9缩放系数),顶部圆角2像素。
技术要点:justifyContent对齐方式。 FlexAlign是ArkUI中Flex/Row/Column容器的子元素主轴对齐方式枚举。FlexAlign.End表示子元素在主轴末端对齐。对于Column容器,主轴是垂直方向,FlexAlign.End使子元素堆叠在容器底部。本例中柱体容器高度46像素,内部柱体高度为
f.left * 0.9(如15*0.9=13.5像素),通过FlexAlign.End使柱体"长"在容器底部,形成"从底向上生长"的柱状图效果。
技术要点:柱状图的纯CSS实现。 本案例的柱状图完全由Column容器和height属性实现,没有使用任何图表库。这种"原生组件拼装"的方式虽然代码量较大,但优势是高度可定制、性能优秀(无需引入第三方库),且每个柱子都可以独立绑定事件和动画。对于简单的柱状图需求,这种方式比引入图表库更轻量。
右耳柱子的结构与左耳完全对称,区别仅在于颜色(#FBBF24黄色)和数值来源(f.right)。每对柱子之间没有显式间距,而是通过layoutWeight(1)均分宽度实现自然间隔。频率标签只在左耳柱子下方显示(右耳柱子下方显示一个空格文本),避免了重复显示。图例区通过两个小色块+文字说明了蓝色代表左耳、黄色代表右耳,并标注了听力损失分级标准。
9.3 耳鸣评估卡片
// 耳鸣评估
Column() {
Text('🌀 耳鸣匹配评估').fontSize(13).fontWeight(700).fontColor('#E2EDF7')
Row() {
Column() {
Text('4kHz').fontSize(16).fontWeight(700).fontColor('#F87171')
Text('匹配频率').fontSize(9).fontColor('#64809C').margin({ top: 3 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column() {
Text('12dB').fontSize(16).fontWeight(700).fontColor('#F59E0B')
Text('匹配响度').fontSize(9).fontColor('#64809C').margin({ top: 3 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column() {
Text('轻度').fontSize(16).fontWeight(700).fontColor('#34D399')
Text('THI 分级').fontSize(9).fontColor('#64809C').margin({ top: 3 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}
.width('100%')
.margin({ top: 12 })
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.padding(14)
.borderRadius(16)
.backgroundColor('#0E2038')
.margin({ left: 14, right: 14, top: 10 })
}
.width('100%')
.padding({ bottom: 12 })
}
耳鸣评估卡片展示了耳鸣匹配测试的三个关键指标:匹配频率(4kHz,红色)、匹配响度(12dB,橙色)、THI分级(轻度,绿色)。三个指标使用三个等宽的Column(各layoutWeight(1))水平排列,形成三列等宽的统计面板。每个指标包含大号数值(fontSize(16)、fontWeight(700))和小号标签(fontSize(9)),数值颜色根据严重程度递减——红色最严重、橙色次之、绿色最轻,与前面dbColor11函数的颜色语义保持一致。
技术要点:三列等宽布局的layoutWeight技巧。 三个Column各设置layoutWeight(1),在Row容器中均分宽度。这种"等分布局"是响应式设计的基础——无论屏幕宽度如何变化,三列始终等宽,无需硬编码宽度值。如果需要不等宽的布局,只需调整各列的layoutWeight值即可,如[2, 1, 1]表示第一列占50%。
十、Tab1 助听器:商品列表与验配进度
10.1 验配进度卡片
@Builder tabAid11() {
Column() {
Column() {
Row() {
Text('🎧 验配进度').fontSize(13).fontWeight(700).fontColor('#E2EDF7')
Text('3/5 步').fontSize(10).fontColor('#38BDF8')
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
Text('已完成:听力检查 → 耳模取型 → 机器选型;待完成:编程调试 → 效果评估').fontSize(10).fontColor('#64809C').margin({ top: 8 }).lineHeight(15)
Row() {
Progress({ value: 60, total: 100, type: ProgressType.Linear })
.layoutWeight(1)
.height(6)
.style({ strokeWidth: 6 })
.color('#38BDF8')
.backgroundColor('#081527')
Text('60%').fontSize(10).fontWeight(700).fontColor('#38BDF8').margin({ left: 10 })
}
.width('100%')
.margin({ top: 10 })
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.padding(14)
.borderRadius(16)
.backgroundColor('#0E2038')
.margin({ left: 14, right: 14, top: 12 })
验配进度卡片展示了助听器验配流程的五步进度。标题行使用Row容器,左侧标题和右侧"3/5步"通过justifyContent(FlexAlign.SpaceBetween)分散两端对齐。描述文本说明了已完成的三个步骤和待完成的两步骤,形成清晰的流程说明。
进度条使用Progress组件的Linear类型(线性进度条),value为60表示60%进度。与Tab0中的Circular类型不同,Linear类型显示为水平条状。通过layoutWeight(1)使进度条占据剩余宽度,右侧显示"60%"百分比文本。进度条高度6像素,线宽6像素,进度色蓝色,背景色深蓝,视觉上精致而清晰。
技术要点:FlexAlign.SpaceBetween两端对齐。 FlexAlign.SpaceBetween使子元素在主轴上均匀分布,首尾元素贴边,中间元素间距相等。这是Row容器中实现"左右两端对齐"的常用方式。其他对齐方式包括FlexAlign.Start(起始端对齐)、FlexAlign.End(末端对齐)、FlexAlign.Center(居中对齐)、FlexAlign.SpaceAround(等间距环绕)、FlexAlign.SpaceEvenly(等间距均匀分布)。
10.2 助听器商品列表
Row() {
Text('🎧 助听器精选(10 款)').fontSize(13).fontWeight(700).fontColor('#E2EDF7')
Text('验配咨询 >').fontSize(10).fontColor('#38BDF8').onClick(() => {
this.showFit11 = true
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ left: 16, right: 16, top: 16, bottom: 4 })
ForEach(AIDS11, (a: AidT11) => {
Column() {
Row() {
Text('🎧').fontSize(26)
Column() {
Row() {
Text(a.brand + ' ' + a.model).fontSize(12).fontWeight(700).fontColor('#E2EDF7')
Text(a.type).fontSize(8).fontColor('#38BDF8').padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(6).backgroundColor('#12314F').margin({ left: 8 })
}
.width('100%')
Text(a.feature).fontSize(9).fontColor('#64809C').margin({ top: 5 }).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Row() {
Text('⭐ ' + a.score.toFixed(1)).fontSize(9).fontColor('#FBBF24')
Text(a.stock ? ' 现货' : ' 预订 2 周').fontSize(9).fontColor(a.stock ? '#34D399' : '#F59E0B').margin({ left: 10 })
}
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 12 })
.layoutWeight(1)
Column() {
Text('¥' + (a.price / 10000).toFixed(1).toString() + '万').fontSize(13).fontWeight(700).fontColor('#38BDF8')
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.alignItems(VerticalAlign.Center)
Text('咨询验配').fontSize(10).fontColor('#081527').fontWeight(700).padding({ left: 14, right: 14, top: 6, bottom: 6 }).borderRadius(13).backgroundColor('#38BDF8').alignSelf(ItemAlign.End).margin({ top: 8 }).onClick(() => {
this.fModel11 = Math.max(0, AIDS11.findIndex((x: AidT11) => x.id === a.id))
this.showFit11 = true
})
}
.width('100%')
.padding(13)
.borderRadius(14)
.backgroundColor('#0E2038')
.border({ width: 0.6, color: '#22385A' })
.margin({ left: 14, right: 14, top: 8 })
}, (a: AidT11) => 'ad' + a.id.toString())
}
.width('100%')
.padding({ bottom: 12 })
}
助听器商品列表通过ForEach遍历AIDS11数组,为每款助听器渲染一个商品卡片。每个卡片是一个Column容器,内部包含商品信息行和"咨询验配"按钮。
商品信息行是一个Row,从左到右依次是:助听器图标(🎧)、商品信息Column(品牌型号+类型标签+特色描述+评分库存)、价格Column。商品信息Column设置了layoutWeight(1)占据剩余空间,价格Column靠右对齐。
技术要点:toFixed格式化浮点数。
a.score.toFixed(1)将评分格式化为一位小数的字符串,如4.9。(a.price / 10000).toFixed(1)将价格从元转换为万元并保留一位小数,如32800元变为"3.3万"。toFixed是Number原型方法,返回字符串,需要注意其在ArkTS中的行为与标准JavaScript一致——会进行四舍五入。
技术要点:alignSelf交叉轴单独对齐。 alignSelf属性允许单个子元素覆盖父容器的默认交叉轴对齐方式。本例中"咨询验配"按钮设置了alignSelf(ItemAlign.End),使其在父Column(默认居中对齐)中单独靠右对齐。ItemAlign.End表示交叉轴末端对齐,在Column中即水平靠右。这种"整体居中、个别特例"的对齐方式在实际开发中非常实用。
"咨询验配"按钮的onClick事件中,AIDS11.findIndex((x: AidT11) => x.id === a.id)查找当前助听器在AIDS11数组中的索引,并通过Math.max(0, …)确保索引非负(findIndex未找到时返回-1,Math.max将其修正为0)。然后将fModel11设为该索引,并打开验配咨询弹框。这样用户点击某款助听器的"咨询验配"时,弹框中会自动预选该型号,提供了良好的用户体验连贯性。
十一、Tab2 训练:课程列表与进度环
11.1 训练总览卡片
@Builder tabTrain11() {
Column() {
Row() {
Stack() {
Progress({ value: 66, total: 100 })
.width(66)
.height(66)
.style({ strokeWidth: 7 })
.color('#34D399')
Column() {
Text('66%').fontSize(14).fontWeight(700).fontColor('#6EE7B7')
Text('总完成率').fontSize(8).fontColor('#64809C')
}
.alignItems(HorizontalAlign.Center)
}
Column() {
Text('🎓 听觉训练计划').fontSize(13).fontWeight(700).fontColor('#E2EDF7')
Text('8 门课程 · 今日待练 2 门 · 连续打卡 23 天').fontSize(10).fontColor('#64809C').margin({ top: 5 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 14 })
.layoutWeight(1)
}
.width('100%')
.alignItems(VerticalAlign.Center)
.padding(14)
.borderRadius(16)
.backgroundColor('#0E2038')
.margin({ left: 14, right: 14, top: 12 })
训练总览卡片与听力概览卡片结构类似,左侧是66%完成率的圆形进度环(绿色#34D399),右侧是训练计划标题和统计信息。66%的完成率意味着8门课程中大部分已完成或进行中,但仍有提升空间。
11.2 课程列表与进度条
ForEach(this.courses11, (c: CourseT11) => {
Row() {
Text(c.icon).fontSize(24)
Column() {
Text(c.name).fontSize(12).fontWeight(700).fontColor('#E2EDF7')
Text('每日 ' + c.mins.toString() + ' 分钟 · ' + c.days.toString() + ' 天周期').fontSize(9).fontColor('#64809C').margin({ top: 4 })
Row() {
Column() {
Column() {
}
.width((c.done / c.days * 100).toFixed(0) + '%')
.height('100%')
.borderRadius(3)
.backgroundColor(c.done >= c.days ? '#34D399' : '#38BDF8')
}
.layoutWeight(1)
.height(5)
.borderRadius(3)
.backgroundColor('#081527')
Text(c.done >= c.days ? '已完成' : c.done.toString() + '/' + c.days.toString()).fontSize(8).fontColor(c.done >= c.days ? '#34D399' : '#64809C').margin({ left: 8 }).width(38)
}
.width('100%')
.margin({ top: 8 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.padding({ left: 12 })
Column() {
Text('▶').fontSize(16).fontColor(c.done >= c.days ? '#5A7590' : '#38BDF8').onClick(() => {
if (c.done < c.days) {
this.courses11 = this.courses11.map((x: CourseT11) => {
if (x.id === c.id) {
return { id: x.id, name: x.name, mins: x.mins, days: x.days, done: x.done + 1, icon: x.icon }
}
return x
})
}
})
Text('🗑️').fontSize(13).margin({ top: 10 }).onClick(() => {
this.courseId11 = c.id
this.armed11 = false
this.showCourseDel11 = true
})
}
.alignItems(HorizontalAlign.Center)
}
.width('100%')
.alignItems(VerticalAlign.Center)
.padding(13)
.borderRadius(14)
.backgroundColor('#0E2038')
.margin({ left: 14, right: 14, top: 8 })
}, (c: CourseT11) => 'cs' + c.id.toString() + '_' + c.done.toString())
}
.width('100%')
.padding({ bottom: 12 })
}
课程列表是Tab2的核心内容。每个课程卡片是一个Row,从左到右包含:课程图标、课程信息Column(名称+时长周期+进度条)、操作Column(播放按钮+删除按钮)。
进度条的实现值得重点关注。外层Column设置了layoutWeight(1)、height(5)、borderRadius(3)、backgroundColor(‘#081527’),作为进度条的背景轨道。内部嵌套一个Column,其width通过(c.done / c.days * 100).toFixed(0) + '%'计算——例如done=12、days=21时,(12/21*100).toFixed(0) = “57”,所以内部柱体宽度为"57%"。height(‘100%’)使内部柱体填满轨道高度。背景色根据完成状态切换:已完成(done >= days)为绿色,进行中为蓝色。
技术要点:map函数实现不可变状态更新。 点击播放按钮时,通过
this.courses11.map(...)生成新数组。map遍历每个课程对象,当x.id === c.id时返回一个新对象(done属性+1),否则返回原对象。这种"生成新对象而非修改原对象"的方式是ArkTS响应式更新的关键——@State数组需要引用变化才能触发UI更新。新对象展开了原对象的所有属性并仅修改done,确保了数据完整性。
技术要点:ForEach键值包含done值。 注意键值生成函数
'cs' + c.id.toString() + '_' + c.done.toString(),键值中包含了done值。这意味着当某课程的done变化时,该项的键值也变化,框架会将其视为"新项"进行重新创建而非更新。这种设计虽然会增加少量渲染开销,但确保了进度条动画的正确性——框架不会尝试在旧进度条上做差异化更新,而是直接创建新的进度条组件。
删除按钮(🗑️)的onClick事件设置了courseId11为目标课程ID,armed11为false(重置确认开关),然后打开删除确认弹框。这种"两步确认"的删除流程,通过armed11布尔开关强制用户先勾选"我已知晓进度清零"才能点击确认移除,有效防止了误操作。
十二、Tab3 防护:分贝标尺柱与场景列表
12.1 噪声暴露柱状图
@Builder tabNoise11() {
Column() {
Column() {
Text('🔊 今日噪声暴露').fontSize(13).fontWeight(700).fontColor('#E2EDF7')
Text('累计 96 分贝·分钟,占日安全上限 31%').fontSize(10).fontColor('#64809C').margin({ top: 5 })
Row() {
ForEach(NOISES11.slice(0, 8), (n: NoiseT11) => {
Column() {
Text(n.db.toString()).fontSize(7).fontColor(dbColor11(n.db)).margin({ bottom: 2 })
Column() {
Column() {
}
.width(12)
.height(dbToH11(n.db))
.borderRadius({ topLeft: 2, topRight: 2 })
.backgroundColor(dbColor11(n.db))
}
.width('100%')
.height(64)
.justifyContent(FlexAlign.End)
Text(n.icon).fontSize(10).margin({ top: 3 })
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
}, (n: NoiseT11) => 'nz' + n.id.toString())
}
.width('100%')
.margin({ top: 12 })
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.padding(14)
.borderRadius(16)
.backgroundColor('#0E2038')
.margin({ left: 14, right: 14, top: 12 })
噪声暴露柱状图展示了8个场景的分贝值柱状图。ForEach遍历NOISES11.slice(0, 8)(取前8项),每个场景渲染为一个柱子。柱体高度通过dbToH11函数计算(分贝值转像素高度),柱体颜色通过dbColor11函数计算(分贝值转颜色)。柱体容器高度64像素,使用justifyContent(FlexAlign.End)使柱体从底部向上生长。
技术要点:slice方法的分页/截取用途。
NOISES11.slice(0, 8)取数组前8项,返回新数组。这种截取在需要"只展示部分数据"的场景中非常实用。slice不修改原数组,而是返回浅拷贝的子数组。在ArkTS中,数组方法(slice、map、filter、concat等)均遵循函数式编程的不可变原则,返回新数组而非修改原数组。
每个柱子上方显示分贝数值(颜色与柱体一致),下方显示场景emoji图标,形成了"数值-柱体-图标"的三段式信息展示。通过颜色编码(红/橙/绿),用户一眼就能识别出哪些场景的噪声暴露超出了安全范围。
12.2 噪声场景记录列表
Row() {
Text('🗺️ 噪声场景记录(' + this.noises11.length.toString() + ')').fontSize(13).fontWeight(700).fontColor('#E2EDF7')
Text('记录 +').fontSize(10).fontColor('#38BDF8').onClick(() => {
this.nScene11 = 0
this.nMins11 = 60
this.showNoise11 = true
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ left: 16, right: 16, top: 16, bottom: 4 })
ForEach(this.noises11, (n: NoiseT11) => {
Row() {
Text(n.icon).fontSize(22)
Column() {
Text(n.scene).fontSize(12).fontWeight(700).fontColor('#E2EDF7')
Text('暴露 ' + n.mins.toString() + ' 分钟 · ' + (n.safe ? '安全上限内' : '超出安全上限')).fontSize(9).fontColor(n.safe ? '#34D399' : '#F87171').margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.padding({ left: 12 })
Column() {
Text(n.db.toString() + 'dB').fontSize(13).fontWeight(700).fontColor(dbColor11(n.db))
Text(n.db >= 85 ? '建议佩戴耳塞' : '无需防护').fontSize(8).fontColor('#64809C').margin({ top: 3 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.alignItems(VerticalAlign.Center)
.padding(13)
.borderRadius(14)
.backgroundColor('#0E2038')
.margin({ left: 14, right: 14, top: 8 })
.onClick(() => {
this.nScene11 = Math.max(0, NOISES11.findIndex((x: NoiseT11) => x.id === n.id))
this.nMins11 = n.mins
this.showNoise11 = true
})
}, (n: NoiseT11) => 'nz2' + n.id.toString())
噪声场景记录列表遍历noises11数组(注意这是@State变量而非NOISES11常量,因此支持动态添加新记录)。每个场景卡片展示场景名称、暴露时长、安全状态、分贝值和防护建议。点击任一场景卡片会打开噪声记录弹框,并预填该场景的数据(场景索引和时长),方便用户编辑。
技术要点:@State数组与const数组的配合使用。 noises11是@State数组(可变,用于UI渲染和动态更新),NOISES11是const数组(不可变,作为数据源)。弹框中的场景选择列表使用NOISES11渲染(固定的场景选项),而主页面的场景记录列表使用noises11渲染(可动态添加)。这种"选项源 + 记录列表"的双数组设计,清晰地区分了"可选选项"和"已选记录"两种数据角色。
列表底部的安全提示"85dB以上暴露超过8小时即可能造成永久性听损,演唱会请务必佩戴降噪耳塞",用通俗易懂的语言传达了听力学核心知识,体现了健康类应用的健康教育功能。
十三、Tab4 复查:时间轴布局
13.1 下次复查卡片
@Builder tabRev11() {
Column() {
Column() {
Text('🗓️ 下次复查').fontSize(13).fontWeight(700).fontColor('#E2EDF7')
Text('2027-02-20 · 爱康听力学中心(望京)· 纯音测听+声导抗').fontSize(10).fontColor('#64809C').margin({ top: 6 })
Row() {
Text('调整复查计划').fontSize(10).fontColor('#081527').fontWeight(700).padding({ left: 12, right: 12, top: 5, bottom: 5 }).borderRadius(14).backgroundColor('#38BDF8').onClick(() => {
this.showRev11 = true
})
Text('立即约测听').fontSize(10).fontColor('#38BDF8').padding({ left: 12, right: 12, top: 5, bottom: 5 }).borderRadius(14).border({ width: 0.8, color: '#38BDF8' }).margin({ left: 8 }).onClick(() => {
this.showTest11 = true
})
}
.margin({ top: 10 })
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.padding(14)
.borderRadius(16)
.backgroundColor('#0E2038')
.margin({ left: 14, right: 14, top: 12 })
下次复查卡片展示下一次预约的复查信息,包括日期、地点和检查项目。两个操作按钮分别打开复查计划弹框(调整周期)和测听预约弹框(立即预约)。
13.2 时间轴历史记录
Row() {
Text('📜 历史复查(' + this.revs11.length.toString() + ')').fontSize(13).fontWeight(700).fontColor('#E2EDF7')
}
.width('100%')
.padding({ left: 16, right: 16, top: 16, bottom: 4 })
ForEach(this.revs11, (r: RevT11) => {
Row() {
Column() {
Circle().width(10).height(10).fill('#38BDF8')
Column() {
}
.width(2)
.layoutWeight(1)
.backgroundColor('#1E3A5C')
}
.width(12)
.alignItems(HorizontalAlign.Center)
.margin({ top: 2 })
Column() {
Row() {
Text(r.date).fontSize(12).fontWeight(700).fontColor('#E2EDF7')
Text('平均 ' + r.avg.toString() + 'dB').fontSize(9).fontColor(r.avg >= 26 ? '#F59E0B' : '#34D399').margin({ left: 10 })
}
.width('100%')
Text(r.place + ' · ' + r.result).fontSize(9).fontColor('#64809C').margin({ top: 4 }).lineHeight(13)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.padding({ left: 10, bottom: 14 })
}
.width('100%')
.alignItems(VerticalAlign.Top)
.margin({ left: 16, right: 16 })
}, (r: RevT11) => 'rv' + r.id.toString())
}
.width('100%')
.padding({ bottom: 12 })
}
历史复查时间轴是Tab4的核心可视化设计。每条记录是一个Row,左侧是时间轴轴线(Column容器),右侧是记录内容(Column容器)。
时间轴轴线的实现非常巧妙:左侧Column宽12像素,内部从上到下包含一个Circle组件(10x10像素的蓝色圆点,代表时间节点)和一个空Column(宽2像素、layoutWeight(1)撑满剩余高度、深蓝色背景,代表连接线)。由于外层Row设置了alignItems(VerticalAlign.Top),左侧Column的高度由右侧内容的高度决定,layoutWeight(1)使连接线Column撑满左侧Column的剩余高度,形成"圆点+竖线"的时间轴节点效果。
技术要点:Circle基础图形组件。 Circle是ArkUI的基础图形绘制组件,用于绘制圆形。通过width和height设置尺寸,fill设置填充颜色。与通过borderRadius: 50%实现的"伪圆形"不同,Circle是真正的矢量图形,在任何分辨率下都保持清晰。ArkUI还提供了Rect(矩形)、Ellipse(椭圆)、Path(路径)、Polyline(折线)等基础图形组件,可通过这些组件绘制自定义的可视化图形。
技术要点:时间轴布局的layoutWeight技巧。 时间轴连接线使用layoutWeight(1)撑满圆点下方的剩余空间。这种设计使得每条记录的连接线长度自动适配内容高度,无需硬编码高度值。最后一条记录的连接线会延伸到其内容的底部,虽然视觉上可能略长,但整体时间轴效果依然连贯。如果需要隐藏最后一条的连接线,可以通过条件渲染或动态样式来处理。
右侧内容区展示检查日期、平均听阈值(颜色根据avg >= 26判断——超过26dB为橙色警告,否则为绿色正常)和检查地点+结论。通过平均听阈值的颜色变化,用户可以直观追踪听力从18dB到28dB的变化趋势,体现了"历史数据可视化"的健康档案设计理念。
十四、Tab5 我的:用户档案与订单列表
14.1 用户信息卡片
@Builder tabMine11() {
Column() {
Row() {
Stack() {
Circle().width(52).height(52).fill('#12314F')
Text('🧑').fontSize(26)
}
Column() {
Text('王先生 · 听力档案 HL-08829').fontSize(14).fontWeight(700).fontColor('#E2EDF7')
Text('高频轻度下降 · 已验配双耳 · 训练打卡 23 天').fontSize(9).fontColor('#64809C').margin({ top: 5 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 12 })
.layoutWeight(1)
Text('预约测听').fontSize(10).fontColor('#081527').padding({ left: 10, right: 10, top: 6, bottom: 6 }).borderRadius(12).backgroundColor('#38BDF8').onClick(() => {
this.showTest11 = true
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
.padding(14)
.borderRadius(16)
.backgroundColor('#0E2038')
.margin({ left: 14, right: 14, top: 12 })
用户信息卡片使用Stack层叠布局实现了"圆形头像底色+emoji人物图标"的 avatar 效果。Stack中先放置一个52x52像素的深蓝色Circle作为底色,再叠加一个fontSize(26)的人物emoji,形成简洁的用户头像。右侧是用户姓名、档案编号和健康摘要信息,最右侧是"预约测听"快捷按钮。
技术要点:Stack的层叠顺序。 Stack中子元素的层叠顺序遵循"后添加者在上"的原则。本例中Circle先添加(底层),Text后添加(上层),因此emoji显示在Circle之上。如果需要动态调整层级,可以使用zIndex属性。Stack还支持alignContent参数设置默认对齐方式,默认为Alignment.Center(居中)。
14.2 订单列表
Row() {
Text('📦 我的订单(' + this.orders11.length.toString() + ')').fontSize(13).fontWeight(700).fontColor('#E2EDF7')
}
.width('100%')
.padding({ left: 16, right: 16, top: 16, bottom: 4 })
ForEach(this.orders11, (o: OrdT11) => {
Row() {
Column() {
Text(o.name).fontSize(11).fontColor('#E2EDF7').maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Row() {
Text(o.state).fontSize(9).fontColor(ordColor11(o.state))
Text(o.date).fontSize(9).fontColor('#64809C').margin({ left: 10 })
}
.margin({ top: 6 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('¥' + o.price.toString()).fontSize(11).fontWeight(700).fontColor('#38BDF8')
}
.width('100%')
.alignItems(VerticalAlign.Center)
.padding(12)
.borderRadius(12)
.backgroundColor('#0B1A2E')
.margin({ left: 14, right: 14, top: 5 })
}, (o: OrdT11) => 'od' + o.id.toString())
订单列表遍历orders11数组,每条订单是一个Row,左侧是订单信息Column(名称+状态行),右侧是价格文本。状态文本的颜色通过ordColor11函数动态计算,不同状态显示不同颜色,使订单状态一目了然。价格直接显示原始数值(如¥32800),没有做万元转换,因为订单列表中的价格跨度大(从89元到32800元),统一用元更清晰。
技术要点:列表项的视觉层级设计。 订单卡片使用了较深的背景色(#0B1A2E)和较小的圆角(12),与前面的卡片(#0E2038背景、14/16圆角)形成微妙差异。这种细微的视觉差异帮助用户在长列表中区分不同类型的卡片。同时,订单卡片的padding(12)比内容卡片padding(14)更紧凑,使列表项在视觉上更密集,符合"列表项比独立卡片更紧凑"的设计惯例。
十五、弹框1:预约测听底部抽屉
15.1 底部抽屉容器结构
@Builder testOverlay11() {
Column() {
Column() {
Column() {
Text('').fontSize(4).width(40).borderRadius(2).backgroundColor('#1E5A8A')
}
.width('100%')
.alignItems(HorizontalAlign.Center)
.padding({ top: 10, bottom: 4 })
Row() {
Text('📅 预约纯音测听').fontSize(15).fontWeight(700).fontColor('#E2EDF7')
Text('✕').fontSize(15).fontColor('#5A7590').onClick(() => {
this.showTest11 = false
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ left: 16, right: 16, top: 4 })
预约测听弹框采用"底部抽屉"模式。最外层是一个全屏Column(width 100%、height 100%),设置了justifyContent(FlexAlign.End)使内容贴底,半透明深色背景(#99081527,约60%透明度)作为遮罩层。内层Column是实际的抽屉面板,设置了顶部圆角(topLeft: 22, topRight: 22)和不透明背景色。
抽屉顶部的拖拽指示条是一个40像素宽、4像素fontSize的空文本(实际由width和backgroundColor渲染为一条小横条),居中显示,是Material Design中底部抽屉的标准视觉元素,提示用户可以从底部拖拽关闭。
技术要点:zIndex层级控制。 弹框的最外层Column设置了zIndex(999),确保它显示在所有其他内容之上。在ArkUI的Stack布局中,zIndex值越大的子元素显示在越上层。本案例的主布局使用Stack作为根容器,弹框和主内容都是Stack的子元素,通过zIndex确保弹框覆盖主内容。如果不设置zIndex,Stack中后添加的子元素默认在上层,但显式设置zIndex更安全。
技术要点:半透明遮罩的颜色编码。 #99081527是8位十六进制颜色,前6位#081527是RGB颜色,后2位#99是Alpha透明度(十进制153,约60%)。这种格式在ArkUI中合法且常用。遮罩层的作用是遮挡底层内容、聚焦用户注意力,同时保持底层内容的可见性,形成"模态"效果。点击遮罩层(onClick将showTest11设为false)关闭弹框,是标准的交互模式。
15.2 耳别选择与时段选择
Text('测听耳别').fontSize(11).fontColor('#64809C').margin({ top: 14 })
Row() {
ForEach(EARS11, (e: string, i: number) => {
Text(e).fontSize(10).fontColor(this.tEar11 === i ? '#081527' : '#64809C').padding({ left: 14, right: 14, top: 7, bottom: 7 }).borderRadius(12).backgroundColor(this.tEar11 === i ? '#38BDF8' : '#12314F').margin({ right: 8 }).onClick(() => {
this.tEar11 = i
})
}, (e: string) => 'te' + e)
}
.margin({ top: 6 })
Text('时段').fontSize(11).fontColor('#64809C').margin({ top: 14 })
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(SLOT11, (t: string, i: number) => {
Text(t).fontSize(10).fontColor(this.tSlot11 === i ? '#081527' : '#64809C').padding({ left: 13, right: 13, top: 7, bottom: 7 }).borderRadius(12).backgroundColor(this.tSlot11 === i ? '#38BDF8' : '#12314F').margin({ right: 8, bottom: 8 }).onClick(() => {
this.tSlot11 = i
})
}, (t: string) => 'tt' + t)
}
.width('100%')
.margin({ top: 6 })
耳别选择使用Row容器(不换行),3个选项横向排列。时段选择使用Flex容器(wrap: FlexWrap.Wrap允许换行),6个时段在一行放不下时自动换到下一行。每个选项是一个Text组件,通过三元运算符动态设置选中态样式:选中时文字深色(#081527)、背景亮蓝(#38BDF8);未选中时文字灰色(#64809C)、背景深蓝(#12314F)。
技术要点:Flex容器与wrap换行。 Flex是ArkUI的弹性布局容器,比Row/Column更灵活。通过wrap参数设置换行模式:FlexWrap.NoWrap(不换行,默认)、FlexWrap.Wrap(自动换行)。本例中时段选择使用Flex+Wrap,因为6个时段在窄屏上可能一行放不下,需要自动换行。Flex还支持direction(主轴方向)、justifyContent(主轴对齐)、alignItems(交叉轴对齐)等参数,功能比Row/Column更全面。
15.3 确认预约与订单生成
Text('确认预约')
.fontSize(13)
.fontWeight(700)
.fontColor('#081527')
.width('100%')
.textAlign(TextAlign.Center)
.padding({ top: 13, bottom: 13 })
.borderRadius(22)
.backgroundColor('#38BDF8')
.margin({ top: 16, bottom: 16 })
.onClick(() => {
const no: OrdT11 = { id: Date.now(), name: '纯音测听(' + EARS11[this.tEar11] + ')', state: '待到店', date: '明天 ' + SLOT11[this.tSlot11], price: 120 }
this.orders11 = [no].concat(this.orders11)
this.showTest11 = false
})
}
.width('100%')
.padding({ left: 16, right: 16 })
.borderRadius({ topLeft: 22, topRight: 22 })
.backgroundColor('#0E2038')
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.End)
.backgroundColor('#99081527')
.zIndex(999)
.onClick(() => {
this.showTest11 = false
})
}
确认预约按钮是全宽实心按钮,onClick事件中执行了三个关键操作:创建新订单对象(使用Date.now()生成唯一ID,从EARS11和SLOT11中取出选中的耳别和时段拼装订单名和日期)、通过concat将新订单插入orders11数组头部([no].concat(this.orders11)生成新数组)、关闭弹框(showTest11 = false)。
技术要点:Date.now()作为唯一ID生成器。 Date.now()返回当前时间的毫秒数时间戳,在单次操作中几乎不可能重复,因此常被用作临时唯一ID。但需注意,如果在同一毫秒内连续创建多条记录,可能产生重复ID。在生产环境中,建议使用UUID库或后端分配的ID。在ArkTS中,Date.now()的行为与标准JavaScript一致。
技术要点:concat数组合并。
[no].concat(this.orders11)将新订单数组[no]与现有订单数组合并,返回新数组。新订单在数组头部,符合"最新记录显示在最前"的展示需求。concat不修改原数组,而是返回新数组,这符合ArkTS响应式更新要求——通过赋值新数组触发@State的UI更新。
十六、弹框2:完整报告居中大卡
16.1 居中弹框容器
@Builder reportOverlay11() {
Column() {
Column() {
Row() {
Text('📈 听力报告解读').fontSize(15).fontWeight(700).fontColor('#E2EDF7')
Text('✕').fontSize(15).fontColor('#5A7590').onClick(() => {
this.showReport11 = false
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
与底部抽屉不同,完整报告弹框采用"居中大卡"模式。最外层Column设置justifyContent(FlexAlign.Center)使内容垂直居中,内层卡片设置了width(‘86%’)和constraintSize({ maxHeight: ‘78%’ }),既限制了宽度(不铺满屏幕),又限制了最大高度(超出时由内部Scroll处理)。
技术要点:constraintSize约束尺寸。 constraintSize用于设置组件的尺寸约束,包括minWidth、maxWidth、minHeight、maxHeight四个维度。本例中maxHeight: '78%'限制卡片最大高度为屏幕的78%,防止内容过多时卡片超出屏幕。与固定height不同,constraintSize只设置上下限,组件仍会根据内容自适应尺寸,在内容少时不会强制撑满。
16.2 报告内容与双进度条
Column() {
Text('双耳语频(500-2kHz)平均听阈 28dB,属于正常范围;4kHz 高频段 45-50dB 轻度下降,符合噪声性听损早期曲线特征。').fontSize(11).fontColor('#E2EDF7').lineHeight(18)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.padding(13)
.borderRadius(12)
.backgroundColor('#12314F')
.margin({ top: 12 })
Column() {
Text('言语识别率(安静)').fontSize(11).fontColor('#64809C')
Row() {
Progress({ value: 96, total: 100, type: ProgressType.Linear })
.layoutWeight(1)
.height(6)
.style({ strokeWidth: 6 })
.color('#34D399')
.backgroundColor('#081527')
Text('96%').fontSize(10).fontWeight(700).fontColor('#34D399').margin({ left: 10 })
}
.width('100%')
.margin({ top: 6 })
Text('言语识别率(噪声 +5dB SNR)').fontSize(11).fontColor('#64809C').margin({ top: 12 })
Row() {
Progress({ value: 68, total: 100, type: ProgressType.Linear })
.layoutWeight(1)
.height(6)
.style({ strokeWidth: 6 })
.color('#F59E0B')
.backgroundColor('#081527')
Text('68%').fontSize(10).fontWeight(700).fontColor('#F59E0B').margin({ left: 10 })
}
.width('100%')
.margin({ top: 6 })
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.padding(13)
.borderRadius(12)
.backgroundColor('#12314F')
.margin({ top: 10 })
报告内容包含三个信息块:诊断描述、言语识别率双进度条、建议。言语识别率展示了两个场景的对比——安静环境下96%(绿色,优秀)和噪声环境下68%(橙色,一般)。这种对比直观地揭示了听力损失对实际交流能力的影响:虽然安静环境下言语识别率接近正常,但在噪声环境中显著下降,这是感音神经性听力损失的典型表现。
每个进度条都是Row容器,Progress组件通过layoutWeight(1)占据剩余宽度,右侧显示百分比数值。双进度条使用不同颜色(绿色和橙色)形成视觉对比,与数值的高低形成语义关联。
技术要点:lineHeight行高设置。 文本组件的lineHeight属性设置行高,单位为像素。本例中诊断描述文本设置了lineHeight(18),使fontSize(11)的文本行间距更宽,提升可读性。在长文本展示中,适当增加lineHeight(通常为fontSize的1.4-1.6倍)能显著改善阅读体验。
16.3 建议与关闭按钮
Column() {
Text('建议').fontSize(11).fontColor('#64809C')
Text('1. 高频暴露场景坚持佩戴降噪耳塞;2. 半年后复查纯音测听;3. 若耳鸣加重或出现耳闷胀感应及时就诊。').fontSize(11).fontColor('#E2EDF7').lineHeight(18).margin({ top: 6 })
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.padding(13)
.borderRadius(12)
.backgroundColor('#0B1A2E')
.margin({ top: 10 })
Text('关闭').fontSize(12).fontColor('#081527').fontWeight(700).width('100%').textAlign(TextAlign.Center).padding({ top: 12, bottom: 12 }).borderRadius(20).backgroundColor('#38BDF8').margin({ top: 14 }).onClick(() => {
this.showReport11 = false
})
}
.width('86%')
.padding(18)
.borderRadius(18)
.backgroundColor('#0E2038')
.border({ width: 0.8, color: '#3338BDF8' })
.constraintSize({ maxHeight: '78%' })
.onClick(() => {
})
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('#99081527')
.zIndex(999)
.onClick(() => {
this.showReport11 = false
})
}
建议信息块使用了更深的背景色(#0B1A2E),与上方的诊断描述(#12314F)形成层次区分。关闭按钮是全宽实心按钮,与底部抽屉的确认按钮风格一致。值得注意的是,内层卡片设置了空onClick(onClick(() => {})),这是为了阻止点击事件冒泡到外层遮罩——如果不阻止冒泡,点击卡片内容区域也会触发外层的关闭逻辑。这是一种常见的事件冒泡阻断技巧。
技术要点:事件冒泡与阻断。 在ArkUI中,父组件和子组件都可以绑定onClick事件。当点击子组件时,事件会先触发子组件的onClick,然后"冒泡"到父组件触发其onClick。如果不想让父组件响应子组件的点击事件,可以在子组件上设置一个空onClick来"吸收"事件。本例中内层卡片的空onClick正是起此作用——点击卡片内容不会关闭弹框,只有点击遮罩层才会关闭。
十七、弹框3:噪声记录与Toggle组件
17.1 场景选择与时长调节
@Builder noiseOverlay11() {
Column() {
Column() {
Text('🔊 记录噪声暴露').fontSize(15).fontWeight(700).fontColor('#E2EDF7')
Text('记录场景与时长,自动折算安全上限占比').fontSize(10).fontColor('#64809C').margin({ top: 6 })
Text('场景').fontSize(11).fontColor('#64809C').margin({ top: 14 })
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(NOISES11, (n: NoiseT11, i: number) => {
Text(n.icon + n.scene).fontSize(9).fontColor(this.nScene11 === i ? '#081527' : '#64809C').padding({ left: 9, right: 9, top: 7, bottom: 7 }).borderRadius(12).backgroundColor(this.nScene11 === i ? '#38BDF8' : '#12314F').margin({ right: 8, bottom: 8 }).onClick(() => {
this.nScene11 = i
})
}, (n: NoiseT11) => 'ns' + n.id.toString())
}
.width('100%')
.margin({ top: 6 })
Text('暴露时长(分钟)').fontSize(11).fontColor('#64809C').margin({ top: 10 })
Row() {
Text('−').fontSize(18).fontColor('#38BDF8').width(36).height(36).borderRadius(18).backgroundColor('#12314F').textAlign(TextAlign.Center).onClick(() => {
if (this.nMins11 > 15) {
this.nMins11 -= 15
}
})
Text(this.nMins11.toString() + ' 分钟').fontSize(13).fontWeight(700).fontColor('#E2EDF7').layoutWeight(1).textAlign(TextAlign.Center)
Text('+').fontSize(18).fontColor('#38BDF8').width(36).height(36).borderRadius(18).backgroundColor('#12314F').textAlign(TextAlign.Center).onClick(() => {
if (this.nMins11 < 480) {
this.nMins11 += 15
}
})
}
.width('70%')
.alignItems(VerticalAlign.Center)
.margin({ top: 8 })
噪声记录弹框是居中卡片模式。场景选择使用Flex+Wrap渲染10个场景选项(emoji+场景名),每个选项fontSize(9)比较小以适应较多选项。时长调节器是一个Row,包含减号按钮、数值显示和加号按钮,宽度70%居中。加减按钮每次步进15分钟,范围限制在15-480分钟之间。
技术要点:步进器的实现模式。 时长调节器通过"减号Text + 数值Text + 加号Text"的Row组合实现了步进器(Stepper)效果。减号按钮的onClick中先判断
this.nMins11 > 15再减15,加号按钮先判断this.nMins11 < 480再加15,确保数值在合理范围内。这种"按钮+条件判断+状态修改"是数值调节器的标准实现模式。ArkUI也提供了Stepper组件,但自定义按钮组合在样式上更灵活。
17.2 Toggle开关与保存逻辑
Row() {
Text('🎧 当时佩戴了防护耳塞').fontSize(11).fontColor('#E2EDF7').layoutWeight(1)
Toggle({ type: ToggleType.Switch, isOn: this.nProtect11 })
.selectedColor('#38BDF8')
.onChange((on: boolean) => {
this.nProtect11 = on
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
.margin({ top: 14 })
Row() {
Text('取消').fontSize(12).fontColor('#64809C').layoutWeight(1).textAlign(TextAlign.Center).padding({ top: 11, bottom: 11 }).borderRadius(20).border({ width: 0.8, color: '#1E5A8A' }).onClick(() => {
this.showNoise11 = false
})
Text('保存记录').fontSize(12).fontColor('#081527').fontWeight(700).layoutWeight(1).textAlign(TextAlign.Center).padding({ top: 11, bottom: 11 }).borderRadius(20).backgroundColor('#38BDF8').margin({ left: 10 }).onClick(() => {
const n: NoiseT11 = NOISES11[this.nScene11]
const nn: NoiseT11 = { id: Date.now(), scene: n.scene, db: n.db, mins: this.nMins11, safe: n.db < 85 || this.nProtect11, icon: n.icon }
this.noises11 = [nn].concat(this.noises11)
this.showNoise11 = false
})
}
.width('100%')
.margin({ top: 18 })
Toggle开关组件是ArkUI的开关控件,type设置为ToggleType.Switch(滑动开关样式),isOn绑定到nProtect11状态。selectedColor设置开启状态的滑块颜色为蓝色。onChange回调在开关状态变化时触发,参数on为新的布尔值,将其赋给nProtect11完成状态同步。
保存记录按钮的onClick中,从NOISES11中取出选中场景的模板数据(n),然后构建新的NoiseT11对象(nn):id用Date.now()生成,scene/db/icon从模板复制,mins用用户调节的时长,safe通过n.db < 85 || this.nProtect11计算——如果分贝值低于85(本身安全)或者用户佩戴了防护耳塞,则标记为安全。最后通过concat将新记录插入noises11数组头部并关闭弹框。
技术要点:Toggle组件详解。 Toggle是ArkUI的开关组件,支持两种类型:ToggleType.Switch(滑动开关,类似iOS的UISwitch)和ToggleType.Checkbox(复选框)。构造参数{ type, isOn }指定类型和初始状态。selectedColor设置开启状态的选中色,通过onChange回调监听状态变化。Toggle在表单中常用于布尔值的切换,如"是否开启通知"、"是否接受协议"等场景。
技术要点:安全状态的动态计算。 safe字段的计算逻辑
n.db < 85 || this.nProtect11体现了听力学知识:85dB是安全暴露的阈值,低于85dB的场景无论如何都是安全的;高于85dB的场景如果佩戴了防护耳塞(nProtect11为true),也视为安全。这种"分贝阈值 OR 防护措施"的逻辑,准确地反映了听力防护的科学原则。
十八、弹框4:复查计划底部抽屉
@Builder revOverlay11() {
Column() {
Column() {
Row() {
Text('🗓️ 调整复查计划').fontSize(15).fontWeight(700).fontColor('#E2EDF7')
Text('✕').fontSize(15).fontColor('#5A7590').onClick(() => {
this.showRev11 = false
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ top: 16, bottom: 4 })
Text('复查周期').fontSize(11).fontColor('#64809C').margin({ top: 12 })
Row() {
ForEach([3, 6, 12], (m: number, i: number) => {
Text(m.toString() + ' 个月').fontSize(10).fontColor(this.revMonths11 === m ? '#081527' : '#64809C').padding({ left: 13, right: 13, top: 7, bottom: 7 }).borderRadius(12).backgroundColor(this.revMonths11 === m ? '#38BDF8' : '#12314F').margin({ right: 8 }).onClick(() => {
this.revMonths11 = m
})
}, (m: number) => 'rm' + m.toString())
}
.margin({ top: 6 })
Text('当前听力曲线稳定,系统建议 6 个月;若耳鸣或耳闷胀感出现请提前。').fontSize(9).fontColor('#64809C').margin({ top: 12 }).lineHeight(14)
Row() {
Text('复查前一天短信+电话双提醒').fontSize(11).fontColor('#E2EDF7').layoutWeight(1)
Toggle({ type: ToggleType.Switch, isOn: true })
.selectedColor('#38BDF8')
.onChange((on: boolean) => {
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
.margin({ top: 10 })
Text('保存计划')
.fontSize(13)
.fontWeight(700)
.fontColor('#081527')
.width('100%')
.textAlign(TextAlign.Center)
.padding({ top: 13, bottom: 13 })
.borderRadius(22)
.backgroundColor('#38BDF8')
.margin({ top: 16, bottom: 16 })
.onClick(() => {
this.revs11 = this.revs11.map((r: RevT11, i: number) => {
if (i === 0) {
return { id: r.id, date: r.date, place: r.place, result: r.result, avg: r.avg, next: '按 ' + this.revMonths11.toString() + ' 个月周期自动排期' }
}
return r
})
this.showRev11 = false
})
}
.width('100%')
.padding({ left: 16, right: 16 })
.borderRadius({ topLeft: 22, topRight: 22 })
.backgroundColor('#0E2038')
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.End)
.backgroundColor('#99081527')
.zIndex(999)
.onClick(() => {
this.showRev11 = false
})
}
复查计划弹框是底部抽屉模式,内容较为简单:复查周期选择(3/6/12个月,ForEach直接遍历数字数组[3, 6, 12])、提醒开关(Toggle固定开启状态)、保存按钮。
保存按钮的onClick通过map函数更新revs11数组:遍历每条记录,当索引i为0(即最新一条记录)时,修改其next字段为"按 X 个月周期自动排期",其他记录保持不变。这种"只修改第一条记录"的逻辑,体现了"最新记录的下次复查计划是可调整的"业务规则。
技术要点:ForEach遍历数字数组。
ForEach([3, 6, 12], (m: number, i: number) => {...})直接遍历数字字面量数组。ArkTS的ForEach支持任意类型的数组,包括基本类型(number、string)和对象类型。键值生成函数'rm' + m.toString()使用数值本身作为键的一部分,确保唯一性。注意这里用m(值)而非i(索引)生成键值,因为值本身就是唯一的。
十九、弹框5:删除课程危险确认卡
@Builder courseDelOverlay11() {
Column() {
Column() {
Text('⚠️').fontSize(30)
Text('移除这门训练课程?').fontSize(15).fontWeight(700).fontColor('#E2EDF7').margin({ top: 10 })
Text('移除后训练进度将清零,总完成率会重新计算。').fontSize(11).fontColor('#64809C').margin({ top: 10 }).textAlign(TextAlign.Center).lineHeight(17)
Row() {
Text('我已知晓进度清零').fontSize(11).fontColor('#F87171').layoutWeight(1)
Toggle({ type: ToggleType.Switch, isOn: this.armed11 })
.selectedColor('#F87171')
.onChange((on: boolean) => {
this.armed11 = on
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
.margin({ top: 16 })
Row() {
Text('再想想').fontSize(12).fontColor('#64809C').layoutWeight(1).textAlign(TextAlign.Center).padding({ top: 11, bottom: 11 }).borderRadius(20).border({ width: 0.8, color: '#1E5A8A' }).onClick(() => {
this.showCourseDel11 = false
})
Text(this.armed11 ? '确认移除' : '请先打开确认')
.fontSize(12)
.fontWeight(700)
.fontColor(this.armed11 ? '#E2EDF7' : '#5A7590')
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 11, bottom: 11 })
.borderRadius(20)
.backgroundColor(this.armed11 ? '#7E2B22' : '#12314F')
.margin({ left: 10 })
.onClick(() => {
if (this.armed11) {
this.courses11 = this.courses11.filter((c: CourseT11) => c.id !== this.courseId11)
this.showCourseDel11 = false
}
})
}
.width('100%')
.margin({ top: 18 })
}
.width('74%')
.padding(18)
.borderRadius(16)
.backgroundColor('#231714')
.border({ width: 1, color: '#4DF87171' })
.onClick(() => {
})
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('#990E0B08')
.zIndex(999)
.onClick(() => {
this.showCourseDel11 = false
})
}
删除课程弹框是居中窄卡片模式(width 74%),整体采用红色警示色调——背景#231714(深红褐)、边框#4DF87171(半透明红)、遮罩#990E0B08(半透明深红)。这种"全红"的视觉设计传达了"危险操作"的强烈信号。
弹框的核心交互是"二次确认"机制:用户必须先打开"我已知晓进度清零"的Toggle开关(armed11变为true),确认移除按钮才会从灰色禁用态(“请先打开确认”、#12314F背景)变为红色激活态(“确认移除”、#7E2B22背景)。按钮文本、文字颜色、背景色都通过三元运算符动态切换,形成了完整的"禁用-激活"状态反馈。
技术要点:危险操作的二次确认设计。 对于不可逆操作(如删除数据),业界通用的最佳实践是"二次确认"。本案例采用了"Toggle开关 + 按钮状态联动"的方案,比简单的"确认/取消"对话框更严格——用户必须显式勾选知晓后果才能执行删除。这种设计在医疗、金融等高风险场景中尤为重要。armed(武装)变量的命名也颇具意味——只有"解除保险"才能"开火"。
确认移除按钮的onClick中,先判断if (this.armed11),只有armed为true时才执行删除操作。删除通过this.courses11.filter((c: CourseT11) => c.id !== this.courseId11)实现——filter返回排除目标课程后的新数组,然后关闭弹框。
技术要点:filter函数的删除模式。 filter是函数式编程中实现"删除"的标准方法。与splice(直接修改原数组)不同,filter返回新数组,不修改原数组。
array.filter(item => item.id !== targetId)的含义是"保留所有id不等于targetId的元素",等价于"删除id等于targetId的元素"。这种"保留不匹配的"思维方式是函数式编程的典型范式。
二十、弹框6:验配咨询底部抽屉与Scroll滚动容器
20.1 Scroll容器与内容
@Builder fitOverlay11() {
Column() {
Column() {
Column() {
Text('').fontSize(4).width(40).borderRadius(2).backgroundColor('#1E5A8A')
}
.width('100%')
.alignItems(HorizontalAlign.Center)
.padding({ top: 10, bottom: 4 })
Row() {
Text('🎧 助听器验配咨询').fontSize(15).fontWeight(700).fontColor('#E2EDF7')
Text('✕').fontSize(15).fontColor('#5A7590').onClick(() => {
this.showFit11 = false
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ left: 16, right: 16, top: 4 })
Scroll() {
Column() {
Text('意向型号').fontSize(11).fontColor('#64809C').margin({ top: 12 })
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(AIDS11, (a: AidT11, i: number) => {
Text(a.brand + '·' + a.model).fontSize(9).fontColor(this.fModel11 === i ? '#081527' : '#64809C').padding({ left: 9, right: 9, top: 7, bottom: 7 }).borderRadius(12).backgroundColor(this.fModel11 === i ? '#38BDF8' : '#12314F').margin({ right: 8, bottom: 8 }).onClick(() => {
this.fModel11 = i
})
}, (a: AidT11) => 'fa' + a.id.toString())
}
.width('100%')
Text('验配耳别').fontSize(11).fontColor('#64809C').margin({ top: 6 })
Row() {
ForEach(EARS11, (e: string, i: number) => {
Text(e).fontSize(10).fontColor(this.fEar11 === i ? '#081527' : '#64809C').padding({ left: 14, right: 14, top: 7, bottom: 7 }).borderRadius(12).backgroundColor(this.fEar11 === i ? '#38BDF8' : '#12314F').margin({ right: 8 }).onClick(() => {
this.fEar11 = i
})
}, (e: string) => 'fe' + e)
}
.margin({ top: 6 })
Text('预算区间').fontSize(11).fontColor('#64809C').margin({ top: 14 })
Row() {
ForEach(BUDGET11, (b: string, i: number) => {
Text(b).fontSize(10).fontColor(this.fBudget11 === i ? '#081527' : '#64809C').padding({ left: 12, right: 12, top: 7, bottom: 7 }).borderRadius(12).backgroundColor(this.fBudget11 === i ? '#38BDF8' : '#12314F').margin({ right: 8 }).onClick(() => {
this.fBudget11 = i
})
}, (b: string) => 'fb' + b)
}
.margin({ top: 6 })
验配咨询弹框是底部抽屉模式,但内容较多(型号选择有10项),因此中间内容区使用了Scroll滚动容器。Scroll内部是一个Column,包含意向型号选择(Flex+Wrap,10款助听器)、验配耳别选择(Row,3项)、预算区间选择(Row,4项)。
技术要点:Scroll滚动容器。 Scroll是ArkUI的滚动容器组件,当子内容超出Scroll的可视区域时,提供滚动能力。Scroll默认垂直滚动,可通过scrollable参数设置滚动方向(Vertical/Horizontal)。本例中Scroll设置了layoutWeight(1)占据抽屉面板的剩余高度,align(Alignment.Top)使内容从顶部开始排列,edgeEffect(EdgeEffect.Spring)设置边缘弹性效果(iOS般的回弹动画)。
意向型号选择使用Flex+Wrap,因为10款助听器的名称长度不一,需要自动换行。每项显示"品牌·型号"(如"峰力·Lumity L90"),fontSize(9)确保每项不会太宽。点击选中时,fModel11更新为该项索引,同时下方的"机型参考价"会通过AIDS11[this.fModel11].price动态显示对应型号的价格。
20.2 费用信息与提交逻辑
Column() {
Row() {
Text('验配服务费').fontSize(10).fontColor('#64809C')
Text('含 3 次免费调试').fontSize(10).fontColor('#34D399')
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
Row() {
Text('机型参考价').fontSize(10).fontColor('#64809C')
Text('¥' + AIDS11[this.fModel11].price.toString()).fontSize(11).fontWeight(700).fontColor('#38BDF8')
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ top: 8 })
Row() {
Text('试听体验').fontSize(10).fontColor('#64809C')
Text('门店免费 30 分钟').fontSize(10).fontColor('#34D399')
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ top: 8 })
}
.width('100%')
.padding(12)
.borderRadius(12)
.backgroundColor('#12314F')
.margin({ top: 14 })
Text('提交后验配师将在 1 小时内回电,安排试听。').fontSize(9).fontColor('#64809C').margin({ top: 10 })
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 12 })
}
.layoutWeight(1)
.align(Alignment.Top)
.edgeEffect(EdgeEffect.Spring)
Text('提交咨询')
.fontSize(13)
.fontWeight(700)
.fontColor('#081527')
.width('100%')
.textAlign(TextAlign.Center)
.padding({ top: 13, bottom: 13 })
.borderRadius(22)
.backgroundColor('#38BDF8')
.margin({ left: 16, right: 16, bottom: 14 })
.onClick(() => {
const a: AidT11 = AIDS11[this.fModel11]
const no: OrdT11 = { id: Date.now(), name: a.brand + ' ' + a.model + '(' + EARS11[this.fEar11] + ')验配咨询', state: '待回电', date: '1 小时内', price: 0 }
this.orders11 = [no].concat(this.orders11)
this.showFit11 = false
})
费用信息区是一个Column卡片,包含三行信息:验配服务费(含3次免费调试)、机型参考价(动态显示选中型号的价格)、试听体验(门店免费30分钟)。每行使用Row+justifyContent(FlexAlign.SpaceBetween)实现左右两端对齐——左侧标签灰色、右侧值蓝色或绿色。
提交咨询按钮的onClick从AIDS11中取出选中型号(a),构建新订单对象(名称为"品牌 型号(耳别)验配咨询",状态"待回电",日期"1小时内",价格0因为咨询本身免费),插入orders11数组头部并关闭弹框。
技术要点:edgeEffect弹性效果。 EdgeEffect是Scroll组件的边缘滑动效果枚举:EdgeEffect.Spring(弹性回弹,类似iOS效果)、EdgeEffect.None(无效果)、EdgeEffect.Fade(渐隐)。本例使用Spring效果,当内容滚动到边缘时会产生回弹动画,提供了流畅自然的滚动体验。这是提升应用"手感"的重要细节。
二十一、主布局build方法:Stack层叠与条件渲染
21.1 主内容布局
build() {
Stack() {
Column() {
this.headerBar11()
Scroll() {
Column() {
if (this.curTab11 === 0) {
this.tabAudi11()
}
if (this.curTab11 === 1) {
this.tabAid11()
}
if (this.curTab11 === 2) {
this.tabTrain11()
}
if (this.curTab11 === 3) {
this.tabNoise11()
}
if (this.curTab11 === 4) {
this.tabRev11()
}
if (this.curTab11 === 5) {
this.tabMine11()
}
}
.width('100%')
}
.layoutWeight(1)
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.width('100%')
.backgroundColor('#071426')
this.tabBar11()
}
.width('100%')
.height('100%')
build方法是每个@Component组件必须实现的方法,它返回整个组件的UI描述。本案例的build方法使用Stack作为根容器,内部包含两个层级:底层是主内容(Column:头部+滚动内容区+底部Tab栏),上层是六个弹框(通过条件渲染控制显示)。
主内容Column从上到下依次是:headerBar11头部、Scroll滚动内容区、tabBar11底部Tab栏。Scroll设置了layoutWeight(1)占据头部和Tab栏之间的所有剩余空间,scrollBar(BarState.Off)隐藏滚动条(因为内容区通过Tab切换,不需要可见的滚动条),edgeEffect(EdgeEffect.Spring)提供弹性滚动效果。
技术要点:build方法的唯一性。 每个@Component组件有且只有一个build方法,它是组件UI的入口。build方法中通过声明式语法描述UI结构,框架会在组件首次创建时执行build生成组件树,后续在@State变量变化时重新执行build进行差异化更新。build方法中不应执行耗时的同步操作(如网络请求、复杂计算),以免阻塞UI线程。
21.2 Tab内容条件渲染
滚动内容区内部使用6个if语句根据curTab11的值渲染对应的Tab内容。这种"if条件渲染"模式是ArkUI中实现Tab切换的简洁方案——每次只有一个Tab的内容被渲染到组件树中,切换Tab时旧Tab被卸载、新Tab被挂载。
技术要点:if条件渲染与Tab切换的性能考量。 使用if条件渲染实现Tab切换的特点是"每次只渲染当前Tab",内存占用低但切换时需要重新构建组件。另一种方案是使用Tabs组件(ArkUI内置的Tab容器),所有Tab内容同时渲染,切换时通过动画过渡,体验更流畅但内存占用更高。对于内容复杂、Tab较多的场景,if条件渲染是更轻量的选择;对于Tab少、切换频繁的场景,Tabs组件更合适。
21.3 弹框条件渲染
if (this.showTest11) {
this.testOverlay11()
}
if (this.showReport11) {
this.reportOverlay11()
}
if (this.showNoise11) {
this.noiseOverlay11()
}
if (this.showRev11) {
this.revOverlay11()
}
if (this.showCourseDel11) {
this.courseDelOverlay11()
}
if (this.showFit11) {
this.fitOverlay11()
}
}
.width('100%')
.height('100%')
.backgroundColor('#071426')
}
}
六个弹框通过if条件渲染挂在Stack的上层。每个弹框的显示由对应的@State布尔变量控制,当变量为true时弹框被渲染到组件树并显示在最上层(通过zIndex(999)),当变量为false时弹框被卸载。由于弹框都设置了全屏尺寸和半透明遮罩背景,同时只有一个弹框显示时,遮罩覆盖整个屏幕,弹框内容居中或贴底显示。
技术要点:Stack作为弹框容器的优势。 使用Stack作为根容器,将主内容和弹框作为Stack的子元素,通过zIndex控制层级,是ArkUI中实现模态弹框的标准方案。Stack的层叠特性天然适合"主内容在下、弹框在上"的需求,无需额外的Overlay机制。多个弹框可以共存于Stack中,通过各自的布尔开关独立控制显隐。
这个流程图展示了build方法的完整渲染结构。Stack根容器将主内容和弹框分为两个层级,主内容通过curTab11的值切换显示6个Tab之一,弹框通过6个布尔开关独立控制显隐。这种架构清晰地将"主导航"和"模态交互"分离,使得代码结构易于理解和维护。
二十二、核心技术点对比汇总
下面通过表格对本案例中涉及的各种数据结构、组件、状态变量、装饰器、工具函数等进行系统对比,帮助读者建立完整的知识图谱。
| 类别 | 名称 | 类型/返回值 | 作用描述 | 使用场景 | 技术要点 |
|---|---|---|---|---|---|
| 数据结构 | FreqT11 | interface | 听力频率数据(频率、双耳听阈) | 听力图Tab柱状图渲染 | 含hz/left/right字段,支持双耳对比 |
| 数据结构 | AidT11 | interface | 助听器商品(品牌、型号、价格等) | 助听器Tab商品列表 | 含stock布尔字段控制现货/预订显示 |
| 数据结构 | CourseT11 | interface | 训练课程(时长、周期、进度) | 训练Tab课程列表 | done/days比值计算完成率 |
| 数据结构 | NoiseT11 | interface | 噪声场景(分贝、时长、安全标记) | 防护Tab场景列表 | safe字段为预计算的布尔值 |
| 数据结构 | RevT11 | interface | 复查记录(日期、地点、结论) | 复查Tab时间轴 | next字段可动态更新 |
| 数据结构 | OrdT11 | interface | 订单(名称、状态、日期、价格) | 我的Tab订单列表 | state字符串映射颜色 |
| 装饰器 | @Entry | 无 | 标记页面入口组件 | 页面根组件 | 每个页面只能有一个@Entry |
| 装饰器 | @Component | 无 | 声明自定义组件 | struct声明 | 生成组件工厂代码 |
| 装饰器 | @State | 无 | 响应式状态变量 | 状态管理 | 引用变化触发UI更新 |
| 装饰器 | @Builder | 无 | UI构建片段 | UI复用 | 支持参数传递,共享组件this |
| 状态变量 | curTab11 | number | 当前选中Tab索引 | Tab切换 | 0-5对应6个Tab |
| 状态变量 | showTest11 | boolean | 预约测听弹框开关 | 弹框显隐控制 | true时渲染弹框组件 |
| 状态变量 | showReport11 | boolean | 完整报告弹框开关 | 弹框显隐控制 | 居中大卡模式 |
| 状态变量 | showNoise11 | boolean | 噪声记录弹框开关 | 弹框显隐控制 | 居中卡片模式 |
| 状态变量 | showRev11 | boolean | 复查计划弹框开关 | 弹框显隐控制 | 底部抽屉模式 |
| 状态变量 | showCourseDel11 | boolean | 删除课程弹框开关 | 弹框显隐控制 | 居中危险确认卡模式 |
| 状态变量 | showFit11 | boolean | 验配咨询弹框开关 | 弹框显隐控制 | 底部抽屉含Scroll滚动 |
| 状态变量 | tSlot11 | number | 预约时段选中索引 | 预约表单 | 0-5对应6个时段 |
| 状态变量 | tEar11 | number | 测听耳别选中索引 | 预约表单 | 0-2对应双耳/左耳/右耳 |
| 状态变量 | nScene11 | number | 噪声场景选中索引 | 噪声记录表单 | 0-9对应10个场景 |
| 状态变量 | nMins11 | number | 暴露时长(分钟) | 噪声记录表单 | 15-480步进15 |
| 状态变量 | nProtect11 | boolean | 是否佩戴防护耳塞 | 噪声记录表单 | 影响safe字段计算 |
| 状态变量 | revMonths11 | number | 复查周期(月) | 复查计划表单 | 3/6/12三个选项 |
| 状态变量 | courseId11 | number | 待删除课程ID | 删除确认 | 配合armed11使用 |
| 状态变量 | armed11 | boolean | 删除确认武装开关 | 删除确认 | false时按钮禁用 |
| 状态变量 | fModel11 | number | 验配型号选中索引 | 验配咨询表单 | 0-9对应10款助听器 |
| 状态变量 | fEar11 | number | 验配耳别选中索引 | 验配咨询表单 | 0-2对应双耳/左耳/右耳 |
| 状态变量 | fBudget11 | number | 预算区间选中索引 | 验配咨询表单 | 0-3对应4个预算档 |
| 状态变量 | waveX11 | number | 声波动画位移值 | 头部装饰条动画 | -20到20往复 |
| 状态变量 | earOp11 | number | 耳机图标透明度 | 头部图标动画 | 0.3到0.9往复 |
| 容器组件 | Column | 无 | 垂直线性布局容器 | 贯穿全页 | 子元素从上到下排列 |
| 容器组件 | Row | 无 | 水平线性布局容器 | 贯穿全页 | 子元素从左到右排列 |
| 容器组件 | Stack | 无 | 层叠布局容器 | 头部/弹框根 | 子元素堆叠,zIndex控层级 |
| 容器组件 | Flex | 无 | 弹性布局容器 | 弹框选项渲染 | 支持wrap换行 |
| 容器组件 | Scroll | 无 | 滚动容器 | 主内容区/验配弹框 | edgeEffect弹性效果 |
| 显示组件 | Text | 无 | 文本显示 | 贯穿全页 | 支持fontSize/fontColor/fontWeight |
| 显示组件 | Progress | 无 | 进度组件 | 听力概览/验配进度/报告 | 支持Circular/Linear/Ring类型 |
| 显示组件 | Toggle | 无 | 开关组件 | 噪声记录/复查/删除弹框 | 支持Switch/Checkbox类型 |
| 显示组件 | Circle | 无 | 圆形图形组件 | 复查时间轴/用户头像 | 矢量图形,fill填充 |
| 工具函数 | dbColor11 | string | 分贝值映射颜色 | 噪声柱状图/场景列表 | 95+红/80+橙/其余绿 |
| 工具函数 | dbToH11 | number | 分贝值映射像素高度 | 噪声柱状图 | (db-60)*1.6,最小6 |
| 工具函数 | ordColor11 | string | 订单状态映射颜色 | 订单列表 | 调试中/进行中蓝,已发货橙 |
| 布局属性 | layoutWeight | number | 权重分配剩余空间 | 贯穿全页 | 正数按比例分配 |
| 布局属性 | justifyContent | FlexAlign | 主轴对齐方式 | 贯穿全页 | SpaceBetween/Center/End等 |
| 布局属性 | alignItems | HorizontalAlign/VerticalAlign | 交叉轴对齐 | 贯穿全页 | Start/Center/End |
| 布局属性 | zIndex | number | 层级控制 | 弹框 | 999确保在最上层 |
| 布局属性 | constraintSize | 对象 | 尺寸约束 | 报告弹框 | maxHeight限制最大高度 |
| 动画API | animateTo | void | 显式动画驱动 | aboutToAppear | duration/iterations/playMode/curve |
| 列表渲染 | ForEach | 无 | 数组遍历渲染 | 所有列表场景 | 需提供键值生成函数 |
| 数组方法 | slice | Array | 截取子数组 | @State初始化 | 返回新数组不修改原数组 |
| 数组方法 | map | Array | 映射新数组 | 课程进度更新 | 不可变更新模式 |
| 数组方法 | filter | Array | 过滤新数组 | 课程删除 | 保留不匹配的元素 |
| 数组方法 | concat | Array | 合并新数组 | 订单插入头部 | 新元素在前,旧元素在后 |
| 生命周期 | aboutToAppear | void | 组件创建后回调 | 动画初始化 | build前执行,只触发一次 |
二十三、总结
本案例作为一个听力健康中心的完整业务页面,充分展示了鸿蒙ArkTS声明式UI的开发范式和技术深度。从整体架构来看,页面采用Stack根容器将主内容层与弹框层分离,主内容通过Column实现"头部-滚动内容区-底部Tab栏"的经典三段式布局,弹框通过6个独立的@State布尔开关控制显隐,这种"主内容+模态弹框"的双层架构是复杂业务页面的标准设计模式。整个页面共包含6个Tab、6个弹框、10个数据结构字段定义、5个数组方法操作、3个工具函数和2个循环动画,体量和复杂度都达到了真实业务场景的水平。
在数据建模层面,本案例通过6个interface定义了听力频率、助听器商品、训练课程、噪声场景、复查记录、订单等核心数据结构。每个interface的字段设计都紧密结合了听力学领域知识——频率的双耳听阈、助听器的型号类型、噪声的分贝安全阈值、复查的平均听阈等,体现了"数据模型反映业务领域"的设计原则。静态数据通过const数组初始化,可变状态通过@State数组的slice()副本管理,"原始数据不可变+状态数据可变副本"的模式确保了数据流的单向性和可追踪性。
在状态管理层面,页面共使用了22个@State变量,覆盖了Tab索引、弹框开关、表单选中值、动画驱动值等多种状态类型。这些状态变量的设计遵循了"单一数据源"原则——每个UI状态都有且只有一个@State变量驱动,避免了状态冗余和不一致。数组状态的更新严格采用不可变模式——通过map、filter、concat等函数返回新数组而非直接修改原数组,这是ArkTS响应式更新机制的基石。理解并熟练运用这种不可变状态更新模式,是从传统命令式编程转向声明式编程的关键思维转变。
安装DevEco Studio程序

选择目标安装目录:

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

新建一个空白模板:

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

完整代码:
// ============================================================
// 声学深蓝 · 6底部Tab · 6弹框
// 布局亮点:听力图Tab双耳频率分组双柱;助听Tab商品列表+验配进度;
// 训练Tab课程卡+完成环;防护Tab分贝标尺柱+场景列表;复查Tab时间轴
// ============================================================
// ---------------- 数据结构 ----------------
interface FreqT11 {
id: number
hz: string
left: number
right: number
}
interface AidT11 {
id: number
brand: string
model: string
type: string
price: number
score: number
feature: string
stock: boolean
}
interface CourseT11 {
id: number
name: string
mins: number
days: number
done: number
icon: string
}
interface NoiseT11 {
id: number
scene: string
db: number
mins: number
safe: boolean
icon: string
}
interface RevT11 {
id: number
date: string
place: string
result: string
avg: number
next: string
}
interface OrdT11 {
id: number
name: string
state: string
date: string
price: number
}
// ---------------- 写死数据 ----------------
const FREQS11: Array<FreqT11> = [
{ id: 1, hz: '250', left: 15, right: 15 },
{ id: 2, hz: '500', left: 15, right: 20 },
{ id: 3, hz: '1k', left: 20, right: 25 },
{ id: 4, hz: '2k', left: 30, right: 30 },
{ id: 5, hz: '3k', left: 40, right: 40 },
{ id: 6, hz: '4k', left: 45, right: 50 },
{ id: 7, hz: '6k', left: 40, right: 45 },
{ id: 8, hz: '8k', left: 35, right: 40 }
]
const AIDS11: Array<AidT11> = [
{ id: 1, brand: '峰力', model: 'Lumity L90', type: '耳背式 RIC', price: 32800, score: 4.9, feature: '双耳直连 iPhone · 噪声下言语识别提升 30%', stock: true },
{ id: 2, brand: '奥迪康', model: 'Intent 1', type: '受话器外置', price: 29800, score: 4.8, feature: '4D 传感器场景自动切换', stock: true },
{ id: 3, brand: '瑞声达', model: '浩听 96', type: '耳背式 BTE', price: 26800, score: 4.8, feature: 'AI 言语聚焦 · 风噪抑制', stock: true },
{ id: 4, brand: '西嘉', model: 'Pure 7IX', type: '深耳道 CIC', price: 35800, score: 4.9, feature: '隐形佩戴 · 原声处理芯片', stock: false },
{ id: 5, brand: '斯达克', model: 'Evolve AI 20', type: '耳内式 ITC', price: 21800, score: 4.7, feature: '中文语境优化', stock: true },
{ id: 6, brand: '唯听', model: 'Moment 440', type: '受话器外置', price: 24800, score: 4.8, feature: '双压缩通道零延迟', stock: true },
{ id: 7, brand: '优利康', model: '蓝鲸 900', type: '耳背式', price: 16800, score: 4.6, feature: '高性价比入门首选', stock: true },
{ id: 8, brand: '新声', model: '灵犀 Pro', type: '耳内式', price: 9800, score: 4.5, feature: '国产旗舰 · 自研芯片', stock: true },
{ id: 9, brand: '爱沃福', model: '微风 8C', type: '深耳道', price: 7800, score: 4.4, feature: '轻中度听损适用', stock: false },
{ id: 10, brand: '耳魔王', model: 'AV-300', type: '耳背式', price: 4800, score: 4.2, feature: '老人简易操作款', stock: true }
]
const COURSES11: Array<CourseT11> = [
{ id: 1, name: '听觉再适应 · 声音辨识', mins: 20, days: 21, done: 12, icon: '👂' },
{ id: 2, name: '嘈杂环境言语训练', mins: 15, days: 14, done: 14, icon: '🎙️' },
{ id: 3, name: '音乐频率感知修复', mins: 25, days: 28, done: 8, icon: '🎵' },
{ id: 4, name: '耳鸣声治疗(白噪声)', mins: 40, days: 30, done: 22, icon: '🌊' },
{ id: 5, name: '方向听觉定位训练', mins: 12, days: 10, done: 10, icon: '🧭' },
{ id: 6, name: '言语速度适应(快语速)', mins: 18, days: 21, done: 5, icon: '⚡' },
{ id: 7, name: '电话语音强化训练', mins: 10, days: 14, done: 9, icon: '📞' },
{ id: 8, name: '安静环境语句复述', mins: 15, days: 12, done: 12, icon: '📝' }
]
const NOISES11: Array<NoiseT11> = [
{ id: 1, scene: '地铁通勤(车厢内)', db: 88, mins: 45, safe: false, icon: '🚇' },
{ id: 2, scene: '办公室工位', db: 55, mins: 480, safe: true, icon: '🏢' },
{ id: 3, scene: '健身房团课', db: 92, mins: 60, safe: false, icon: '🏋️' },
{ id: 4, scene: '演唱会内场', db: 105, mins: 150, safe: false, icon: '🎤' },
{ id: 5, scene: '咖啡馆闲聊', db: 62, mins: 90, safe: true, icon: '☕' },
{ id: 6, scene: '马路骑行', db: 78, mins: 35, safe: true, icon: '🚴' },
{ id: 7, scene: '电钻装修(邻居家)', db: 96, mins: 120, safe: false, icon: '🛠️' },
{ id: 8, scene: '影院 IMAX 厅', db: 85, mins: 130, safe: false, icon: '🎬' },
{ id: 9, scene: 'KTV 包厢', db: 98, mins: 120, safe: false, icon: '🎤' },
{ id: 10, scene: '居家夜晚', db: 38, mins: 600, safe: true, icon: '🏠' }
]
const REVS11: Array<RevT11> = [
{ id: 1, date: '2026-08-20', place: '爱康听力学中心·望京', result: '高频轻度下降 4kHz/45dB', avg: 28, next: '2027-02-20' },
{ id: 2, date: '2026-02-21', place: '爱康听力学中心·望京', result: '双耳高频临界 4kHz/40dB', avg: 26, next: '2026-08-20' },
{ id: 3, date: '2025-08-18', place: '爱康国宾·国贸分院', result: '正常范围,注意防护', avg: 22, next: '2026-02-21' },
{ id: 4, date: '2025-02-15', place: '爱康国宾·国贸分院', result: '正常范围', avg: 20, next: '2025-08-18' },
{ id: 5, date: '2024-09-02', place: '社区听力筛查车', result: '正常范围', avg: 18, next: '2025-02-15' }
]
const ORDS11: Array<OrdT11> = [
{ id: 1, name: '纯音测听 + 声导抗复查', state: '已完成', date: '2026-08-20', price: 120 },
{ id: 2, name: '峰力 Lumity L90 双耳验配', state: '调试中', date: '2026-08-25', price: 32800 },
{ id: 3, name: '定制耳模(软耳膜)', state: '已完成', date: '2026-08-23', price: 680 },
{ id: 4, name: '助听器干燥盒', state: '已发货', date: '2026-08-22', price: 89 },
{ id: 5, name: '听觉训练季卡', state: '进行中', date: '2026-07-01', price: 398 },
{ id: 6, name: '耳鸣声治疗评估', state: '已完成', date: '2026-06-12', price: 260 },
{ id: 7, name: '音乐节定制耳塞', state: '已完成', date: '2026-05-20', price: 1280 },
{ id: 8, name: '听力健康年卡', state: '生效中', date: '2026-01-01', price: 680 }
]
const SLOT11: Array<string> = ['09:00', '10:15', '11:30', '13:45', '15:00', '16:15']
const EARS11: Array<string> = ['双耳', '左耳', '右耳']
const BUDGET11: Array<string> = ['1万内', '1-2万', '2-3万', '3万以上']
// ---------------- 工具函数 ----------------
function dbColor11(db: number): string {
if (db >= 95) {
return '#F87171'
}
if (db >= 80) {
return '#F59E0B'
}
return '#34D399'
}
function dbToH11(db: number): number {
return Math.max((db - 60) * 1.6, 6)
}
function ordColor11(s: string): string {
if (s === '调试中' || s === '进行中') {
return '#38BDF8'
}
if (s === '已发货') {
return '#F59E0B'
}
return '#34D399'
}
// ================= 页面 =================
@Entry
@Component
struct Index {
@State curTab11: number = 0
@State freqs11: Array<FreqT11> = FREQS11.slice()
@State courses11: Array<CourseT11> = COURSES11.slice()
@State noises11: Array<NoiseT11> = NOISES11.slice()
@State revs11: Array<RevT11> = REVS11.slice()
@State orders11: Array<OrdT11> = ORDS11.slice()
// 弹框开关
@State showTest11: boolean = false
@State showReport11: boolean = false
@State showNoise11: boolean = false
@State showRev11: boolean = false
@State showCourseDel11: boolean = false
@State showFit11: boolean = false
// 测听预约
@State tSlot11: number = 1
@State tEar11: number = 0
// 噪声记录
@State nScene11: number = 0
@State nMins11: number = 60
@State nProtect11: boolean = true
// 复查计划
@State revMonths11: number = 6
// 课程删除
@State courseId11: number = 0
@State armed11: boolean = false
// 验配咨询
@State fModel11: number = 0
@State fEar11: number = 0
@State fBudget11: number = 2
// 特效
@State waveX11: number = -20
@State earOp11: number = 0.3
aboutToAppear(): void {
this.getUIContext().animateTo({ duration: 2400, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut }, () => {
this.waveX11 = 20
})
this.getUIContext().animateTo({ duration: 1300, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut }, () => {
this.earOp11 = 0.9
})
}
// ---------------- 头部(无动画 · 深色声学风) ----------------
@Builder headerBar11() {
Column() {
Row() {
Column() {
Text('👂 HEARING LAB').fontSize(17).fontWeight(700).fontColor('#7DD3FC').letterSpacing(1)
Text('纯音测听 · 助听验配 · 听觉训练').fontSize(10).fontColor('#64809C').margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('🎧').fontSize(22).opacity(this.earOp11)
}
.width('100%')
.padding({ left: 16, right: 16, top: 14, bottom: 8 })
// 静态声波装饰条
Row() {
ForEach([6, 14, 22, 30, 22, 14, 6, 14, 22, 30, 22, 14, 6, 14, 22, 30, 22, 14], (h: number, i: number) => {
Column() {
}
.width(4)
.height(h)
.borderRadius(2)
.backgroundColor(i % 3 === 0 ? '#38BDF8' : '#1E5A8A')
.margin({ right: 6 })
.translate({ x: i % 2 === 0 ? this.waveX11 : -this.waveX11 })
}, (h: number, i: number) => 'wv' + i.toString() + '_' + h.toString())
}
.width('100%')
.justifyContent(FlexAlign.Center)
.padding({ top: 6, bottom: 10 })
Row() {
Text('🔎').fontSize(13)
Text('查助听器型号 / 听力术语 / 门店').fontSize(11).fontColor('#64809C').margin({ left: 6 }).layoutWeight(1).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Text('📊').fontSize(16).onClick(() => {
this.showReport11 = true
})
}
.width('100%')
.padding({ left: 12, right: 12, top: 9, bottom: 9 })
.borderRadius(20)
.backgroundColor('#0E2038')
.margin({ left: 16, right: 16, bottom: 12 })
}
.width('100%')
.backgroundColor('#081527')
.borderRadius({ bottomLeft: 20, bottomRight: 20 })
}
// ---------------- 底部Tab(一排6个) ----------------
@Builder tabItem11(icon: string, label: string, idx: number) {
Column() {
Text(icon).fontSize(19)
Text(label).fontSize(9).fontColor(this.curTab11 === idx ? '#38BDF8' : '#5A7590').margin({ top: 3 })
}
.alignItems(HorizontalAlign.Center)
.padding({ top: 8, bottom: 6 })
.layoutWeight(1)
.onClick(() => {
this.curTab11 = idx
})
}
@Builder tabBar11() {
Row() {
this.tabItem11('📈', '听力图', 0)
this.tabItem11('🎧', '助听器', 1)
this.tabItem11('🎓', '训练', 2)
this.tabItem11('🔊', '防护', 3)
this.tabItem11('🗓️', '复查', 4)
this.tabItem11('👤', '我的', 5)
}
.width('100%')
.backgroundColor('#0B1A2E')
.border({ width: 0.5, color: '#3338BDF8' })
}
// ============ Tab0 听力图(双耳频率双柱) ============
@Builder tabAudi11() {
Column() {
Row() {
Stack() {
Progress({ value: 30, total: 100 })
.width(66)
.height(66)
.style({ strokeWidth: 7 })
.color('#38BDF8')
Column() {
Text('28dB').fontSize(13).fontWeight(700).fontColor('#7DD3FC')
Text('平均听阈').fontSize(8).fontColor('#64809C')
}
.alignItems(HorizontalAlign.Center)
}
Column() {
Text('2026-08-20 纯音测听').fontSize(13).fontWeight(700).fontColor('#E2EDF7')
Text('双耳高频轻度下降(4kHz 45dB),语频区正常,建议防护+半年复查。').fontSize(10).fontColor('#64809C').margin({ top: 5 }).lineHeight(14)
Row() {
Text('预约测听').fontSize(10).fontColor('#081527').fontWeight(700).padding({ left: 12, right: 12, top: 5, bottom: 5 }).borderRadius(14).backgroundColor('#38BDF8').onClick(() => {
this.showTest11 = true
})
Text('完整报告').fontSize(10).fontColor('#38BDF8').padding({ left: 12, right: 12, top: 5, bottom: 5 }).borderRadius(14).border({ width: 0.8, color: '#38BDF8' }).margin({ left: 8 }).onClick(() => {
this.showReport11 = true
})
}
.margin({ top: 9 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 14 })
.layoutWeight(1)
}
.width('100%')
.alignItems(VerticalAlign.Center)
.padding(14)
.borderRadius(16)
.backgroundColor('#0E2038')
.margin({ left: 14, right: 14, top: 12 })
// 双耳频率柱
Column() {
Text('📈 气导听阈(dB HL,柱越低越好)').fontSize(13).fontWeight(700).fontColor('#E2EDF7')
Row() {
ForEach(this.freqs11, (f: FreqT11) => {
Column() {
Text(f.left.toString()).fontSize(7).fontColor('#7DD3FC')
Column() {
Column() {
}
.width(11)
.height(f.left * 0.9)
.borderRadius({ topLeft: 2, topRight: 2 })
.backgroundColor('#38BDF8')
}
.justifyContent(FlexAlign.End)
.height(46)
Text(f.hz).fontSize(7).fontColor('#64809C').margin({ top: 3 })
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
Column() {
Text(f.right.toString()).fontSize(7).fontColor('#FBBF24')
Column() {
Column() {
}
.width(11)
.height(f.right * 0.9)
.borderRadius({ topLeft: 2, topRight: 2 })
.backgroundColor('#FBBF24')
}
.justifyContent(FlexAlign.End)
.height(46)
Text(' ').fontSize(7)
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
}, (f: FreqT11) => 'fq' + f.id.toString())
}
.width('100%')
.margin({ top: 14 })
Row() {
Row() {
Column() {
}
.width(9)
.height(9)
.borderRadius(2)
.backgroundColor('#38BDF8')
Text('左耳').fontSize(9).fontColor('#64809C').margin({ left: 5 })
}
.alignItems(VerticalAlign.Center)
Row() {
Column() {
}
.width(9)
.height(9)
.borderRadius(2)
.backgroundColor('#FBBF24')
Text('右耳').fontSize(9).fontColor('#64809C').margin({ left: 5 })
}
.alignItems(VerticalAlign.Center)
.margin({ left: 16 })
Text('正常 < 26dB · 轻度 26-40 · 中度 41-60').fontSize(8).fontColor('#64809C').layoutWeight(1).textAlign(TextAlign.End)
}
.width('100%')
.margin({ top: 10 })
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.padding(14)
.borderRadius(16
}
}
.width('100%')
.height('100%')
.backgroundColor('#071426')
}
}

在UI构建层面,@Builder装饰器是本案例的核心组织手段。15个@Builder方法将庞大的UI拆分为可管理的模块——6个Tab构建器、6个弹框构建器、头部构建器、Tab栏构建器、Tab项构建器。每个@Builder方法聚焦于一个独立的UI区域,通过参数传递实现复用(如tabItem11接收icon/label/idx三个参数生成6个Tab项)。@Builder与@Component子组件的区别在于:@Builder更轻量、共享当前组件状态,适合页面内的UI模块化;@Component更独立、需要显式传参,适合跨页面复用。本案例全部使用@Builder实现,因为所有UI模块都紧密依赖页面的@State状态,无需独立复用。
在可视化层面,本案例没有使用任何第三方图表库,完全通过ArkUI的基础组件(Column、Row、Text、Circle、Progress等)和属性(height、backgroundColor、justifyContent等)手工搭建了双耳频率双柱状图、噪声分贝标尺柱、训练进度条、复查时间轴、圆形进度环、线性进度条等多种数据可视化组件。这种"原生组件拼装"的方式虽然代码量较大,但优势在于高度可定制、性能优秀、无第三方依赖。每个柱子、每条进度条都可以独立绑定事件和动画,灵活性远超封装好的图表库。对于柱状图、进度条、时间轴等常见可视化需求,掌握这种原生实现方式是非常有价值的技能。
在交互动画层面,本案例通过animateTo显式动画API实现了两个无限循环动画——声波装饰条的左右移动和耳机图标的透明度脉动。animateTo的iterations: -1实现无限循环,playMode: PlayMode.Alternate实现正反交替播放,curve: Curve.EaseInOut实现先加速后减速的自然缓动。这两个动画为页面增添了动态生命力,避免了纯静态界面的沉闷感。在交互层面,6个弹框涵盖了底部抽屉、居中大卡、居中窄卡三种模式,分别适用于不同场景——底部抽屉适合表单输入(预约、验配咨询),居中大卡适合信息展示(报告),居中窄卡适合警示确认(删除)。删除课程的"Toggle开关+按钮状态联动"二次确认机制,体现了对不可逆操作的安全设计意识。
在事件处理层面,本案例展示了ArkUI事件系统的多种用法:onClick点击事件用于按钮和卡片的交互响应;onChange回调用于Toggle开关的状态同步;事件冒泡阻断通过空onClick实现,防止点击弹框内容时触发遮罩层的关闭逻辑。这些事件处理技巧虽然细微,但对用户体验的影响显著——正确的事件冒泡处理确保了"点击内容不关闭、点击遮罩才关闭"的符合直觉的交互行为。总体而言,本案例是一个涵盖数据建模、状态管理、UI构建、可视化、动画、交互、事件处理等全方位技术点的综合性实践范例,对深入理解和掌握鸿蒙ArkTS声明式UI开发具有重要的参考价值。
更多推荐




所有评论(0)