HarmonyOS旅行探索——旅行工具箱页面四合一实现

发布时间:2026/7/12 19:02:57
HarmonyOS旅行探索——旅行工具箱页面四合一实现 底部导航栏的工具Tab 跳转到的不是单个功能页而是一个聚合页面——旅行工具箱。这个页面把四个独立的功能模块塞进了一个 Scroll 里常用工具网格、出行清单、小费计算器、世界时间。每个模块的交互模式都不一样放在一起恰好展示了 ArkTS 里几种典型的数据处理方式。完整效果页面结构概览从截图看页面从上到下依次是常用工具2×2 网格四个工具卡片点击跳转到对应页面出行清单10 项待办列表可勾选带进度条小费计算器输入金额 选择比例 实时计算结果世界时间6 个城市的当前时间基于系统时钟 UTC 偏移计算整体布局用Scroll Column实现四个模块依次纵向排列内容超出屏幕时上下滚动。常用工具2×2 网格Grid(){GridItem(){this.ToolCard(,汇率换算,#00B894,(){router.pushUrl({url:pages/CurrencyPage})})}GridItem(){this.ToolCard(️,旅行短语,#8A5DFA,(){router.pushUrl({url:pages/PhraseBook})})}GridItem(){this.ToolCard(,行程规划,#5DADE2,(){router.pushUrl({url:pages/TripPlanner})})}GridItem(){this.ToolCard(,美食指南,#E8734A,(){router.pushUrl({url:pages/CuisinePage})})}}.columnsTemplate(1fr 1fr).columnsGap(10).rowsGap(10)和首页的旅行工具区域功能相同但布局不同——首页是横向一排四个按钮这里是一个 2×2 的网格。同样的功能入口不同的展示方式这是 App 里首页快捷入口 vs 工具页完整列表的常见分工。ToolCard Builder 的实现和首页的 ToolBtn 类似但多了一个圆角背景BuilderToolCard(icon:string,label:string,color:string,action:()void){Column(){Text(icon).fontSize(32).margin({bottom:8})Text(label).fontSize(13).fontWeight(FontWeight.Medium).fontColor(T1)}.width(100%).padding(16).backgroundColor(color10).borderRadius(14).onClick(action)}color 10是主色加 6% 透明度的浅色背景和首页的 ToolBtn 用的是同一个透明度值。这种一致的视觉处理让用户在不同页面看到相同的功能入口时能快速识别这是同一类东西。出行清单带进度的待办列表数据结构interfaceCheckItem{name:string;done:boolean;icon:string}StatecheckItems:CheckItem[][{name:护照/签证,done:false,icon:},{name:机票/行程单,done:false,icon:✈️},{name:充电器/转换插头,done:false,icon:},{name:常用药品,done:false,icon:},{name:换洗衣物,done:false,icon:},{name:洗漱用品,done:false,icon:},{name:相机,done:false,icon:},{name:雨伞/雨衣,done:false,icon:☂️},{name:移动电源,done:false,icon:},{name:防晒霜,done:false,icon:},]和行程规划的PlanItem结构类似但没有day字段——出行清单是扁平列表不需要天数序号。多了icon字段给每项配一个 emoji 图标。勾选逻辑privatetoggleCheck(idx:number):void{this.checkItems[idx].done!this.checkItems[idx].doneconstcopy:CheckItem[][]for(leti:number0;ithis.checkItems.length;i){copy.push(this.checkItems[i])}this.checkItemscopy}和行程规划的toggleDay完全一样的模式——修改属性 复制数组 替换引用。这个复制触发刷新的模式在需要动态修改数组的场景里反复出现已经是一个固定套路了。进度展示Row(){Text( 出行清单).fontSize(16).fontWeight(FontWeight.Bold).fontColor(T1)Blank()Text(this.doneCount()/this.checkItems.length).fontSize(12).fontColor(A).fontWeight(FontWeight.Bold)}Row(){}.width((this.doneCount()/this.checkItems.length)*100).height(4).borderRadius(2).backgroundColor(A)进度条比行程规划的更细——高度只有 4vp行程规划是 6vp。因为出行清单的卡片更紧凑每项只有文字 勾选框进度条太粗会显得不协调。这个高度差异是根据卡片密度调整的。勾选框的圆角差异Row(){}.width(20).height(20).borderRadius(6).backgroundColor(item.done?A:transparent).border({width:1.5,color:item.done?A:#D0CED8})出行清单的勾选框用borderRadius(6)圆角 6而行程规划用borderRadius(11)圆形。视觉上一个是小方块一个是正圆。这种差异可能是有意的——出行清单的卡片更矮更紧凑小方块比正圆更节省垂直空间。小费计算器实时计算的输入输出交互流程用户输入消费金额 → 选择小费比例 → 实时显示小费金额。三个操作都会触发重新计算。数据定义StatetipAmount:string100StatetipPercent:number15StatetipResult:string15三个State分别对应输入金额、选中比例、计算结果。tipAmount是字符串类型因为TextInput的onChange回调返回的是string直接存字符串避免了类型转换。计算逻辑privatecalcTip():void{constamt:numberparseFloat(this.tipAmount)||0this.tipResult(amt*this.tipPercent/100).toFixed(1)}parseFloat || 0的防御性处理和汇率换算页面一样。计算公式是金额 × 比例 / 100结果用toFixed(1)保留一位小数。这个方法在三个地方被调用输入金额变化时this.tipAmount v; this.calcTip()选择比例时this.tipPercent p; this.calcTip()页面初始化时也会被调用一次因为State有初始值比例选择器Row({space:8}){ForEach([10,15,18,20,25],(p:number){Text(p%).fontSize(12).fontWeight(this.tipPercentp?FontWeight.Bold:FontWeight.Regular).fontColor(this.tipPercentp?#FFFFFF:T2).padding({left:14,right:14,top:6,bottom:6}).borderRadius(14).backgroundColor(this.tipPercentp?A:#F5F5F7).onClick((){this.tipPercentp;this.calcTip()})})}五个预设比例用Row横向排列选中态用橙色背景 白色文字未选中态用灰色背景 灰色文字。和旅行短语页面的语言切换用的是同一套视觉模式——选中态高亮未选中态低调。比例的间距用Row({ space: 8 })统一控制不需要给每个 Text 单独设 margin。结果展示Row(){Text(小费金额).fontSize(14).fontColor(T1).layoutWeight(1)Text(¥this.tipResult).fontSize(24).fontWeight(FontWeight.Bold).fontColor(A)}.width(100%).padding(12).backgroundColor(#FFF5F0).borderRadius(10)结果用大号橙色字体显示在浅橙色背景上视觉上和输入区域形成对比——输入区是白底灰框结果区是橙底橙字。这种输入低调、输出高调的视觉层次让用户一眼就能看到计算结果。世界时间系统时钟 UTC 偏移城市数据BuilderTimeCard(flag:string,city:string,offset:number,country:string){Row(){Text(flag).fontSize(20).width(40).height(40).borderRadius(10).backgroundColor(#F5F5F7).textAlign(TextAlign.Center)Column(){Text(city).fontSize(14).fontWeight(FontWeight.Bold).fontColor(T1)Text(country · UTC(offset0?:)offset.toString()).fontSize(11).fontColor(T3)}.alignItems(HorizontalAlign.Start).margin({left:12}).layoutWeight(1)Column(){Text(this.getTime(offset)).fontSize(16).fontWeight(FontWeight.Bold).fontColor(A)Text(当地时间).fontSize(10).fontColor(T3)}.alignItems(HorizontalAlign.End)}}每个城市卡片分三部分左侧国旗 emoji40×40 圆角容器中间城市名和时区信息右侧当前时间。国旗 emoji 用textAlign(TextAlign.Center)在容器内居中。时区显示格式是UTC8、UTC-5等。(offset 0 ? : )处理正负号——正偏移加号负偏移自带-号。时间计算privategetTime(offset:number):string{voidthis.timeStampconstnow:DatenewDate()constutc:numbernow.getTime()now.getTimezoneOffset()*60000constlocal:DatenewDate(utcoffset*3600000)consth:stringlocal.getHours().toString()constm:stringlocal.getMinutes().toString()returnh:m}这段代码有三个步骤获取当前 UTC 时间now.getTimezoneOffset()返回本地时区和 UTC 的分钟差比如北京时间是 -480 分钟。加上这个偏移量后得到 UTC 时间戳。加上目标城市的偏移offset * 3600000把小时偏移转换成毫秒1 小时 3600000 毫秒。格式化输出取getHours()和getMinutes()拼成HH:MM格式。void this.timeStamp是一个关键技巧——它不读取timeStamp的值但引用了这个变量。当timeStamp变化时用户点击刷新按钮getTime方法会被重新调用因为它依赖了这个变量。如果去掉这行getTime只在页面初始化时调用一次之后时间不会更新。这个void引用是一种手动强制刷新的模式——在没有定时器的场景下通过改变一个辅助变量来触发重新计算。刷新机制Text( 刷新).fontSize(12).fontColor(A).fontWeight(FontWeight.Medium).onClick((){this.timeStamp})点击刷新按钮时this.timeStamp改变了State变量的值触发 UI 重新渲染所有getTime调用都会用最新的系统时间重新计算。这是一个简易的手动刷新方案。更好的做法是用setInterval每分钟自动刷新但在原型阶段手动刷新已经够用——用户需要看最新时间时点一下就行。四个模块的数据模式模块数据来源变化频率交互方式常用工具静态数据不变点击跳转出行清单本地 State用户操作时勾选小费计算器用户输入 计算实时输入 选择世界时间系统时钟每分钟手动刷新四个模块代表了四种不同的数据生命周期静态数据常用工具的四个入口永远不会变用户状态出行清单的勾选状态是用户自己维护的实时计算小费计算器每次输入变化都会重新计算系统时钟世界时间依赖系统时间手动触发刷新这种一个页面包含多种数据模式的设计在工具类 App 里很常见。每个模块独立运作互不影响但共享同一个页面的导航和背景。从代码角度看四个模块的State完全独立——修改出行清单不会影响小费计算器修改小费比例不会影响世界时间。Builder 复用页面用了两个 BuilderToolCard常用工具的 2×2 卡片接收图标、文字、颜色、回调四个参数TimeCard世界时间的每个城市行接收国旗、城市、偏移量、国家四个参数出行清单的每一项没有抽 Builder因为它在ForEach里只出现一次抽出来的收益不大。小费计算器的比例选择也没有抽 Builder五个按钮的样式差异选中 vs 未选中用条件表达式内联处理更直观。选哪些 UI 模式抽 Builder哪些内联处理标准只有一个重复出现两次以上的 UI 模式值得抽 Builder。只出现一次的内联就行。