HarmonyOS ArkTS API 24实战解析:从数据建模到动效弹窗的营养餐盘应用
一、鸿蒙开发背景与ArkTS语言概述
鸿蒙操作系统(HarmonyOS)是华为面向万物互联时代打造的分布式操作系统,其应用开发框架提供了一套全新的声明式UI开发范式。在这一范式下,开发者使用ArkTS语言来构建用户界面。ArkTS是在TypeScript基础上扩展而来的编程语言,它保留了TypeScript的静态类型检查能力,同时深度集成了鸿蒙系统的UI声明能力。与传统的命令式UI开发不同,ArkTS采用声明式语法,开发者只需描述界面"是什么样子",而不需要关心"怎么渲染"——框架会自动追踪状态变化并驱动UI更新,这种数据驱动的设计理念极大地简化了复杂界面的开发流程。
ArkTS语言的核心优势在于它将UI描述与业务逻辑有机融合在同一个代码文件中。开发者可以在一个.ets文件中同时定义数据接口、状态变量、业务方法和UI布局,这种"高内聚"的代码组织方式使得组件的职责边界清晰,维护成本显著降低。ArkTS通过丰富的装饰器系统(如@Entry、@Component、@State、@Builder等)来标注不同类型的代码块,编译器据此生成高效的渲染逻辑。与React的JSX或Flutter的Widget树不同,ArkTS的声明式语法更加接近自然语言描述,结构化嵌套的组件调用方式让UI层级的可读性极强。
二、声明式UI范式与ArkUI组件体系
鸿蒙的声明式UI范式遵循"状态驱动视图"的核心原则。当状态变量发生变化时,框架会自动比较新旧状态差异,仅更新受影响的UI节点,这一过程称为"差分更新"或"重渲染"。开发者无需手动调用setText()或setColor()等方法来更新界面,只需修改被@State等装饰器标注的变量,UI就会自动响应。这种模式不仅减少了样板代码,更重要的是消除了状态与视图不一致的风险——界面始终是状态的忠实映射。
ArkUI是鸿蒙的UI组件框架,提供了从基础容器到高级交互组件的完整组件体系。容器组件如Column(纵向布局)、Row(横向布局)、Stack(层叠布局)、Flex(弹性布局)等负责组织和定位子组件;基础组件如Text(文本)、Image(图片)、TextInput(输入框)、Toggle(开关)、Progress(进度条)等负责内容展示和用户交互;滚动组件如Scroll提供了内容超出屏幕时的滚动能力。每个组件都通过链式调用的方式设置属性,如.fontSize()、.fontColor()、.backgroundColor()、.borderRadius()、.margin()、.padding()等,这种链式属性设置方式使得UI描述既简洁又富有表现力。
在ArkUI中,组件的布局采用Flexbox思想,支持主轴和交叉轴的对齐控制。justifyContent控制主轴方向的对齐方式,alignItems控制交叉轴方向的对齐方式。layoutWeight属性允许子组件按比例分配父容器的剩余空间,这在实现等宽列、自适应宽度等布局时非常有用。此外,position属性支持绝对定位,zIndex属性控制组件的层级堆叠顺序,这些能力为实现弹窗、遮罩层等浮动UI提供了基础。配合animateTo动画API,开发者可以让任意属性变化都带有平滑的过渡效果,从而打造出富有动态质感的用户界面。
三、项目概览:营养餐盘应用架构分析
本文将深入剖析一个完整的鸿蒙ArkTS应用——营养餐盘(MEAL PLATE)。这是一个面向健康管理场景的饮食追踪应用,具备五个标签页(餐盘、食物库、记录、营养、我的)和六个弹窗(记一餐、自定义食物、编辑记录、删除确认、食物详情、饮水打卡),涵盖了环形进度展示、营养素横条图、每周柱状图、饮水格子条等多种数据可视化形式,并融入了餐盘旋转动画和光点脉冲特效。通过对这个项目的逐段解析,读者将全面掌握ArkTS从数据建模、状态管理到UI构建、动画实现的完整开发流程。
下面是整个应用的整体架构流程图,展示了从数据层到视图层的完整流转关系:
四、数据接口定义:TypeScript类型系统在ArkTS中的应用
interface MealT {
id: number
meal: string
icon: string
food: string
kcal: number
portion: number
time: string
protein: number
carb: number
fat: number
}
interface FoodT {
id: number
icon: string
name: string
kcal: number
cat: string
protein: number
often: boolean
}
interface WeekT {
day: string
kcal: number
}
interface NutriT {
name: string
got: number
target: number
color: string
unit: string
}
interface WaterT {
icon: string
label: string
desc: string
}

应用的第一步是定义数据模型。ArkTS继承了TypeScript的interface关键字来定义数据接口,这些接口充当了整个应用的数据契约。首先看到的是MealT接口,它描述了一条饮食记录的完整结构:id是唯一标识符,meal标识餐次类型(早餐、午餐、晚餐、加餐),icon存储Emoji表情作为视觉图标,food是食物名称描述,kcal记录每份热量值,portion记录份数(支持小数,如0.5份),time记录用餐时间,protein/carb/fat分别记录蛋白质、碳水、脂肪的克数。
技术概念:ArkTS的interface与TypeScript的interface
ArkTS中的interface用法与TypeScript基本一致,用于定义对象的形状(Shape)。但ArkTS对类型安全的要求更加严格——所有变量必须显式声明类型,不支持TypeScript中的隐式any类型。这种严格的类型系统在编译期就能捕获大量潜在错误,是ArkTS区别于普通TypeScript的重要特征之一。
FoodT接口描述的是食物库中的食物条目,与MealT有所不同。FoodT更侧重于食物本身的属性:name是食物名称,cat是分类(主食、蛋白、蔬果、零食、饮品),often是一个布尔值,标记是否为"常吃"食物。值得注意的是,FoodT中没有carb和fat字段,只有protein,这是因为食物库主要关注热量和蛋白质这两个核心指标,简化了数据结构。
WeekT、NutriT、WaterT三个接口则分别服务于不同的展示场景。WeekT只有day和kcal两个字段,用于每周柱状图展示;NutriT包含name、got(已摄入量)、target(目标量)、color(展示颜色)、unit(单位),用于营养素横条图展示;WaterT包含icon、label、desc,用于饮水知识的时间线展示。这些接口的设计体现了"按需定义"的原则——每个接口只包含其展示场景所需的字段,避免数据冗余。
五、静态数据常量:应用初始状态的构建
const TABS0: Array<string> = ['餐盘', '食物库', '记录', '营养', '我的']
const MEALS0: Array<MealT> = [
{ id: 1, meal: '早餐', icon: '🥣', food: '燕麦牛奶碗 + 水煮蛋', kcal: 348, portion: 1, time: '07:40', protein: 18, carb: 46, fat: 9 },
{ id: 2, meal: '加餐', icon: '🍎', food: '红富士苹果一个', kcal: 95, portion: 1, time: '10:20', protein: 1, carb: 24, fat: 0 },
{ id: 3, meal: '午餐', icon: '🍗', food: '黄焖鸡米饭(少汁)', kcal: 612, portion: 1, time: '12:15', protein: 34, carb: 78, fat: 16 },
{ id: 4, meal: '加餐', icon: '☕', food: '燕麦拿铁(无糖)', kcal: 142, portion: 1, time: '15:30', protein: 8, carb: 14, fat: 5 },
{ id: 5, meal: '晚餐', icon: '🥗', food: '鸡胸牛油果沙拉', kcal: 386, portion: 1, time: '18:50', protein: 32, carb: 22, fat: 18 },
{ id: 6, meal: '加餐', icon: '🥛', food: '无糖酸奶半盒', kcal: 68, portion: 0.5, time: '21:10', protein: 6, carb: 8, fat: 2 },
{ id: 7, meal: '早餐', icon: '🥯', food: '全麦贝果 + 奶酪', kcal: 376, portion: 1, time: '昨天 07:55', protein: 16, carb: 52, fat: 11 },
{ id: 8, meal: '午餐', icon: '🍜', food: '番茄牛肉面', kcal: 548, portion: 1, time: '昨天 12:30', protein: 28, carb: 82, fat: 10 },
{ id: 9, meal: '晚餐', icon: '🐟', food: '清蒸鲈鱼 + 杂粮饭', kcal: 428, portion: 1, time: '昨天 19:05', protein: 38, carb: 42, fat: 9 },
{ id: 10, meal: '加餐', icon: '🌰', food: '每日坚果一袋', kcal: 156, portion: 1, time: '前天 15:40', protein: 5, carb: 12, fat: 12 },
{ id: 11, meal: '午餐', icon: '🍱', food: '轻食便当(鸡胸藜麦)', kcal: 468, portion: 1, time: '前天 12:20', protein: 36, carb: 48, fat: 12 },
{ id: 12, meal: '晚餐', icon: '🍲', food: '冬瓜排骨汤 + 米饭', kcal: 495, portion: 1, time: '前天 18:40', protein: 26, carb: 58, fat: 15 },
{ id: 13, meal: '加餐', icon: '🍪', food: '两块曲奇(罪恶)', kcal: 232, portion: 2, time: '周一 16:00', protein: 3, carb: 32, fat: 11 }
]
const FOODS0: Array<FoodT> = [
{ id: 1, icon: '🍚', name: '白米饭', kcal: 116, cat: '主食', protein: 3, often: true },
{ id: 2, icon: '🥔', name: '蒸红薯', kcal: 90, cat: '主食', protein: 2, often: true },
{ id: 3, icon: '🌾', name: '燕麦片', kcal: 367, cat: '主食', protein: 13, often: true },
{ id: 4, icon: '🍗', name: '鸡胸肉', kcal: 133, cat: '蛋白', protein: 24, often: true },
{ id: 5, icon: '🥩', name: '瘦牛肉', kcal: 143, cat: '蛋白', protein: 21, often: false },
{ id: 6, icon: '🐟', name: '鲈鱼', kcal: 105, cat: '蛋白', protein: 19, often: true },
{ id: 7, icon: '🥦', name: '西兰花', kcal: 27, cat: '蔬果', protein: 3, often: true },
{ id: 8, icon: '🍅', name: '番茄', kcal: 15, cat: '蔬果', protein: 1, often: false },
{ id: 9, icon: '🥑', name: '牛油果', kcal: 161, cat: '蔬果', protein: 2, often: false },
{ id: 10, icon: '🍪', name: '曲奇饼干', kcal: 502, cat: '零食', protein: 6, often: false },
{ id: 11, icon: '🧋', name: '珍珠奶茶', kcal: 437, cat: '饮品', protein: 3, often: false },
{ id: 12, icon: '🥛', name: '无糖酸奶', kcal: 62, cat: '饮品', protein: 10, often: true }
]
const WEEKS0: Array<WeekT> = [
{ day: '一', kcal: 1980 },
{ day: '二', kcal: 2260 },
{ day: '三', kcal: 1740 },
{ day: '四', kcal: 2130 },
{ day: '五', kcal: 2480 },
{ day: '六', kcal: 1890 },
{ day: '日', kcal: 1651 }
]
const NUTRIS0: Array<NutriT> = [
{ name: '蛋白质', got: 99, target: 120, color: '#F2762E', unit: 'g' },
{ name: '碳水', got: 201, target: 240, color: '#4E8BC8', unit: 'g' },
{ name: '脂肪', got: 61, target: 65, color: '#9B6BC3', unit: 'g' },
{ name: '膳食纤维', got: 14, target: 25, color: '#5AA469', unit: 'g' }
]
const WATERS0: Array<WaterT> = [
{ icon: '🌅', label: '晨起一杯', desc: '唤醒肠胃,补足夜间流失' },
{ icon: '☕', label: '上午一杯', desc: '搭配加餐,拒绝含糖饮料' },
{ icon: '🍚', label: '饭前一杯', desc: '增加饱腹感,防止吃多' },
{ icon: '🏃', label: '运动前后', desc: '少量多次,别等渴了才喝' },
{ icon: '🌙', label: '睡前小口', desc: '半杯即可,避免起夜' }
]
const MEALKINDS0: Array<string> = ['早餐', '午餐', '晚餐', '加餐']
const CATS0: Array<string> = ['全部', '主食', '蛋白', '蔬果', '零食', '饮品']
const KCAL_TARGET0: number = 2100

在定义了数据接口之后,应用通过一系列const常量来初始化静态数据。这些常量名都以0结尾,这是一种命名约定,暗示它们是"第0版"的初始数据,后续可能会被状态变量覆盖或修改。TABS0定义了底部导航栏的五个标签名称;MEALS0是核心的餐记录数组,包含13条记录,覆盖了今天、昨天、前天和周一的用餐数据,每条记录都完整填充了MealT接口定义的所有字段。
FOODS0是食物库数据,包含12种常见食物,按主食、蛋白、蔬果、零食、饮品五个分类组织。每种食物都标注了每100g的热量值和蛋白质含量,以及是否为常吃食物。WEEKS0是本周七天的热量摄入数据,用于柱状图展示。NUTRIS0定义了四大营养素的当日摄入量和目标值,每种营养素都关联了一个展示颜色——蛋白质用橙色、碳水用蓝色、脂肪用紫色、膳食纤维用绿色,这种颜色编码在整个应用的UI中保持一致。WATERS0是饮水知识卡片数据,按时间线排列了五个饮水建议。
技术概念:const常量与@State状态变量的区别
const声明的常量在编译期就确定了值,运行时不可修改。而@State装饰的状态变量虽然也可以赋予初始值,但它的值在运行时可以被修改,且修改后会自动触发UI重渲染。在本应用中,静态数据常量作为初始值赋给@State变量后,所有的数据变更操作(增删改)都作用于@State变量,而原始常量保持不变。
MEALKINDS0和CATS0是两个字符串数组,分别定义了餐次类型和食物分类。MEALKINDS0有四个元素(早餐、午餐、晚餐、加餐),在添加餐记录和编辑餐记录的弹窗中用作选择项。CATS0有六个元素,第一个是"全部",用于食物库的分类筛选——当用户选择"全部"时显示所有食物,选择其他分类时只显示对应类别的食物。KCAL_TARGET0是每日热量目标值2100 kcal,这个值在多处被引用用于计算热量百分比和剩余可用热量。
六、辅助函数:餐次颜色映射
function mealColor0(m: string): string {
if (m === '早餐') {
return '#F2B23E'
}
if (m === '午餐') {
return '#F2762E'
}
if (m === '晚餐') {
return '#9B6BC3'
}
return '#5AA469'
}

mealColor0是一个全局辅助函数,接收餐次名称字符串,返回对应的十六进制颜色值。早餐映射为金黄色#F2B23E,午餐映射为橙色#F2762E,晚餐映射为紫色#9B6BC3,加餐则默认返回绿色#5AA469。这个函数在多处被调用——餐记录列表中的餐次标签背景色、记录页中的餐次图标背景色、热量数字颜色等都使用它来获取对应餐次的主题色。
技术概念:全局函数与组件方法的区别
在ArkTS中,使用function关键字定义在组件外部的函数是全局函数,它不依赖任何组件实例,可以在文件的任何位置调用。而定义在struct内部的方法则属于组件实例,需要通过this来调用。全局函数适合那些纯逻辑计算、不涉及组件状态的工具函数,如颜色映射、格式化等。
这种颜色映射设计体现了"视觉一致性"原则——同一种餐次在整个应用中始终使用同一种颜色,帮助用户快速识别。函数内部使用简单的if条件判断而非switch或对象映射,这是因为餐次类型只有四种,条件判断的可读性已经足够好,没有必要过度设计。
七、组件入口与状态变量声明
@Entry
@Component
struct Index {
@State currentTab: number = 0
@State meals: Array<MealT> = MEALS0
@State foods: Array<FoodT> = FOODS0
@State waterCups: number = 5
@State catIdx: number = 0
// 弹框开关
@State showAddMeal: boolean = false
@State showAddFood: boolean = false
@State showEdit: boolean = false
@State showDel: boolean = false
@State showFood: boolean = false
@State showWater: boolean = false
// 记一餐
@State amKind: number = 0
@State amFood: number = 0
@State amPortionTen: number = 2
@State amSync: boolean = true
// 添加食物
@State afName: string = ''
@State afCat: number = 0
@State afKcal: number = 150
@State afOften: boolean = false
// 编辑餐记录
@State editIdx: number = 0
@State editKind: number = 0
@State editPortionTen: number = 2
// 删除
@State delIdx: number = 0
@State dangerDel: boolean = false
// 食物详情
@State foodIdx: number = 0
// 饮水打卡
@State waterSlot: number = 0
// 特效
@State plateAngle: number = 0
@State dotOp: number = 0.3
@State dotScale: number = 0.9

这是整个应用的核心组件定义。@Entry装饰器标记Index结构体为应用的入口组件——每个ArkTS页面有且仅有一个@Entry组件,它是页面组件树的根节点。@Component装饰器标记Index为一个自定义组件,使其可以被复用和组合。这两个装饰器搭配使用是鸿蒙页面组件的标准写法。
@State是ArkTS中最核心的状态管理装饰器。被@State标注的变量成为"可观察的状态变量",当其值发生变化时,框架会自动重新渲染引用了该变量的UI部分。这里声明了大量状态变量,可以按功能分为几组:currentTab控制当前激活的标签页;meals和foods是两个核心数据源,分别存储餐记录列表和食物列表;waterCups记录今日饮水杯数;catIdx控制食物库的分类筛选索引。
技术概念:@State装饰器的工作原理
@State装饰器在底层通过观察者模式实现状态追踪。当组件初次渲染时,框架会记录每个@State变量在UI树中的引用关系。当变量值被修改时,框架会通知所有引用了该变量的UI节点进行差分更新。需要注意的是,对于数组类型的状态变量,必须赋值一个新数组才能触发更新——直接通过push()或splice()修改原数组不会触发重渲染。这就是为什么后续代码中所有的数组操作都采用了concat()、map()、filter()等返回新数组的方法。
六组弹窗开关变量(showAddMeal、showAddFood、showEdit、showDel、showFood、showWater)都是布尔类型,初始值为false。每个开关控制一个弹窗的显示与隐藏,在build()方法中通过if条件判断来决定是否渲染对应的弹窗组件。这种"布尔开关+条件渲染"的模式是ArkTS实现弹窗的最简方案。
接下来是各弹窗内部的表单状态变量。"记一餐"弹窗使用am前缀(Add Meal):amKind记录选中的餐次索引,amFood记录选中的食物索引,amPortionTen记录份量值(以0.5为步进,用整数存储再除以2来避免浮点精度问题),amSync控制是否同步给营养师。"添加食物"弹窗使用af前缀(Add Food):afName是食物名称输入框的值,afCat是分类索引,afKcal是热量值,afOften是是否加入常吃清单。"编辑餐记录"弹窗使用edit前缀:editIdx是编辑的记录索引,editKind是修改后的餐次,editPortionTen是修改后的份量。"删除"弹窗使用del前缀:delIdx是删除索引,dangerDel是二次确认开关。
技术概念:份量值的整数化存储技巧
amPortionTen和editPortionTen的命名中带有"Ten"后缀,实际存储的是份量乘以2后的整数值(如1.0份存为2,1.5份存为3,2.0份存为4)。这种设计避免了浮点数运算的精度问题——在UI上显示时除以2即可得到实际份量值。这是一个实用的工程技巧,特别适用于需要0.5步进的数值选择器场景。
最后是动画特效相关的状态变量:plateAngle控制餐盘图标的旋转角度,dotOp控制营养素指示点的透明度,dotScale控制指示点的缩放比例。这三个变量在aboutToAppear生命周期中被设置为无限循环动画的目标值,驱动餐盘页的持续动效。
八、生命周期与动画初始化:aboutToAppear
aboutToAppear(): void {
this.getUIContext().animateTo({ duration: 9000, iterations: -1, curve: Curve.Linear }, () => {
this.plateAngle = 360
})
this.getUIContext().animateTo({ duration: 1200, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut }, () => {
this.dotOp = 0.85
this.dotScale = 1.15
})
}

aboutToAppear是ArkTS组件的生命周期回调方法,在组件创建后、UI渲染前被调用。它是初始化状态、启动动画、发起网络请求等操作的理想位置。在这个方法中,通过this.getUIContext().animateTo()启动了两个无限循环动画。
第一个动画驱动餐盘旋转:duration: 9000表示动画时长9秒,iterations: -1表示无限循环,curve: Curve.Linear表示匀速线性动画。动画闭包中将this.plateAngle从0设置到360,这意味着餐盘图标会以9秒一周的速度持续匀速旋转。Linear曲线确保旋转速度均匀,不会出现加速或减速的"呼吸感",模拟了餐盘匀速转动的物理效果。
第二个动画驱动营养素指示点的脉冲效果:duration: 1200表示动画时长1.2秒,iterations: -1表示无限循环,playMode: PlayMode.Alternate表示交替播放——即正向播放完毕后反向播放,形成"呼吸"效果,curve: Curve.EaseInOut表示缓入缓出曲线。动画闭包中将this.dotOp从0.3设置到0.85(透明度变化),this.dotScale从0.9设置到1.15(缩放变化)。由于Alternate模式,指示点会在0.3到0.85透明度之间、0.9到1.15缩放之间来回过渡,形成脉冲呼吸的视觉效果。
技术概念:animateTo动画API详解
animateTo是ArkUI提供的命令式动画API,接收两个参数:第一个是动画选项对象(包含duration时长、iterations循环次数、curve动画曲线、playMode播放模式等),第二个是闭包函数——闭包内对状态变量的修改会以动画形式过渡,而非瞬间跳变。iterations设为-1表示无限循环;PlayMode.Alternate表示正向播放完毕后自动反向播放,适合做"呼吸"效果;Curve.Linear是匀速曲线,Curve.EaseInOut是缓入缓出曲线。与CSS动画不同,animateTo直接操作状态变量,动画过程中的每一帧都会触发UI重渲染。
技术概念:getUIContext()的作用
this.getUIContext()返回当前组件的UI上下文对象,通过它可以访问一些与UI相关的系统能力,如动画、弹窗、页面路由等。在较新的ArkUI版本中,animateTo需要通过getUIContext()来调用,这是为了确保动画在正确的UI上下文中执行。这种方式比全局直接调用animateTo更加安全,避免了多线程或多实例场景下的上下文混乱。
九、计算方法:热量与营养素的实时统计
todayKcal(): number {
let sum: number = 0
this.meals.forEach((m: MealT) => {
sum += Math.round(m.kcal * m.portion)
})
return sum
}
todayProtein(): number {
let sum: number = 0
this.meals.forEach((m: MealT) => {
sum += Math.round(m.protein * m.portion)
})
return sum
}
kcalPct(): number {
return Math.min(Math.round(this.todayKcal() / KCAL_TARGET0 * 100), 100)
}

这三个方法是应用的核心计算逻辑。todayKcal()遍历所有餐记录,将每条记录的kcal(每份热量)乘以portion(份数),用Math.round()四舍五入后累加,返回今日总摄入热量。todayProtein()的逻辑完全类似,只是累加的是蛋白质值。kcalPct()计算今日热量摄入占目标的百分比,使用Math.min()确保返回值不超过100——即使实际摄入超过了目标值,环形进度条也最多显示满格。
这些方法没有使用@State或任何缓存机制,每次调用都会重新计算。这在数据量不大时是完全可接受的——遍历十几条记录的计算开销可以忽略不计。但由于它们在UI中被多次调用(如头部栏、餐盘页、记录页都调用了todayKcal()),当数据量增大时可能需要考虑缓存优化。
技术概念:ArkTS中的方法调用与UI刷新
在ArkTS的build()方法和@Builder方法中调用组件方法时,方法返回值会被自动纳入状态依赖追踪。也就是说,如果todayKcal()内部引用了@State变量this.meals,那么当meals发生变化时,所有调用了todayKcal()的UI部分都会自动重渲染。这种"方法即派生状态"的设计让开发者无需手动管理计算属性的依赖关系。
十、数据变更逻辑:增删改查操作
openAddMeal(): void {
this.amKind = 0
this.amFood = 0
this.amPortionTen = 2
this.amSync = true
this.showAddMeal = true
}
confirmAddMeal(): void {
const f: FoodT = this.foods[this.amFood]
const portion: number = this.amPortionTen / 2
this.meals = [{ id: this.meals.length + 20, meal: MEALKINDS0[this.amKind], icon: f.icon, food: f.name + (portion === 1 ? '' : ' ×' + portion.toString()), kcal: f.kcal, portion: portion, time: '21:45', protein: f.protein, carb: 24, fat: 6 } as MealT].concat(this.meals)
this.showAddMeal = false
}

openAddMeal()是打开"记一餐"弹窗的初始化方法。它将表单状态变量重置为默认值:餐次选第一项(早餐)、食物选第一项、份量设为2(即1.0份)、同步开关设为true,然后将showAddMeal设为true触发弹窗显示。这种"先重置再显示"的模式确保每次打开弹窗时表单都是干净的初始状态。
confirmAddMeal()是确认添加餐记录的核心方法。首先从食物列表中取出当前选中的食物f,计算实际份量portion(整数除以2)。然后使用[{...} as MealT].concat(this.meals)创建一个新数组——新记录被放在数组头部(concat将this.meals拼接到新记录后面),这样最新的记录会显示在列表顶部。新记录的id使用this.meals.length + 20来生成一个不太可能重复的ID(加20是为了避免与初始数据的ID冲突)。食物名称后面会根据份量添加" ×1.5"这样的标注(当份量不等于1时)。最后关闭弹窗。
技术概念:不可变数据更新模式
注意this.meals = [...].concat(this.meals)这行代码——它没有使用this.meals.unshift()来在原数组头部插入元素,而是创建了一个全新的数组再赋值给this.meals。这就是ArkTS状态更新的"不可变数据"模式:必须赋值新数组才能触发@State的变更检测。如果使用push()、unshift()、splice()等原地修改方法,虽然数组内容确实变了,但框架的依赖追踪系统不会感知到变化,UI不会刷新。这与React中的setState要求返回新数组是同样的道理。
openAddFood(): void {
this.afName = ''
this.afCat = 0
this.afKcal = 150
this.afOften = false
this.showAddFood = true
}
confirmAddFood(): void {
const icon: string = this.afCat === 0 ? '🍚' : (this.afCat === 1 ? '🍗' : (this.afCat === 2 ? '🥦' : (this.afCat === 3 ? '🍪' : '🥤')))
this.foods = [{ id: this.foods.length + 20, icon: icon, name: this.afName.length > 0 ? this.afName : '自定义食物', kcal: this.afKcal, cat: CATS0[this.afCat + 1], protein: 5, often: this.afOften } as FoodT].concat(this.foods)
this.showAddFood = false
}

confirmAddFood()处理自定义食物的添加。有趣的是图标的选择逻辑——通过嵌套的三元运算符根据分类索引afCat来匹配对应的Emoji图标:主食用🍚、蛋白用🍗、蔬果用🥦、零食用🍪、饮品用🥤。食物名称如果用户留空则默认填入"自定义食物"。分类通过CATS0[this.afCat + 1]获取——因为CATS0的第一个元素是"全部",实际分类从索引1开始,所以需要+1偏移。蛋白质含量固定设为5g(因为自定义食物没有精确的营养数据,使用一个保守的默认值)。
openEdit(idx: number): void {
this.editIdx = idx
this.editKind = MEALKINDS0.indexOf(this.meals[idx].meal)
if (this.editKind < 0) {
this.editKind = 0
}
this.editPortionTen = Math.round(this.meals[idx].portion * 2)
this.showEdit = true
}
confirmEdit(): void {
this.meals = this.meals.map((m: MealT, i: number) => {
if (i === this.editIdx) {
return { id: m.id, meal: MEALKINDS0[this.editKind], icon: m.icon, food: m.food, kcal: m.kcal, portion: this.editPortionTen / 2, time: m.time, protein: m.protein, carb: m.carb, fat: m.fat } as MealT
}
return m
})
this.showEdit = false
}
编辑功能的实现采用了map()遍历模式。openEdit(idx)接收要编辑的记录索引,从现有记录中读取餐次和份量填充到编辑表单中。MEALKINDS0.indexOf()用于将餐次名称反查为索引值,如果没找到则默认设为0。confirmEdit()使用this.meals.map()遍历所有记录,当索引匹配editIdx时返回一个修改后的新对象(更新餐次和份量),其他记录原样返回。这种写法确保了只替换目标记录,其余记录的引用不变,最大化了差分更新的效率。
openDel(idx: number): void {
this.delIdx = idx
this.dangerDel = false
this.showDel = true
}
doDel(): void {
this.meals = this.meals.filter((m: MealT, i: number) => i !== this.delIdx)
this.showDel = false
}
删除功能使用filter()方法过滤掉目标索引的记录。openDel(idx)保存删除索引并重置二次确认开关,doDel()执行实际删除——filter会返回一个不包含目标索引的新数组。这里需要注意,使用索引作为过滤条件在并发删除场景下可能有问题(删除一条后其他记录的索引会变化),但在单用户操作的应用中这是可以接受的。
openFood(f: FoodT): void {
const idx: number = this.foods.indexOf(f)
if (idx >= 0) {
this.foodIdx = idx
}
this.showFood = true
}
eatFood(): void {
const f: FoodT = this.foods[this.foodIdx]
this.meals = [{ id: this.meals.length + 30, meal: '加餐', icon: f.icon, food: f.name, kcal: f.kcal, portion: 1, time: '21:46', protein: f.protein, carb: 24, fat: 6 } as MealT].concat(this.meals)
this.showFood = false
}
toggleOften(f: FoodT): void {
this.foods = this.foods.map((x: FoodT) => {
if (x.id === f.id) {
return { id: x.id, icon: x.icon, name: x.name, kcal: x.kcal, cat: x.cat, protein: x.protein, often: !x.often } as FoodT
}
return x
})
}
openFood(f)接收一个FoodT对象,通过indexOf()找到其在数组中的索引并保存,然后显示食物详情弹窗。eatFood()从食物详情弹窗中将当前食物直接加入今日餐盘——创建一条新的餐记录,餐次默认为"加餐",份数为1。toggleOften(f)切换食物的"常吃"状态,同样使用map()遍历返回新数组,通过id匹配目标食物后取反often字段。
技术概念:对象引用与indexOf查找
openFood方法中使用this.foods.indexOf(f)来查找食物索引,这依赖于对象引用相等性——传入的f必须是foods数组中的同一个对象引用。在ForEach渲染中,每个迭代项都是数组中的原始对象引用,因此indexOf能正确找到。但如果在传递过程中创建了对象的副本,indexOf将返回-1。这就是为什么代码中加了if (idx >= 0)的保护判断。
openWater(): void {
this.waterSlot = 0
this.showWater = true
}
confirmWater(): void {
this.waterCups += 1
this.showWater = false
}
饮水打卡的逻辑最为简单——openWater()重置时间段选择并打开弹窗,confirmWater()将饮水杯数加1并关闭弹窗。每次打卡增加一杯水(250ml),在UI上会实时反映到饮水格子条和饮水统计文字中。
下面是应用中数据变更操作的完整流程图:
十一、头部栏构建:电商风格的横向布局
@Builder
headerBar() {
Column() {
Row() {
Column() {
Text('营养餐盘')
.fontSize(19)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('MEAL PLATE')
.fontSize(8)
.fontColor('#FFE0C7')
.fontWeight(FontWeight.Bold)
.letterSpacing(2)
.margin({ top: 1 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 14 })
Row() {
Text('🔍')
.fontSize(13)
Text('查食物热量,一键记餐')
.fontSize(11)
.fontColor('#C98A5E')
.margin({ left: 6 })
}
.layoutWeight(1)
.height(34)
.borderRadius(17)
.backgroundColor('#FFFFFF')
.margin({ left: 12, right: 8 })
.justifyContent(FlexAlign.Center)
Text('🧺')
.fontSize(16)
.margin({ right: 10 })
Text('碗')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor('#C9551B')
.width(30)
.height(30)
.textAlign(TextAlign.Center)
.borderRadius(15)
.backgroundColor('#FFFFFF')
.margin({ right: 12 })
}
.width('100%')
.height(54)
.alignItems(VerticalAlign.Center)
Row() {
Text('🥗')
.fontSize(13)
Text('今日已摄入 ' + this.todayKcal().toString() + ' / ' + KCAL_TARGET0.toString() + ' kcal · 剩余 ' + (KCAL_TARGET0 - this.todayKcal()).toString())
.fontSize(10)
.fontColor('#FFFFFF')
.margin({ left: 6 })
Column()
.layoutWeight(1)
.height(1)
Text('记一餐')
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor('#C9551B')
.padding({ left: 10, right: 10, top: 3, bottom: 3 })
.borderRadius(10)
.backgroundColor('#FFFFFF')
.onClick(() => {
this.openAddMeal()
})
}
.width('100%')
.height(30)
.alignItems(VerticalAlign.Center)
.padding({ left: 14, right: 14 })
.backgroundColor('#E0641F')
}
.width('100%')
.backgroundColor('#F2762E')
}
headerBar()是使用@Builder装饰器标注的UI构建方法。@Builder是ArkTS中用于封装可复用UI片段的装饰器——被@Builder标注的方法返回一段组件树,可以在build()或其他@Builder中通过this.xxx()调用。与独立的@Component不同,@Builder方法不需要额外的组件实例化开销,它更像是UI片段的"宏展开"。
头部栏采用Column容器嵌套两个Row的布局结构。外层Column设置背景色为橙色#F2762E,这是应用的主题色。第一个Row是主标题栏,高度54像素,包含三部分内容:左侧是应用名称(中文"营养餐盘"加英文"MEAL PLATE"的竖排排列,英文使用letterSpacing(2)增加字符间距增强设计感);中间是一个白色圆角搜索框样式的Row,使用layoutWeight(1)占据剩余空间,高度34像素、圆角17像素(高度的一半形成胶囊形),内含搜索图标和提示文字;右侧是购物篮图标和用户头像(用"碗"字代替)。
技术概念:layoutWeight权重分配
layoutWeight是ArkUI中Flex布局的核心属性之一。它指定组件在主轴方向上占据剩余空间的比例权重。当多个子组件设置了layoutWeight时,它们按权重比例分配父容器的剩余空间(扣除固定宽度的子组件后)。在本例中,搜索框Row设置了layoutWeight(1),它会占据父Row中除了标题列和右侧图标以外的所有空间。如果两个组件都设置layoutWeight(1),它们会平分剩余空间;如果一个设为1、另一个设为2,则按1:2分配。
第二个Row是热量状态条,高度30像素,背景色为更深的橙色#E0641F,展示今日已摄入热量和剩余可用热量。这里调用了this.todayKcal()方法来获取实时热量值,字符串拼接将数值嵌入到文案中。右侧是一个白色胶囊形的"记一餐"按钮,点击后调用this.openAddMeal()打开弹窗。值得注意的是两个Column().layoutWeight(1).height(1)的用法——这是一个常见的"弹性分隔符"技巧,用一个空Column占据剩余空间,将右侧按钮推到行尾。
技术概念:@Builder方法的特点与适用场景
@Builder方法与直接在build()中写UI的区别在于:@Builder可以将复杂的UI拆分为逻辑独立的模块,提高代码可读性和复用性。@Builder方法可以接收参数,但参数不会触发UI更新(只有@State变量才能触发更新)。当@Builder内部引用了@State变量时,该变量变化会自动触发@Builder内的UI刷新。需要注意@Builder方法不能有返回值,它的组件树通过调用this.xxx()隐式插入到调用位置。
十二、标签栏与标签项构建
@Builder
tabItem(title: string, icon: string, idx: number) {
Column() {
Text(icon)
.fontSize(17)
Text(title)
.fontSize(9)
.fontWeight(this.currentTab === idx ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.currentTab === idx ? '#F2762E' : '#9A8577')
.margin({ top: 2 })
}
.layoutWeight(1)
.padding({ top: 7, bottom: 7 })
.justifyContent(FlexAlign.Center)
.onClick(() => {
this.currentTab = idx
})
}
@Builder
tabBar() {
Row() {
this.tabItem('餐盘', '🍽️', 0)
this.tabItem('食物库', '📖', 1)
this.tabItem('记录', '📓', 2)
this.tabItem('营养', '🧪', 3)
this.tabItem('我的', '👤', 4)
}
.width('94%')
.borderRadius(24)
.backgroundColor('#FFFFFF')
.border({ width: 1, color: '#F0DFD1' })
.margin({ bottom: 10 })
}
标签栏的实现分为两个@Builder方法:tabItem负责构建单个标签项,tabBar负责构建整个标签栏容器。tabItem接收三个参数:title(标签文字)、icon(Emoji图标)、idx(标签索引)。内部使用Column纵向排列图标和文字,通过三元运算符this.currentTab === idx ? ... : ...来区分选中态和未选中态的样式——选中时文字加粗且颜色为主题橙色,未选中时文字常规且颜色为灰色。
tabBar使用Row横向排列五个tabItem,每个标签项设置了layoutWeight(1)实现等宽分配。容器宽度设为94%(留出两侧3%的边距),圆角24像素,白色背景配以浅色边框。这种悬浮式圆角标签栏是移动端常见的导航设计,比传统的底部固定标签栏更加精致。
技术概念:三元运算符在声明式UI中的条件渲染
在ArkTS的UI描述中,三元运算符condition ? valueA : valueB是实现条件样式的最简方式。它可以用于任何属性值——.fontColor()、.fontWeight()、.backgroundColor()等。当条件依赖于@State变量时(如本例中的this.currentTab),条件变化会自动触发UI更新。对于更复杂的条件渲染(如显示/隐藏整个组件块),则使用if...else语句。
十三、餐盘标签页:环形进度与动画特效
@Builder
plateTab() {
Column() {
Column() {
Stack() {
Progress({ value: this.kcalPct(), total: 100 })
.width(150)
.height(150)
.style({ strokeWidth: 14 })
Column() {
Text('🍽️')
.fontSize(26)
.rotate({ x: 0, y: 0, z: 1, angle: this.plateAngle })
Text(this.todayKcal().toString())
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor('#33261C')
.margin({ top: 2 })
Text('已摄入 / ' + KCAL_TARGET0.toString() + ' kcal')
.fontSize(9)
.fontColor('#9A8577')
.margin({ top: 2 })
}
.justifyContent(FlexAlign.Center)
}
.width(150)
.height(150)
.margin({ top: 4 })
餐盘页是应用的主页面,也是最复杂的标签页。其核心视觉元素是一个环形进度条,由Stack层叠布局容器包裹Progress进度组件和Column信息列实现。Stack是ArkUI的层叠布局容器,子组件按声明顺序从底到顶堆叠,后声明的子组件覆盖在前面的之上。这里Progress作为底层环形进度条,Column作为上层信息展示——叠放在一起就形成了"环中有内容"的经典设计。
Progress组件通过{ value: this.kcalPct(), total: 100 }参数配置进度值和总量,.style({ strokeWidth: 14 })设置环形线条宽度为14像素。kcalPct()返回的值在0到100之间,环形进度条会按比例填充。Column中先是一个餐盘Emoji图标🍽️,通过.rotate({ x: 0, y: 0, z: 1, angle: this.plateAngle })实现绕Z轴旋转——this.plateAngle在aboutToAppear中被设置为360度无限循环动画,因此餐盘图标会持续旋转。图标下方是今日摄入热量的大号数字和目标热量的小号说明文字。
技术概念:Stack层叠布局
Stack是ArkUI中的层叠布局容器,所有子组件默认居中堆叠。可以通过.align(Alignment.Top)等属性调整对齐方式。Stack的子组件按代码声明顺序从底层到顶层排列——第一个子组件在最底层,最后一个在最顶层。这种特性非常适合实现"背景+前景"的叠加效果,如头像上的角标、图片上的蒙层文字、本例中的环形进度条+中心信息等。
技术概念:rotate旋转变换
.rotate()是ArkUI的图形变换属性,接收一个对象参数指定旋转轴和角度。{ x: 0, y: 0, z: 1, angle: this.plateAngle }表示绕Z轴(垂直于屏幕的轴)旋转plateAngle度。当plateAngle从0动画到360时,组件会完成一周完整的旋转。x、y、z三个值定义旋转轴向量的方向,通常只需要Z轴旋转(平面内旋转),X轴和Y轴旋转会产生3D翻转效果。
Row() {
Column() {
Column()
.width(8)
.height(8)
.borderRadius(4)
.backgroundColor('#F2762E')
.opacity(this.dotOp)
.scale({ x: this.dotScale, y: this.dotScale })
Text('蛋白质 ' + this.todayProtein().toString() + 'g')
.fontSize(9)
.fontColor('#9A8577')
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
Column() {
Column()
.width(8)
.height(8)
.borderRadius(4)
.backgroundColor('#4E8BC8')
.opacity(this.dotOp)
.scale({ x: this.dotScale, y: this.dotScale })
Text('碳水 ' + (this.todayKcal() > 1600 ? '201' : '168') + 'g')
.fontSize(9)
.fontColor('#9A8577')
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
Column() {
Column()
.width(8)
.height(8)
.borderRadius(4)
.backgroundColor('#9B6BC3')
.opacity(this.dotOp)
.scale({ x: this.dotScale, y: this.dotScale })
Text('脂肪 61g')
.fontSize(9)
.fontColor('#9A8577')
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
}
.width('100%')
.margin({ top: 12 })
环形进度条下方是三个营养素指示点,使用Row横向排列,每个指示点是一个Column包含一个8x8像素的圆点(通过borderRadius(4)实现圆形)和一行说明文字。三个圆点分别使用橙色(蛋白质)、蓝色(碳水)、紫色(脂肪)对应NUTRIS0中的颜色编码。每个圆点都设置了.opacity(this.dotOp)和.scale({ x: this.dotScale, y: this.dotScale })——这两个属性绑定到在aboutToAppear中初始化的脉冲动画状态变量,使三个圆点同步呼吸闪烁。
技术概念:opacity与scale图形变换
.opacity()控制组件的透明度(0完全透明,1完全不透明),.scale()控制组件的缩放比例(1为原始大小,大于1放大,小于1缩小)。这两个属性与.rotate()一样属于图形变换属性,它们不改变组件的布局空间占用,只是在渲染层做视觉变换。这意味着即使组件被放大到1.15倍,它仍只占据原始大小的布局空间,不会挤压周围组件。这种特性使脉冲动画不会导致周围元素的位置抖动。
碳水值的展示使用了一个内联条件判断:this.todayKcal() > 1600 ? '201' : '168'——当今日热量超过1600 kcal时显示201g碳水,否则显示168g。这是一个简化的动态数据展示逻辑,实际应用中应该从真实数据计算得出。
Row() {
Text('今日还剩 ' + (KCAL_TARGET0 - this.todayKcal()).toString() + ' kcal 可用,约等于一个汉堡 🍔')
.fontSize(9)
.fontColor('#C9551B')
.layoutWeight(1)
}
.width('100%')
.margin({ top: 8 })
Row() {
Text('记一餐')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.layoutWeight(1)
.height(36)
.textAlign(TextAlign.Center)
.borderRadius(18)
.backgroundColor('#F2762E')
.onClick(() => {
this.openAddMeal()
})
Text('喝水打卡')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor('#4E8BC8')
.layoutWeight(1)
.height(36)
.textAlign(TextAlign.Center)
.borderRadius(18)
.border({ width: 1, color: '#4E8BC8' })
.margin({ left: 8 })
.onClick(() => {
this.openWater()
})
}
.width('100%')
.margin({ top: 12 })
}
.width('100%')
.padding(16)
.borderRadius(18)
.backgroundColor('#FFFFFF')
.border({ width: 1, color: '#F0DFD1' })
餐盘页主卡片底部是两个操作按钮:左侧"记一餐"按钮使用实心橙色填充,右侧"喝水打卡"按钮使用蓝色描边的空心样式。两个按钮都设置了layoutWeight(1)等宽分配,高度36像素,圆角18像素(高度的一半形成胶囊形)。textAlign(TextAlign.Center)确保文字在按钮中水平居中。两个按钮通过.margin({ left: 8 })保持间距。这种"一主一辅"的按钮设计是移动端常见的操作组合模式——主操作用实心强调,辅操作用描边弱化。
技术概念:borderRadius与胶囊形按钮
当borderRadius的值等于组件高度的一半时,矩形会变成完全的胶囊形(两侧半圆)。这是移动端按钮设计的主流圆角方案。ArkUI的borderRadius可以接收一个数字(四角统一)或一个对象{ topLeft, topRight, bottomLeft, bottomRight }来分别设置四个角的圆角,后者可以实现只圆顶部或只圆某一侧的特殊效果。
Column() {
Row() {
Text('💧 饮水打卡')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#33261C')
Column()
.layoutWeight(1)
.height(1)
Text(this.waterCups.toString() + '/8 杯')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor('#4E8BC8')
}
.width('100%')
Row() {
ForEach([0, 1, 2, 3, 4, 5, 6, 7], (n: number) => {
Column() {
Text(n < this.waterCups ? '💧' : '🥛')
.fontSize(16)
.opacity(n < this.waterCups ? 1 : 0.35)
}
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
}, (n: number) => n.toString())
}
.width('100%')
.margin({ top: 10 })
Text(this.waterCups >= 8 ? '🎉 今日饮水目标达成,约 ' + (this.waterCups * 250).toString() + 'ml' : '还差 ' + (8 - this.waterCups).toString() + ' 杯达成目标,每杯约 250ml')
.fontSize(9)
.fontColor('#9A8577')
.margin({ top: 8 })
.width('100%')
}
.width('100%')
.padding(14)
.borderRadius(16)
.backgroundColor('#FFFFFF')
.border({ width: 1, color: '#F0DFD1' })
.margin({ top: 12 })
.alignItems(HorizontalAlign.Start)
饮水打卡卡片展示了ForEach列表渲染的典型用法。ForEach接收三个参数:第一个是数据源数组[0, 1, 2, 3, 4, 5, 6, 7](代表8个水杯位置),第二个是项渲染函数(根据索引n与waterCups的比较结果决定显示满水杯💧还是空水杯🥛,并设置不同的透明度),第三个是键值生成函数(n: number) => n.toString()(为每个项生成唯一键值,帮助框架高效做差分更新)。
技术概念:ForEach列表渲染详解
ForEach是ArkUI的列表渲染组件,类似于Vue的v-for或React的map。它接收三个参数:arr(数据源数组)、itemGenerator(项渲染闭包,接收项值和索引两个参数)、keyGenerator(键值生成闭包,为每项生成唯一标识)。keyGenerator的作用至关重要——当数据源发生变化时,框架通过键值来判断哪些项是新增的、哪些被删除、哪些需要更新,从而实现高效的差分渲染。如果键值不唯一或不稳定,可能导致渲染异常或性能下降。
卡片底部是一行动态文案:当waterCups >= 8时显示"今日饮水目标达成",否则显示"还差X杯达成目标"。这种条件文案通过三元运算符实现,使UI状态与数据保持同步。
ForEach(this.meals.slice(0, 6), (m: MealT, i: number) => {
Row() {
Column() {
Text(m.icon)
.fontSize(18)
}
.width(42)
.height(42)
.borderRadius(12)
.backgroundColor('#FFF7EF')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.margin({ right: 12 })
Column() {
Row() {
Text(m.food)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor('#33261C')
Text(m.meal)
.fontSize(8)
.fontColor('#FFFFFF')
.padding({ left: 6, right: 6, top: 1, bottom: 1 })
.borderRadius(6)
.backgroundColor(mealColor0(m.meal))
.margin({ left: 6 })
}
Text(m.time + ' · ' + (m.portion === 1 ? '标准份' : m.portion.toString() + ' 份') + ' · 蛋白 ' + m.protein.toString() + 'g')
.fontSize(9)
.fontColor('#9A8577')
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column() {
Text('+' + Math.round(m.kcal * m.portion).toString())
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#F2762E')
Text('kcal')
.fontSize(8)
.fontColor('#9A8577')
Text('✎')
.fontSize(13)
.fontColor('#4E8BC8')
.margin({ top: 4 })
.onClick(() => {
this.openEdit(i)
})
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.padding(12)
.borderRadius(14)
.backgroundColor('#FFFFFF')
.border({ width: 1, color: '#F0DFD1' })
.margin({ bottom: 8 })
.alignItems(VerticalAlign.Center)
}, (m: MealT) => m.id.toString() + 'm' + m.portion.toString())
餐盘页底部的餐记录列表使用ForEach渲染this.meals.slice(0, 6)——只取前6条记录在首页展示。每条记录是一个Row卡片,包含三列:左侧是食物图标的圆角方块(42x42像素,浅橙背景),中间是食物名称(带餐次标签)、时间、份量和蛋白质信息,右侧是热量值和编辑按钮。餐次标签使用mealColor0(m.meal)获取对应颜色作为背景色,白色文字,形成彩色小标签。
键值生成函数(m: MealT) => m.id.toString() + 'm' + m.portion.toString()将记录ID和份量拼接为唯一键值。加入portion是为了在编辑份量时也能触发对应项的重渲染——如果只用id作为键值,修改份量后框架可能认为键值没变而跳过更新。
技术概念:ForEach的slice与键值设计
this.meals.slice(0, 6)创建了一个新数组传递给ForEach,这只是截取了前6条数据用于首页展示。slice不会修改原数组,它返回一个浅拷贝。键值函数中加入portion字段是一个精妙的设计——当用户编辑某条记录的份量时,该记录的键值会发生变化,迫使ForEach重新渲染该项,确保UI数据与状态完全同步。
十四、食物库标签页:分类筛选与卡片网格
@Builder
foodTab() {
Column() {
Row() {
ForEach(CATS0, (c: string, i: number) => {
Text(c)
.fontSize(11)
.fontWeight(this.catIdx === i ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.catIdx === i ? '#FFFFFF' : '#9A8577')
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
.borderRadius(14)
.backgroundColor(this.catIdx === i ? '#F2762E' : '#FFFFFF')
.border({ width: 1, color: this.catIdx === i ? '#F2762E' : '#F0DFD1' })
.margin({ right: 8 })
.onClick(() => {
this.catIdx = i
})
}, (c: string) => c)
}
.width('100%')
.margin({ bottom: 10 })
Row() {
Text('共 ' + this.foods.length.toString() + ' 种食物 · 每 100g 热量')
.fontSize(10)
.fontColor('#9A8577')
.layoutWeight(1)
Text('+ 自定义食物')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.borderRadius(10)
.backgroundColor('#F2762E')
.onClick(() => {
this.openAddFood()
})
}
.width('100%')
.margin({ bottom: 10 })
食物库页顶部是分类筛选栏,使用ForEach渲染CATS0数组(全部、主食、蛋白、蔬果、零食、饮品)。每个分类是一个胶囊形标签,选中态为橙色实心填充白字,未选中态为白色背景浅色边框灰字。点击标签时更新this.catIdx,由于ForEach内部引用了@State变量catIdx,样式变化会自动触发重渲染。
分类栏下方是统计信息和"自定义食物"按钮。this.foods.length显示当前食物总数,按钮点击后调用this.openAddFood()打开添加食物弹窗。
Row() {
ForEach(this.foods.filter((f: FoodT) => this.catIdx === 0 || f.cat === CATS0[this.catIdx]), (f: FoodT) => {
Column() {
Column() {
Row() {
Text(f.often ? '⭐' : '☆')
.fontSize(13)
.onClick(() => {
this.toggleOften(f)
})
}
.width('100%')
.justifyContent(FlexAlign.End)
Text(f.icon)
.fontSize(28)
.margin({ top: -20 })
Text(f.name)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor('#33261C')
.margin({ top: 6 })
Text(f.kcal.toString() + ' kcal/100g')
.fontSize(9)
.fontColor('#F2762E')
.fontWeight(FontWeight.Bold)
.margin({ top: 3 })
Text(f.cat + ' · 蛋白 ' + f.protein.toString() + 'g')
.fontSize(8)
.fontColor('#9A8577')
.margin({ top: 2 })
Row() {
Text('看详情')
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.padding({ left: 12, right: 12, top: 4, bottom: 4 })
.borderRadius(10)
.backgroundColor('#F2762E')
.margin({ top: 8 })
}
.onClick(() => {
this.openFood(f)
})
}
.width('100%')
.padding(10)
.borderRadius(14)
.backgroundColor('#FFF7EF')
.border({ width: 1, color: '#F0DFD1' })
.alignItems(HorizontalAlign.Center)
}
.layoutWeight(1)
.margin({ bottom: 10 })
}, (f: FoodT) => f.id.toString() + (f.often ? 'o' : 'n'))
}
.width('100%')
}
.width('100%')
}
食物卡片网格使用ForEach渲染经过筛选的食物列表。筛选逻辑是this.catIdx === 0 || f.cat === CATS0[this.catIdx]——当catIdx为0(“全部”)时显示所有食物,否则只显示分类匹配的食物。filter()返回一个新数组,这符合ArkTS不可变数据的要求。
每个食物卡片是一个Column,设置了layoutWeight(1)使所有卡片等宽排列。卡片内部从上到下依次是:常吃星标(点击切换toggleOften)、食物图标(28号字大小,margin({ top: -20 })向上偏移与星标重叠)、食物名称、热量值(橙色加粗)、分类和蛋白质信息、"看详情"按钮。卡片使用浅橙背景#FFF7EF和圆角边框,视觉上与页面整体风格统一。
键值函数(f: FoodT) => f.id.toString() + (f.often ? 'o' : 'n')将ID和常吃状态拼接为键值。当用户点击星标切换常吃状态时,键值从1o变为1n(或反过来),迫使ForEach重新渲染该项,更新星标图标。这又是一个利用键值变化驱动重渲染的技巧。
技术概念:filter筛选与ForEach的配合
this.foods.filter(...)在每次重渲染时都会执行,根据当前的catIdx筛选出符合条件的食物。由于filter返回新数组,ForEach会将其视为新数据源进行差分比较。配合精心设计的键值函数,只有真正变化的项(如常吃状态切换的项)才会被重新渲染,其余项保持不动。这种"计算属性+ForEach"的模式是ArkTS处理筛选列表的标准做法。
十五、记录标签页:完整列表与操作入口
@Builder
recordTab() {
Column() {
Column() {
Text('全部饮食记录')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#33261C')
Text('累计 ' + this.meals.length.toString() + ' 笔 · 今日 ' + this.todayKcal().toString() + ' kcal')
.fontSize(9)
.fontColor('#9A8577')
.margin({ top: 3 })
}
.width('100%')
.margin({ bottom: 10 })
ForEach(this.meals, (m: MealT, i: number) => {
Column() {
Row() {
Column() {
Text(m.meal.slice(0, 1))
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
}
.width(40)
.height(40)
.borderRadius(12)
.backgroundColor(mealColor0(m.meal))
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.margin({ right: 12 })
Column() {
Text(m.food)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor('#33261C')
Text(m.meal + ' · ' + m.time + ' · ' + (m.portion === 1 ? '标准份' : m.portion.toString() + ' 份'))
.fontSize(9)
.fontColor('#9A8577')
.margin({ top: 3 })
Text('蛋白 ' + m.protein.toString() + 'g · 碳水 ' + m.carb.toString() + 'g · 脂肪 ' + m.fat.toString() + 'g')
.fontSize(8)
.fontColor('#9A8577')
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column() {
Text(Math.round(m.kcal * m.portion).toString())
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(mealColor0(m.meal))
Text('kcal')
.fontSize(8)
.fontColor('#9A8577')
Row() {
Text('✎')
.fontSize(12)
.fontColor('#4E8BC8')
.onClick(() => {
this.openEdit(i)
})
Text('🗑')
.fontSize(11)
.fontColor('#D9534F')
.margin({ left: 10 })
.onClick(() => {
this.openDel(i)
})
}
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.alignItems(VerticalAlign.Center)
}
.width('100%')
.padding(12)
.borderRadius(14)
.backgroundColor('#FFFFFF')
.border({ width: 1, color: '#F0DFD1' })
.margin({ bottom: 8 })
}, (m: MealT) => m.id.toString() + 'r' + m.portion.toString())
}
.width('100%')
}
记录页与餐盘页的餐记录列表类似,但展示了全部记录(不使用slice截取),且每条记录的信息更加详细。左侧图标改为餐次首字的圆形色块(如"早"字配橙色背景),使用m.meal.slice(0, 1)截取餐次名称的第一个字。中间列增加了碳水m.carb和脂肪m.fat的数值展示。右侧除了热量值和编辑按钮外,还增加了删除按钮🗑,点击后调用this.openDel(i)打开删除确认弹窗。
热量数字的颜色使用mealColor0(m.meal)——不同餐次的热量数字显示不同颜色,这种视觉编码帮助用户快速区分记录类型。编辑和删除按钮水平排列,编辑用蓝色✎,删除用红色🗑,颜色语义清晰。
技术概念:slice字符串截取
m.meal.slice(0, 1)是JavaScript/TypeScript的字符串方法,截取索引0到1(不含1)的子字符串,即第一个字符。ArkTS完全支持标准的字符串方法。在UI中使用首字代替图标是一种常见的简约设计手法,尤其在已有颜色区分的场景下,单个汉字比Emoji更加简洁清晰。
十六、营养标签页:横条图、柱状图与时间线
@Builder
nutriTab() {
Column() {
Column() {
Text('三大营养素达成')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#33261C')
.width('100%')
Text('TODAY MACROS TARGET')
.fontSize(8)
.fontColor('#C98A5E')
.letterSpacing(1)
.margin({ top: 2 })
.width('100%')
ForEach(NUTRIS0, (n: NutriT) => {
Row() {
Text(n.name)
.fontSize(10)
.fontColor('#33261C')
.width(56)
Column() {
Column()
.width(Math.min(Math.round(n.got / n.target * 100), 100) + '%')
.height(12)
.borderRadius(6)
.backgroundColor(n.color)
}
.layoutWeight(1)
.height(12)
.borderRadius(6)
.backgroundColor('#FFF7EF')
Text(n.got.toString() + '/' + n.target.toString() + n.unit)
.fontSize(9)
.fontColor(n.color)
.width(64)
.textAlign(TextAlign.End)
}
.width('100%')
.margin({ top: 12 })
}, (n: NutriT) => n.name)
}
.width('100%')
.padding(14)
.borderRadius(16)
.backgroundColor('#FFFFFF')
.border({ width: 1, color: '#F0DFD1' })
.alignItems(HorizontalAlign.Start)
营养页的第一个卡片是营养素横条图,使用ForEach渲染NUTRIS0数组。每个营养素是一行Row:左侧是名称(固定宽度56像素),中间是横条进度条,右侧是数值文字。横条进度条的实现方式是嵌套两个Column——外层Column是灰色轨道(背景色#FFF7EF,高度12像素,圆角6像素,layoutWeight(1)占据剩余空间),内层Column是彩色填充(宽度按百分比设置,背景色为营养素对应颜色)。
填充宽度的计算方式是Math.min(Math.round(n.got / n.target * 100), 100) + '%'——将已摄入量除以目标量得到百分比,四舍五入后用Math.min限制不超过100,最后拼接百分号作为字符串宽度值。这种"外层轨道+内层填充"的横条图实现是ArkTS中纯代码绘制进度条的常用模式,无需引入图表库。
技术概念:用嵌套Column实现横条进度条
ArkUI没有内置的横向进度条组件(Progress组件主要支持环形和线性两种样式),因此开发者常用"外层容器做轨道+内层容器做填充"的方式手动实现。外层设置固定高度和背景色作为轨道,内层通过百分比宽度和不同背景色作为填充。这种方式的灵活性远高于组件封装——可以自由设置圆角、渐变色、动画等效果。
Column() {
Text('本周摄入 vs 目标')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#33261C')
Text('目标 2100 kcal · 绿色为达标日')
.fontSize(8)
.fontColor('#9A8577')
.margin({ top: 2 })
Row() {
ForEach(WEEKS0, (w: WeekT) => {
Column() {
Text(w.kcal.toString())
.fontSize(8)
.fontColor('#9A8577')
.margin({ bottom: 3 })
Column() {
Column()
.width('62%')
.height(w.kcal / 16)
.borderRadius({ topLeft: 4, topRight: 4 })
.backgroundColor(w.kcal <= KCAL_TARGET0 ? '#5AA469' : '#F2762E')
}
.layoutWeight(1)
.height('100%')
.justifyContent(FlexAlign.End)
.alignItems(HorizontalAlign.Center)
Text(w.day)
.fontSize(9)
.fontColor('#9A8577')
.margin({ top: 5 })
}
.layoutWeight(1)
.height(150)
.justifyContent(FlexAlign.End)
.alignItems(HorizontalAlign.Center)
}, (w: WeekT) => w.day)
}
.width('100%')
.margin({ top: 12 })
}
.width('100%')
.padding(14)
.borderRadius(16)
.backgroundColor('#FFFFFF')
.border({ width: 1, color: '#F0DFD1' })
.margin({ top: 12 })
本周柱状图使用ForEach渲染WEEKS0的七天数据。每个柱子是一个Column,固定高度150像素,layoutWeight(1)等宽分配。柱子内部从上到下是:热量数值文字、柱体(高度为w.kcal / 16——将热量值除以16得到像素高度,如1980/16≈124像素)、星期文字。柱体颜色根据是否达标选择绿色或橙色:w.kcal <= KCAL_TARGET0 ? '#5AA469' : '#F2762E'。
柱体使用了.borderRadius({ topLeft: 4, topRight: 4 })只圆顶部两个角,模拟传统柱状图的视觉风格。外层Column设置了.justifyContent(FlexAlign.End)使柱体和文字从底部对齐,确保所有柱子都"站"在同一条基线上。
技术概念:borderRadius的分区设置
.borderRadius()可以接收一个对象来分别设置四个角的圆角半径:{ topLeft, topRight, bottomLeft, bottomRight }。这种分区设置在实现"只圆顶部"的卡片或柱状图中非常实用。配合Stack或Column的justifyContent(FlexAlign.End),可以实现标准的柱状图底部对齐效果。
Column() {
Text('💧 饮水小知识')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor('#4E8BC8')
.width('100%')
ForEach(WATERS0, (w: WaterT) => {
Row() {
Column() {
Text(w.icon)
.fontSize(14)
Column()
.width(2)
.layoutWeight(1)
.backgroundColor('#D8E8F5')
.margin({ top: 4 })
}
.width(30)
.alignItems(HorizontalAlign.Center)
Column() {
Text(w.label)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor('#33261C')
Text(w.desc)
.fontSize(9)
.fontColor('#9A8577')
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.padding({ bottom: 14 })
}
.width('100%')
.alignItems(VerticalAlign.Top)
}, (w: WaterT) => w.label)
}
.width('100%')
.padding(14)
.borderRadius(16)
.backgroundColor('#FFFFFF')
.border({ width: 1, color: '#F0DFD1' })
.margin({ top: 12 })
.alignItems(HorizontalAlign.Start)
饮水小知识卡片使用ForEach渲染WATERS0数组,实现了时间线效果。每个条目是一个Row:左侧是一个30像素宽的Column,包含图标和一条2像素宽的竖线(用Column设置宽2像素、layoutWeight(1)占据剩余高度、浅蓝色背景实现),竖线连接各个时间节点形成时间轴效果;右侧是标签和描述文字。alignItems(VerticalAlign.Top)确保左右两列顶部对齐,竖线从图标下方延伸。
技术概念:用Column模拟时间线竖线
时间线(Timeline)是常见的UI模式,在ArkUI中没有内置组件,但可以通过Column设置固定宽度(如2像素)和layoutWeight(1)来模拟竖线。layoutWeight(1)使竖线Column占据图标下方的所有剩余空间,配合backgroundColor形成视觉上的连接线。这种"用容器模拟线条"的技巧在ArkTS开发中非常普遍。
营养页最后还有一个"营养师点评"卡片,使用绿色背景#EEF7F0区分于其他白色卡片,内含三条文字建议。这些文字是静态的,实际应用中应该由后端营养师生成。
十七、我的标签页:用户信息与统计面板
@Builder
mineTab() {
Column() {
Row() {
Column() {
Text('盘')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
}
.width(56)
.height(56)
.borderRadius(28)
.backgroundColor('#F2762E')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.margin({ right: 12 })
Column() {
Text('陈小盘')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#33261C')
Text('女 · 27 岁 · 减脂期第 6 周 · 目标 2100 kcal/日')
.fontSize(10)
.fontColor('#9A8577')
.margin({ top: 3 })
Text('⭐ 已坚持记录 68 天')
.fontSize(10)
.fontColor('#5AA469')
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.padding(14)
.borderRadius(18)
.backgroundColor('#FFFFFF')
.border({ width: 1, color: '#F0DFD1' })
Row() {
Column() {
Text('68')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#F2762E')
Text('记录天数')
.fontSize(9)
.fontColor('#9A8577')
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column() {
Text('41')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#5AA469')
Text('达标天数')
.fontSize(9)
.fontColor('#9A8577')
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column() {
Text('1.6kg')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#4E8BC8')
Text('已减重')
.fontSize(9)
.fontColor('#9A8577')
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column() {
Text('12')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#9B6BC3')
Text('自定义食物')
.fontSize(9)
.fontColor('#9A8577')
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}
.width('100%')
.padding({ top: 12, bottom: 12 })
.borderRadius(16)
.backgroundColor('#FFFFFF')
.border({ width: 1, color: '#F0DFD1' })
.margin({ top: 10 })
"我的"页顶部是用户信息卡片,左侧是56x56像素的圆形头像(用"盘"字代替,橙色背景,borderRadius(28)实现圆形),右侧是用户名、基本信息和坚持天数。下方是四列统计面板,每列使用layoutWeight(1)等宽分配,分别展示记录天数(68天,橙色)、达标天数(41天,绿色)、已减重(1.6kg,蓝色)、自定义食物数(12个,紫色)。四个数字使用四种不同颜色,与营养素颜色编码体系保持一致。
技术概念:Flex布局的等宽分配
当一个Row中的所有子组件都设置了layoutWeight(1)时,它们会平分父容器的全部宽度(因为没有任何子组件有固定宽度,全部剩余空间按1:1:1:1分配)。这是实现等宽列布局的最简方式。如果需要不等宽分配,只需改变权重值,如layoutWeight(1)和layoutWeight(2)的搭配可以实现1:2的宽度比例。
"我的"页还包含饮食偏好卡片(忌口、饮食模式、营养师信息)和记录小贴士卡片,内容以静态文字为主,布局模式与前面介绍的卡片一致,此处不再赘述。
十八、弹窗实现:遮罩层与zIndex层级控制
@Builder
addMealOverlay() {
Column() {
}
.width('100%')
.height('100%')
.backgroundColor('#66331F0F')
.position({ x: 0, y: 0 })
.zIndex(999)
.onClick(() => {
this.showAddMeal = false
})
Column() {
Row() {
Text('🍱 记一餐')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#33261C')
Text('ADD MEAL')
.fontSize(8)
.fontColor('#C98A5E')
.letterSpacing(1)
.margin({ left: 8 })
Column()
.layoutWeight(1)
.height(1)
Text('✕')
.fontSize(14)
.fontColor('#9A8577')
.onClick(() => {
this.showAddMeal = false
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
"记一餐"弹窗的实现展示了ArkTS中弹窗的标准模式。每个弹窗由两个Column组成:第一个是遮罩层,第二个是弹窗内容。遮罩层是一个空的Column,设置width('100%')和height('100%')铺满全屏,背景色#66331F0F(#66是40%透明度的十六进制表示,331F0F是深褐色),position({ x: 0, y: 0 })绝对定位到屏幕左上角,zIndex(999)设置层级为999。点击遮罩层关闭弹窗(this.showAddMeal = false)。
弹窗内容Column设置zIndex(1000)——比遮罩层高1级,确保内容显示在遮罩层之上。position({ x: '6%', y: '8%' })将弹窗定位到屏幕偏上位置。宽度88%(左右各留6%边距),白色背景,圆角边框。
技术概念:zIndex层级控制
zIndex属性控制同一父容器中子组件的堆叠顺序。数值越大,组件越在上层。在本例中,遮罩层zIndex(999),弹窗内容zIndex(1000),确保弹窗内容覆盖在遮罩层之上。需要注意的是,zIndex只在同一父容器的子组件之间生效——如果两个组件分属不同的父容器,其zIndex不会互相比较。这就是为什么所有弹窗都放在build()方法的同一个Stack根容器中。
技术概念:position绝对定位
.position({ x, y })将组件从正常文档流中脱出,相对于父容器进行绝对定位。x和y可以是像素值(如0)或百分比字符串(如'6%')。弹窗通过position精确控制在屏幕上的位置——居中弹窗使用百分比定位,底部抽屉使用y: '50%'或y: '52%'定位到屏幕下半部分。与Flex布局不同,position定位的组件不参与父容器的空间分配。
弹窗内容区包含了完整的表单:餐次选择器(ForEach渲染MEALKINDS0)、食物选择器(横向Scroll滚动选择)、份量步进器(减号-数字-加号)、热量预览区、同步开关(Toggle组件)、取消和确认按钮。每个表单控件都与对应的@State变量双向绑定。
Scroll() {
Row() {
ForEach(this.foods, (f: FoodT, i: number) => {
Row() {
Text(f.icon)
.fontSize(13)
Text(f.name)
.fontSize(10)
.fontWeight(this.amFood === i ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.amFood === i ? '#FFFFFF' : '#33261C')
.margin({ left: 4 })
}
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.borderRadius(14)
.backgroundColor(this.amFood === i ? '#F2762E' : '#FFF7EF')
.border({ width: 1, color: this.amFood === i ? '#F2762E' : '#F0DFD1' })
.margin({ right: 6 })
.onClick(() => {
this.amFood = i
})
}, (f: FoodT) => f.id.toString() + this.amFood.toString())
}
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.margin({ top: 6 })
食物选择器使用Scroll容器包裹横向排列的ForEach。.scrollable(ScrollDirection.Horizontal)设置滚动方向为水平,.scrollBar(BarState.Off)隐藏滚动条以保持界面整洁。每个食物选项是一个胶囊形标签,选中态为橙色实心白字,未选中态为浅色背景深色文字。点击选项更新this.amFood索引。
技术概念:Scroll滚动容器
Scroll是ArkUI的滚动容器组件,当内容超出容器尺寸时提供滚动能力。通过.scrollable()设置滚动方向(Horizontal水平、Vertical垂直),通过.scrollBar()控制滚动条显示状态(On显示、Off隐藏、Auto自动)。Scroll只能有一个直接子组件,因此需要用Row或Column包裹多个子项。在本例中,横向Scroll+Row实现了水平滚动的食物选择器。
技术概念:Toggle开关组件
弹窗中的同步开关使用了Toggle组件:Toggle({ type: ToggleType.Switch, isOn: this.amSync })。ToggleType.Switch是iOS风格的滑动开关,isOn绑定状态变量。.selectedColor('#F2762E')设置开启状态的主题色。.onChange((on: boolean) => { this.amSync = on })监听开关变化并更新状态变量。Toggle还支持ToggleType.Checkbox(复选框)和ToggleType.Button(按钮式开关)两种类型。
十九、编辑弹窗与底部抽屉模式
@Builder
editOverlay() {
Column() {
}
.width('100%')
.height('100%')
.backgroundColor('#66331F0F')
.position({ x: 0, y: 0 })
.zIndex(999)
.onClick(() => {
this.showEdit = false
})
Column() {
Column()
.width(40)
.height(4)
.borderRadius(2)
.backgroundColor('#F0DFD1')
.margin({ top: 10 })
Row() {
Text('✎ 修改这份记录')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#33261C')
Column()
.layoutWeight(1)
.height(1)
Text('✕')
.fontSize(14)
.fontColor('#9A8577')
.onClick(() => {
this.showEdit = false
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
.padding({ left: 16, right: 16, top: 12 })
编辑弹窗采用了底部抽屉的交互模式,与"记一餐"弹窗的居中模式不同。弹窗内容Column设置了.borderRadius({ topLeft: 24, topRight: 24 })只圆顶部两个角,.position({ x: 0, y: '50%' })定位到屏幕中下方,宽度100%铺满屏幕宽度。顶部有一个40x4像素的灰色拖拽指示条(borderRadius(2)圆角),这是底部抽屉的标志性视觉元素。
技术概念:底部抽屉的设计规范
底部抽屉(Bottom Sheet)是移动端常见的交互模式,特别适合编辑、筛选等操作。其设计特征包括:从底部滑入、顶部圆角、顶部有拖拽指示条、内容区可滚动。与居中弹窗相比,底部抽屉更方便单手操作(手指自然位于屏幕下方),且不遮挡上半部分的内容。在本应用中,编辑弹窗和饮水打卡弹窗都使用底部抽屉模式。
编辑弹窗的内容与"记一餐"弹窗类似——餐次选择器和份量步进器,但少了食物选择器(因为编辑时不改变食物本身,只调整餐次和份量)。份量步进器的减号按钮有下限保护if (this.editPortionTen > 1)(最小0.5份),加号按钮有上限保护if (this.editPortionTen < 20)(最大10份)。底部显示修改后的热量预览值,通过Math.round(this.meals[this.editIdx].kcal * this.editPortionTen / 2)实时计算。
二十、删除确认弹窗:二次确认与危险操作保护
@Builder
delOverlay() {
Column() {
}
.width('100%')
.height('100%')
.backgroundColor('#66331F0F')
.position({ x: 0, y: 0 })
.zIndex(999)
.onClick(() => {
this.showDel = false
})
Column() {
Text('🗑️')
.fontSize(30)
Text('删掉这笔饮食记录?')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#33261C')
.margin({ top: 8 })
Text(this.meals.length > this.delIdx ? this.meals[this.delIdx].food + ' · ' + Math.round(this.meals[this.delIdx].kcal * this.meals[this.delIdx].portion).toString() + ' kcal' : '')
.fontSize(10)
.fontColor('#9A8577')
.margin({ top: 4 })
Text('删除后今日热量与营养素统计将同步扣减。')
.fontSize(10)
.fontColor('#D9534F')
.textAlign(TextAlign.Center)
.margin({ top: 8 })
Row() {
Text('我已确认')
.fontSize(10)
.fontColor('#9A8577')
Toggle({ type: ToggleType.Switch, isOn: this.dangerDel })
.selectedColor('#D9534F')
.margin({ left: 8 })
.onChange((on: boolean) => {
this.dangerDel = on
})
}
.margin({ top: 12 })
Row() {
Text('保留')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor('#9A8577')
.layoutWeight(1)
.height(38)
.textAlign(TextAlign.Center)
.borderRadius(19)
.backgroundColor('#FFF7EF')
.onClick(() => {
this.showDel = false
})
Text(this.dangerDel ? '确认删除' : '先勾选')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.layoutWeight(1.4)
.height(38)
.textAlign(TextAlign.Center)
.borderRadius(19)
.backgroundColor(this.dangerDel ? '#D9534F' : '#E7D3C4')
.margin({ left: 10 })
.onClick(() => {
if (this.dangerDel) {
this.doDel()
}
})
}
.width('100%')
.margin({ top: 14 })
}
.width('74%')
.padding(18)
.borderRadius(16)
.backgroundColor('#FFFFFF')
.border({ width: 1, color: '#F2D6CE' })
.alignItems(HorizontalAlign.Center)
.position({ x: '13%', y: '28%' })
.zIndex(1000)
}
删除确认弹窗是一个居中的窄卡片(宽度74%),设计上强调"危险操作"的警示感。弹窗顶部是删除图标🗑️(30号字大小),下方是确认标题和待删除记录的信息。红色警告文字"删除后今日热量与营养素统计将同步扣减"使用#D9534F红色,提醒用户操作的不可逆性。
弹窗的核心安全机制是Toggle二次确认开关。用户必须先打开"我已确认"开关(dangerDel变为true),底部的"确认删除"按钮才会从灰色禁用态变为红色激活态。按钮文字也会根据状态变化——未确认时显示"先勾选"(灰色背景),已确认时显示"确认删除"(红色背景)。这种"二次确认"模式可以有效防止误删除。
技术概念:危险操作的二次确认设计
对于不可逆的删除操作,UI设计通常采用"渐进式确认"策略:首先弹出确认弹窗,要求用户明确点击确认;对于更危险的操作,还要求用户勾选复选框或输入确认文字。本应用使用Toggle开关作为二次确认——用户需要先拨动开关再点击按钮,两步操作显著降低了误删风险。按钮的backgroundColor和文字根据dangerDel状态动态变化,给用户明确的视觉反馈。
二十一、食物详情弹窗与饮水打卡弹窗
@Builder
foodOverlay() {
Column() {
}
.width('100%')
.height('100%')
.backgroundColor('#66331F0F')
.position({ x: 0, y: 0 })
.zIndex(999)
.onClick(() => {
this.showFood = false
})
Column() {
Row() {
Text('🍎 食物详情')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#33261C')
Text('FOOD CARD')
.fontSize(8)
.fontColor('#C98A5E')
.letterSpacing(1)
.margin({ left: 8 })
Column()
.layoutWeight(1)
.height(1)
Text('✕')
.fontSize(14)
.fontColor('#9A8577')
.onClick(() => {
this.showFood = false
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
Row() {
Column() {
Text(this.foods[this.foodIdx].icon)
.fontSize(40)
}
.width(72)
.height(72)
.borderRadius(20)
.backgroundColor('#FFF7EF')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.margin({ right: 14 })
Column() {
Text(this.foods[this.foodIdx].name)
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#33261C')
Text(this.foods[this.foodIdx].cat + ' · 每 100g 参考值')
.fontSize(10)
.fontColor('#9A8577')
.margin({ top: 4 })
Row() {
Text(this.foods[this.foodIdx].kcal.toString() + ' kcal')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#F2762E')
Text(this.foods[this.foodIdx].often ? ' · 常吃' : '')
.fontSize(9)
.fontColor('#F2B23E')
.margin({ left: 4 })
}
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.margin({ top: 10 })
食物详情弹窗是一个居中弹窗,展示选中食物的详细信息。顶部是食物大图标(72x72像素圆角方块,40号字Emoji)和食物名称、分类、热量值、常吃标签。下方是一个三列对照区:蛋白质含量、相当于多少个鸡蛋(Math.max(1, Math.round(this.foods[this.foodIdx].kcal / 90))——将热量除以90kcal/个鸡蛋得到对照数量),以及一个智能提示——根据食物热量是否超过今日剩余可用热量,显示"会超出目标,注意份量"或"还有富余,放心吃"。
技术概念:TextInput输入框组件
在添加食物弹窗中使用了TextInput组件接收用户输入:TextInput({ text: this.afName, placeholder: '如:妈妈牌韭菜盒子' })。text参数绑定状态变量(注意TextInput是单向绑定,需要通过.onChange()手动更新状态),placeholder是占位提示文字。.placeholderColor()和.placeholderFont()可以自定义占位符样式。.onChange((v: string) => { this.afName = v })监听输入变化并更新状态变量。与React或Vue的双向绑定不同,ArkTS的TextInput需要手动处理状态同步。
饮水打卡弹窗采用底部抽屉模式,内容包括今日饮水进度、时间段选择器(ForEach渲染WATERS0的五个时段)和打卡按钮。时间段选择器是一个横向排列的标签组,选中态为蓝色实心白字,未选中态为浅色背景灰字。打卡按钮点击后调用this.confirmWater()将waterCups加1并关闭弹窗。
二十二、build主入口:Stack根容器与条件渲染
build() {
Stack() {
Column() {
this.headerBar()
Scroll() {
Column() {
if (this.currentTab === 0) {
this.plateTab()
} else if (this.currentTab === 1) {
this.foodTab()
} else if (this.currentTab === 2) {
this.recordTab()
} else if (this.currentTab === 3) {
this.nutriTab()
} else {
this.mineTab()
}
}
.width('100%')
.padding({ left: 12, right: 12, top: 10, bottom: 16 })
}
.layoutWeight(1)
.width('100%')
.scrollBar(BarState.Off)
.align(Alignment.Top)
this.tabBar()
}
.width('100%')
.height('100%')
if (this.showAddMeal) {
this.addMealOverlay()
}
if (this.showAddFood) {
this.addFoodOverlay()
}
if (this.showEdit) {
this.editOverlay()
}
if (this.showDel) {
this.delOverlay()
}
if (this.showFood) {
this.foodOverlay()
}
if (this.showWater) {
this.waterOverlay()
}
}
.width('100%')
.height('100%')
.backgroundColor('#FFF7EF')
}
build()方法是每个@Component组件必须实现的方法,它返回组件的UI树。整个页面的build()方法使用Stack作为根容器——这是实现弹窗层叠的关键。Stack中第一个子组件是主内容区Column(包含头部栏、可滚动内容区、标签栏),后续的六个if条件块控制六个弹窗的显示。
主内容区的结构是:headerBar()固定在顶部不滚动,Scroll容器包裹可滚动的内容区(layoutWeight(1)占据头部和标签栏之间的所有空间),tabBar()固定在底部。内容区内部通过if...else if...else条件判断根据currentTab的值渲染对应的标签页@Builder方法。.align(Alignment.Top)确保滚动内容从顶部开始对齐。
技术概念:Stack作为弹窗容器的原理
使用Stack作为根容器是实现弹窗层叠的标准方案。Stack的子组件按声明顺序堆叠——主内容区在底层,弹窗在上层。当弹窗的if条件为true时,弹窗组件被插入到Stack中覆盖在主内容区之上;当条件变为false时,弹窗组件被移除。这种"条件渲染+Stack堆叠"的模式比visibility属性控制显隐更加高效——未显示的弹窗完全不参与渲染,不占用任何资源。
技术概念:if条件渲染与ForEach的区别
if...else和ForEach都是ArkTS中的控制流语句,但工作原理不同。if条件渲染是"全有或全无"的——条件为true时整个UI块被创建,为false时被销毁。每次条件翻转都会重新创建/销毁组件,有一定的性能开销。ForEach则是"差分更新"——根据键值函数复用已有组件,只增删变化的项。因此,对于频繁切换的场景应优先使用ForEach或visibility属性,对于低频切换的场景(如弹窗开关)使用if完全没问题。
六个弹窗的if条件都是独立的@State布尔变量,互不排斥。理论上可以同时显示多个弹窗(如先打开"记一餐"再打开"食物详情"),但在实际交互中,弹窗的打开路径设计使得同时弹出多个弹窗的概率很低。每个弹窗的遮罩层都能接收点击事件关闭自身,因此即使多个弹窗叠加,用户点击遮罩层也会从最上层开始逐个关闭。
二十三、核心技术要素对比表
以下表格对应用中涉及的核心数据结构、组件、状态变量、装饰器、动画API等技术要素进行了系统性对比:
| 技术要素 | 类别 | 作用描述 | 使用位置 | 关键特征 |
|---|---|---|---|---|
| MealT | 接口 | 餐记录数据结构 | MEALS0、meals状态变量 | 包含id/meal/icon/food/kcal/portion/time/protein/carb/fat十个字段 |
| FoodT | 接口 | 食物库数据结构 | FOODS0、foods状态变量 | 包含id/icon/name/kcal/cat/protein/often七个字段 |
| WeekT | 接口 | 周热量数据结构 | WEEKS0常量 | 仅含day和kcal两个字段,用于柱状图 |
| NutriT | 接口 | 营养素数据结构 | NUTRIS0常量 | 含name/got/target/color/unit,用于横条图 |
| WaterT | 接口 | 饮水知识数据结构 | WATERS0常量 | 含icon/label/desc,用于时间线展示 |
| @Entry | 装饰器 | 标记页面入口组件 | Index结构体 | 每个页面仅一个,是组件树根节点 |
| @Component | 装饰器 | 标记自定义组件 | Index结构体 | 使结构体成为可复用的ArkUI组件 |
| @State | 装饰器 | 声明可观察状态变量 | currentTab/meals/foods等 | 值变化时自动触发引用该变量的UI重渲染 |
| @Builder | 装饰器 | 封装可复用UI片段 | headerBar/tabBar/plateTab等 | 无额外实例化开销,通过this.xxx()调用 |
| Column | 容器组件 | 纵向布局容器 | 几乎所有UI区块 | 子组件从上到下排列 |
| Row | 容器组件 | 横向布局容器 | 头部栏/标签栏/列表项等 | 子组件从左到右排列 |
| Stack | 容器组件 | 层叠布局容器 | build根容器/环形进度区 | 子组件按声明顺序从底到顶堆叠 |
| Progress | 基础组件 | 环形进度条 | 餐盘页中心 | 通过value/total配置进度,style设置线宽 |
| Scroll | 滚动容器 | 内容滚动 | 主内容区/食物选择器 | 支持水平和垂直滚动,可隐藏滚动条 |
| ForEach | 列表渲染 | 遍历数组生成UI | 餐记录列表/食物网格/饮水格子等 | 三参数:数据源/项渲染/键值生成 |
| Toggle | 交互组件 | 开关选择器 | 同步开关/常吃开关/危险确认 | 支持Switch/Checkbox/Button三种类型 |
| TextInput | 交互组件 | 文本输入框 | 添加食物弹窗 | 需通过onChange手动同步状态 |
| layoutWeight | 布局属性 | 权重分配剩余空间 | 标签栏等宽列/搜索框弹性宽度 | 数值表示比例权重,如1和1.6的搭配 |
| position | 布局属性 | 绝对定位 | 所有弹窗的遮罩层和内容区 | 脱离文档流,相对父容器定位 |
| zIndex | 布局属性 | 层级控制 | 弹窗遮罩(999)和内容(1000) | 数值越大越在上层,仅同父级下有效 |
| animateTo | 动画API | 命令式动画 | aboutToAppear生命周期 | 通过闭包修改状态变量实现动画过渡 |
| borderRadius | 样式属性 | 圆角设置 | 卡片/按钮/头像等 | 支持四角统一或分区设置 |
| rotate | 图形变换 | 旋转 | 餐盘图标旋转 | 绕指定轴旋转指定角度 |
| opacity | 图形变换 | 透明度 | 营养素脉冲指示点 | 0到1之间,不改变布局空间 |
| scale | 图形变换 | 缩放 | 营养素脉冲指示点 | 大于1放大,不改变布局空间 |
| mealColor0 | 全局函数 | 餐次颜色映射 | 餐次标签/记录图标等 | 早餐金/午餐橙/晚餐紫/加餐绿 |
| aboutToAppear | 生命周期 | 组件创建后渲染前 | Index组件 | 初始化动画和状态 |
二十四、总结与回顾
通过对这个营养餐盘应用的完整剖析,我们可以清晰地看到ArkTS声明式UI开发范式的全貌。从数据建模开始,应用通过五个interface定义了严格类型的数据契约,这些接口不仅约束了数据的形状,更在整个开发过程中提供了编译期的类型安全保障。静态数据常量(MEALS0、FOODS0等)以const声明作为初始数据源,随后被赋值给@State状态变量,完成了从"静态常量"到"动态状态"的转换——这一转换是应用具备数据变更能力的基础。
状态管理是本应用的核心架构设计。全部20余个@State变量按功能分组:核心数据源(meals、foods、waterCups)、导航控制(currentTab、catIdx)、弹窗开关(六个show布尔变量)、表单临时状态(各弹窗的am*、af*、edit*变量)、动画状态(plateAngle、dotOp、dotScale)。这种分组管理使状态的可追踪性极强——当某个UI出现问题时,开发者可以快速定位到对应的状态变量组进行排查。所有数据变更操作都遵循"不可变更新"原则——使用concat()、map()、filter()返回新数组,确保@State的变更检测能正常工作。
UI构建方面,@Builder装饰器将复杂的界面拆分为14个逻辑独立的构建方法,每个方法负责一个UI区块(5个标签页+1个头部栏+1个标签栏+6个弹窗+1个标签项)。这种拆分方式使build()主方法极为简洁——只需组合@Builder调用和if条件判断即可构建出完整的页面结构。每个@Builder方法内部的组件嵌套清晰地反映了UI的层级关系,配合链式属性调用,代码的可读性接近自然语言描述。
安装DevEco Studio程序

选择目标安装目录:

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

新建一个空白模板:

设置API为24的模板项目:

初始化项目,自动下载相关依赖:

完整代码:
// 场景:爱康体检风营养饮食管理 · 5 tab · 6 弹框 · 摄入环形 / 营养素横条 / 每周柱状 / 饮水格子条 · 餐盘旋转 / 光点脉冲特效
interface MealT {
id: number
meal: string
icon: string
food: string
kcal: number
portion: number
time: string
protein: number
carb: number
fat: number
}
interface FoodT {
id: number
icon: string
name: string
kcal: number
cat: string
protein: number
often: boolean
}
interface WeekT {
day: string
kcal: number
}
interface NutriT {
name: string
got: number
target: number
color: string
unit: string
}
interface WaterT {
icon: string
label: string
desc: string
}
const TABS0: Array<string> = ['餐盘', '食物库', '记录', '营养', '我的']
const MEALS0: Array<MealT> = [
{ id: 1, meal: '早餐', icon: '🥣', food: '燕麦牛奶碗 + 水煮蛋', kcal: 348, portion: 1, time: '07:40', protein: 18, carb: 46, fat: 9 },
{ id: 2, meal: '加餐', icon: '🍎', food: '红富士苹果一个', kcal: 95, portion: 1, time: '10:20', protein: 1, carb: 24, fat: 0 },
{ id: 3, meal: '午餐', icon: '🍗', food: '黄焖鸡米饭(少汁)', kcal: 612, portion: 1, time: '12:15', protein: 34, carb: 78, fat: 16 },
{ id: 4, meal: '加餐', icon: '☕', food: '燕麦拿铁(无糖)', kcal: 142, portion: 1, time: '15:30', protein: 8, carb: 14, fat: 5 },
{ id: 5, meal: '晚餐', icon: '🥗', food: '鸡胸牛油果沙拉', kcal: 386, portion: 1, time: '18:50', protein: 32, carb: 22, fat: 18 },
{ id: 6, meal: '加餐', icon: '🥛', food: '无糖酸奶半盒', kcal: 68, portion: 0.5, time: '21:10', protein: 6, carb: 8, fat: 2 },
{ id: 7, meal: '早餐', icon: '🥯', food: '全麦贝果 + 奶酪', kcal: 376, portion: 1, time: '昨天 07:55', protein: 16, carb: 52, fat: 11 },
{ id: 8, meal: '午餐', icon: '🍜', food: '番茄牛肉面', kcal: 548, portion: 1, time: '昨天 12:30', protein: 28, carb: 82, fat: 10 },
{ id: 9, meal: '晚餐', icon: '🐟', food: '清蒸鲈鱼 + 杂粮饭', kcal: 428, portion: 1, time: '昨天 19:05', protein: 38, carb: 42, fat: 9 },
{ id: 10, meal: '加餐', icon: '🌰', food: '每日坚果一袋', kcal: 156, portion: 1, time: '前天 15:40', protein: 5, carb: 12, fat: 12 },
{ id: 11, meal: '午餐', icon: '🍱', food: '轻食便当(鸡胸藜麦)', kcal: 468, portion: 1, time: '前天 12:20', protein: 36, carb: 48, fat: 12 },
{ id: 12, meal: '晚餐', icon: '🍲', food: '冬瓜排骨汤 + 米饭', kcal: 495, portion: 1, time: '前天 18:40', protein: 26, carb: 58, fat: 15 },
{ id: 13, meal: '加餐', icon: '🍪', food: '两块曲奇(罪恶)', kcal: 232, portion: 2, time: '周一 16:00', protein: 3, carb: 32, fat: 11 }
]
const FOODS0: Array<FoodT> = [
{ id: 1, icon: '🍚', name: '白米饭', kcal: 116, cat: '主食', protein: 3, often: true },
{ id: 2, icon: '🥔', name: '蒸红薯', kcal: 90, cat: '主食', protein: 2, often: true },
{ id: 3, icon: '🌾', name: '燕麦片', kcal: 367, cat: '主食', protein: 13, often: true },
{ id: 4, icon: '🍗', name: '鸡胸肉', kcal: 133, cat: '蛋白', protein: 24, often: true },
{ id: 5, icon: '🥩', name: '瘦牛肉', kcal: 143, cat: '蛋白', protein: 21, often: false },
{ id: 6, icon: '🐟', name: '鲈鱼', kcal: 105, cat: '蛋白', protein: 19, often: true },
{ id: 7, icon: '🥦', name: '西兰花', kcal: 27, cat: '蔬果', protein: 3, often: true },
{ id: 8, icon: '🍅', name: '番茄', kcal: 15, cat: '蔬果', protein: 1, often: false },
{ id: 9, icon: '🥑', name: '牛油果', kcal: 161, cat: '蔬果', protein: 2, often: false },
{ id: 10, icon: '🍪', name: '曲奇饼干', kcal: 502, cat: '零食', protein: 6, often: false },
{ id: 11, icon: '🧋', name: '珍珠奶茶', kcal: 437, cat: '饮品', protein: 3, often: false },
{ id: 12, icon: '🥛', name: '无糖酸奶', kcal: 62, cat: '饮品', protein: 10, often: true }
]
const WEEKS0: Array<WeekT> = [
{ day: '一', kcal: 1980 },
{ day: '二', kcal: 2260 },
{ day: '三', kcal: 1740 },
{ day: '四', kcal: 2130 },
{ day: '五', kcal: 2480 },
{ day: '六', kcal: 1890 },
{ day: '日', kcal: 1651 }
]
const NUTRIS0: Array<NutriT> = [
{ name: '蛋白质', got: 99, target: 120, color: '#F2762E', unit: 'g' },
{ name: '碳水', got: 201, target: 240, color: '#4E8BC8', unit: 'g' },
{ name: '脂肪', got: 61, target: 65, color: '#9B6BC3', unit: 'g' },
{ name: '膳食纤维', got: 14, target: 25, color: '#5AA469', unit: 'g' }
]
const WATERS0: Array<WaterT> = [
{ icon: '🌅', label: '晨起一杯', desc: '唤醒肠胃,补足夜间流失' },
{ icon: '☕', label: '上午一杯', desc: '搭配加餐,拒绝含糖饮料' },
{ icon: '🍚', label: '饭前一杯', desc: '增加饱腹感,防止吃多' },
{ icon: '🏃', label: '运动前后', desc: '少量多次,别等渴了才喝' },
{ icon: '🌙', label: '睡前小口', desc: '半杯即可,避免起夜' }
]
const MEALKINDS0: Array<string> = ['早餐', '午餐', '晚餐', '加餐']
const CATS0: Array<string> = ['全部', '主食', '蛋白', '蔬果', '零食', '饮品']
const KCAL_TARGET0: number = 2100
function mealColor0(m: string): string {
if (m === '早餐') {
return '#F2B23E'
}
if (m === '午餐') {
return '#F2762E'
}
if (m === '晚餐') {
return '#9B6BC3'
}
return '#5AA469'
}
@Entry
@Component
struct Index {
@State currentTab: number = 0
@State meals: Array<MealT> = MEALS0
@State foods: Array<FoodT> = FOODS0
@State waterCups: number = 5
@State catIdx: number = 0
// 弹框开关
@State showAddMeal: boolean = false
@State showAddFood: boolean = false
@State showEdit: boolean = false
@State showDel: boolean = false
@State showFood: boolean = false
@State showWater: boolean = false
// 记一餐
@State amKind: number = 0
@State amFood: number = 0
@State amPortionTen: number = 2
@State amSync: boolean = true
// 添加食物
@State afName: string = ''
@State afCat: number = 0
@State afKcal: number = 150
@State afOften: boolean = false
// 编辑餐记录
@State editIdx: number = 0
@State editKind: number = 0
@State editPortionTen: number = 2
// 删除
@State delIdx: number = 0
@State dangerDel: boolean = false
// 食物详情
@State foodIdx: number = 0
// 饮水打卡
@State waterSlot: number = 0
// 特效
@State plateAngle: number = 0
@State dotOp: number = 0.3
@State dotScale: number = 0.9
aboutToAppear(): void {
this.getUIContext().animateTo({ duration: 9000, iterations: -1, curve: Curve.Linear }, () => {
this.plateAngle = 360
})
this.getUIContext().animateTo({ duration: 1200, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut }, () => {
this.dotOp = 0.85
this.dotScale = 1.15
})
}
todayKcal(): number {
let sum: number = 0
this.meals.forEach((m: MealT) => {
sum += Math.round(m.kcal * m.portion)
})
return sum
}
todayProtein(): number {
let sum: number = 0
this.meals.forEach((m: MealT) => {
sum += Math.round(m.protein * m.portion)
})
return sum
}
kcalPct(): number {
return Math.min(Math.round(this.todayKcal() / KCAL_TARGET0 * 100), 100)
}
// ================= 数据变更逻辑 =================
openAddMeal(): void {
this.amKind = 0
this.amFood = 0
this.amPortionTen = 2
this.amSync = true
this.showAddMeal = true
}
confirmAddMeal(): void {
const f: FoodT = this.foods[this.amFood]
const portion: number = this.amPortionTen / 2
this.meals = [{ id: this.meals.length + 20, meal: MEALKINDS0[this.amKind], icon: f.icon, food: f.name + (portion === 1 ? '' : ' ×' + portion.toString()), kcal: f.kcal, portion: portion, time: '21:45', protein: f.protein, carb: 24, fat: 6 } as MealT].concat(this.meals)
this.showAddMeal = false
}
openAddFood(): void {
this.afName = ''
this.afCat = 0
this.afKcal = 150
this.afOften = false
this.showAddFood = true
}
confirmAddFood(): void {
const icon: string = this.afCat === 0 ? '🍚' : (this.afCat === 1 ? '🍗' : (this.afCat === 2 ? '🥦' : (this.afCat === 3 ? '🍪' : '🥤')))
this.foods = [{ id: this.foods.length + 20, icon: icon, name: this.afName.length > 0 ? this.afName : '自定义食物', kcal: this.afKcal, cat: CATS0[this.afCat + 1], protein: 5, often: this.afOften } as FoodT].concat(this.foods)
this.showAddFood = false
}
openEdit(idx: number): void {
this.editIdx = idx
this.editKind = MEALKINDS0.indexOf(this.meals[idx].meal)
if (this.editKind < 0) {
this.editKind = 0
}
this.editPortionTen = Math.round(this.meals[idx].portion * 2)
this.showEdit = true
}
confirmEdit(): void {
this.meals = this.meals.map((m: MealT, i: number) => {
if (i === this.editIdx) {
return { id: m.id, meal: MEALKINDS0[this.editKind], icon: m.icon, food: m.food, kcal: m.kcal, portion: this.editPortionTen / 2, time: m.time, protein: m.protein, carb: m.carb, fat: m.fat } as MealT
}
return m
})
this.showEdit = false
}
openDel(idx: number): void {
this.delIdx = idx
this.dangerDel = false
this.showDel = true
}
doDel(): void {
this.meals = this.meals.filter((m: MealT, i: number) => i !== this.delIdx)
this.showDel = false
}
openFood(f: FoodT): void {
const idx: number = this.foods.indexOf(f)
if (idx >= 0) {
this.foodIdx = idx
}
this.showFood = true
}
eatFood(): void {
const f: FoodT = this.foods[this.foodIdx]
this.meals = [{ id: this.meals.length + 30, meal: '加餐', icon: f.icon, food: f.name, kcal: f.kcal, portion: 1, time: '21:46', protein: f.protein, carb: 24, fat: 6 } as MealT].concat(this.meals)
this.showFood = false
}
toggleOften(f: FoodT): void {
this.foods = this.foods.map((x: FoodT) => {
if (x.id === f.id) {
return { id: x.id, icon: x.icon, name: x.name, kcal: x.kcal, cat: x.cat, protein: x.protein, often: !x.often } as FoodT
}
return x
})
}
openWater(): void {
this.waterSlot = 0
this.showWater = true
}
confirmWater(): void {
this.waterCups += 1
this.showWater = false
}
// ================= 头部(无动画,电商风) =================
@Builder
headerBar() {
Column() {
Row() {
Column() {
Text('营养餐盘')
.fontSize(19)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('MEAL PLATE')
.fontSize(8)
.fontColor('#FFE0C7')
.fontWeight(FontWeight.Bold)
.letterSpacing(2)
.margin({ top: 1 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 14 })
Row() {
Text('🔍')
.fontSize(13)
Text('查食物热量,一键记餐')
.fontSize(11)
.fontColor('#C98A5E')
.margin({ left: 6 })
}
.layoutWeight(1)
.height(34)
.borderRadius(17)
.backgroundColor('#FFFFFF')
.margin({ left: 12, right: 8 })
.justifyContent(FlexAlign.Center)
Text('🧺')
.fontSize(16)
.margin({ right: 10 })
Text('碗')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor('#C9551B')
.width(30)
.height
Text(this.meals.length > this.delIdx ? this.meals[this.delIdx].food + ' · ' + Math.round(th
.height('100%')
.backgroundColor('#FFF7EF')
}
}

动画与特效的实现展示了animateTo命令式动画API的强大能力。通过在aboutToAppear中启动两个无限循环动画,应用实现了餐盘匀速旋转和营养素指示点脉冲呼吸两个持续动效。动画的核心机制是"在闭包中修改状态变量"——plateAngle从0到360的动画驱动了.rotate()属性的变化,dotOp和dotScale的交替动画驱动了.opacity()和.scale()属性的变化。这种"状态驱动动画"的设计使得动画与UI渲染天然集成,无需额外的动画框架或手动刷新逻辑。
弹窗系统是本应用交互设计的一大亮点。六个弹窗覆盖了增(记一餐、添加食物)、删(删除确认)、改(编辑记录)、查(食物详情)和辅助功能(饮水打卡)五大场景,采用了居中弹窗和底部抽屉两种交互模式。所有弹窗都通过"遮罩层zIndex(999)+内容区zIndex(1000)+position绝对定位"的三件套实现,配合if条件渲染控制显隐。删除弹窗的Toggle二次确认机制体现了对危险操作的安全设计意识。整个弹窗系统不依赖任何第三方弹窗库,纯用ArkTS原生组件实现,代码可维护性极高。
从工程实践的角度看,这个应用虽然功能丰富,但代码结构清晰、职责分明。数据接口层定义契约,常量层提供初始数据,状态管理层驱动UI更新,业务逻辑层处理增删改查,UI构建层声明界面结构,动画层提供视觉增强——每一层都有明确的边界和约定。ForEach的键值函数设计尤为精妙——通过在键值中拼接portion、often等可变字段,确保了编辑操作能正确触发对应项的重渲染。这种对ArkTS差分更新机制的深入理解,是编写高效声明式UI的关键。整个应用无需任何第三方依赖,完全基于ArkUI原生组件和ArkTS语言能力构建,充分展现了鸿蒙声明式UI开发范式的完备性和表现力。
更多推荐


所有评论(0)