HarmonyOS旅行探索——旅行工具四个页面实现详解

📅 2026/7/12 13:24:53
HarmonyOS旅行探索——旅行工具四个页面实现详解
首页的旅行工具区域放了四个快捷入口行程规划、美食指南、汇率换算、旅行短语。这四个页面的复杂度差异很大——行程规划有状态管理和动态添加功能美食指南是纯粹的数据展示汇率换算涉及实时计算和自定义选择器旅行短语用的是语言切换 数据过滤。放在一起看恰好覆盖了 ArkTS 页面开发中几种常见的交互模式。完整效果TripPlanner行程规划的状态管理行程规划页面是四个工具里交互最复杂的——它需要处理待办事项的勾选、新行程的添加、进度条的动态计算。数据结构interfacePlanItem{day:number;title:string;desc:string;done:boolean}四个字段天数序号、标题、描述、完成状态。done是唯一会变化的状态字段。勾选逻辑引用变更触发刷新privatetoggleDay(idx:number):void{this.days[idx].done!this.days[idx].doneconstcopy:PlanItem[][]for(leti:number0;ithis.days.length;i){copy.push(this.days[i])}this.dayscopy}这段代码有一个关键的步骤容易被忽略——this.days copy。如果只写this.days[idx].done !this.days[idx].doneUI 不会刷新。原因是 ArkTS 的State只追踪引用变更不追踪对象内部属性的修改。修改done属性不会改变days数组的引用所以State认为数据没有变化不会触发重新渲染。解决办法是创建一个新数组把旧数据复制进去然后用this.days copy替换整个引用。这样State检测到引用变了就会重新渲染 UI。for循环复制而不是用slice()或展开运算符是因为 ArkTS 里数组方法的类型推断有时不够精确手动循环是最稳妥的方式。添加行程StatenewTitle:stringprivateaddDay():void{if(this.newTitle.trim().length0)returnthis.days.push({day:this.days.length1,title:this.newTitle,desc:自定义行程,done:false})this.newTitleconstcopy:PlanItem[][]for(leti:number0;ithis.days.length;i){copy.push(this.days[i])}this.dayscopy}添加行程的流程先检查输入是否为空不为空就 push 一个新对象到数组里清空输入框然后复制数组触发刷新。新行程的day序号用this.days.length 1计算——假设原来有 5 天新添加的就是 Day 6。有一个问题如果用户删除了中间某一天后续的day序号会不连续。目前这个页面没有删除功能所以不会出现这个问题。但如果以后加上删除需要在addDay里重新计算所有day值。进度条Row(){Text(已完成 this.doneCount()/this.days.length 天).fontSize(13).fontColor(T2)Blank()Row(){}.width((this.doneCount()/this.days.length)*100).height(6).borderRadius(3).backgroundColor(A)}进度条的宽度用(doneCount / total) * 100计算单位是 vp。如果 5 天里完成了 3 天宽度就是3/5 * 100 60vp。这里有个隐患如果days为空用户删掉了所有行程doneCount / days.length会除以零。目前days有默认值所以不会出现但加上防御性检查会更稳健。每日卡片的勾选 UIStack({alignContent:Alignment.Center}){Row(){}.width(22).height(22).borderRadius(11).backgroundColor(item.done?A:transparent).border({width:1.5,color:item.done?A:#D0CED8})if(item.done){Text(✓).fontSize(12).fontWeight(FontWeight.Bold).fontColor(Color.White)}}勾选框用Stack实现——底层是一个圆形Row选中时橙色填充未选中时透明 灰色边框上层是一个条件渲染的勾号。Stack让勾号自动居中显示在圆形上方。左侧的竖线也根据done状态改变颜色Column(){}.width(2).height(20).backgroundColor(item.done?A40:#F0EEF4)已完成的竖线用主色的 25% 透明度A 40未完成的用浅灰色。这和勾选框的颜色保持了视觉一致。CuisinePage数据聚合与扁平化美食指南页面把所有城市的美食数据聚合到一个列表里按城市分组显示。数据准备StateallCuisines:FoodItem[][]aboutToAppear():void{constlist:FoodItem[][]for(leti:number0;iCITIES.length;i){for(letj:number0;jCITIES[i].cuisines.length;j){list.push({city:CITIES[i].name,emoji:CITIES[i].cuisines[j].emoji,name:CITIES[i].cuisines[j].name,desc:CITIES[i].cuisines[j].desc,price:CITIES[i].cuisines[j].price})}}this.allCuisineslist}双重for循环遍历所有城市的所有美食把它们扁平化成一个一维数组。每个FoodItem额外加了一个city字段记录这道美食属于哪个城市。为什么不直接用CITIES[i].cuisines[j]因为Cuisine接口里没有city字段——它只有 name、emoji、desc、price。但美食列表页面需要显示这道菜来自哪个城市所以必须在遍历时手动补上。这是数据聚合的一个常见模式原始数据的结构不一定满足展示需求需要在中间层做一次转换。aboutToAppear就是做这个转换的地方——在页面渲染之前把数据处理成 UI 需要的格式。城市标签Row(){Text(f.name).fontSize(14).fontWeight(FontWeight.Bold).fontColor(T1)Text(f.city).fontSize(10).fontColor(A).padding({left:4,right:4,top:1,bottom:1}).backgroundColor(#FFF5F0).borderRadius(3).margin({left:6})}每道美食名称后面跟了一个橙色的小标签显示它所属的城市。标签用极小的字号10px、浅橙色背景#FFF5F0和紧凑的内边距保证不抢主信息的视觉焦点但用户需要时又能快速定位来源。CurrencyPage实时计算与自定义选择器汇率换算是四个工具里逻辑最密集的页面——它需要处理用户输入、货币选择、实时汇率计算。汇率数据privatecurrencies:string[][CNY 人民币,USD 美元,EUR 欧元,JPY 日元,KRW 韩元,THB 泰铢,GBP 英镑,AUD 澳元]privaterateMap:Recordstring,number{CNY:1,USD:0.138,EUR:0.127,JPY:20.0,KRW:183.0,THB:4.95,GBP:0.109,AUD:0.21}汇率用Recordstring, number存储key 是货币代码CNY、USD 等value 是相对于人民币的汇率。比如 1 CNY 20 JPY所以 JPY 的值是 20.0。这些汇率是硬编码的 mock 数据。真正的 App 会从汇率 API 实时获取但在原型阶段写死数据能快速验证 UI 和计算逻辑。转换计算privateconvert():void{constfromCode:stringthis.from.substring(0,3)consttoCode:stringthis.to.substring(0,3)constamt:numberparseFloat(this.amount)||0constresultVal:numberamt*(this.rateMap[toCode]/this.rateMap[fromCode])this.resultresultVal.toFixed(2)this.rates1 fromCode (this.rateMap[toCode]/this.rateMap[fromCode]).toFixed(4) toCode}计算逻辑分三步提取货币代码this.from.substring(0, 3)从CNY 人民币里取前三个字符得到CNY查汇率做除法rateMap[toCode] / rateMap[fromCode]算出源货币到目标货币的汇率格式化结果toFixed(2)保留两位小数显示金额toFixed(4)保留四位小数显示汇率parseFloat(this.amount) || 0是一个防御性处理——如果用户输入了非数字内容parseFloat返回NaN|| 0会把NaN替换成 0避免计算结果出现NaN。这个转换在三个地方被调用输入金额变化时、切换源货币时、切换目标货币时。任何可能导致结果变化的操作都会触发重新计算。自定义货币选择器BuilderCurrencyPicker(selected:string,onSelect:(c:string)void){Scroll(){Column({space:4}){ForEach(this.currencies,(c:string){Text(c).fontSize(14).fontWeight(cselected?FontWeight.Bold:FontWeight.Regular).fontColor(cselected?#FF6B35:T2).padding({left:14,right:14,top:8,bottom:8}).backgroundColor(cselected?#FFF5F0:transparent).borderRadius(10).width(100%).onClick((){onSelect(c)})})}}.height(120).scrollBar(BarState.Off)}没有用 HarmonyOS 内置的Select组件而是自己用Scroll Column ForEach拼了一个选择器。高度固定 120vp只显示大约 4 个选项超出部分可滚动。选中状态通过selected参数判断——和当前选中货币相同的那一项加粗、变橙色、加浅橙背景。点击后调用onSelect回调父组件更新from或to的值然后触发convert()重新计算。这个选择器被复用了两次this.CurrencyPicker(this.from,(c:string){this.fromc;this.convert()})this.CurrencyPicker(this.to,(c:string){this.toc;this.convert()})同一个 Builder不同的参数和回调——这就是 Builder 模式的核心优势。PhraseBook语言切换与数据过滤旅行短语页面的交互模式是选语言 → 显示对应短语。语言切换Statelang:string日语Row({space:6}){ForEach([日语,法语,英语],(l:string){Text(l).fontSize(13).fontWeight(this.langl?FontWeight.Bold:FontWeight.Regular).fontColor(this.langl?#FFFFFF:T2).padding({left:14,right:14,top:6,bottom:6}).borderRadius(16).backgroundColor(this.langl?#FF6B35:#F5F5F7).onClick((){this.langl})})}三个语言标签用Row横向排列选中态用橙色背景 白色文字未选中态用灰色背景 灰色文字。切换语言时只需要修改State lang的值UI 自动更新。这种Tab 切换模式在 ArkTS 里很常见——不需要用复杂的 Tab 组件一个Row ForEach onClick就能实现。关键在于this.lang的值决定了每个 Tab 的样式点击时修改这个值就行。数据过滤privategetPhrases():Phrase[]{constp:Phrase[]|undefinedPHRASES[this.lang]if(p)returnpreturn[]}PHRASES是一个Recordstring, Phrase[]key 是语言名称value 是该语言的短语数组。getPhrases()根据当前选中的语言从PHRASES里取出对应的短语列表。如果this.lang对应的语言在PHRASES里不存在undefined返回空数组。这保证了ForEach不会因为数据为空而崩溃。这个根据状态过滤数据的模式和 CuisinePage 的聚合数据正好相反——CuisinePage 是把多个数据源合并成一个列表PhraseBook 是从一个大字典里按 key 取子集。短语卡片Column(){Row(){Text(p.chinese).fontSize(16).fontWeight(FontWeight.Bold).fontColor(T1)Blank()Text().fontSize(16)}.width(100%).margin({bottom:8})Text(p.foreign).fontSize(20).fontWeight(FontWeight.Bold).fontColor(#FF6B35).margin({bottom:4})Text(p.pronunciation).fontSize(13).fontColor(T3)}每张短语卡片有三层信息中文黑色粗体、外文橙色大号粗体、发音灰色小字。右上角有一个喇叭 emoji暗示点击播放语音——目前没有绑定实际的 TTS 功能只是一个视觉占位。外文用橙色#FF6B35和大号字体突出显示因为它是用户最需要看到的信息——“这句话用外语怎么说”。中文作为对照放在上方发音辅助放在下方。四个页面的 Builder 复用统计页面使用的 Builder参数数量有回调参数TripPlanner无ForEach 内联--CuisinePage无ForEach 内联--CurrencyPageCurrencyPicker2是onSelectPhraseBook无ForEach 内联--四个页面里只有 CurrencyPage 抽了 Builder。原因很简单——货币选择器在页面里出现了两次源货币和目标货币抽成 Builder 可以避免重复代码。其他页面的列表项只出现一次抽 Builder 的收益不大。这个规律可以总结为重复出现的 UI 模式值得抽 Builder只出现一次的内联写就行。不要为了架构好看而强行抽象——过度抽象比代码重复更难维护。数据流向的差异四个页面的数据流向各不相同TripPlanner数据完全本地化所有状态在State里不依赖外部数据源。添加和勾选操作都在本地完成。CuisinePage数据来自TravelData.ets的CITIES数组在aboutToAppear里做一次聚合转换之后所有操作基于转换后的allCuisines。CurrencyPage数据来自本地rateMap但计算逻辑是动态的——每次用户输入或选择变化都会触发重新计算。PhraseBook数据来自TravelData.ets的PHRASES字典根据State lang动态过滤切换语言时实时更新列表。这四种模式——本地状态管理、数据聚合、实时计算、条件过滤——基本覆盖了移动 App 里最常见的数据处理场景。理解了这四种模式大多数页面的数据层设计都能套用。