HarmonyOS菜谱——详情页的多区块布局与交互实现 适用读者已经掌握 ArkTS 基础布局、想学习复杂页面拆分策略的开发者。本文以菜谱详情页为例拆解六个功能区块的布局思路和交互实现。完整效果一、页面概览六个区块的职责划分从截图看详情页从上到下依次是区块内容交互类型导航栏返回 菜名 收藏按钮点击返回/收藏切换Hero大图标 菜名 描述 属性纯展示食材清单标题 加入清单按钮 食材列表点击加入清单步骤标题 编号时间线纯展示小贴士背景提示文字纯展示份量调整−/数字/ 按钮点击增减烹饪计时器时间显示 预设按钮 开始/停止点击切换六个区块用Column Scroll垂直排列每个区块是一个独立的Column容器外层统一加白色背景和圆角。这种一区块一容器的模式让每个区块可以独立调整样式互不影响。二、Hero 区块视觉重心的设计Column(){Text(this.recipe.icon).fontSize(72).margin({bottom:10})Text(this.recipe.name).fontSize(24).fontWeight(FontWeight.Bold).fontColor(T1).margin({bottom:4})Text(this.recipe.desc).fontSize(14).fontColor(T2)Row({space:12}){Text(⏱ this.recipe.time).fontSize(12).fontColor(T2)Text( this.recipe.difficulty).fontSize(12).fontColor(T2)Text( this.recipe.servings).fontSize(12).fontColor(T2)}.margin({top:10})}.width(100%).padding(24).backgroundColor(#FFFFFF).borderRadius(18).margin({left:16,right:16,bottom:16}).alignItems(HorizontalAlign.Center)Hero 区块是整个页面的视觉重心——72px 的大 emoji 占据了最大的视觉面积。为什么用 emoji 而不是真实图片因为 mock 阶段没有图片资源emoji 是最轻量的视觉替代方案。真实 App 应该用高清食物照片但 emoji 在 demo 阶段也能传达这是什么菜的信息。alignItems(HorizontalAlign.Center)让所有文字居中对齐。Hero 区块和其他区块不同——其他区块的文字都是左对齐alignItems(HorizontalAlign.Start)Hero 居中是为了营造标题页的感觉和其他内容区块形成视觉区分。三个属性用Row({ space: 12 })横向排列。时间、难度、人数用 “·” 分隔——这不是真正的分隔符而是 Text 字符的一部分⏱ this.recipe.time。这种写法比用三个独立 Text 加分隔符更简洁但缺点是无法单独控制分隔符的颜色和间距。三、食材清单标题行复用与列表渲染Column(){Row(){Text( 食材).fontSize(17).fontWeight(FontWeight.Bold).fontColor(T1).layoutWeight(1)Text( 加入清单).fontSize(12).fontColor(#FF6B6B).fontWeight(FontWeight.Medium).padding({left:10,right:10,top:5,bottom:5}).backgroundColor(#FFF0F0).borderRadius(10).onClick((){if(this.recipe){for(leti:number0;ithis.recipe.ingredients.length;i){addToShop(this.recipe.ingredients[i].name this.recipe.ingredients[i].amount)}}})}.width(100%).margin({bottom:10})Column({space:6}){ForEach(this.recipe.ingredients,(ing:Ingredient){Row(){Row(){}.width(6).height(6).borderRadius(3).backgroundColor(A).margin({right:10})Text(ing.name).fontSize(14).fontColor(T1).layoutWeight(1)Text(ing.amount).fontSize(13).fontWeight(FontWeight.Medium).fontColor(A)}.width(100%).padding({top:4,bottom:4})})}}标题行的双按钮设计标题行左侧是 食材标题右侧是 加入清单按钮。这种标题 操作按钮的模式在前面的 App 里没有出现过——之前的标题行都是标题 数字如房间 5 个右侧是纯展示信息。这里的右侧是一个可点击的操作按钮。layoutWeight(1)让标题占据所有剩余空间。这样加入清单按钮固定在右侧不会被标题文字挤压。无论标题多长 食材很短但如果是 所需食材清单就会很长按钮位置不变。加入清单按钮的样式——浅红背景 红色文字。#FFF0F0是主色 #FF6B6B 的极低透明度背景#FF6B6B是主色文字。这种浅色背景 深色文字的组合让按钮看起来像一个标签而不是实心按钮视觉上更轻量。食材列表的小圆点Row(){}.width(6).height(6).borderRadius(3).backgroundColor(A).margin({right:10})这是一个 6×6 的红色小圆点——用 Row 而不是 Circle 或 Dot 组件。Row 没有内容纯色块borderRadius(3)让方形变成圆形3 6/2。这种空 Row borderRadius的做法在 ArkTS 里是最简单的画圆方式不需要引入额外的图形组件。为什么用 Row 而不是 Text(‘●’)Text 的圆点大小受 fontSize 控制不容易精确到 6px。Row 的 width/height 可以精确控制尺寸更适合做装饰性元素。食材数量的动态性食材列表用ForEach(this.recipe.ingredients, ...)渲染——每道菜的食材数量不同红烧肉 7 种拍黄瓜 6 种。Column({ space: 6 })自动处理间距不需要手动计算每个食材项的位置。四、步骤时间线编号圆圈 连接线Column(){Text( 步骤).fontSize(17).fontWeight(FontWeight.Bold).fontColor(T1).width(100%).margin({bottom:10})ForEach(this.recipe.steps,(step:string,idx:number){Column(){Row(){Column(){Text((idx1).toString()).fontSize(14).fontWeight(FontWeight.Bold).fontColor(#FFFFFF)}.width(30).height(30).borderRadius(15).backgroundColor(A).justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)Text(step).fontSize(14).fontColor(T1).lineHeight(22).margin({left:12}).layoutWeight(1)}.width(100%)if(idxthis.recipe!.steps.length-1){Row(){}.width(2).height(16).backgroundColor(#FFF0F0).margin({left:14})}}.width(100%)})}编号圆圈的实现Column(){Text((idx1).toString()).fontSize(14).fontWeight(FontWeight.Bold).fontColor(#FFFFFF)}.width(30).height(30).borderRadius(15).backgroundColor(A).justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)编号用 Column 包裹 TextColumn 设定 30×30 的尺寸和圆形borderRadius(15)Text 居中显示步骤序号。idx 1而不是idx——因为数组索引从 0 开始但步骤编号从 1 开始。为什么用 Column 而不是 RowRow 是水平排列Text 在 Row 里默认左对齐。Column 是垂直排列配合justifyContent(FlexAlign.Center)和alignItems(HorizontalAlign.Center)能同时实现水平和垂直居中。如果用 Row需要额外加textAlign(TextAlign.Center)和外层居中代码更复杂。连接线的条件渲染if(idxthis.recipe!.steps.length-1){Row(){}.width(2).height(16).backgroundColor(#FFF0F0).margin({left:14})}连接线只在非最后一个步骤之间显示。idx this.recipe!.steps.length - 1判断当前步骤不是最后一步时才渲染连接线。最后一步下方不需要连接线——因为下面没有更多步骤了。margin({ left: 14 })让连接线和编号圆圈对齐。编号圆圈宽 30px居中后圆心在 15px 位置。连接线宽 2pxmargin left 14px 让连接线的中心14 1 15和圆圈中心对齐。这种手动计算 margin 对齐的做法比较原始但在 ArkTS 里没有更简便的方式。踩坑记录最初连接线用的是margin({ left: 15 })视觉上连接线偏右 1px。改成margin({ left: 14 })后完美对齐。这种 1px 的偏差在手机上不太明显但在模拟器预览里能看出来。this.recipe!的非空断言this.recipe!.steps.length用了!非空断言——告诉编译器this.recipe一定不是 undefined。这是因为外层已经有if (this.recipe)判断进入这个分支后this.recipe肯定有值。但 TypeScript 编译器不会自动推断 if 分支内的类型收窄因为this.recipe是 State 变量可能在异步操作中被改为 undefined所以需要手动断言。五、小贴士卡片背景色的差异化Column(){Text( 小贴士).fontSize(17).fontWeight(FontWeight.Bold).fontColor(T1).width(100%).margin({bottom:8})Text(this.recipe!.tips).fontSize(13).fontColor(T2).lineHeight(22)}.width(100%).padding(18).backgroundColor(#FFF8F5).borderRadius(18).margin({left:16,right:16}).border({width:1,color:#FFE8D0})小贴士卡片用了暖色背景#FFF8F5极浅橙色和边框#FFE8D0浅橙色和其他白色卡片形成视觉区分。这种暖色背景 暖色边框的组合暗示这是提示/建议类信息和正文内容在视觉上分开。lineHeight(22)控制行高。小贴士是一段较长的文字没有用 ForEach 逐行渲染而是用一个 Text 组件显示整段文字。lineHeight(22)让行间距适中不会太挤也不会太松。如果不设 lineHeight默认行高可能太小长段文字阅读体验差。六、份量调整边界检查的实现Stateservings:number1Column(){Text( 份量调整).fontSize(17).fontWeight(FontWeight.Bold).fontColor(T1).width(100%).margin({bottom:8})Row(){Text(−).fontSize(20).fontWeight(FontWeight.Bold).fontColor(T2).width(40).height(40).borderRadius(20).backgroundColor(#F5F5FA).textAlign(TextAlign.Center).onClick((){if(this.servings1)this.servings--})Text(this.servings.toString() 人份).fontSize(18).fontWeight(FontWeight.Bold).fontColor(A).margin({left:16,right:16})Text().fontSize(20).fontWeight(FontWeight.Bold).fontColor(T2).width(40).height(40).borderRadius(20).backgroundColor(#F5F5FA).textAlign(TextAlign.Center).onClick((){if(this.servings8)this.servings})}.width(100%).justifyContent(FlexAlign.Center)}边界检查的重要性// 减号按钮.onClick((){if(this.servings1)this.servings--})// 加号按钮.onClick((){if(this.servings8)this.servings})两个边界检查最小 1 人份不能减到 0 或负数最大 8 人份不能无限加。如果没有边界检查用户可以减到 0 甚至负数——0 人份在业务上没有意义可能导致后续计算出错比如乘以 0 得到 0 食材用量。为什么最大是 8因为 mock 数据里最大的 servings 是6-8人戚风蛋糕。8 是一个合理的上限——家庭聚餐一般不会超过 8 人。这个上限是业务逻辑决定的不是技术限制。按钮样式的对称设计减号和加号按钮用完全相同的样式——40×40 的圆形灰色背景按钮。对称设计让用户直觉上知道这两个按钮功能相反。中间的数字用红色粗体fontColor(A).fontWeight(FontWeight.Bold)视觉上最突出——用户一眼就能看到当前的份数。textAlign(TextAlign.Center)的必要性。减号−和加号都是单字符但 Text 组件默认左对齐。在 40×40 的按钮里单字符左对齐会偏左。textAlign(TextAlign.Center)让字符在按钮内水平居中。七、烹饪计时器状态按钮的样式切换StatetimerOn:booleanfalseStatetimerMin:number0StatetimerSec:number0// 时间显示Text(this.timerMin.toString():(this.timerSec10?0:)this.timerSec.toString()).fontSize(32).fontWeight(FontWeight.Bold).fontColor(this.timerOn?A:T3)// 按钮组Row({space:10}){Text(3分钟).onClick((){this.timerMin3;this.timerSec0;this.timerOntrue})Text(5分钟).onClick((){this.timerMin5;this.timerSec0;this.timerOntrue})Text(10分钟).onClick((){this.timerMin10;this.timerSec0;this.timerOntrue})Text(this.timerOn?⏸ 停止:▶ 开始).fontColor(#FFFFFF).backgroundColor(this.timerOn?#FF6B6B:A).onClick((){this.timerOn!this.timerOn})}时间显示的格式化(this.timerSec 10 ? 0 : )在秒数小于 10 时补零——“0:05而不是0:5”。这是时间显示的标准做法保证冒号后面的数字始终是两位数。状态按钮的双重切换开始/停止按钮同时切换三个属性// 点击3分钟this.timerMin3// 设置分钟this.timerSec0// 清零秒this.timerOntrue// 启动计时// 点击停止this.timerOn!this.timerOn// 切换状态按钮的文字和颜色都根据timerOn切换状态文字背景色时间颜色未启动▶ 开始#FF6B6B主色#BBBBBB灰色运行中⏸ 停止#FF6B6B主色#FF6B6B主色踩坑记录最初开始按钮的背景色是灰色#F5F5FA和预设时间按钮一样。用户分不清哪个是开始哪个是预设。改成主色后开始/停止按钮在视觉上最突出用户一眼就能找到。当前未实现的功能计时器目前只有 UI 和状态切换没有实际的倒计时逻辑。要实现倒计时需要setInterval// 后续需要实现的倒计时逻辑aboutToAppear():void{setInterval((){if(this.timerOn){if(this.timerSec0){this.timerSec--}elseif(this.timerMin0){this.timerMin--this.timerSec59}else{this.timerOnfalse// 播放提示音或弹通知}}},1000)}setInterval每秒执行一次——秒数减到 0 后分钟减 1、秒数重置为 59分钟和秒都为 0 时停止计时。当前没有实现是因为setInterval在 ArkTS 里的生命周期管理比较复杂——页面销毁时需要clearInterval否则会内存泄漏。八、六个区块的间距规律六个区块之间的间距值区块间margin bottom原因Hero → 食材16px大区块之间用大间距食材 → 步骤16px同上步骤 → 小贴士16px同上小贴士 → 份量16px同上份量 → 计时器无紧挨两个功能区紧密关联前四个区块之间都是 16px 间距计时器紧贴份量调整没有额外 margin。这种功能相关的区块紧贴、功能独立的区块拉开的间距策略让页面有节奏感——用户能感知到份量调整和计时器是一组功能。所有区块的左右 margin 都是{ left: 16, right: 16 }和首页、分类页的边距一致。统一的边距让所有页面的视觉风格保持一致——用户切换页面时不会感到页面宽度变了。九、整体嵌套结构Column 页面容器 ├→ Row 导航栏 └→ if (this.recipe) 数据加载判断 └→ Scroll 可滚动内容 └→ Column 垂直排列 ├→ Column padding(24) Hero 区块 ├→ Column padding(18) 食材清单 ├→ Column padding(18) 步骤时间线 ├→ Column padding(18) 小贴士 ├→ Column padding(16) 份量调整 ├→ Column padding(16) 烹饪计时器 └→ Blank().height(20) 底部留白每个区块都是Column 白色背景 圆角 padding的标准结构。区块之间的间距通过margin({ bottom: 16 })控制。这种区块 Column 样式的模式让代码结构清晰——看到一个新的 Column 就知道是一个新的功能区块。if (this.recipe)的必要性。aboutToAppear里通过getRecipeById查询菜谱数据如果传入的 id 无效比如用户手动修改了 URL 参数this.recipe是 undefined。if (this.recipe)保证只在数据加载成功后才渲染内容避免访问this.recipe.name时抛出 undefined 错误。