鸿蒙开发HarmonyOS4.0入门与实践

鸿蒙开发HarmonyOS4.0

配合视频一起食用,效果更佳
课程地址:https://www.bilibili.com/video/BV1Sa4y1Z7B1/
源码地址:https://gitee.com/szxio/harmonyOS4

准备工作

官网地址

鸿蒙开发者官网:https://developer.huawei.com/consumer/cn/develop/

工具下载

打开 HUAWEI DevEco Studio和SDK下载和升级 | 华为开发者联盟 网站,选择对应的文件点击下载安装即可

image-20240309203545487

入门案例

安装好之后,选择一个空白项目创建

image-20240309203635598

等待工具加载完成,打开这个 pages/Index.ets 文件

image-20240309203732381

这个文件是一个入口文件,点击工具的右侧 Previewer 按钮,会出来预览界面,我们在左侧改动代码会实时的在这里显示

image-20240310193607007

如果点击 Previewer 按钮出来的是一对文字,可以关掉工具,重启一下即可

上面我们修改了文字的颜色,并且给文字添加了一个点击事件,点击之后改变文字的内容为 Hello ArkTS

华为手机模拟器安装

安装文档:https://b11et3un53m.feishu.cn/wiki/LGprwXi1biC7TQkWPNDc45IXndh

ArkUI组件

Image组件

方式一:加载网络图片

Image("https://pic.rmb.bdstatic.com/bjh/37f17dae02f15085e1becd5954b990839309.jpeg@h_1280").width(300)

这种方式需要开通网络访问权限才可以在真机上正常加载

添加网络权限,更多文档说明

找到 module.json5 文件,添加如下配置

{"module" : {"requestPermissions": [{"name": "ohos.permission.INTERNET" // 开启网络访问权限}],}
}

此时就可正常查看这个图片了

image-20240310200735246

方式二:加载本地文件

// 加载本地文件
Image($r("app.media.icon")).width(300).interpolation(ImageInterpolation.High)

app 是固定的开头,media.icon 表示当前图片所在目录,图片的后缀不需要写

interpolation(ImageInterpolation.High) 表示抗锯齿效果,可以提高图片的清晰度

image-20240310201035451

抗锯齿打开效果

image-20240310201244187

抗锯齿关闭效果

image-20240310201302813

Text组件

基本用法

Text("hello world") // 字体内容.fontSize(30) // 字体大小.fontWeight(FontWeight.Bold) // 字体加粗.textAlign(TextAlign.Center) // 水平居中.width("100%") // 宽度.textCase(TextCase.UpperCase) // 设置字体变大写.fontColor("#09c") // 字体颜色

配置国际显示

首先在 string.json 文件中定义好键值对

image-20240310203601950

英文也对应的配置成一样的

然后基础的 element.string.json 中配置一个name一样的,value无所谓

image-20240310203709310

然后可以使用下面方式来展示配置的国际化语言

Text($r("app.string.Image_width")) // 字体内容.fontSize(30) // 字体大小.fontWeight(FontWeight.Bold) // 字体加粗.textAlign(TextAlign.Center) // 水平居中.width("100%") // 宽度.textCase(TextCase.UpperCase) // 设置字体变大写.fontColor("#09c") // 字体颜色

默认根据当前手机系统的语言,显示对应的value值,可以修改系统语言,显示不同的文字

image-20240310204312982

TextInput组件

绑定一个值改变图片宽度

@Entry
@Component
struct ImagePage {@State imageWidth:number = 200build() {Row(){Column(){Image($r("app.media.icon")).width(this.imageWidth).interpolation(ImageInterpolation.High)Text($r("app.string.Image_width")).fontSize(30)TextInput({placeholder:"请输入图片宽度",text:this.imageWidth.toString()}).width(200).type(InputType.Number).onChange(value=>{this.imageWidth = value ? parseInt(value) : 20})}.width("100%")}.height("100%")}
}
image-20240310211112180

Button组件

普通用法

Button("缩小").width(80).type(ButtonType.Circle).stateEffect(true).onClick(()=>{if(this.imageWidth >= 10){this.imageWidth -= 10}
})Button("放大").width(80).stateEffect(true).margin(10).onClick(()=>{if(this.imageWidth < 300){this.imageWidth += 10}
})

type支持的类型

类型描述
Capsule胶囊型按钮(圆角默认为高度的一半)。
Circle圆形按钮。
Normal普通按钮(默认不带圆角)。

image-20240311135126685

图片按钮

Button(){Image($r("app.media.jian")).width(20).margin(15)
}
.width(80)
.type(ButtonType.Circle)
.stateEffect(true)
.onClick(()=>{if(this.imageWidth >= 10){this.imageWidth -= 10}
})

Slider滑动条

// 滑块
Slider({value: this.imageWidth,step: 10,min:10,max:100,// 设置Slider的滑块与滑轨显示样式,// OutSet 滑块在滑轨上。// InSet 滑块在滑轨内。style: SliderStyle.OutSet}).blockColor("#36D") // 设置滑块的颜色。.trackColor("#ececec") // 设置滑轨的背景颜色。.selectedColor("#09C") // 设置滑轨的已滑动部分颜色。.showSteps(true) // 设置当前是否显示步长刻度值.showTips(true) // 设置滑动时是否显示百分比气泡提示。.trackThickness(7) // 滑动条的粗细.onChange((value: number, mode: SliderChangeMode) => {this.imageWidth = parseInt(value.toFixed(0))})

image-20240311140942599

Columl和Row

Column和Row在主轴方向上的对齐方式

image-20240311142257609

在交叉轴的对齐方式

image-20240311142428140

设置图片大小Demo

@Entry
@Component
struct ImagePage {@State imageWidth:number = 200build() {Column({space:20}){Row(){Image($r("app.media.icon")).width(this.imageWidth).interpolation(ImageInterpolation.High)}.width("100%").height(350).margin({bottom:20}).justifyContent(FlexAlign.Center).backgroundColor("#ececec")Row(){Text($r("app.string.Image_width")).fontSize(20).margin({right:15})TextInput({placeholder:"请输入图片宽度",text:this.imageWidth.toString()}).width(200).type(InputType.Number).onChange(value=>{this.imageWidth = value ? parseInt(value) : 20})}Row(){/*文字类型按钮*/Button("缩小").width(80).stateEffect(true).onClick(()=>{if(this.imageWidth >= 10){this.imageWidth -= 10}})/*文字类型按钮*/Button("放大").width(80).stateEffect(true).margin(10).onClick(()=>{if(this.imageWidth < 300){this.imageWidth += 10}})}.width("80%").justifyContent(FlexAlign.SpaceBetween)Row(){// 滑块Slider({value: this.imageWidth,step: 10,min:10,max:100,// 设置Slider的滑块与滑轨显示样式,// OutSet 滑块在滑轨上。// InSet 滑块在滑轨内。style: SliderStyle.OutSet}).blockColor("#36D") // 设置滑块的颜色。.trackColor("#ececec") // 设置滑轨的背景颜色。.selectedColor("#09C") // 设置滑轨的已滑动部分颜色。.showSteps(true) // 设置当前是否显示步长刻度值.showTips(true) // 设置滑动时是否显示百分比气泡提示。.trackThickness(7) // 滑动条的粗细.onChange((value: number, mode: SliderChangeMode) => {this.imageWidth = parseInt(value.toFixed(0))})}.width("90%")}.width("100%").height("100%")}
}

image-20240311143701924

List和ForEach

  • layoutWeight(1) 样式权重,数值越大,权重越高,会将除了其他低权重区域的高度减掉之后,剩下的都是自己的
class Item {name:stringprice:numberimg:Resourcediscount:numberconstructor(name:string,img:Resource,price:number,discount?:number) {this.name = namethis.img = imgthis.price = pricethis.discount = discount}
}@Entry
@Component
struct ItemsPage {@State ItemList:Array<Item> = []// 页面显示时触发onPageShow(){// 模拟从后端加载数据setTimeout(()=>{this.ItemList = [new Item("华为Meta60",$r("app.media.phone"),6799,500),new Item("小米14",$r("app.media.phone"),4999),new Item("vivo X100",$r("app.media.phone"),4699),new Item("红米K70",$r("app.media.phone"),2799),new Item("vivo X100",$r("app.media.phone"),4699),new Item("红米K70",$r("app.media.phone"),2799),new Item("vivo X100",$r("app.media.phone"),4699),new Item("红米K70",$r("app.media.phone"),2799)]},2000)}build() {Column(){// 顶部标题Row(){Text("百亿补贴").fontSize(30).fontColor(Color.Red).fontWeight(FontWeight.Bold)}.width("100%").height(45).margin({bottom:20})List({space:15}){// 遍历每一个ForEach(this.ItemList,(item:Item)=>{// List组件内必须用ListItem组件包裹ListItem(){// 每一个商品卡片Row(){// 左侧商品图片Image(item.img).width("30%")// 右侧商品信息Column({space:10}){// 商品名称Row(){Text(item.name).fontSize(25)}.width("100%")// 判断是否有折扣if(item.discount){// 原价Row(){Text(`原价 ¥${item.price}`).fontSize(16).fontColor("#ccc").decoration({type:TextDecorationType.LineThrough})}.width("100%")// 折扣价Row(){Text(`补贴 ¥${item.discount}`).fontSize(18).fontColor(Color.Red)}.width("100%")// 现在价格Row(){Text(`折扣价 ¥${item.price - item.discount}`).fontSize(20).fontColor(Color.Red)}.width("100%")}else{// 价格Row(){Text(`折扣价 ¥${item.price}`).fontSize(20).fontColor(Color.Red)}.width("100%")}}}.width("100%").padding(10).borderRadius(5).alignItems(VerticalAlign.Top).backgroundColor(Color.White)}})}.width("100%").layoutWeight(1) // 样式权重,数值越大,权重越高,会将除了其他低权重区域的高度减掉之后,剩下的都是自己的}.padding(15).width("100%").height("100%").backgroundColor("#ececec")}
}

实现效果

image-20240311153547454

Toast

import promptAction from '@ohos.promptAction'Button("Toast").onClick(()=>{promptAction.showToast({message:"消息提示"})
})

image-20240324212649298

自定义组件

新建组件 src/main/ets/components/Header.ets

// 定义Header组件
@Component
export struct Header {// 定义参数,父组件使用时通过参数传递过来private title:stringbuild() {// 顶部标题Row(){Text(this.title).fontSize(30).fontColor(Color.Red).fontWeight(FontWeight.Bold)}.width("100%").height(45)}
}

使用方法

import { Header } from '../components/Header'@Entry
@Component
struct ItemsPage {build() {Column(){// 引用顶部标题Header({title:"百亿补贴"}).margin({bottom:20})}}
}

自定义构建函数

全局自定义构建函数

可以定义在组件外部,并且可以接受参数

// 全局自定义构建函数,函数前面加上 @Builder
@Builder function ItemCar(item:Item){// 每一个商品卡片Row(){// 左侧商品图片Image(item.img).width("30%")// ......}
}

使用方法

build() {Column(){Header({title:"百亿补贴"}).margin({bottom:20})List({space:15}){ForEach(this.ItemList,(item:Item)=>{ListItem(){// 使用自定义构建函数ItemCar(item)}})}}
}

局部构建函数

和全局定义构建函数类似,不需要添加 function 关键词,必须和 build 函数同级,不能放在 build 函数内部

// 局部自定义构建函数
@Builder function ItemCar(item:Item){// 每一个商品卡片Row(){// 左侧商品图片Image(item.img).width("30%")// ......}
}

使用局部构建函数时要添加 this.xxx

build() {Column(){Header({title:"百亿补贴"}).margin({bottom:20})List({space:15}){ForEach(this.ItemList,(item:Item)=>{ListItem(){// 使用自定义构建函数this.ItemCar(item)}})}}
}// 局部自定义构建函数
@Builder function ItemCar(item:Item){// 每一个商品卡片Row(){// 左侧商品图片Image(item.img).width("30%")// ......}
}

样式封装

公共样式封装

封装公共样式包含的属性也必须是公共的属性,特殊组件的特殊属性不支持在公共样式内

// 公共样式封装
@Styles function pageCommonStyle(){.padding(15).width("100%").height("100%").backgroundColor("#ececec")
}@Entry
@Component
struct ItemsPage {build() {Column() {//......}.pageCommonStyle() // 使用公共样式}
}

自定义样式封装

可以封装特殊组件的样式

// 特殊组件的样式封装
@Extend(Text) function textStyle(fontSize:number){.fontSize(fontSize).fontColor(Color.Red)
}

使用

// 折扣价
Row() {Text(`补贴 ¥${item.discount}`).textStyle(18)
}
.width("100%")// 现在价格
Row() {Text(`折扣价 ¥${item.price - item.discount}`).textStyle(20)
}
.width("100%")

状态管理

@State

  • @State装饰器标记的变量必须初始化,不能为空值
  • @State支持Object,class,string,number,boolean,enum类型以及这些类型的数组
  • 嵌套类型以及数组中的对象属性发生变化,无法触发页面更新
class User{name:stringage:numberconstructor(name,age) {this.name = namethis.age = age}
}@Entry
@Component
struct Index {@State age: number = 18@State jack:User = new User("Jack",19)@State gfs:User[] = [new User("露丝",18),new User("玛丽",20)]build() {Column() {// Row(){//   Text(`${this.age}`)//     .fontSize(25)//     .onClick(()=>{//       // 基础类型的数据变化可以触发页面更新//       this.age++//     })// }Row(){Text(`${this.jack.name} ${this.jack.age}`).fontSize(30).fontWeight(FontWeight.Bold).onClick(()=>{// 单层对象的内容是可以实时响应的this.jack.age++})}Row(){Text(`===女友列表===`).fontSize(25).fontWeight(FontWeight.Bold)}.width("100%").margin({top:20}).justifyContent(FlexAlign.Center)Row(){Button("增加").onClick(()=>{// 新增一项也可以触发更新this.gfs.push(new User(`女友${this.gfs.length}`,18))})}ForEach(this.gfs,(gf:User,index)=>{Row(){Text(`${gf.name} ${gf.age}`).fontSize(30).fontWeight(FontWeight.Bold).onClick(()=>{// 嵌套层级的数据改变,不会触发页面更新gf.age++})Button("删除").onClick(()=>{// 删除数组可以触发更新this.gfs.splice(index,1)})}.margin({top:20})})}.width('100%').height('100%').padding(20)}
}

image-20240311175038381

任务列表Demo

// 任务对象
class Task{static id = 1name:stringfinish:booleanconstructor() {this.name = `任务${Task.id++}`this.finish = false}
}// 定义卡片公共样式
@Styles function carStyle() {.borderRadius(8).shadow({radius: 20,color: "#bbb",offsetX: 3,offsetY: 4}).backgroundColor(Color.White).width("100%")
}const FinishColor = "#36D"@Entry
@Component
struct TaskList {// 任务总数量@State taskTotal:number = 0// 已完成数量@State finishTotal:number = 0// 任务数组@State taskList:Task[] = [new Task(),new Task()]handleTaskChange(){this.taskTotal = this.taskList.lengththis.finishTotal = this.taskList.filter(i=>i.finish).length}onPageShow(){this.handleTaskChange()}build() {Column() {Row(){Text("任务列表").fontSize(25).fontWeight(FontWeight.Bold)// 栈组件,让多个组件堆叠在一起Stack(){// 进度条Progress({value:this.finishTotal,total:this.taskTotal,type:ProgressType.ScaleRing // 设置成环形进度条}).width(100).color(FinishColor).style({strokeWidth:5})Row(){Text(`${this.finishTotal}`).fontColor(FinishColor).fontSize(25)Text(` / ${this.taskTotal}`).fontSize(25)}}}.carStyle().padding(35).justifyContent(FlexAlign.SpaceBetween)Row(){Button("添加任务").width(200).margin({top:30,bottom:30}).backgroundColor(FinishColor).onClick(()=>{this.taskList.push(new Task())this.handleTaskChange()})}List({space:20}){ForEach(this.taskList,(task:Task,index)=> {ListItem(){Row(){if(task.finish){Text(`${task.name}`).fontColor("#ccc").decoration({ type: TextDecorationType.LineThrough })}else{Text(`${task.name}`)}Checkbox().select(task.finish).selectedColor(FinishColor).onChange(val=>{task.finish = valthis.handleTaskChange()})}.carStyle().padding(20).justifyContent(FlexAlign.SpaceBetween)}.swipeAction({ // 往左边滑动时出现自定义的构建函数end:this.deleteBuilder(index)})})}.width("100%").layoutWeight(1)}.width("100%").height("100%").padding(15).backgroundColor("#ececec")}@Builder deleteBuilder(index){Button(){Image($r("app.media.deleteIcon")).width(20).interpolation(ImageInterpolation.High)}.width(40).height(40).margin({left:15}).backgroundColor(Color.Red).onClick(()=>{this.taskList.splice(index,1)this.handleTaskChange()})}
}

实现效果

tasklist

@Prop和@Link

@prop@LInk
同步类型单项同步双向同步
允许装饰的变量类型@Prop只支持string、number、boolean、enum类型
父组件是对象类型,子组件是对象属性
不可以是数组、any
父子类型一致:string、number、boolean、enum、object、class、以及他们的数组
数组中的元素增、删、改、查等都会引起刷新
嵌套类型以及数组中的对象属性无法引起刷新
初始化方式不允许子组件进行初始化父组件传递、禁止子组件进进行初始化

现在我们使用@Prop和@Link将上面的代码进行组件封装

新建 components/taskComponents/HeaderCar 定义顶部卡片组件

const FinishColor = "#36D"
// 定义卡片公共样式
@Styles function carStyle() {.borderRadius(8).shadow({radius: 20,color: "#bbb",offsetX: 3,offsetY: 4}).backgroundColor(Color.White).width("100%")
}@Component
export struct HeaderCar {// 定义从父组件接收的字段@Prop finishTotal: number@Prop taskTotal: numberbuild() {Row(){Text("任务列表").fontSize(25).fontWeight(FontWeight.Bold)// 栈组件,让多个组件堆叠在一起Stack(){// 进度条Progress({value:this.finishTotal,total:this.taskTotal,type:ProgressType.ScaleRing // 设置成环形进度条}).width(100).color(FinishColor).style({strokeWidth:5})Row(){Text(`${this.finishTotal}`).fontColor(FinishColor).fontSize(25)Text(` / ${this.taskTotal}`).fontSize(25)}}}.carStyle().padding(35).justifyContent(FlexAlign.SpaceBetween)}
}

新建 components/taskComponents/TaskListItem 封装任务列表组件

class Task{static id = 1name:stringfinish:booleanconstructor() {this.name = `任务${Task.id++}`this.finish = false}
}// 定义卡片公共样式
@Styles function carStyle() {.borderRadius(8).shadow({radius: 20,color: "#bbb",offsetX: 3,offsetY: 4}).backgroundColor(Color.White).width("100%")
}const FinishColor = "#36D"@Component
export struct TaskItem {@Link taskTotal: number@Link finishTotal: number@State taskList: Task[] = []handleTaskChange(){this.taskTotal = this.taskList.lengththis.finishTotal = this.taskList.filter(i=>i.finish).length}build() {Column(){Button("添加任务").width(200).margin({top:30,bottom:30}).backgroundColor(FinishColor).onClick(()=>{this.taskList.push(new Task())this.handleTaskChange()})Row(){List({space:20}){ForEach(this.taskList,(task:Task,index)=> {ListItem(){Row(){if(task.finish){Text(`${task.name}`).fontColor("#ccc").decoration({ type: TextDecorationType.LineThrough })}else{Text(`${task.name}`)}Checkbox().select(task.finish).selectedColor(FinishColor).onChange(val=>{task.finish = valthis.handleTaskChange()})}.carStyle().padding(20).justifyContent(FlexAlign.SpaceBetween)}.swipeAction({ // 往左边滑动时出现自定义的构建函数end:this.deleteBuilder(index)})})}.width("100%").layoutWeight(1)}}}// 自定义删除按钮的构建函数@Builder deleteBuilder(index){Button(){Image($r("app.media.deleteIcon")).width(20).interpolation(ImageInterpolation.High)}.width(40).height(40).margin({left:15}).backgroundColor(Color.Red).onClick(()=>{this.taskList.splice(index,1)this.handleTaskChange()})}
}

最后父组件引用上面个子组件

// 任务对象
import { HeaderCar } from '../components/taskComponents/HeaderCar'
import { TaskItem } from '../components/taskComponents/TaskListItem'@Entry
@Component
struct TaskList {// 任务总数量@State taskTotal:number = 0// 已完成数量@State finishTotal:number = 0onPageShow(){// 调用子组件的方法TaskItem.prototype.handleTaskChange()}build() {Column() {// 头部卡片HeaderCar({taskTotal:this.taskTotal,finishTotal:this.finishTotal})// 底部的任务列表组件TaskItem({taskTotal:$taskTotal,finishTotal:$finishTotal}).layoutWeight(1)}.width("100%").height("100%").padding(15).backgroundColor("#ececec")}
}

效果一致

image-20240311213516805

@Provide和@Consume

@Provide和@Consume适用于跨组件传递数据的场景

在父组件定义一个变量,并且用@Provide修饰,然后子组件或者孙子组件使用@Consume修饰接收的变量,然后父组件引用这些子组件时不需要传递参数,子组件可以自动的获取父组件的变量值。并且支持双向同步

代码示例

@Entry
@Component
struct ProvidePage {@Provide name: string = "李四"build() {Column(){Row(){Text(`父组件的值:${this.name}`).fontSize(30)}// 定义子组件NameCom()}}
}@Component
struct NameCom {@Consume name: stringbuild(){Column(){Row(){Text(`${this.name}`)}Row(){TextInput({text:this.name}).onChange(val => {this.name = val})}}}
}

效果展示

Provide

@Observed和@ObjectLink

上面我们知道,嵌套的字段发生改变时,页面不会刷新。为了解决这个问题,我们就要使用 @Observed和@ObjectLink

现在我们来修改任务列表这个代码,我们发现点击完右侧的复选框后,文字的样式并没有发生变化

修改 components/taskComponents/TaskListItem

@Observed
class Task{static id = 1name:stringfinish:booleanconstructor() {this.name = `任务${Task.id++}`this.finish = false}
}// 定义卡片公共样式
@Styles function carStyle() {.borderRadius(8).shadow({radius: 20,color: "#bbb",offsetX: 3,offsetY: 4}).backgroundColor(Color.White).width("100%")
}const FinishColor = "#36D"@Component
export struct TaskItem {@Link taskTotal: number@Link finishTotal: number@State taskList: Task[] = []handleTaskChange(){this.taskTotal = this.taskList.lengththis.finishTotal = this.taskList.filter(i=>i.finish).length}build() {Column(){Button("添加任务").width(200).margin({top:30,bottom:30}).backgroundColor(FinishColor).onClick(()=>{this.taskList.push(new Task())this.handleTaskChange()})Row(){List({space:20}){ForEach(this.taskList,(task:Task,index)=> {ListItem(){// 每一行组件RowItem({task:task,// 将父组件定义的方法传递给子组件,并绑定this为父组件的thishandleTaskChange:this.handleTaskChange.bind(this)})}.swipeAction({ // 往左边滑动时出现自定义的构建函数end:this.deleteBuilder(index)})})}.width("100%").layoutWeight(1)}}}// 自定义删除按钮的构建函数@Builder deleteBuilder(index){Button(){Image($r("app.media.deleteIcon")).width(20).interpolation(ImageInterpolation.High)}.width(40).height(40).margin({left:15}).backgroundColor(Color.Red).onClick(()=>{this.taskList.splice(index,1)this.handleTaskChange()})}
}@Component
struct RowItem {@ObjectLink task:TaskhandleTaskChange: ()=>voidbuild() {Row(){if(this.task.finish){Text(`${this.task.name}`).fontColor("#ccc").decoration({ type: TextDecorationType.LineThrough })}else{Text(`${this.task.name}`)}Checkbox().select(this.task.finish).selectedColor(FinishColor).onChange(val=>{this.task.finish = valthis.handleTaskChange()})}.carStyle().padding(20).justifyContent(FlexAlign.SpaceBetween)}
}

class Task 添加了 @Observe 修饰,然后将每一行做了组件抽离,并接收参数,使用 @ObjectLink 修饰

然后我们需要在RowItem组件中调用父组件的handleTaskChange方法,所以定义了一个handleTaskChange参数,通过父组件传递过来,但是在子组件调用时,this指向会发生变化,所以父组件在传递方法时,使用bind改变这个方法内部的this指向

现在代码的运行效果就是正常的

Observe

页面路由

  1. 页面栈的最大容量上限是32个,使用 router.clear() 方法可以清空页面栈,释放内存
  2. Router有两种跳转模式,分别为:
    • router.pushUrl():目标页面不会替换当前页面,而是压入页面栈,因此可以用 router.back() 返回当前页面
    • router.replaceUrl():目标页面会替换当前页面,当前页面会被销毁并释放资源,无法返回当前页面
  3. Router有两种页面实例模式,分别是:
    • Standard:标准页面实例,每次跳转都会新建一个目标页面压入页面栈,默认就是此模式
    • Single:单实例模式,如果目标页已经在页面栈中,则距离页面栈顶部最近的同Url页面会被移动到栈顶,并重新加载

修改首页代码

import router from '@ohos.router'class RouterItem {url: stringtitle: stringconstructor(url, title) {this.url = urlthis.title = title}
}@Entry
@Component
struct Index {@State message: string = '页面列表'routerList: RouterItem[] = [new RouterItem("pages/ImagePage", "查看图片页面"),new RouterItem("pages/ItemsPage", "商品列表页面"),new RouterItem("pages/StatePage", "Jack和他的女朋友们"),new RouterItem("pages/TaskListPage", "任务列表"),]build() {Column() {Row() {Text(this.message).fontSize(50).fontWeight(FontWeight.Bold).fontColor("#36d").onClick(() => {this.message = "Hello ArkTS"})}List({ space: 20 }) {ForEach(this.routerList, (r: RouterItem, index: number) => {ListItem() {RouterItemBox({item: r,rid: index + 1})}})}.width("100%").margin({ top: 35 }).layoutWeight(1)}.width('100%').height("100%").padding(15)}
}@Component
struct RouterItemBox {item: RouterItemrid: numberbuild() {Row() {Text(`${this.rid}.`).fontColor(Color.White).fontSize(18).fontWeight(FontWeight.Bold)Blank()Text(`${this.item.title}`).fontColor(Color.White).fontSize(18).fontWeight(FontWeight.Bold)}.width("100%").padding({top: 15,right: 25,bottom: 15,left: 25}).backgroundColor("#36D").borderRadius(30).shadow({radius: 8,color: "#ff484848",offsetX: 5,offsetY: 5}).justifyContent(FlexAlign.SpaceBetween).onClick(() => {router.pushUrl({url: this.item.url},router.RouterMode.Single,err => {if(err){console.log(`页面跳转出错,errCode:${err.code},errMsg:${err.message}`)}})})}
}

修改公共的Header组件,添加点击返回功能

// 定义Header组件
import router from '@ohos.router'
@Component
export struct Header {// 定义参数,父组件使用时通过参数传递过来private title:stringbuild() {// 顶部标题Row(){Row({space:15}){Image($r("app.media.back")).width(30).onClick(()=>{// 返回前确认弹框,用户点击确认后,才会继续往下执行代码。否则不会继续往下执行router.showAlertBeforeBackPage({message:"确认离开当前页面吗?",})// 返回上一页router.back()})Text(this.title).fontSize(20)}Image($r("app.media.refresh")).width(25)}.width("100%").padding({left:15,right:15,top:15,bottom:15}).alignItems(VerticalAlign.Center).justifyContent(FlexAlign.SpaceBetween)}
}

最后需要配置页面地址,找到 resources/base/profile/main_pages.json 文件,添加页面路由信息

{"src": ["pages/Index","pages/ImagePage","pages/ItemsPage","pages/StatePage","pages/TaskListPage"]
}

如果不配置,则不会跳转

另外,在新建时,可以选择新建 Page,这样会自动的往该文件中添加路由信息

image-20240312154237370

效果展示

ohmoRouter2

动画

属性动画

image-20240312155633801

实例代码

import router from '@ohos.router'@Entry
@Component
struct AnimationPage {// 小鱼坐标@State fishX: number = 200@State fishY: number = 180// 小鱼角度@State angle: number = 0// 小鱼图片@State src: Resource = $r("app.media.yu")// 是否开始游戏@State isBegin: boolean = false// 移动速度@State speed: number = 20build() {Row() {Stack() {Button("返回").position({ x: 15, y: 15 }).width(80).backgroundColor("#bc515151").onClick(() => {router.back()})if (!this.isBegin) {Button("开始游戏").onClick(() => {this.isBegin = true})} else {Image(this.src).position({ x: this.fishX - 40, y: this.fishY - 40 }).rotate({ angle: this.angle, centerX: "50%", centerY: "50%" }).width(80).height(80).animation({duration: 500, // 动画时长,当上面的动画值发生变化时会触发动画})}// 摇杆区域if (this.isBegin) {Row() {Button("←").backgroundColor("#bc515151").onClick(() => {this.fishX -= this.speedthis.src = $r("app.media.yu")})Column({ space: 40 }) {Button("↑").backgroundColor("#bc515151").onClick(() => {this.fishY -= this.speed})Button("↓").backgroundColor("#bc515151").onClick(() => {this.fishY += this.speed})}Button("→").backgroundColor("#bc515151").onClick(() => {this.fishX += this.speedthis.src = $r("app.media.yuR")})}.width(240).height(240).position({ x: 15, y: 150 })}}.height('100%').width("100%")}.justifyContent(FlexAlign.Center).alignItems(VerticalAlign.Center).backgroundImage($r("app.media.yuBg")).backgroundImageSize(ImageSize.Cover) // 背景图片铺满}
}

上面代码完成了小鱼游动的效果,点击上下箭头,可以看到小鱼很平滑的在移动

image-20240312170542861

显示动画

image-20240312171446745

修改上面的代码为显示动画

Stack() {Button("返回").position({ x: 15, y: 15 }).width(80).backgroundColor("#bc515151").onClick(() => {router.back()})if (!this.isBegin) {Button("开始游戏").onClick(() => {this.isBegin = true})} else {Image(this.src).position({ x: this.fishX - 40, y: this.fishY - 40 }).rotate({ angle: this.angle, centerX: "50%", centerY: "50%" }).width(80).height(80)}// 摇杆区域if (this.isBegin) {Row() {Button("←").backgroundColor("#bc515151").onClick(() => {// 全局暴露的动画函数,第一个参数设置动画相关内容// 第二个是修改的动画值animateTo({duration: 500},() => {this.fishX -= this.speedthis.src = $r("app.media.yu")})})Column({ space: 40 }) {Button("↑").backgroundColor("#bc515151").onClick(() => {animateTo({duration: 500},() => {this.fishY -= this.speed})})Button("↓").backgroundColor("#bc515151").onClick(() => {animateTo({duration: 500},() => {this.fishY += this.speed})})}Button("→").backgroundColor("#bc515151").onClick(() => {animateTo({duration: 500},() => {this.fishX += this.speedthis.src = $r("app.media.yuR")})})}.width(240).height(240).position({ x: 15, y: 150 })}
}
.height('100%')
.width("100%")

组件转场动画

image-20240312172055505

为小鱼添加入场动画,修改开始游戏按钮的方法

if (!this.isBegin) {Button("开始游戏").onClick(() => {// 点击开始游戏后,使用animateTo控制小鱼开始执行入场动画// 注意:必须使用animateTo方法的回调控制变量,才能触发transition动画animateTo({duration:1000},()=>{this.isBegin = true})})
} else {Image(this.src).position({ x: this.fishX - 40, y: this.fishY - 40 }).rotate({ angle: this.angle, centerX: "50%", centerY: "50%" }).width(80).height(80)// 添加初始位置,点击开始游戏后,由下面的样式变成上面定义的样式.transition({type:TransitionType.Insert, // Insert 表示入场动画translate:{x:-this.fishX}, // x 轴上的位置,设置为负数,表示从屏幕外面移动到屏幕里面})
}

效果展示

transition

实现摇杆功能

完整代码

import router from '@ohos.router'
import curves from '@ohos.curves'@Entry
@Component
struct AnimationPage {// 小鱼坐标@State fishX: number = 300@State fishY: number = 180// 小鱼角度@State angle: number = 0// 小鱼图片@State src: Resource = $r("app.media.yuR")// 是否开始游戏@State isBegin: boolean = false// 移动速度@State speed: number = 20// 摇杆中心区域坐标centerX: number = 120centerY: number = 120// 大小圆的半径maxRadius: number = 100radius: number = 20// 摇杆小圆球的初始位置@State positionX: number = this.centerX@State positionY: number = this.centerY// 角度正弦和余弦sin: number = 0cos: number = 0taskId: number = 1scaleTaskId: number = 1@State fishScale:number = 1build() {Row() {Stack() {Button("返回").position({ x: 15, y: 15 }).width(80).backgroundColor("#bc515151").onClick(() => {router.back()})if (!this.isBegin) {Button("开始游戏").onClick(() => {// 点击开始游戏后,使用animateTo控制小鱼开始执行入场动画// 注意:必须使用animateTo方法的回调控制变量,才能触发transition动画animateTo({duration:1000},()=>{this.isBegin = true})})} else {Image(this.src).position({ x: this.fishX - 40, y: this.fishY - 40 }).rotate({ angle: this.angle, centerX: "50%", centerY: "50%" }).width(80).height(80).scale({x:this.fishScale,y:this.fishScale})// 添加初始位置,点击开始游戏后,由下面的样式变成上面定义的样式.transition({type:TransitionType.Insert, // Insert 表示入场动画translate:{x:-this.fishX}, // x 轴上的位置}).interpolation(ImageInterpolation.High)}// 摇杆区域Row() {Circle({width:this.maxRadius * 2,height:this.maxRadius * 2}).fill("#3a101020").position({x:this.centerX-this.maxRadius,y:this.centerY-this.maxRadius})Circle({width:this.radius*2,height:this.radius *2}).fill("#ffeaa311").position({x:this.positionX-this.radius,y:this.positionY-this.radius})}.width(240).height(240).justifyContent(FlexAlign.Center).position({ x: 0, y: 120 }).onTouch(this.onTouchEvent.bind(this))}.height('100%').width("100%")}.justifyContent(FlexAlign.Center).alignItems(VerticalAlign.Center).backgroundImage($r("app.media.yuBg")).backgroundImageSize(ImageSize.Cover) // 背景图片铺满}// 处理摇杆区域的触摸事件onTouchEvent(event:TouchEvent){// 区分不同的类型switch (event.type){// 手指松开事件case TouchType.Up:animateTo({curve:curves.springMotion()},()=>{// 还原小球的位置this.positionX = this.centerXthis.positionY = this.centerY// 还原小鱼的倾斜角度this.angle = 0// 还原小鱼大小this.fishScale = 1})clearInterval(this.taskId)clearInterval(this.scaleTaskId)break// 手指点击事件case TouchType.Down:// 不断的更新小鱼的位置this.taskId = setInterval(()=>{this.fishX += this.speed * this.costhis.fishY += this.speed * this.sin},40)// 每隔500毫秒让小鱼逐渐变大this.scaleTaskId = setInterval(()=>{animateTo({curve:curves.springMotion()},()=>{this.fishScale += 0.2})},500)break// 手指移动事件case TouchType.Move:// 1.获取手指位置坐标let x = event.touches[0].xlet y = event.touches[0].y// 2.计算手指与中心点坐标的差值let vx = x - this.centerXlet vy = y - this.centerY// 3.计算手指与中心点连线和x轴半径的夹角,单位是弧度let angle = Math.atan2(vy,vx)// 4.计算手指与中心点的距离let distance = this.getDistance(vx,vy)// 5.计算摇杆小球的坐标this.cos = Math.cos(angle)this.sin = Math.sin(angle)animateTo({// 设置动画为连续动画curve:curves.responsiveSpringMotion()},()=>{this.positionX = this.centerX + distance * Math.cos(angle)this.positionY = this.centerY + distance * Math.sin(angle)// 6.计算小鱼的位置this.speed = 5// 计算角度绝对值,如果小于90则需要翻转图片if(Math.abs(angle * 2) < Math.PI){this.src = $r("app.media.yuR")}else{this.src = $r("app.media.yu")angle = angle < 0 ? angle + Math.PI : angle - Math.PI}// 弧度转角度计算公式:弧度 * (180 / π)this.angle = angle * (180 / Math.PI)})break}}getDistance(x,y){// 求平方根,计算两点的距离let d = Math.sqrt(x*x + y*y)return Math.min(d,this.maxRadius)}
}

image-20240313170808603

Stage模型

文档介绍

https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V2/application-configuration-file-overview-stage-0000001428061460-V2

在需要的时候来翻阅文档即可

生命周期

页面及组件的生命周期

完成流程图

image-20240313212734129

接下来通过两个案例来查看生命周期函数的执行情况

案例一

首先给首页添加生命周期函数

import router from '@ohos.router'class RouterItem {url: stringtitle: stringconstructor(url, title) {this.url = urlthis.title = title}
}@Entry
@Component
struct Index {@State message: string = '页面列表'routerList: RouterItem[] = [new RouterItem("pages/ImagePage", "查看图片页面"),new RouterItem("pages/ItemsPage", "商品列表页面"),new RouterItem("pages/StatePage", "Jack和他的女朋友们"),new RouterItem("pages/TaskListPage", "任务列表"),new RouterItem("pages/AnimationPage", "小鱼动画"),new RouterItem("pages/AnimationPageV2", "小鱼动画V2"),new RouterItem("pages/LifeCyclePage", "生命周期案例1"),new RouterItem("pages/LifeCyclePage1", "生命周期案例2"),]tag: string = "Index Page"aboutToAppear(){console.log(`${this.tag} aboutToAppear,页面创建完成`)}onBackPress(){console.log(`${this.tag} aboutToAppear,页面返回前触发`)}onPageShow(){console.log(`${this.tag} aboutToAppear,页面显示完成`)}onPageHide(){console.log(`${this.tag} aboutToAppear,页面隐藏完成`)}aboutToDisappear(){console.log(`${this.tag} aboutToAppear,页面销毁完成`)}build() {Column() {Row() {Text(this.message).fontSize(50).fontWeight(FontWeight.Bold).fontColor("#36d")}List({ space: 20 }) {ForEach(this.routerList, (r: RouterItem, index: number) => {ListItem() {RouterItemBox({item: r,rid: index + 1})}})}.width("100%").margin({ top: 35 }).layoutWeight(1)}.width('100%').height("100%").padding(15)}
}@Component
struct RouterItemBox {item: RouterItemrid: numberbuild() {Row() {Text(`${this.rid}.`).fontColor(Color.White).fontSize(18).fontWeight(FontWeight.Bold)Blank()Text(`${this.item.title}`).fontColor(Color.White).fontSize(18).fontWeight(FontWeight.Bold)}.width("100%").padding({top: 15,right: 25,bottom: 15,left: 25}).backgroundColor("#36D").borderRadius(30).shadow({radius: 8,color: "#ff484848",offsetX: 5,offsetY: 5}).justifyContent(FlexAlign.SpaceBetween).onClick(() => {router.pushUrl({url: this.item.url},router.RouterMode.Single,err => {if(err){console.log(`页面跳转出错,errCode:${err.code},errMsg:${err.message}`)}})})}
}

在加载完首页后会触发 aboutToAppearonPageShow

image-20240313220640521

然后点击跳转到 pages/LifeCyclePage,页面代码如下

@Entry
@Component
struct LifeCyclePage {@State isShow: boolean = false@State emptyList: any[] = [0]tag: string = "LifeCyclePage"aboutToAppear() {console.log(`${this.tag} aboutToAppear,页面创建完成`)}onBackPress() {console.log(`${this.tag} aboutToAppear,页面返回前触发`)}onPageShow() {console.log(`${this.tag} aboutToAppear,页面显示完成`)}onPageHide() {console.log(`${this.tag} aboutToAppear,页面隐藏完成`)}aboutToDisappear() {console.log(`${this.tag} aboutToAppear,页面销毁完成`)}build() {Row() {Column({ space: 35 }) {Button("显示组件").margin({ top: 30 }).onClick(() => {this.isShow = !this.isShow})if (this.isShow) {MyText()}Button("增加组件").onClick(() => {this.emptyList.push(this.emptyList.length + 1)})ForEach(this.emptyList, (item,index) => {Row({ space: 25 }) {MyText()Button("删除").onClick(() => {this.emptyList.splice(index, 1)})}.width("100%").justifyContent(FlexAlign.Center)})}.width('100%').height("100%").alignItems(HorizontalAlign.Center)}.height('100%')}
}@Component
struct MyText {messages: string = "hello world"tag: string = "MyText"aboutToAppear() {console.log(`${this.tag} aboutToAppear,页面创建完成`)}// 组件没有onBackPress、onPageShow、onPageHide这三个钩子函数onBackPress() {console.log(`${this.tag} aboutToAppear,页面返回前触发`)}onPageShow() {console.log(`${this.tag} aboutToAppear,页面显示完成`)}onPageHide() {console.log(`${this.tag} aboutToAppear,页面隐藏完成`)}aboutToDisappear() {console.log(`${this.tag} aboutToAppear,页面销毁完成`)}build() {Column() {Text(this.messages)}}
}

会打印如下

image-20240313220736436

  • 首先调用页面的 aboutToAppear 页面创建钩子
  • 然后触发组件的 aboutToAppear 页面创建钩子
  • 接着触发首页的 aboutToDisappear 页面销毁钩子
  • 最后触发页面的 onPageShow 显示钩子

这时在页面上显示和隐藏组件,或者增加遍历组件,都只会触发组件的 aboutToAppear 创建和 aboutToDisappear 销毁

image-20240313221014359

这也再次印证了组件是不包含 onBackPressonPageShowonPageHide 这三个页面级别的生命周期函数

然后再返回首页时,会触发下面的钩子

image-20240313221142154

案例二

首先准备两个页面

LifeCyclePage1.ets

import router from '@ohos.router'
@Entry
@Component
struct LifeCyclePage1 {pageName: string = "LifeCycle Page1"aboutToAppear() {console.log(`${this.pageName} aboutToAppear,页面创建完成`)}onBackPress() {console.log(`${this.pageName} aboutToAppear,页面返回前触发`)}onPageShow() {console.log(`${this.pageName} aboutToAppear,页面显示完成`)}onPageHide() {console.log(`${this.pageName} aboutToAppear,页面隐藏完成`)}aboutToDisappear() {console.log(`${this.pageName} aboutToAppear,页面销毁完成`)}build() {Column({space:35}) {Row(){Text(this.pageName).fontSize(30).fontWeight(FontWeight.Bold)}.margin({top:35})Row({space:5}){Button("push 跳转Page2").onClick(()=>{router.pushUrl({url:"pages/LifeCyclePage2"})})Button("replace 跳转Page2").onClick(()=>{router.replaceUrl({url:"pages/LifeCyclePage2"})})}}.height('100%').width("100%")}
}

LifeCyclePage2.ets

import router from '@ohos.router'
@Entry
@Component
struct LifeCyclePage2 {pageName: string = "LifeCycle Page2"aboutToAppear() {console.log(`${this.pageName} aboutToAppear,页面创建完成`)}onBackPress() {console.log(`${this.pageName} aboutToAppear,页面返回前触发`)}onPageShow() {console.log(`${this.pageName} aboutToAppear,页面显示完成`)}onPageHide() {console.log(`${this.pageName} aboutToAppear,页面隐藏完成`)}aboutToDisappear() {console.log(`${this.pageName} aboutToAppear,页面销毁完成`)}build() {Column({space:35}) {Row(){Text(this.pageName).fontSize(30).fontWeight(FontWeight.Bold)}.margin({top:35})Row({space:5}){Button("push 跳转Page1").onClick(()=>{router.pushUrl({url:"pages/LifeCyclePage1"})})Button("replace 跳转Page1").onClick(()=>{router.replaceUrl({url:"pages/LifeCyclePage1"})})}}.height('100%').width("100%")}
}

首先点击 “push跳转” 按钮,查看打印结果

image-20240313221952327

会发现在不断地触发创建和隐藏钩子,但是没有触发aboutToDisappear 页面销毁钩子,这说明通过push方式跳转的页面,系统会帮我们做缓存

接下来点击 “replace跳转” 按钮,查看打印结果

image-20240313222147108

发现通过 replace 跳转会触发上一页面的销毁钩子

UIAbility的启动模式

模式介绍

模式类型作用
singleton每一个UIAbility只存在唯一实例。是默认启动模式,任务列表中只会存在一个相同的UIAbility
standard每次启动UIAbility都会创建一个实例。任务列表中会存在多个相同的UIAbility
specified每个UIAbility实例可以设置key标识,启动UIAbility时,需要指定Key,存在相同的Key的实力会直接被拉起,不存在则创建一个新的实例

案例演示

下面我们来使用一下 specified 模式

首先新建 pages/DocumentPage.ets 页面

import { Header } from '../components/Header'
import common from '@ohos.app.ability.common'
import Want from '@ohos.app.ability.Want'
@Entry
@Component
struct DocumentPage {@State index: number = 1@State documentList:number[] = []context = getContext(this) as common.UIAbilityContextbuild() { Column() {Header({title:"文档列表"})Column({space:15}){Row(){Button("添加文档").onClick(()=>{this.documentList.push(this.index)let want:Want = {deviceId:"",// deviceId为空表示本设备bundleName:"com.example.myapplication", // 包的名称,对应AppScope/app.json5的app.bundleNameabilityName:"DocumentAbility", // 要跳转到目标ability名称moduleName:"entry", // 当前的模块名称parameters:{instanceKey: this.index // 传过去的key}}// 跳转到一个新的Abilitythis.context.startAbility(want)this.index++})}ForEach(this.documentList,id=>{Row({space:15}){Image($r("app.media.doc")).width(25)Text(`文档${id}`).fontSize(20).fontWeight(FontWeight.Bold).onClick(()=>{let want:Want = {deviceId:"",// deviceId为空表示本设备bundleName:"com.example.myapplication", // 包的名称,对应AppScope/app.json5的app.bundleNameabilityName:"DocumentAbility", // 要跳转到目标ability名称moduleName:"entry", // 当前的模块名称parameters:{instanceKey: id // 传过去的key}}// 跳转到一个新的Abilitythis.context.startAbility(want)})}.width("100%")})}.width('100%').height('100%').padding(15)}.width('100%').height('100%')}
}

接着新建文档编辑页面 pages/DocumentEdit.ets

import Want from '@ohos.app.ability.Want'
import common from '@ohos.app.ability.common'
@Entry
@Component
struct DocumentEdit {@State docEdit: boolean = true@State docName: string = ""context = getContext(this) as common.UIAbilityContextonPageShow(){let abilityInfo = this.contextconsole.log(`DocumnetAbility: ${JSON.stringify(abilityInfo)}`)}build() {Column() {Row({ space: 15 }) {Image($r("app.media.back")).width(25).onClick(()=>{let want:Want = {deviceId:"",// deviceId为空表示本设备bundleName:"com.example.myapplication", // 包的名称,对应AppScope/app.json5的app.bundleNameabilityName:"EntryAbility", // 要跳转到目标ability名称moduleName:"entry", // 当前的模块名称}// 跳转到一个新的Abilitythis.context.startAbility(want)})if(this.docEdit){TextInput({placeholder: "请输入文档名称",text: this.docName}).onChange(val=>{this.docName = val}).layoutWeight(1)}else {Text(this.docName).fontSize(25).layoutWeight(1)}Button("确定").onClick(() => {this.docEdit = !this.docEdit})}.width('100%')Row(){TextArea({placeholder: 'The text area can hold an unlimited amount of text. input your word...',}).placeholderFont({ size: 16, weight: 400 }).fontSize(16).fontColor('#182431').height("98%")}.width('100%').layoutWeight(1)}.width('100%').height('100%').padding(15)}
}

然后再首页中添加跳转按钮

import router from '@ohos.router'class RouterItem {url: stringtitle: stringconstructor(url, title) {this.url = urlthis.title = title}
}@Entry
@Component
struct Index {@State message: string = '页面列表'routerList: RouterItem[] = [new RouterItem("pages/ImagePage", "查看图片页面"),new RouterItem("pages/ItemsPage", "商品列表页面"),new RouterItem("pages/StatePage", "Jack和他的女朋友们"),new RouterItem("pages/TaskListPage", "任务列表"),new RouterItem("pages/AnimationPage", "小鱼动画"),new RouterItem("pages/AnimationPageV2", "小鱼动画V2"),new RouterItem("pages/LifeCyclePage", "生命周期案例1"),new RouterItem("pages/LifeCyclePage1", "生命周期案例2"),new RouterItem("pages/DocumentPage", "文档列表页面"),]tag: string = "Index Page"aboutToAppear(){console.log(`${this.tag} aboutToAppear,页面创建完成`)}onBackPress(){console.log(`${this.tag} aboutToAppear,页面返回前触发`)}onPageShow(){console.log(`${this.tag} aboutToAppear,页面显示完成`)}onPageHide(){console.log(`${this.tag} aboutToAppear,页面隐藏完成`)}aboutToDisappear(){console.log(`${this.tag} aboutToAppear,页面销毁完成`)}build() {Column() {Row() {Text(this.message).fontSize(50).fontWeight(FontWeight.Bold).fontColor("#36d")}List({ space: 20 }) {ForEach(this.routerList, (r: RouterItem, index: number) => {ListItem() {RouterItemBox({item: r,rid: index + 1})}})}.width("100%").margin({ top: 35 }).layoutWeight(1)}.width('100%').height("100%").padding(15)}
}@Component
struct RouterItemBox {item: RouterItemrid: numberbuild() {Row() {Text(`${this.rid}.`).fontColor(Color.White).fontSize(18).fontWeight(FontWeight.Bold)Blank()Text(`${this.item.title}`).fontColor(Color.White).fontSize(18).fontWeight(FontWeight.Bold)}.width("100%").padding({top: 15,right: 25,bottom: 15,left: 25}).backgroundColor("#36D").borderRadius(30).shadow({radius: 8,color: "#ff484848",offsetX: 5,offsetY: 5}).justifyContent(FlexAlign.SpaceBetween).onClick(() => {router.pushUrl({url: this.item.url},router.RouterMode.Single,err => {if(err){console.log(`页面跳转出错,errCode:${err.code},errMsg:${err.message}`)}})})}
}
image-20240314222733379

然后再 ets 文件夹右键,选择新建一个 Ability,名称是 DocumentAbility.ts

image-20240314222845313

完成之后会自动帮我们创建好文件,将 DocumentAbility.ts 文件中的默认打开页面修改成文档编辑页面

image-20240314223119938

接着修改 src/main/resources/base/profile/main_pages.json ,设置 DocumentAbility 的启动模式为 specified

image-20240314223242865

然后新建 src/main/ets/myabilitystage/MyAbilityStage.ts 接收key,并返回一个新的key

import AbilityStage from '@ohos.app.ability.AbilityStage';
import Want from '@ohos.app.ability.Want';
export default class MyAbility extends AbilityStage{onAcceptWant(want:Want): string{// 判断被启动的Ability的名称if(want.abilityName === "DocumentAbility"){return `DocumentAbility_${want.parameters.instanceKey}`}return ""}
}

然后在 src/main/ets/myabilitystage/MyAbilityStage.ts 中指定 srcEntry

image-20240314223425674

现在启动手机模拟器,查看效果,通过动画我们就实现根据Key打开Ability

gif1

网络请求

内置的Httprequest请求

准备node服务

需要安装 express

npm install express

新建 nodeServe/index.js

let express = require('express');
let app = express();
let allData = require("./data.json")
app.use('/images', express.static('images')); // 设置静态资源目录app.get("/shop", (req, res) => {console.log(req.query,'接收的参数')let {pageNo, pageSize} = req.query// 确保pageNo和pageSize是正整数pageNo = Math.max(1, parseInt(pageNo, 10));pageSize = Math.max(1, parseInt(pageSize, 10));// 计算起始索引和结束索引let startIndex = (pageNo - 1) * pageSize;let endIndex = startIndex + pageSize;// 返回当前页的数据let currentPageData = allData.slice(startIndex, endIndex);// 返回总页数let totalPages = Math.ceil(allData.length / pageSize);res.send({code: "200",data: {total: allData.length,rows: currentPageData,totalPages: totalPages}})
})app.listen(3000, () => {console.log(`服务启动成功 http://localhost:3000`)
})

准备json数据,新建 data.json 文件,内容如下,这个文件模拟了10条数据

[{"id":1,"name":"新白鹿烤鱼餐厅(西湖店)","images":["/images/1.jpg"],"area":"西湖区","address":"西湖大道1号西湖天地F5","avgPrice":61,"comments":8045,"score":47,"openHours":"11:00-21:00"},{"id":2,"name":"两岸咖啡(下城区店)","images":["/images/2.jpg","/images/3.jpg"],"area":"下城区","address":"中山路5号下城区广场F7","avgPrice":80,"comments":1500,"score":39,"openHours":"09:00-23:00"},{"id":3,"name":"味庄餐厅(上城区店)","images":["/images/4.jpg","/images/5.jpg"],"area":"上城区","address":"清泰街5号上城区购物中心F4","avgPrice":55,"comments":5689,"score":43,"openHours":"11:00-21:00"},{"id":4,"name":"杭州小笼包(拱墅区店)","images":[],"area":"拱墅区","address":"莫干山路2号拱墅区购物中心F2","avgPrice":48,"comments":4500,"score":42,"openHours":"07:00-21:00"},{"id":5,"name":"咖啡时光(江干区店)","images":[],"area":"江干区","address":"钱塘路10号江干区广场F1","avgPrice":75,"comments":3200,"score":41,"openHours":"10:00-22:00"},{"id":6,"name":"大福来餐厅(滨江店)","images":[],"area":"滨江区","address":"江南大道6号滨江购物中心F6","avgPrice":68,"comments":2900,"score":40,"openHours":"11:30-21:30"},{"id":7,"name":"老杭州餐厅(下城区店)","images":[],"area":"下城区","address":"中山路3号下城区广场F3","avgPrice":58,"comments":6500,"score":45,"openHours":"10:30-20:30"},{"id":8,"name":"豪客来牛排馆(江干区店)","images":[],"area":"江干区","address":"钱塘路8号江干区广场F8","avgPrice":95,"comments":1200,"score":38,"openHours":"11:00-21:00"},{"id":9,"name":"小尾羊火锅(上城区店)","images":[],"area":"上城区","address":"清泰街10号上城区购物中心F10","avgPrice":70,"comments":0,"score":37,"openHours":"11:00-21:00"},{"id":10,"name":"新概念咖啡(下城区店)","images":[],"area":"下城区","address":"中山路12号下城区广场F8","avgPrice":50,"comments":1000,"score":36,"openHours":"08:00-22:00"}]

然后启动 node 服务

node index.js

image-20240316123438911

测试服务是否正常运行

image-20240316123504897

viewModel

新建 src/main/ets/viewModel,这个文件用来放所有页面模型数据

在该文件夹下添加如下文件

ShopInfo.ts

export default class ShopInfo{id: numbername: stringimages: string[]area: stringaddress: stringavgPrice: numbercomments: numberscore: numberopenHours: string
}

ResponseInfo.ts

class responseData{total: numbertotalPages: numberrows: any[]
}export default class ResponseInfo{code: numberdata: responseData
}
model

新建 src/main/ets/model 文件夹,这个文件夹用来放有关请求的文件

在该文件夹下新增

ShopModel.ts

import http from '@ohos.net.http'
import ResponseInfo from '../viewModel/ResponseInfo'class ShopModel {pageNo: number = 1pageSize: number = 3baseUrl: string = "http://localhost:3000"buildUrl(url) {return `${this.baseUrl}${url}`}getListFun(): Promise<ResponseInfo> {return new Promise((resolve, reject) => {// 1.创建Http请求对象let httpRequest = http.createHttp()// 2.发送请求体httpRequest.request(// 请求路径this.buildUrl(`/shop?pageNo=${this.pageNo}&pageSize=${this.pageSize}}`),// 请求体{method: http.RequestMethod.GET,  // 请求方式}).then(res => {// 3.拿到请求结果if (res.responseCode === 200) {resolve(JSON.parse(res.result.toString()))} else {console.log(`请求失败:${JSON.stringify(res)}`)reject()}}).catch(err => {console.log(`请求失败:${JSON.stringify(err)}`)reject()})})}
}export default new ShopModel()
pages

新建页面 ShopPage.ets

import { Header } from '../components/Header'
import ShopInfo from '../viewModel/ShopInfo'
import { ShopItem } from '../views/ShopItem'
import ShopModel from "../model/ShopModel"@Entry
@Component
struct ShopPage {@State shopList: ShopInfo[] = []@State total: number = 0@State isLoading: boolean = falseaboutToAppear() {this.getShopList()}build() {Column() {Header({ title: "商铺列表" })List({ space: 10 }) {ForEach(this.shopList, (shop: ShopInfo, index: number) => {ListItem() {ShopItem({ shop: shop })}})}.layoutWeight(1).width('100%').padding(10).onReachEnd(()=>{console.log("触底")// 页面触底方法if(!this.isLoading && this.shopList.length < this.total){this.isLoading = trueShopModel.pageNo++this.getShopList()console.log("触底加载")}})}.width('100%').height('100%').backgroundColor("#ececec")}getShopList() {ShopModel.getListFun().then(res => {const shops = res.data.rowsshops.forEach(item=>{if(item.images && item.images.length > 0){item.images.forEach((img,i)=>{item.images[i] = `http://localhost:3000` + img})}else{item.images = [$r("app.media.mt")]}})this.shopList = this.shopList.concat(shops)this.total = res.data.total // 获取总数this.isLoading = false})}
}

里面用到了 ShopItem 组件,代码如下

view

新建 src/main/ets/views 文件夹,我们将页面用到的组件都放在这个文件夹中

新增 ShopItem.ets

import ShopInfo from '../viewModel/ShopInfo'
@Component
export struct ShopItem {shop: ShopInfobuild() {Column({space:8}){Row(){Text(this.shop.name).fontSize(20).fontWeight(FontWeight.Bold)Text(`${this.computedScore(this.shop.score)}`).fontColor(Color.Orange).fontSize(21).fontWeight(FontWeight.Bold)}.width("100%").justifyContent(FlexAlign.SpaceBetween)Row({space:5}){Image($r("app.media.dh")).width(15)Text(this.shop.address).fontColor("#a3a3a3")}.width("100%")Row(){Text(`${this.shop.comments}条评价`).fontSize(18).fontWeight(FontWeight.Bold)Text(`${this.shop.avgPrice}/人`).fontSize(18).fontWeight(FontWeight.Bold)}.width("100%").justifyContent(FlexAlign.SpaceBetween)List({space:10}){ForEach(this.shop.images,src=>{ListItem(){Image(src).width(150).borderRadius(5)}})}.width("100%").listDirection(Axis.Horizontal) // 水平滑动}.width("100%").padding(15).borderRadius(15).backgroundColor(Color.White)}computedScore(score:number){return (score / 10).toFixed(1)}
}
实现效果
image-20240316124631986
总结

上面商铺列表的核心请求逻辑在 ShopModel.ts 文件中,主要代码利用了内置的 httpRequest 来完成请求

// 1.创建Http请求对象
let httpRequest = http.createHttp()
// 2.发送请求体
httpRequest.request(// 请求路径this.buildUrl(`/shop?pageNo=${this.pageNo}&pageSize=${this.pageSize}}`),// 请求体{method: http.RequestMethod.GET,  // 请求方式}
)
.then(res => {// 3.拿到请求结果if (res.responseCode === 200) {resolve(JSON.parse(res.result.toString()))} else {console.log(`请求失败:${JSON.stringify(res)}`)reject()}
})
.catch(err => {console.log(`请求失败:${JSON.stringify(err)}`)reject()
})

第三方库Axios使用

工具安装

首先需要安装一个命令行工具

打开官网相关文档,点击如下按钮

image-20240320195737932

选择自己的系统进行下载

image-20240320195814586

下载好之后,进入ohpm/bin 目录下,执行 init.bat

image-20240320200449481

然后等待安装完成后,输入 ohpm -v 查看版本

接着配置环境变量

将 bin 目录的位置添加到环境变量中

image-20240320200625407

然后再随便目录下查看版本

image-20240320200713387

可以出现版本号表示安装成功

安装axios

打开**OpenHarmony三方库中心仓**网站,搜索 axios 即可查看安装和使用方式

image-20240320201945272

在项目根目录下执行

ohpm install @ohos/axios

image-20240320202404741

项目中使用

首先简单封装一下 axios,新建 src/main/ets/utils/service.ts

import axios from '@ohos/axios'axios.defaults.headers['Content-Type'] = 'application/json;charset=utf-8'// 创建axios实例
const service = axios.create({// axios中请求配置有baseURL选项,表示请求URL公共部分baseURL: "http://localhost:3000",// 超时1分钟timeout: 1000 * 60 * 60,
})// request拦截器
service.interceptors.request.use((config) => {return config},(error) => {Promise.reject(error)}
)// 响应拦截器
service.interceptors.response.use((res) => {// 二进制数据则直接返回if (res.request.responseType === 'blob' || res.request.responseType === 'arraybuffer') {return res.data}return res.data},(error) => {return Promise.reject(error)}
)export default service

然后新建接口请求api文件,这个文件用来放所有的请求部分

src/main/ets/api/ShopModelApi.ts

import service from "../utils/service"/*** 获取商铺列表方法* @param pageNo* @param pageSize* @returns*/
export function getShopModelListFun(pageNo, pageSize) {return service({url: "/shop",method: "get",params: {pageNo,pageSize}})
}

然后修改 src/main/ets/model/ShopModel.ts,使用我们上面写好的方法来加载数据

import { getShopModelListFun } from '../api/ShopModelApi'class ShopModel {pageNo: number = 1pageSize: number = 3getListFun() {return getShopModelListFun(this.pageNo,this.pageSize)}
}export default new ShopModel()

应用数据持久化

首选项实现轻量级数据持久化

场景介绍

用户首选项为应用提供Key-Value键值型的数据处理能力,支持应用持久化轻量级数据,并对其修改和查询。当用户希望有一个全局唯一存储的地方,可以采用用户首选项来进行存储。Preferences会将该数据缓存在内存中,当用户读取的时候,能够快速从内存中获取数据。Preferences会随着存放的数据量越多而导致应用占用的内存越大,因此,Preferences不适合存放过多的数据,适用的场景一般为应用保存用户的个性化设置(字体大小,是否开启夜间模式)等。

运作机制

如图所示,用户程序通过JS接口调用用户首选项读写对应的数据文件。开发者可以将用户首选项持久化文件的内容加载到Preferences实例,每个文件唯一对应到一个Preferences实例,系统会通过静态容器将该实例存储在内存中,直到主动从内存中移除该实例或者删除该文件。

应用首选项的持久化文件保存在应用沙箱内部,可以通过context获取其路径。具体可见获取应用开发路径。

img

约束限制
  • Key键为string类型,要求非空且长度不超过80个字节。
  • 如果Value值为string类型,可以为空,不为空时长度不超过8192个字节。
  • 内存会随着存储数据量的增大而增大,所以存储的数据量应该是轻量级的,建议存储的数据不超过一万条,否则会在内存方面产生较大的开销。
使用方法

封装 PreferenceUtils 文件,添加操作缓存的几个方法。新建 src/main/ets/utils/PreferencesUtils.ts

import dataPreferences from '@ohos.data.preferences';class PreferencesUtils {private prefMap: Map<string, dataPreferences.Preferences> = new Map()/*** 加载Preference* @param context 上下文实例* @param name 每个Preferences实例的唯一标识*/async onLoadPreferences(context, name: string) {try {// 创建Preference实例let pre = await dataPreferences.getPreferences(context, name)// 将得到的Preference保存到一个map中this.prefMap.set(name, pre)console.log("test-preference", `创建【preference ${name}】成功`)} catch (e) {console.log("test-preference", `创建【preference ${name}】失败`, JSON.stringify(e))}}/*** 保存缓存数据* @param name preference唯一表示* @param key 缓存的键名* @param value 缓存的键值*/async putPreferences(name: string, key: string, value: dataPreferences.ValueType) {const pref = this.prefMap.get(name)if (!pref) {console.log("test-preferences", `preferences:【${name}】实例不存在`)return}try {// 写入数据await pref.put(key, value)// 刷入磁盘await pref.flush()console.log("test-preferences", `保存【${key} = ${value}】成功`)} catch (e) {console.log("test-preferences", `保存【${key} = ${value}】失败`, JSON.stringify(e))}}/*** 读取缓存数据* @param name preference唯一表示* @param key 读取的键名* @param defValue 当键名不存在时默认的返回值* @returns*/async getPreferences(name: string, key: string, defValue: dataPreferences.ValueType) {const pref = this.prefMap.get(name)if (!pref) {console.log("test-preferences", `preferences:【${name}】实例不存在`)return}try {let value = await pref.get(key, defValue)console.log("test-preferences", `读取【${key} = ${value}】成功`)return value} catch (e) {console.log("test-preferences", `读取【${key}】失败`, JSON.stringify(e))}}/*** 删除指定key的缓存数据* @param name preference唯一表示* @param key 要删除的键名*/async deletePreferences(name: string, key: string) {const pref = this.prefMap.get(name)if (!pref) {console.log("test-preferences", `preferences:【${name}】实例不存在`)return}try {await pref.delete(key)console.log("test-preferences", `删除【${key}】成功`)} catch (e) {console.log("test-preferences", `删除【${key}】失败`, JSON.stringify(e))}}/*** 监听缓存变化* @param name preference唯一表示* @param callback 缓存变化后触发的回调,会通过参数传递当前变化的key*/async onPreferences(name: string, callback) {const pref = this.prefMap.get(name)if (!pref) {console.log("test-preferences", `preferences:【${name}】实例不存在`)return}pref.on("change", callback)}
}export default new PreferencesUtils()

然后再应用Ability启动时,去获取 Preference 实例

image-20240321094652436

然后修改首页,增加了控制字体大小的功能,并且将修改后的结果保存到缓存中,重新启动时会从缓存读取上次保存的字体大小

新增一个控制字体大小的组件 src/main/ets/views/IndexFontSizePanel.ets

import PreferenceUtils from "../utils/PreferencesUtils"@Component
export struct IndexFontSizePanel {@Consume fontSize:numberfontSizeMap:object = {14:"小",16:"标准",18:"大",20:"特大"}build() {Column({space:10}){Row(){Text(`${this.fontSizeMap[this.fontSize]}`).fontSize(this.fontSize)}.width("100%").height(20).justifyContent(FlexAlign.Center)Row({space:10}){Text(`A`).fontSize(14).fontWeight(FontWeight.Bold)Slider({min:14,max:20,step:2,value:this.fontSize}).onChange(val=>{this.fontSize = val// 修改字体大小后将最新值保存到缓存中PreferenceUtils.putPreferences("MyPreference","fontSize",val)}).layoutWeight(1).trackThickness(6)Text(`A`).fontSize(20).fontWeight(FontWeight.Bold)}.width("100%").padding({left:5,right:5})}.width("100%").padding(10).backgroundColor('#fff1f0f0').borderRadius(20)}
}

然后再IndexPages中使用

import RouterItem from '../viewModel/RouterItem'
import { IndexFontSizePanel } from '../views/IndexFontSizePanel'
import { RouterItemBox } from '../views/RouterItemBox'
import PreferenceUtils from "../utils/PreferencesUtils"const routerList: RouterItem[] = [new RouterItem("pages/ImagePage", "查看图片页面"),new RouterItem("pages/ItemsPage", "商品列表页面"),new RouterItem("pages/StatePage", "Jack和他的女朋友们"),new RouterItem("pages/TaskListPage", "任务列表"),new RouterItem("pages/AnimationPage", "小鱼动画"),new RouterItem("pages/AnimationPageV2", "小鱼动画V2"),new RouterItem("pages/LifeCyclePage", "生命周期案例1"),new RouterItem("pages/LifeCyclePage1", "生命周期案例2"),new RouterItem("pages/DocumentPage", "文档列表页面"),new RouterItem("pages/ShopPage", "商铺列表"),
]@Entry
@Component
struct Index {@State message: string = '页面列表'tag: string = "Index Page"@State isShowPanel: boolean = false@Provide fontSize:number = 16// 页面加载成功后,从缓存中读取fontSizeasync aboutToAppear() {this.fontSize = await PreferenceUtils.getPreferences("MyPreference","fontSize",16) as number}build() {Column() {Row() {Text(this.message).fontSize(30).fontWeight(FontWeight.Bold).fontColor("#36d")Image($r("app.media.settingPng")).width(25).onClick(()=>{animateTo({duration:500,curve: Curve.EaseOut},()=>{this.isShowPanel = !this.isShowPanel})})}.width("100%").justifyContent(FlexAlign.SpaceBetween).padding(10)List({ space: 10 }) {ForEach(routerList, (r: RouterItem, index: number) => {ListItem() {RouterItemBox({item: r,rid: index + 1})}})}.width("100%").layoutWeight(1).padding(10)if(this.isShowPanel){IndexFontSizePanel().transition({translate:{y:115}})}}.width('100%').height("100%")}
}

image-20240321095424636

注意:首选项缓存只能在模拟器或者真机中有效

关系型数据库

官方文档

新建页面

新建页面 src/main/ets/pages/TaskSqlPage.ets

import { Header } from '../components/Header'
import { HeaderCar } from '../views/task/HeaderCar'
import { TaskItem } from '../views/task/TaskListItem'@Entry
@Component
struct TaskSqlPage {// 任务总数量@State taskTotal: number = 0// 已完成数量@State finishTotal: number = 0build() {Column() {Header({ title: "任务列表SQL版本" })Column() {// 头部卡片HeaderCar({taskTotal: this.taskTotal,finishTotal: this.finishTotal})// 底部的任务列表组件TaskItem({taskTotal: $taskTotal,finishTotal: $finishTotal}).layoutWeight(1)}.height('100%').width('100%').padding(15)}.height('100%').width('100%')}
}

views/task/HeaderCar

const FinishColor = "#36D"
// 定义卡片公共样式
@Styles function carStyle() {.borderRadius(8).shadow({radius: 20,color: "#bbb",offsetX: 3,offsetY: 4}).backgroundColor(Color.White).width("100%")
}@Component
export struct HeaderCar {// 定义从父组件接收的字段@Prop finishTotal: number@Prop taskTotal: numberbuild() {Row(){Text("任务列表").fontSize(25).fontWeight(FontWeight.Bold)// 栈组件,让多个组件堆叠在一起Stack(){// 进度条Progress({value:this.finishTotal,total:this.taskTotal,type:ProgressType.ScaleRing // 设置成环形进度条}).width(100).color(FinishColor).style({strokeWidth:5})Row(){Text(`${this.finishTotal}`).fontColor(FinishColor).fontSize(25)Text(` / ${this.taskTotal}`).fontSize(25)}}}.carStyle().padding(35).justifyContent(FlexAlign.SpaceBetween)}
}

src/main/ets/views/task/TaskListItem.ets

import { Task } from '../../viewModel/TaskInfo'
import { TaskDialog } from './TaskDialog'
import { RowItem } from './TaskRowItem'
import taskModel from "../../model/TaskModel"@Component
export struct TaskItem {@Link taskTotal: number@Link finishTotal: number@State taskList: Task[] = []// 任务弹框dialogController: CustomDialogController = new CustomDialogController({builder: TaskDialog({onTaskConfirm: this.addTaskName.bind(this)}),})aboutToAppear() {console.log("test-tag:TaskItem onPageShow")taskModel.getTaskList().then(res=>{this.taskList = resconsole.log("test-tag:查询数据",JSON.stringify(this.taskList))this.handleTaskChange()})}handleTaskChange() {this.taskTotal = this.taskList.lengththis.finishTotal = this.taskList.filter(i => i.finish).length}addTaskName(taskName: string) {taskModel.addTask(taskName).then(() => {console.log(`test-tag:添加任务成功:${taskName}`)this.taskList.push(new Task(1, taskName))this.handleTaskChange()}).catch(err => {console.log(`test-tag:添加任务失败:${JSON.stringify(err)}`)})}build() {Column() {Row() {Button("添加任务").width(200).margin({ top: 30, bottom: 30 }).backgroundColor("#36D").onClick(() => {this.dialogController.open()})}List({ space: 20 }) {ForEach(this.taskList, (task: Task, index) => {ListItem() {// 每一行组件RowItem({task: task,// 将父组件定义的方法传递给子组件,并绑定this为父组件的thishandleTaskChange: this.handleTaskChange.bind(this)})}.swipeAction({// 往左边滑动时出现自定义的构建函数end: this.deleteBuilder(index, task.id)})})}.width("100%").layoutWeight(1)}}// 自定义删除按钮的构建函数@Builder deleteBuilder(index, id: number) {Button() {Image($r("app.media.deleteIcon")).width(20).interpolation(ImageInterpolation.High)}.width(40).height(40).margin({ left: 15 }).backgroundColor(Color.Red).onClick(() => {// 删除任务taskModel.deleteTaskById(id)this.taskList.splice(index, 1)this.handleTaskChange()})}
}

添加弹框组件

src/main/ets/views/task/TaskDialog.ets

@CustomDialog
export struct TaskDialog {controller: CustomDialogController// 任务名称name: string// 点击确认后触发的事件onTaskConfirm: (name: string) => voidbuild() {Column({ space: 20 }) {Row() {TextInput({placeholder: "请输入任务名称",text: this.name}).onChange(val => {this.name = val})}.width("100%")Row() {Button("取消").backgroundColor(Color.Gray).width("100").onClick(() => {this.controller.close()})Button("确定").backgroundColor("#36d").fontColor(Color.White).width("100").onClick(() => {// 对外触发确认事件,并发送填写的任务名称this.onTaskConfirm(this.name)this.controller.close()})}.width("100%").justifyContent(FlexAlign.SpaceAround)}.width('100%').padding(20)}
}

src/main/ets/views/task/TaskRowItem.ets

import { Task } from '../../viewModel/TaskInfo'
// 定义卡片公共样式
@Styles function carStyle() {.borderRadius(8).shadow({radius: 20,color: "#bbb",offsetX: 3,offsetY: 4}).backgroundColor(Color.White).width("100%")
}@Component
export  struct RowItem {@ObjectLink task: TaskhandleTaskChange: () => voidbuild() {Row() {if (this.task.finish) {Text(`${this.task.name}`).fontColor("#ccc").decoration({ type: TextDecorationType.LineThrough })} else {Text(`${this.task.name}`)}Checkbox().select(this.task.finish).selectedColor("#036D").onChange(val => {this.task.finish = valthis.handleTaskChange()})}.carStyle().padding(20).justifyContent(FlexAlign.SpaceBetween)}
}
封装接口方法

src/main/ets/model/TaskModel.ets

import relationalStore from "@ohos.data.relationalStore"
import { Task } from '../viewModel/TaskInfo';class TaskModel {// 数据库实例private rdbStore: relationalStore.RdbStore// 表名称private tableName: string = 'TASK'/*** 初始化数据库* @param context 上下文*/initTaskDB(context) {// rdb配置const config = {name: "Task.db", // 数据库文件名,也是数据库唯一标识符。securityLevel: relationalStore.SecurityLevel.S1};// 创建数据库的SQL语句const sql = `CREATE TABLE IF NOT EXISTS TASK (ID INTEGER PRIMARY KEY AUTOINCREMENT,NAME TEXT NOT NULL,FINISH bit)`relationalStore.getRdbStore(context, config, (err, rdbStore) => {if (err) {console.log("test-tag", `数据库Task.db创建失败`)return}// 执行SQLrdbStore.executeSql(sql)// 保存rdbthis.rdbStore = rdbStoreconsole.log(`test-tag 初始化数据库成功`)})}/*** 查询数据*/async getTaskList() {// 1.构建查询条件let predicates = new relationalStore.RdbPredicates(this.tableName)// 2.查询let result = await this.rdbStore.query(predicates, ['ID', 'NAME', 'FINISH'])// 3.解析查询结果// 3.1.定义一个数组,组装最终的查询结果let tasks: Task[] = []// 3.2.遍历封装while(!result.isAtLastRow){// 3.3.指针移动到下一行result.goToNextRow()// 3.4.获取数据let id = result.getLong(result.getColumnIndex('ID'))let name = result.getString(result.getColumnIndex('NAME'))let finish = result.getLong(result.getColumnIndex('FINISH'))// 3.5.封装到数组tasks.push({id, name, finish: !!finish})}console.log('test-tag', '查询到数据:', JSON.stringify(tasks))return tasks}/*** 添加任务* @param name 任务名称*/async addTask(name: string) {return await this.rdbStore.insert(this.tableName, {name,finish: false})}/*** 更新数据* @param id* @param finish* @returns*/async updateTaskById(id: number, finish: boolean) {// 1 要更新的数据let data = { finish }// 2 创建条件构造器let predicates = new relationalStore.RdbPredicates(this.tableName)// 3 先找到这个数据predicates.equalTo("ID", id)// 4 更新return await this.rdbStore.update(data, predicates)}/*** 删除数据* @param id* @param finish* @returns*/async deleteTaskById(id: number) {// 1 创建条件构造器let predicates = new relationalStore.RdbPredicates(this.tableName)// 2 先找到这个数据predicates.equalTo("ID", id)// 3 删除return await this.rdbStore.delete(predicates)}
}export default new TaskModel()

通知

基础通知

import notify from '@ohos.notificationManager';
import { Header } from '../components/Header'
import image from '@ohos.multimedia.image';
@Entry
@Component
struct NotificationMessagePage {@State mid: number = 100@State picture: PixelMap = nullasync aboutToAppear(){// 获取资源管理器let rm = getContext(this).resourceManager;// 读取图片let file = await rm.getMediaContent($r('app.media.xiaomi14'))// 创建PixelMapimage.createImageSource(file.buffer).createPixelMap().then(value => this.picture = value).catch(reason => console.log('testTag', '加载图片异常', JSON.stringify(reason)))}build() {Column() {Header({title:"消息通知"})Column(){Row(){Button("发送normal通知").onClick(()=>{this.publishBasicText()})}.width('100%')Row(){Button("发送longText通知").onClick(()=>{this.publishLongText()})}.width('100%')Row(){Button("发送multiLine通知").onClick(()=>{this.publishMultilineText()})}.width('100%')Row(){Button("发送picture通知").onClick(()=>{this.publishPictureText()})}.width('100%')}.width('100%').height('100%').padding(15)}.width('100%').height('100%')}// normal通知publishBasicText(){let request:notify.NotificationRequest = {id:this.mid++,content:{contentType:notify.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,normal:{title:"通知标题" + this.mid,text:"我是通知内容",additionalText:"我是附加内容"}},showDeliveryTime:true, // 是否显示通知时间deliveryTime:new Date().getTime(), // 通知时间groupName:"wechat", // 通知分组slotType:notify.SlotType.SOCIAL_COMMUNICATION // 通知通道}this.publish(request)}// 长文本通知publishLongText(){let request:notify.NotificationRequest = {id:this.mid++,content:{contentType:notify.ContentType.NOTIFICATION_CONTENT_LONG_TEXT,longText:{title:"通知标题" + this.mid,text:"我是通知内容",additionalText:"我是附加内容",longText:"我是很长的文本,我是很长的文本,我是很长的文本,我是很长的文本",expandedTitle:"展开后的标题",briefText:"通知展开后的概要"}},showDeliveryTime:true, // 是否显示通知时间deliveryTime:new Date().getTime(), // 通知时间groupName:"wechat", // 通知分组slotType:notify.SlotType.SOCIAL_COMMUNICATION // 通知通道}this.publish(request)}// 多行标题publishMultilineText(){let request:notify.NotificationRequest = {id:this.mid++,content:{contentType:notify.ContentType.NOTIFICATION_CONTENT_MULTILINE,multiLine:{title:"通知标题" + this.mid,text:"我是通知内容",additionalText:"我是附加内容",briefText:"通知展开时的概要",longTitle:"展开时的标题",lines:["第一行","第二行","第三行"]}},showDeliveryTime:true, // 是否显示通知时间deliveryTime:new Date().getTime(), // 通知时间groupName:"wechat", // 通知分组slotType:notify.SlotType.SOCIAL_COMMUNICATION // 通知通道}this.publish(request)}// 图文消息publishPictureText(){let request:notify.NotificationRequest = {id:this.mid++,content:{contentType:notify.ContentType.NOTIFICATION_CONTENT_PICTURE,picture:{title:"通知标题" + this.mid,text:"我是通知内容",additionalText:"我是附加内容",briefText:"通知展开时的概要",expandedTitle:"展开时的标题",picture:this.picture // 图片信息}},showDeliveryTime:true, // 是否显示通知时间deliveryTime:new Date().getTime(), // 通知时间groupName:"wechat", // 通知分组slotType:notify.SlotType.SOCIAL_COMMUNICATION // 通知通道}this.publish(request)}publish(request:notify.NotificationRequest){notify.publish(request).then(()=>{console.log("通知发送成功")}).catch(err=>{console.log(`通知发送失败:${JSON.stringify(err)}`)})}
}

不同的通道类型,发送消息提醒的权限

notify.SlotType 枚举类型

image-20240324211115977

效果展示

image-20240324210938443

进度条通知

import promptAction from '@ohos.promptAction'
import notify from '@ohos.notificationManager';enum DownloadState {NOT_BEGIN = '未开始',DOWNLOADING = '下载中',PAUSE = '已暂停',FINISHED = '已完成',
}@Component
export struct ProgressCar {// 下载进度@State progressValue: number = 0progressMaxValue: number = 100// 任务状态@State state: DownloadState = DownloadState.NOT_BEGIN// 下载的文件名filename: string = '圣诞星.mp4'// 模拟下载的任务的idtaskId: number = -1// 通知idnotificationId: number = 999isSupport: boolean = falseasync aboutToAppear(){// 1.判断当前系统是否支持进度条模板// 注意:进度条模板名称固定 downloadTemplatethis.isSupport = await notify.isSupportTemplate("downloadTemplate")}build() {Column({space:10}){Row({ space: 10 }) {Image($r('app.media.video')).width(50)Column({ space: 5 }) {Row() {Text(this.filename)Text(`${this.progressValue}%`).fontColor('#c1c2c1')}.width('100%').justifyContent(FlexAlign.SpaceBetween)Progress({value: this.progressValue,total: this.progressMaxValue,})Row({ space: 5 }) {Text(`${(this.progressValue * 0.43).toFixed(2)}MB`).fontSize(14).fontColor('#c1c2c1')Blank()if (this.state === DownloadState.NOT_BEGIN) {Button('开始').downloadButton().onClick(() => this.download())} else if (this.state === DownloadState.DOWNLOADING) {Button('取消').downloadButton().backgroundColor('#d1d2d3').onClick(() => this.cancel())Button('暂停').downloadButton().onClick(() => this.pause())} else if (this.state === DownloadState.PAUSE) {Button('取消').downloadButton().backgroundColor('#d1d2d3').onClick(() => this.cancel())Button('继续').downloadButton().onClick(() => this.download())} else {Button('打开').downloadButton().onClick(() => this.open())}}.width('100%')}.layoutWeight(1)}.width('100%').borderRadius(20).padding(15).backgroundColor(Color.White).shadow({ radius: 15, color: "#ff929292", offsetX: 10, offsetY: 10 })Row(){Button("重新开始").onClick(()=>{this.cancel()})}}}// 下载download() {if(this.taskId > -1){clearInterval(this.taskId)}this.taskId = setInterval(()=>{if(this.progressValue >= 100){// 如果已经下载完成,删除定时任务clearInterval(this.taskId)// 标记任务已完成this.state = DownloadState.FINISHED// 发送通知this.publishDownloadNotification()return}this.progressValue += 2// 发送通知this.publishDownloadNotification()},500)this.state = DownloadState.DOWNLOADING}// 取消cancel() {if(this.taskId > -1){clearInterval(this.taskId)this.taskId = -1}this.progressValue = 0this.state = DownloadState.NOT_BEGIN// 取消通知this.cleanProgressNotifyMessage()}// 暂停pause() {// 取消定时任务if(this.taskId > 0){clearInterval(this.taskId);this.taskId = -1}// 标记任务状态:已暂停this.state = DownloadState.PAUSE// 发送通知this.publishDownloadNotification()}// 打开open() {promptAction.showToast({message: "功能暂未实现"})}// 发送进度条模板publishDownloadNotification(){// 1.判断当前系统是否支持进度条模板if(!this.isSupport){return}// 2.准备进度条模板的参数let template = {name:"downloadTemplate",data:{// 当前的进度progressValue:this.progressValue,// 最大进度progressMaxValue:this.progressMaxValue}}// 3.准备消息requestlet request: notify.NotificationRequest = {id:this.notificationId,template:template,content:{contentType:notify.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,normal:{title:this.filename + ":" + this.state,text:"",additionalText:this.progressValue + "%"}}}// 4.发送通知notify.publish(request).then(()=>{console.log("test-notify","发送通知成功")}).catch(err=>{console.log("test-notify","发送通知失败",JSON.stringify(err))})}// 取消进度条通知cleanProgressNotifyMessage(){// 根据消息ID清除通知notify.cancel(this.notificationId)}
}@Extend(Button) function downloadButton() {.width(75).height(28).fontSize(14)
}

效果

image-20240324223600457

image-20240324223524158

添加行为意图

通过给通知添加行为意图,可以实现点击通知后自动返回到应用内

import wantAgent, { WantAgent } from '@ohos.app.ability.wantAgent'@Component
export struct ProgressCar{// 行为意图wantAgentInstance: WantAgentasync aboutToAppear(){// 1.判断当前系统是否支持进度条模板// 注意:进度条模板名称固定 downloadTemplatethis.isSupport = await notify.isSupportTemplate("downloadTemplate")// 2. 创建拉取当前应用的行为意图// 2.1 创建wantInfo信息let wantInfo: wantAgent.WantAgentInfo = {wants:[{bundleName:"com.example.myapplication",abilityName:"EntryAbility" // 声明要拉起的AbilityName}],requestCode:0,operationType:wantAgent.OperationType.START_ABILITY, // 开启一个AbilitywantAgentFlags:[wantAgent.WantAgentFlags.CONSTANT_FLAG]}// 2.2 创建wantAgent实例this.wantAgentInstance = await wantAgent.getWantAgent(wantInfo)}// .....省略其他代码// 发送进度条模板publishDownloadNotification(){// 1.判断当前系统是否支持进度条模板if(!this.isSupport){return}// 2.准备进度条模板的参数let template = {name:"downloadTemplate",data:{// 当前的进度progressValue:this.progressValue,// 最大进度progressMaxValue:this.progressMaxValue}}// 3.准备消息requestlet request: notify.NotificationRequest = {id:this.notificationId,template:template,// 设置行为意图wantAgent:this.wantAgentInstance,content:{contentType:notify.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,normal:{title:this.filename + ":" + this.state,text:"",additionalText:this.progressValue + "%"}}}// 4.发送通知notify.publish(request).then(()=>{console.log("test-notify","发送通知成功")}).catch(err=>{console.log("test-notify","发送通知失败",JSON.stringify(err))})}
}
image-20240330221629726

黑马健康实战案例

欢迎页实现

静态代码

// 文字样式封装
@Extend(Text) function opacityColor(opacity:number,fontSize:number = 10){.fontColor(Color.White).fontSize(fontSize).opacity(opacity)
}@Entry
@Component
struct WelcomePage {build() {Column({space:10}) {Row() {Image($r("app.media.home_slogan")).width(200)}.layoutWeight(1)Image($r("app.media.home_logo")).width(150)Row() {Text("黑马健康APP支持").opacityColor(0.8,13)Text("IPV6").opacityColor(0.8,13).border({ style: BorderStyle.Solid, width: 1, color: Color.White, radius: 16 }).padding({ left: 5, right: 5 })Text("网络").opacityColor(0.8,13)}Text(`'减更多'指黑马健康App希望通过软件工具的形式,帮助更多用户实现身材管理`).opacityColor(0.6)Text(`浙ICP备0000000号-36D`).opacityColor(0.4).margin({bottom:35})}.width('100%').height('100%').backgroundColor($r("app.color.welcome_page_background"))}
}
image-20240401213841129

用户协议弹框

新建一个弹框组件页面

src/main/ets/view/welcome/UserPrivacyDialog.ets

@CustomDialog
export default  struct UserPrivacyDialog {// 定义一个构造器,类型是自定义弹框类型controller: CustomDialogControllerconfirm:()=>voidcancel:()=>voidbuild() {Column({space:10}){Text($r("app.string.user_privacy_title")).fontSize(22).fontWeight(FontWeight.Bold)Text($r("app.string.user_privacy_content"))Button("我同意").width(150).backgroundColor($r("app.color.primary_color")).onClick(()=>{this.confirm()})Button("不同意").width(150).backgroundColor($r("app.color.lightest_primary_color")).onClick(()=>{this.cancel()this.controller.close()})}.width("100%").padding(15)}
}

然后在欢迎页使用

// 首选项工具
import preferenceUtil from "../common/utils/PreferenceUtil"
import router from '@ohos.router'
import common from '@ohos.app.ability.common'// 是否同意的Key
const PREF_KEY = 'userPrivacyKey'@Entry
@Component
struct WelcomePage {// 上下文context = getContext(this) as common.UIAbilityContext// 定义弹框controller: CustomDialogController = new CustomDialogController({builder: UserPrivacyDialog({confirm: this.confirm.bind(this),cancel: this.cancel.bind(this)})})// 弹框确定方法confirm() {// 设置首选项preferenceUtil.putPreferenceValue(PREF_KEY,true)// 跳转到首页this.jumpToIndex()}// 弹框不同意方法cancel() {// terminateSelf 终止自身this.context.terminateSelf()}// 页面显示触发async aboutToAppear(){// 判断用户是否同意let isAgree = await preferenceUtil.getPreferenceValue(PREF_KEY,false)if(isAgree){this.jumpToIndex()}else{this.controller.open()}}// 跳转到首页jumpToIndex(){setTimeout(()=>{router.replaceUrl({url:"pages/Index"})},2000)}build() {// .... 省略重复代码}
}
image-20240401225618538

首页Tab实现

import { CommonConstants } from '../common/constants/CommonConstants'@Entry
@Component
struct Index {@State currentIndex: number = 0// 自定义tabBar@Builder builderTabBar(title: Resource, image: Resource, index: number) {Column({ space: CommonConstants.SPACE_2 }) {Image(image).width(22).fillColor(this.selectColor(index))Text(title).fontSize(14).fontColor(this.selectColor(index))}}// 根据当前选中的tab自动切换选中颜色selectColor(index: number) {return this.currentIndex === index ? $r("app.color.primary_color") : $r("app.color.gray")}build() {// barPosition:BarPosition.End 定义Tab的位置Tabs({ barPosition: BarPosition.End }) {TabContent() {Text("页签1")}.tabBar(this.builderTabBar($r("app.string.tab_record"), $r("app.media.ic_calendar"), 0))TabContent() {Text("页签2")}.tabBar(this.builderTabBar($r("app.string.tab_discover"), $r("app.media.discover"), 1))TabContent() {Text("页签3")}.tabBar(this.builderTabBar($r("app.string.tab_user"), $r("app.media.ic_user_portrait"), 2))}.width('100%').onChange(index => {this.currentIndex = index})}
}
image-20240404145708751

头部搜索框

import { CommonConstants } from '../../common/constants/CommonConstants'
@Component
export default struct HeaderSearch {build() {Row({space:CommonConstants.SPACE_4}){Search({placeholder:"请输入食物名称"}).layoutWeight(1)// 角标Badge({count:2,style:{fontSize:12}}){Image($r("app.media.ic_public_email")).width(24)}}.width(CommonConstants.THOUSANDTH_940)}
}

image-20240404163856492

日期和日期弹框

日期展示组件

import { CommonConstants } from '../../common/constants/CommonConstants'
import DateUtils from '../../common/utils/DateUtils'
import DatePickDialog from './DatePickDialog'@Component
export default struct StatsCard {// 从全局存储中读取数据@StorageProp("selectedDate") selectedDate:number = DateUtils.beginTimeOfDate(new Date())controller: CustomDialogController = new CustomDialogController({builder: DatePickDialog({selectedDate: new Date(this.selectedDate)})})build() {Column() {// 日期行Row({ space: CommonConstants.SPACE_4 }) {Text(DateUtils.formatDateTime(this.selectedDate)).fontColor($r("app.color.secondary_color"))Image($r("app.media.ic_public_spinner")).width(25).fillColor($r("app.color.secondary_color"))}.width("100%").padding({ left: 15, top: 10, bottom: 25 }).onClick(() => {this.controller.open()})// 轮播卡片Row() {}.width("100%").height(200).backgroundColor(Color.White).borderRadius(18).margin({ top: -20 })}.width(CommonConstants.THOUSANDTH_940).backgroundColor($r("app.color.stats_title_bgc")).borderRadius(18)}
}

日期弹框组件

import { CommonConstants } from '../../common/constants/CommonConstants'@CustomDialog
export default struct DatePickDialog {controller: CustomDialogControllerprivate selectedDate: Date = new Date()build() {Column({space:CommonConstants.SPACE_4}) {DatePicker({start: new Date('2020-1-1'),end: new Date('2100-1-1'),selected: this.selectedDate}).onChange((value: DatePickerResult) => {this.selectedDate.setFullYear(value.year, value.month, value.day)})Row({space:CommonConstants.SPACE_4}) {Button("取消").width(120).backgroundColor($r("app.color.light_gray")).onClick(()=>{this.controller.close()})Button("确定").width(120).backgroundColor($r("app.color.primary_color")).onClick(()=>{// 将选中的日期保存到全局存储中AppStorage.SetOrCreate("selectedDate",this.selectedDate.getTime())this.controller.close()})}}.padding(CommonConstants.SPACE_2)}
}

用到的日期工具类代码

DateUtils.ts

export default class DateUtils{static beginTimeOfDate(date:Date){// 获取日期对象的时间戳(包含时分秒)const timestampWithTime = date.getTime();// 创建一个新的Date对象,将时间设置为1970-01-01 00:00:00const dateWithoutTime = new Date(1970, 0, 1, 0, 0, 0, 0);// 将包含时分秒的时间戳赋值给不含时分秒的日期对象dateWithoutTime.setTime(timestampWithTime);// 返回不包含时分秒的时间戳return dateWithoutTime.getTime();}static formatDateTime(dateTime:number){let date = new Date(dateTime)// 获取年、月、日const year = date.getFullYear();const month = date.getMonth() + 1; // 月份是从0开始的,所以需要+1const day = date.getDate();// 格式化月和日,如果不足两位数,前面补0const formattedMonth = month < 10 ? '0' + month : month;const formattedDay = day < 10 ? '0' + day : day;// 返回格式化的日期字符串return `${year}/${formattedMonth}/${formattedDay}`;}
}
image-20240404164002615

统计信息卡片

使用轮播组件,将两个组件包裹起来

import { CommonConstants } from '../../common/constants/CommonConstants'
import CalorieState from './CalorieStats'
import NutrientState from './NutrientStats'@Component
export default struct StatsCard {build() {Column() {// 1. 日期行// 2. 轮播卡片Swiper() {// 2.1 热量信息CalorieState()// 2.2 卡路里信息NutrientState()}.width("100%").backgroundColor(Color.White).borderRadius(18).margin({ top: -20 }).indicatorStyle({selectedColor:$r("app.color.primary_color")})}.width(CommonConstants.THOUSANDTH_940).backgroundColor($r("app.color.stats_title_bgc")).borderRadius(18)}
}

热量信息卡片

CalorieStats.ets

import { CommonConstants } from '../../common/constants/CommonConstants'
@Component
export default struct CalorieState {intake:number = 600 // 饮食摄入expend:number = 192 // 运动消耗recommend:number = CommonConstants.RECOMMEND_CALORIE // 推荐卡路里// 计算还可以吃多少remainCalorie(){return this.recommend - this.intake + this.expend}build() {Row(){this.StatsBuilder("饮食摄入",this.intake)Stack(){// 进度条Progress({value:this.intake,total:this.recommend,type:ProgressType.Ring}).width(130).style({strokeWidth:8}).color(this.remainCalorie() < 0 ? Color.Red : $r("app.color.primary_color"))this.StatsBuilder("还可以吃",this.remainCalorie(),this.recommend)}this.StatsBuilder("运动消耗",this.expend)}.width("100%").justifyContent(FlexAlign.SpaceEvenly).padding({top:30,bottom:35})}@Builder StatsBuilder(label:string,value:number,tip?:number){Column({space:CommonConstants.SPACE_6}){Text(label).fontSize(16).fontWeight(FontWeight.Bold)Text(`${value.toFixed(0)}`).fontSize(25).fontWeight(FontWeight.Bold)if(tip){Text(`推荐${tip.toFixed(0)}`).fontSize(14).fontColor($r("app.color.light_gray"))}}}
}
image-20240404175538115

卡路里信息卡片

NutrientState.ets

import { CommonConstants } from '../../common/constants/CommonConstants'
@Component
export default struct NutrientState {carbon:number = 23 // 碳水protein:number = 9 // 蛋白质fat:number = 7 // 脂肪recommendCarbon:number = CommonConstants.RECOMMEND_CARBONrecommendProtein:number = CommonConstants.RECOMMEND_PROTEINrecommendFat:number = CommonConstants.RECOMMEND_FATbuild() {Row(){this.StatsBuilder("碳水化合物",this.carbon,this.recommendCarbon,$r("app.color.carbon_color"))this.StatsBuilder("蛋白质",this.protein,this.recommendProtein,$r("app.color.protein_color"))this.StatsBuilder("脂肪",this.fat,this.recommendFat,$r("app.color.fat_color"))}.width("100%").justifyContent(FlexAlign.SpaceEvenly).padding({top:30,bottom:35})}@Builder StatsBuilder(label:string,value:number,recommend:number,color:ResourceStr){Column({space:CommonConstants.SPACE_6}){Stack(){// 进度条Progress({value:value,total:recommend,type:ProgressType.Ring}).width(105).style({strokeWidth:6}).color(value > recommend ? Color.Red : color)Column({space:CommonConstants.SPACE_6}){Text("摄入推荐").fontColor($r("app.color.gray"))Text(`${value.toFixed(0)}/${recommend.toFixed(0)}`).fontSize(20).fontWeight(FontWeight.Bold)}}Text(`${label}(克)`).fontColor($r("app.color.light_gray"))}}
}
image-20240404175642223

实现记录列表

import { CommonConstants } from '../../common/constants/CommonConstants'@Extend(Text) function grayText(){.fontSize(14).fontColor($r("app.color.light_gray"))
}@Component
export default struct RecordList {build() {List({space:CommonConstants.SPACE_10}){ForEach([1,2,3,4,5],item => {ListItem(){Column({space:CommonConstants.SPACE_6}){// 主分类信息Row({space:CommonConstants.SPACE_6}){Image($r("app.media.ic_breakfast")).width(24)Text("早餐").fontSize(18).fontWeight(CommonConstants.FONT_WEIGHT_700)Text("建议423~592千卡").grayText()Blank()Text("190").fontColor($r("app.color.primary_color")).fontWeight(CommonConstants.FONT_WEIGHT_700)Text("千卡").grayText()Image($r("app.media.ic_public_add_norm_filled")).width(24).fillColor($r("app.color.primary_color"))}.width("100%")// 子分类信息List({space:CommonConstants.SPACE_6}){ForEach([1,2],child => {ListItem(){Row({space:CommonConstants.SPACE_4}){Image($r("app.media.toast")).width(50)Column({space:CommonConstants.SPACE_6}){Text("全麦吐司").fontWeight(CommonConstants.FONT_WEIGHT_500).fontSize(14)Text("1片").fontSize(12).fontColor($r("app.color.gray")).textAlign(TextAlign.Start)}.alignItems(HorizontalAlign.Start)Blank()Text("91千卡").grayText()}.width("100%")}.swipeAction({// 左滑出现删除按钮end:this.deleteBuilder.bind(this)})})}}.padding(15).backgroundColor(Color.White).borderRadius(10)}})}.layoutWeight(1).width(CommonConstants.THOUSANDTH_940).margin({top:15,bottom:15})}// 左滑出现删除按钮@Builder deleteBuilder(){Row(){Image($r("app.media.ic_public_delete_filled")).width(25).fillColor(Color.Red).margin({left:5})}.width(35).justifyContent(FlexAlign.End)}
}

image-20240404185301571

添加食物列表页面

新建页面ItemIndexPage

import { CommonConstants } from '../common/constants/CommonConstants'
import router from '@ohos.router'
import ItemTabList from '../view/ItemIndex/ItemTabList'@Entry
@Component
struct ItemIndexPage {build() {Column() {// 头部导航组件this.ItemHeaderBuilder()// tab列表组件ItemTabList()}.width('100%').height('100%')}@Builder ItemHeaderBuilder(){Row(){Image($r("app.media.ic_public_back")).width(30).interpolation(ImageInterpolation.High).onClick(()=>{router.back()})Text("早餐").fontSize(18).fontWeight(CommonConstants.FONT_WEIGHT_700)}.height(35).width(CommonConstants.THOUSANDTH_940).justifyContent(FlexAlign.SpaceBetween)}
}

tab列表组件代码

ItemTabList.ets

import { CommonConstants } from '../../common/constants/CommonConstants'@Component
export default struct ItemTabList {build() {Column() {Tabs() {TabContent() {this.TabContentList()}.tabBar("全部")TabContent() {this.TabContentList()}.tabBar("主食")TabContent() {this.TabContentList()}.tabBar("肉蛋奶")}}.layoutWeight(1).width(CommonConstants.THOUSANDTH_940)}@Builder TabContentList(){List({space:CommonConstants.SPACE_6}){ForEach([1,2,3,4,5],child => {ListItem(){Row({space:CommonConstants.SPACE_4}){Image($r("app.media.toast")).width(50)Column({space:CommonConstants.SPACE_6}){Text("全麦吐司").fontWeight(CommonConstants.FONT_WEIGHT_500).fontSize(14)Text("91千卡/1片").fontSize(12).fontColor($r("app.color.gray")).textAlign(TextAlign.Start)}.alignItems(HorizontalAlign.Start)Blank()Image($r("app.media.ic_public_add_norm_filled")).width(25).fillColor($r("app.color.primary_color")).interpolation(ImageInterpolation.High)}.width("100%")}})}.height("100%").width("100%")}
}

效果显示

image-20240407214034553

底部Panel实现

ItemIndexPage.ets 页面增加 Panel 组件

import { CommonConstants } from '../common/constants/CommonConstants'
import router from '@ohos.router'
import ItemTabList from '../view/ItemIndex/ItemTabList'
import PanelHeader from '../view/ItemIndex/PanelHeader'
import PanelFoodInfo from '../view/ItemIndex/PanelFoodInfo'
import PanelInput from '../view/ItemIndex/PanelInput'@Entry
@Component
struct ItemIndexPage {@State showPanel: boolean = falseonPanelShow(){this.showPanel = true}onPanelClose(){this.showPanel = false}build() {Column() {// 头部导航组件this.ItemHeaderBuilder()// tab列表组件ItemTabList({onPanelShow:this.onPanelShow.bind(this)})// 底部弹框组件Panel(this.showPanel) {// 弹框顶部日期PanelHeader()// 食物信息PanelFoodInfo()// 键盘区域PanelInput({onPanelClose:this.onPanelClose.bind(this)})}.mode(PanelMode.Full).dragBar(false).backgroundMask("#98eeeeee").backgroundColor(Color.White)}.width('100%').height('100%')}@Builder ItemHeaderBuilder() {Row() {Image($r("app.media.ic_public_back")).width(30).interpolation(ImageInterpolation.High).onClick(() => {router.back()})Text("早餐").fontSize(18).fontWeight(CommonConstants.FONT_WEIGHT_700)}.height(35).width(CommonConstants.THOUSANDTH_940).justifyContent(FlexAlign.SpaceBetween)}
}

PanelHeader 弹框顶部日期

import { CommonConstants } from '../../common/constants/CommonConstants'
@Component
export default struct PanelHeader {build() {Row({space:CommonConstants.SPACE_4}){Text("1月17日 早餐")Image($r("app.media.ic_public_spinner")).width(20)}.height(45)}
}

PanelFoodInfo 食物信息

import { CommonConstants } from '../../common/constants/CommonConstants'
@Component
export default struct PanelFoodInfo {build() {Column({space:CommonConstants.SPACE_10}){Row(){Image($r("app.media.toast")).width(130)}Row(){Text("全麦吐司").fontWeight(CommonConstants.FONT_WEIGHT_700)}.backgroundColor($r("app.color.lightest_primary_color")).padding(10).borderRadius(4).margin({bottom:10})Divider().width(CommonConstants.THOUSANDTH_940).opacity(0.6)Row({space:CommonConstants.SPACE_10}){this.NutrientInfo("热量(千卡)",91.0)this.NutrientInfo("碳水(克)",15.5)this.NutrientInfo("蛋白质(克)",4.4)this.NutrientInfo("脂肪(克)",1.3)}Divider().width(CommonConstants.THOUSANDTH_940).opacity(0.6)}.margin({top:-10})}@Builder NutrientInfo(label:string,number:number){Column({space:CommonConstants.SPACE_6}){Text(label).fontSize(13).fontColor($r("app.color.light_gray"))Text(`${number}`).fontSize(16).fontWeight(CommonConstants.FONT_WEIGHT_700)}} 
}

PanelInput 键盘区域

import { CommonConstants } from '../../common/constants/CommonConstants'
@Component
export default struct PanelInput {onPanelClose:()=>voidbuild() {Column(){Row({space:CommonConstants.SPACE_10}){Column(){Text(`1`).fontSize(50).fontWeight(CommonConstants.FONT_WEIGHT_700).fontColor($r("app.color.primary_color"))Divider().width(100).backgroundColor($r("app.color.primary_color"))}Text(" / 片").fontSize(25).fontWeight(CommonConstants.FONT_WEIGHT_700).fontColor($r("app.color.primary_color"))}.alignItems(VerticalAlign.Bottom)// 自定义键盘Row(){}.height(300)// 按钮Row({space:CommonConstants.SPACE_10}){Button("取消").width(110).backgroundColor($r("app.color.light_gray")).type(ButtonType.Normal).borderRadius(5).onClick(()=>{this.onPanelClose()})Button("确定").width(110).backgroundColor($r("app.color.primary_color")).type(ButtonType.Normal).borderRadius(5).onClick(()=>{this.onPanelClose()})}.margin({top:10})}}
}

image-20240407224641778

实现数字键盘

这里使用到了Grid布局

键盘组件代码实现

import { CommonConstants } from '../../common/constants/CommonConstants'@Component
export default struct PanelInput {// 父组件传递过来的关闭Panel方法onPanelClose: () => voidonChangeAmount: (amount) => voidgridList: string[] = ["1", "2", "3","4", "5", "6","7", "8", "9",".", "0"]// 食物数量,声明成Link类型,实现父子组件双向绑定@Link amount: number// 每次点击的数组@State value: string = ""@Styles keyBoxStyle(){.height(60).backgroundColor(Color.White).borderRadius(5)}build() {Column() {Row({ space: CommonConstants.SPACE_10 }) {Column() {Text(`${this.amount.toFixed(1)}`).fontSize(50).fontWeight(CommonConstants.FONT_WEIGHT_700).fontColor($r("app.color.primary_color"))Divider().width(100).backgroundColor($r("app.color.primary_color"))}Text(" / 片").fontSize(20).fontWeight(CommonConstants.FONT_WEIGHT_700).fontColor($r("app.color.light_gray"))}.alignItems(VerticalAlign.Bottom)// 自定义键盘Grid() {ForEach(this.gridList, item => {GridItem() {Text(`${item}`).fontSize(16).fontWeight(CommonConstants.FONT_WEIGHT_900)}.keyBoxStyle().onClick(() => {this.clickNumber(item)})})GridItem() {Text(`删除`).fontSize(16).fontWeight(CommonConstants.FONT_WEIGHT_900)}.keyBoxStyle().onClick(() => {this.removeKey()})}.width("100%").height(280).columnsTemplate("1fr 1fr 1fr").columnsGap(8).rowsGap(8).backgroundColor($r("app.color.index_page_background")).padding(8).margin({ top: 10 })// 按钮Row({ space: CommonConstants.SPACE_10 }) {Button("取消").width(110).backgroundColor($r("app.color.light_gray")).type(ButtonType.Normal).borderRadius(5).onClick(() => {this.onPanelClose()})Button("确定").width(110).backgroundColor($r("app.color.primary_color")).type(ButtonType.Normal).borderRadius(5).onClick(() => {this.onPanelClose()})}.margin({ top: 10 })}}// 删除按钮removeKey(){this.value = this.value.substring(0,this.value.length - 1)this.amount = this.parseFloat(this.value)}// 点击键盘事件clickNumber(num: string) {// 1.拼接用户输入的内容let val = this.value + num// 2.校验输入的格式是否正确let firstIndex = val.indexOf(".")let lastIndex = val.lastIndexOf(".")if (firstIndex !== lastIndex || (lastIndex !== -1 && lastIndex < val.length - 2)) {return}// 3.将字符串转成数值类型let amount = this.parseFloat(val)// 4.保存if (amount > 999) {this.amount = 999this.value = "999"} else {this.amount = amountthis.value = val}}parseFloat(str: string) {if (!str) {return 0}if(str.endsWith(".")){str = str.substring(0,str.length - 1)}return parseFloat(str || '0')}
}

父组件代码

import { CommonConstants } from '../common/constants/CommonConstants'
import router from '@ohos.router'
import ItemTabList from '../view/ItemIndex/ItemTabList'
import PanelHeader from '../view/ItemIndex/PanelHeader'
import PanelFoodInfo from '../view/ItemIndex/PanelFoodInfo'
import PanelInput from '../view/ItemIndex/PanelInput'@Entry
@Component
struct ItemIndexPage {@State showPanel: boolean = false@State amount:number = 1onPanelShow(){this.showPanel = true}onPanelClose(){this.showPanel = false}build() {Column() {// 头部导航组件this.ItemHeaderBuilder()// tab列表组件ItemTabList({onPanelShow:this.onPanelShow.bind(this)})// 底部弹框组件Panel(this.showPanel) {// 弹框顶部日期PanelHeader()// 食物信息PanelFoodInfo({amount:$amount})// 键盘区域PanelInput({onPanelClose:this.onPanelClose.bind(this),amount:$amount})}.mode(PanelMode.Full).dragBar(false).backgroundMask("#98eeeeee").backgroundColor(Color.White)}.width('100%').height('100%')}@Builder ItemHeaderBuilder() {Row() {Image($r("app.media.ic_public_back")).width(30).interpolation(ImageInterpolation.High).onClick(() => {router.back()})Text("早餐").fontSize(18).fontWeight(CommonConstants.FONT_WEIGHT_700)}.height(35).width(CommonConstants.THOUSANDTH_940).justifyContent(FlexAlign.SpaceBetween)}
}

食物信息组件修改,根据传递进来的数量,自动计算对应的热量信息

import { CommonConstants } from '../../common/constants/CommonConstants'
@Component
export default struct PanelFoodInfo {@Link amount:numberbuild() {Column({space:CommonConstants.SPACE_10}){Row(){Image($r("app.media.toast")).width(130)}Row(){Text("全麦吐司").fontWeight(CommonConstants.FONT_WEIGHT_700)}.backgroundColor($r("app.color.lightest_primary_color")).padding(10).borderRadius(4).margin({bottom:10})Divider().width(CommonConstants.THOUSANDTH_940).opacity(0.6)Row({space:CommonConstants.SPACE_10}){this.NutrientInfo("热量(千卡)",91.0)this.NutrientInfo("碳水(克)",15.5)this.NutrientInfo("蛋白质(克)",4.4)this.NutrientInfo("脂肪(克)",1.3)}Divider().width(CommonConstants.THOUSANDTH_940).opacity(0.6)}.margin({top:-10})}@Builder NutrientInfo(label:string,number:number){Column({space:CommonConstants.SPACE_6}){Text(label).fontSize(13).fontColor($r("app.color.light_gray"))Text(`${(number * this.amount).toFixed(1)}`).fontSize(16).fontWeight(CommonConstants.FONT_WEIGHT_700)}}
}

多设备响应式开发

同一个页面,在手机、折叠手机、平板等设备上显示的方式是不一样的,我们可以通过官方提供的 @ohos.mediaquery 库来获取当前屏幕的宽度,然后根据不同宽度做不同处理

第一步:定义一个Bean,这个文件的作用是传入一个配置对象,然后调用 getValue 方法返回不同尺寸下对应的值

src/main/ets/common/bean/BreanpointType.ets

declare interface BreakpointTypeOptions<T>{sm?:T,md?:T,lg?:T
}export default class BreakpointType<T>{options: BreakpointTypeOptions<T>constructor(options: BreakpointTypeOptions<T>) {this.options = options}getValue(breakpoint: string): T{return this.options[breakpoint]}
}

第二步:定义一个常量类,声明各种查询条件及配置对象

src/main/ets/common/constants/BreakpointConstants.ets

import BreakpointType from '../bean/BreanpointType';export default class BreakpointConstants {/*** 小屏幕设备的 Breakpoints 标记.*/static readonly BREAKPOINT_SM: string = 'sm';/*** 中等屏幕设备的 Breakpoints 标记.*/static readonly BREAKPOINT_MD: string = 'md';/*** 大屏幕设备的 Breakpoints 标记.*/static readonly BREAKPOINT_LG: string = 'lg';/*** 当前设备的 breakpoints 存储key*/static readonly CURRENT_BREAKPOINT: string = 'currentBreakpoint';/*** 小屏幕设备宽度范围.*/static readonly RANGE_SM: string = '(320vp<=width<600vp)';/*** 中屏幕设备宽度范围.*/static readonly RANGE_MD: string = '(600vp<=width<840vp)';/*** 大屏幕设备宽度范围.*/static readonly RANGE_LG: string = '(840vp<=width)';/*** 定义Bar在不同屏幕下的位置*/static readonly BAR_POSITION: BreakpointType<BarPosition> = new BreakpointType({sm: BarPosition.End,md: BarPosition.Start,lg: BarPosition.Start,})/*** 定义Bar在不同屏幕下的布局方向*/static readonly BAR_VERTICAL: BreakpointType<boolean> = new BreakpointType({sm:false,md:true,lg:true})}

第三步:创建媒体查询工具类,创建不同尺寸的监听器,当命中时将结果保存到全局存储中

src/main/ets/common/utils/BreakpotionSystem.ets

import mediaQuery from '@ohos.mediaquery';
import BreakpointConstants from '../constants/BreakpointConstants';export default class BreakpointSystem{// 创建容器宽度监听器private smListener: mediaQuery.MediaQueryListener = mediaQuery.matchMediaSync(BreakpointConstants.RANGE_SM)private mdListener: mediaQuery.MediaQueryListener = mediaQuery.matchMediaSync(BreakpointConstants.RANGE_MD)private lgListener: mediaQuery.MediaQueryListener = mediaQuery.matchMediaSync(BreakpointConstants.RANGE_LG)// 开始监听容器register(){this.smListener.on("change",this.smListenerCallback.bind(this))this.mdListener.on("change",this.mdListenerCallback.bind(this))this.lgListener.on("change",this.lgListenerCallback.bind(this))}// 取消注册unRegister(){this.smListener.off("change",this.smListenerCallback.bind(this))this.mdListener.off("change",this.mdListenerCallback.bind(this))this.lgListener.off("change",this.lgListenerCallback.bind(this))}// 监听器命中的回调smListenerCallback(result:mediaQuery.MediaQueryResult){if(result.matches){this.updateCurrentBreakpoint(BreakpointConstants.BREAKPOINT_SM)}}mdListenerCallback(result:mediaQuery.MediaQueryResult){if(result.matches){this.updateCurrentBreakpoint(BreakpointConstants.BREAKPOINT_MD)}}lgListenerCallback(result:mediaQuery.MediaQueryResult){if(result.matches){this.updateCurrentBreakpoint(BreakpointConstants.BREAKPOINT_LG)}}// 更新缓存值updateCurrentBreakpoint(breakpoint:string){AppStorage.SetOrCreate(BreakpointConstants.CURRENT_BREAKPOINT,breakpoint)}
}

第四步:页面使用

现在我们来修改首页代码,加入响应式功能,实现在不同设备上,Bar的位置显示到不同的地方

src/main/ets/pages/Index.ets

import BreakpointConstants from '../common/constants/BreakpointConstants'
import { CommonConstants } from '../common/constants/CommonConstants'
import BreakpointSystem from '../common/utils/BreakpotionSystem'
import RecordIndex from '../view/record/RecordIndex'@Entry
@Component
struct Index {@State currentIndex: number = 0// 创建监听设备宽度的实例breakpointSystem:BreakpointSystem = new BreakpointSystem()// 获取当前设备宽度的缓存值@StorageProp("currentBreakpoint") currentBreakpoint:string = BreakpointConstants.BREAKPOINT_SMaboutToAppear(){this.breakpointSystem.register()}aboutToDisappear(){this.breakpointSystem.unRegister()}// 自定义tabBar@Builder builderTabBar(title: Resource, image: Resource, index: number) {Column({ space: CommonConstants.SPACE_2 }) {Image(image).width(22).fillColor(this.selectColor(index))Text(title).fontSize(14).fontColor(this.selectColor(index))}}// 根据当前选中的tab自动切换选中颜色selectColor(index: number) {return this.currentIndex === index ? $r("app.color.primary_color") : $r("app.color.gray")}// 根据设备宽度设置Bar栏位置chooseBarPosition(){return BreakpointConstants.BAR_POSITION.getValue(this.currentBreakpoint)}build() {// barPosition:BarPosition.End 定义Tab的位置Tabs({ barPosition: this.chooseBarPosition() }) {TabContent() {RecordIndex()}.tabBar(this.builderTabBar($r("app.string.tab_record"), $r("app.media.ic_calendar"), 0))TabContent() {Text("页签2")}.tabBar(this.builderTabBar($r("app.string.tab_discover"), $r("app.media.discover"), 1))TabContent() {Text("页签3")}.tabBar(this.builderTabBar($r("app.string.tab_user"), $r("app.media.ic_user_portrait"), 2))}.width('100%').onChange(index => {this.currentIndex = index}).vertical(BreakpointConstants.BAR_VERTICAL.getValue(this.currentBreakpoint))}
}

还要修改卡片信息,在平板设备上就不要左右滑动显示了,而是直接显示两个卡片

src/main/ets/view/record/StatsCard.ets

import BreakpointType from '../../common/bean/BreanpointType'
import BreakpointConstants from '../../common/constants/BreakpointConstants'
import { CommonConstants } from '../../common/constants/CommonConstants'
import DateUtils from '../../common/utils/DateUtils'
import CalorieState from './CalorieStats'
import DatePickDialog from './DatePickDialog'
import NutrientState from './NutrientStats'@Component
export default struct StatsCard {// 从全局存储中读取数据@StorageProp("selectedDate") selectedDate:number = DateUtils.beginTimeOfDate(new Date())@StorageProp("currentBreakpoint") currentBreakpoint:string = BreakpointConstants.BREAKPOINT_SMcontroller: CustomDialogController = new CustomDialogController({builder: DatePickDialog({selectedDate: new Date(this.selectedDate)})})build() {Column() {// 1. 日期行Row({ space: CommonConstants.SPACE_4 }) {Text(DateUtils.formatDateTime(this.selectedDate)).fontColor($r("app.color.secondary_color"))Image($r("app.media.ic_public_spinner")).width(25).fillColor($r("app.color.secondary_color"))}.padding({ left: 15, top: 5, bottom: 5 }).onClick(() => {this.controller.open()})// 2. 轮播卡片Swiper() {// 2.1 热量信息CalorieState()// 2.2 卡路里信息NutrientState()}.width("100%").backgroundColor(Color.White).borderRadius(18).indicatorStyle({selectedColor:$r("app.color.primary_color")})// 设置滑动组件一页显示几个组件.displayCount(new BreakpointType({sm:1,md:1,lg:2}).getValue(this.currentBreakpoint))// 设置是否显示指示点.indicator(new BreakpointType({sm:true,md:true,lg:false}).getValue(this.currentBreakpoint))// 设置是否禁用滑动功能.disableSwipe(new BreakpointType({sm:false,md:false,lg:true}).getValue(this.currentBreakpoint))}.width(CommonConstants.THOUSANDTH_940).backgroundColor($r("app.color.stats_title_bgc")).borderRadius(18)}
}

最后来看预览效果

首先点击这里,将多设备预览功能按钮打开,这样可以同时看到页面在手机、折叠屏、平板三种设备的显示效果

image-20240409153643642

下面是不同设备的显示结果

image-20240409153809299

显示不同的记录项

核心处理代码

ItemModel.ets

import GroupInfo from '../viewmodel/GroupInfo'
import RecordItem from '../viewmodel/RecordItem'
import { FoodCategories, FoodCategoryEnum, WorkoutCategories, WorkoutCategoryEnum } from './ItemCategoryModel'const foods: RecordItem[] = [new RecordItem(0, '米饭', $r('app.media.rice'), FoodCategoryEnum.STAPLE, '碗', 209, 46.6, 4.7, 0.5),new RecordItem(1, '馒头', $r('app.media.steamed_bun'), FoodCategoryEnum.STAPLE, '个', 114, 24.0, 3.6, 0.6),new RecordItem(2, '面包', $r('app.media.bun'), FoodCategoryEnum.STAPLE, '个', 188, 35.2, 5.0, 3.1),new RecordItem(3, '全麦吐司', $r('app.media.toast'), FoodCategoryEnum.STAPLE, '片', 91, 15.5, 4.4, 1.3),new RecordItem(4, '紫薯', $r('app.media.purple_potato'), FoodCategoryEnum.STAPLE, '个', 163, 42.0, 1.6, 0.4),new RecordItem(5, '煮玉米', $r('app.media.corn'), FoodCategoryEnum.STAPLE, '根', 111, 22.6, 4.0, 1.2),new RecordItem(6, '黄瓜', $r('app.media.cucumber'), FoodCategoryEnum.FRUIT, '根', 29, 5.3, 1.5, 0.4),new RecordItem(7, '蓝莓', $r('app.media.blueberry'), FoodCategoryEnum.FRUIT, '盒', 71, 18.1, 0.9, 0.4),new RecordItem(8, '草莓', $r('app.media.strawberry'), FoodCategoryEnum.FRUIT, '颗', 14, 3.1, 0.4, 0.1),new RecordItem(9, '火龙果', $r('app.media.pitaya'), FoodCategoryEnum.FRUIT, '个', 100, 24.6, 2.2, 0.5),new RecordItem(10, '奇异果', $r('app.media.kiwi'), FoodCategoryEnum.FRUIT, '个', 25, 8.4, 0.5, 0.3),new RecordItem(11, '煮鸡蛋', $r('app.media.egg'), FoodCategoryEnum.MEAT, '个', 74, 0.1, 6.2, 5.4),new RecordItem(12, '煮鸡胸肉', $r('app.media.chicken_breast'), FoodCategoryEnum.MEAT, '克', 1.15, 0.011, 0.236, 0.018),new RecordItem(13, '煮鸡腿肉', $r('app.media.chicken_leg'), FoodCategoryEnum.MEAT, '克', 1.87, 0.0, 0.243, 0.092),new RecordItem(14, '牛肉', $r('app.media.beef'), FoodCategoryEnum.MEAT, '克', 1.22, 0.0, 0.23, 0.033),new RecordItem(15, '鱼肉', $r("app.media.fish"), FoodCategoryEnum.MEAT, '克', 1.04, 0.0, 0.206, 0.024),new RecordItem(16, '牛奶', $r("app.media.milk"), FoodCategoryEnum.MEAT, '毫升', 0.66, 0.05, 0.03, 0.038),new RecordItem(17, '酸奶', $r("app.media.yogurt"), FoodCategoryEnum.MEAT, '毫升', 0.7, 0.10, 0.032, 0.019),new RecordItem(18, '核桃', $r("app.media.walnut"), FoodCategoryEnum.NUT, '颗', 42, 1.2, 1.0, 3.8),new RecordItem(19, '花生', $r("app.media.peanut"), FoodCategoryEnum.NUT, '克', 3.13, 0.13, 0.12, 0.254),new RecordItem(20, '腰果', $r("app.media.cashew"), FoodCategoryEnum.NUT, '克', 5.59, 0.416, 0.173, 0.367),new RecordItem(21, '无糖拿铁', $r("app.media.coffee"), FoodCategoryEnum.OTHER, '毫升', 0.43, 0.044, 0.028, 0.016),new RecordItem(22, '豆浆', $r("app.media.soybean_milk"), FoodCategoryEnum.OTHER, '毫升', 0.31, 0.012, 0.030, 0.016),
]const workouts: RecordItem[] = [new RecordItem(10000, '散步', $r('app.media.ic_walk'), WorkoutCategoryEnum.WALKING, '小时', 111),new RecordItem(10001, '快走', $r('app.media.ic_walk'), WorkoutCategoryEnum.WALKING, '小时', 343),new RecordItem(10002, '慢跑', $r('app.media.ic_running'), WorkoutCategoryEnum.RUNNING, '小时', 472),new RecordItem(10003, '快跑', $r('app.media.ic_running'), WorkoutCategoryEnum.RUNNING, '小时', 652),new RecordItem(10004, '自行车', $r('app.media.ic_ridding'), WorkoutCategoryEnum.RIDING, '小时', 497),new RecordItem(10005, '动感单车', $r('app.media.ic_ridding'), WorkoutCategoryEnum.RIDING, '小时', 587),new RecordItem(10006, '瑜伽', $r('app.media.ic_aerobics'), WorkoutCategoryEnum.AEROBICS, '小时', 172),new RecordItem(10007, '健身操', $r('app.media.ic_aerobics'), WorkoutCategoryEnum.AEROBICS, '小时', 429),new RecordItem(10008, '游泳', $r('app.media.ic_swimming'), WorkoutCategoryEnum.SWIMMING, '小时', 472),new RecordItem(10009, '冲浪', $r('app.media.ic_swimming'), WorkoutCategoryEnum.SWIMMING, '小时', 429),new RecordItem(10010, '篮球', $r('app.media.ic_basketball'), WorkoutCategoryEnum.BALLGAME, '小时', 472),new RecordItem(10011, '足球', $r('app.media.ic_football'), WorkoutCategoryEnum.BALLGAME, '小时', 515),new RecordItem(10012, '排球', $r("app.media.ic_volleyball"), WorkoutCategoryEnum.BALLGAME, '小时', 403),new RecordItem(10013, '羽毛球', $r("app.media.ic_badminton"), WorkoutCategoryEnum.BALLGAME, '小时', 386),new RecordItem(10014, '乒乓球', $r("app.media.ic_table_tennis"), WorkoutCategoryEnum.BALLGAME, '小时', 257),new RecordItem(10015, '哑铃飞鸟', $r("app.media.ic_dumbbell"), WorkoutCategoryEnum.STRENGTH, '小时', 343),new RecordItem(10016, '哑铃卧推', $r("app.media.ic_dumbbell"), WorkoutCategoryEnum.STRENGTH, '小时', 429),new RecordItem(10017, '仰卧起坐', $r("app.media.ic_sit_up"), WorkoutCategoryEnum.STRENGTH, '小时', 515),
]class ItemModel {// 根据大类返回对应的所有内容list(isFood: boolean) {return isFood ? foods : workouts}// 获取不同的分类以及分类对应的listgetGroupList(isFood: boolean) { // 根据是否是食物切换显示不同的类型列表let categories = isFood ? FoodCategories : WorkoutCategorieslet items = isFood ? foods : workouts// 遍历tab类型let data = categories.map(itemCategory => new GroupInfo(itemCategory, []))items.forEach(item=>{data[item.categoryId].items.push(item)})return data}
}let itemModel = new ItemModel()export default itemModel as ItemModel

tab列表页面获取数据后遍历显示不同的页签以及对应的list

ItemTabList.ets

import { CommonConstants } from '../../common/constants/CommonConstants'
import RecordItem from '../../viewmodel/RecordItem'
import itemModel from "../../model/ItemModel"
import GroupInfo from '../../viewmodel/GroupInfo'@Component
export default struct ItemTabList {onPanelShow:(item:RecordItem)=>void// 是否是食物类型@State isFood:boolean = truebuild() {Column() {Tabs() {TabContent() {this.TabContentList(itemModel.list(this.isFood))}.tabBar("全部")// 获取不同的分类信息ForEach(itemModel.getGroupList(this.isFood),(groupInfo:GroupInfo)=>{TabContent() {this.TabContentList(groupInfo.items)}.tabBar(groupInfo.type.name)})}.barMode(BarMode.Scrollable)}.layoutWeight(1).width(CommonConstants.THOUSANDTH_940)}@Builder TabContentList(items:RecordItem[]){List({space:CommonConstants.SPACE_6}){ForEach(items,(item:RecordItem) => {ListItem(){Row({space:CommonConstants.SPACE_4}){Image(item.image).width(50)Column({space:CommonConstants.SPACE_6}){Text(item.name).fontWeight(CommonConstants.FONT_WEIGHT_500).fontSize(14)Text(`${item.calorie}千卡 / ${item.unit}`).fontSize(12).fontColor($r("app.color.gray")).textAlign(TextAlign.Start)}.alignItems(HorizontalAlign.Start)Blank()Image($r("app.media.ic_public_add_norm_filled")).width(25).fillColor($r("app.color.primary_color")).interpolation(ImageInterpolation.High)}.width("100%").onClick(()=>{this.onPanelShow(item)})}})}.height("100%").width("100%")}
}

然后点击每一个分类时将当前的分类信息传递给信息展示弹框中

PanelFoodInfo.ets

import { CommonConstants } from '../../common/constants/CommonConstants'
import RecordItem from '../../viewmodel/RecordItem'@Component
export default struct PanelFoodInfo {@Link amount: number@Link recordItem: RecordItembuild() {Column({ space: CommonConstants.SPACE_10 }) {Row() {Image(this.recordItem.image).width(130)}Row() {Text(this.recordItem.name).fontWeight(CommonConstants.FONT_WEIGHT_700)}.backgroundColor($r("app.color.lightest_primary_color")).padding(10).borderRadius(4).margin({ bottom: 10 })Divider().width(CommonConstants.THOUSANDTH_940).opacity(0.6)Row({ space: CommonConstants.SPACE_10 }) {this.NutrientInfo("热量(千卡)", this.recordItem.calorie)if(this.recordItem.id < 10000){this.NutrientInfo("碳水(克)", this.recordItem.carbon)this.NutrientInfo("蛋白质(克)", this.recordItem.protein)this.NutrientInfo("脂肪(克)", this.recordItem.fat)}}Divider().width(CommonConstants.THOUSANDTH_940).opacity(0.6)}.margin({ top: -10 })}@Builder NutrientInfo(label: string, number: number) {Column({ space: CommonConstants.SPACE_6 }) {Text(label).fontSize(13).fontColor($r("app.color.light_gray"))Text(`${(number * this.amount).toFixed(1)}`).fontSize(16).fontWeight(CommonConstants.FONT_WEIGHT_700)}}
}

实现效果

image-20240410105736295

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/diannao/4738.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

纯血鸿蒙APP实战开发——全局状态保留能力弹窗

全局状态保留能力弹窗 介绍 全局状态保留能力弹窗一种很常见的能力&#xff0c;能够保持状态&#xff0c;且支持全局控制显隐状态以及自定义布局。使用效果参考评论组件 效果图预览 使用说明 使用案例参考短视频案例 首先程序入口页对全局弹窗初始化&#xff0c;使用Globa…

高扬程水泵,提升水源新选择!— 恒峰智慧科技

在炎炎夏日&#xff0c;阳光炙烤着大地&#xff0c;森林火灾的发生频率也随之上升。火势猛烈&#xff0c;烟雾弥漫&#xff0c;给森林带来了极大的破坏。为了保护森林资源&#xff0c;我们必须采取有效的措施来扑灭火灾。而在这其中&#xff0c;高扬程水泵成为了提升水源新选择…

笔记:编写程序,绘制一个展示马尾松、樟树、杉木、 桂花 4 个树种不同季节的细根生物量的误差棒图。

文章目录 前言一、分析题目二、什么是误差棒图&#xff1f;二、编写程序总结 前言 编写程序&#xff0c;绘制一个展示马尾松、樟树、杉木、 桂花 4 个树种不同季节的细根生物量的误差棒图&#xff0c;实现过程如下&#xff1a; &#xff08;1&#xff09; 导入 matplotlib.pyp…

【数据结构与算法(C语言)】1. 线性表的顺序存储

文章目录 前言一. 线性表插入和删除1. 元素的插入2. 元素的删除 二. 代码三. 优缺点 前言 线性表的顺序存储结构&#xff0c;指的是用一段地址连续的存储单元依次存储线性表的数据结构 一. 线性表插入和删除 1. 元素的插入 插入位置之后的数据都向后移一位&#xff0c;上图中元…

银行业ESB架构:构建安全高效的金融信息交换平台

在金融行业&#xff0c;信息交换是银行业务运作的核心。为了实现不同系统之间的数据交互和业务流程的协同&#xff0c;银行通常采用企业服务总线&#xff08;ESB&#xff09;架构。本文将探讨银行业ESB架构的设计理念、关键技术以及实践经验&#xff0c;帮助银行构建安全高效的…

《软件过程与管理》复习

《软件过程与管理》复习 1 高质量编程及测试 1.1 如何选择正确的评审方法 选择评审方法最有效的标准是&#xff1a; “对于最可能产生风险的工作成果&#xff0c;要采用最正式的评审方法.” 例如&#xff1a;核心代码的失效也会带来很严重的后果&#xff0c;所以也应该采用…

【Unity学习笔记】第十四 Prefab 概念解惑

目录 1 prefab、prefab变体、prefab覆盖和prefab 嵌套2 connect 与unpack3 prefab到底是什么&#xff0c;它和gameobject又有什么区别&#xff1f;4 为什么要用prefab&#xff1f;5 代码动态加载prefab6 为什么我unity PrefabUtility.InstantiatePrefab() 得到的是null7 Prefab…

卫浴品牌商家做展示预约小程序的作用是什么

卫浴品牌类别多、普通/智能、场景化等&#xff0c;无论企业还是经销商市场门店都比较饱满&#xff0c;虽然市场需求度高&#xff0c;但同样需要商家不断拓宽销售渠道和挖掘客户价值&#xff0c;破圈增长。 线上多平台发展尤为重要&#xff0c;而小程序作为连接点&#xff0c;对…

PostgreSQL的学习心得和知识总结(一百三十九)|深入理解PostgreSQL数据库GUC参数 allow_alter_system 的使用和原理

目录结构 注&#xff1a;提前言明 本文借鉴了以下博主、书籍或网站的内容&#xff0c;其列表如下&#xff1a; 1、参考书籍&#xff1a;《PostgreSQL数据库内核分析》 2、参考书籍&#xff1a;《数据库事务处理的艺术&#xff1a;事务管理与并发控制》 3、PostgreSQL数据库仓库…

C++ //练习 14.2 为Sales_data编写重载的输入、输出、加法和复合赋值运算符。

C Primer&#xff08;第5版&#xff09; 练习 14.2 练习 14.2 为Sales_data编写重载的输入、输出、加法和复合赋值运算符。 环境&#xff1a;Linux Ubuntu&#xff08;云服务器&#xff09; 工具&#xff1a;vim 代码块 /************************************************…

Windows使用bat远程操作Linux并执行命令

背景&#xff1a;让客户可以简单在Windows中能自己执行 Linux中的脚本&#xff0c;傻瓜式操作&#xff01; 方法&#xff1a;做一个简单的bat脚本&#xff01;能远程连接到Linux&#xff0c;并执行Linux命令&#xff01;客户双击就能使用&#xff01; 1、原先上网查询到使用P…

Java | Leetcode Java题解之第55题跳跃游戏

题目&#xff1a; 题解&#xff1a; public class Solution {public boolean canJump(int[] nums) {int n nums.length;int rightmost 0;for (int i 0; i < n; i) {if (i < rightmost) {rightmost Math.max(rightmost, i nums[i]);if (rightmost > n - 1) {retu…

社交技巧:网络社交有哪些优点?

既然网络社交已经成为了我们进行社交的一个必不可少的方式&#xff0c;那今天就来和大家分析一下&#xff0c;网络社交有哪些优点&#xff0c;以帮助大家更恰当地使用社交媒体 1.拓展更多的关系机会 有很多人在现实生活中可能过于内向腼腆&#xff0c;有社交焦虑&#xff0c;这…

C#-快速剖析文件和流,并使用(持续更新)

目录 一、概述 二、文件系统 1、检查驱动器信息 2、Path 3、文件和文件夹 三、流 1、FileStream 2、StreamWriter与StreamReader 3、BinaryWriter与BinaryReader 一、概述 文件&#xff0c;具有永久存储及特定顺序的字节组成的一个有序、具有名称的集合&#xff1b; …

Pandas dataframe 中显示包含NaN值的单元格

大部分教程只讲如何打印含有NA的列或行。这个函数可以直接定位到单元格&#xff0c;当dataframe的行和列都很多的时候更加直观。 # Finding NaN locations for df.loc def locate_na(df):nan_indices set()nan_columns set()for col, vals in df_descriptors.items():for in…

小米汽车充电枪继电器信号

继电器型号&#xff1a; 参考链接 小米SU7&#xff0c;便捷充放电枪拆解 (qq.com)https://mp.weixin.qq.com/s?__bizMzU5ODA2NDg4OQ&mid2247486086&idx1&sn0dd4e7c9f7c72d10ea1c9f506faabfcc&chksmfe48a110c93f2806f6e000f6dc6b67569f6e504220bec14654ccce7d…

Qt 6 开源版(免费) -- 安装图解

Qt6起&#xff0c;两项重大改变&#xff08;并非指技术&#xff09;&#xff1a; 必须在线安装&#xff0c;不再提供单独的安装包主推收费的商业版 当然的&#xff0c;为了引流、培养市场&#xff0c;提供了一个免费的开源版本。 开源版相对于收费的商业版&#xff0c;主体是…

【YOLO改进】换遍IoU损失函数之SIoU Loss(基于MMYOLO)

SIoU损失函数 论文链接:https://arxiv.org/pdf/2205.12740 SIoU&#xff08;Simplified IoU&#xff09;损失函数是一种基于IoU&#xff08;Intersection over Union&#xff09;的改进损失函数&#xff0c;主要用于目标检测任务中的边界框回归。与传统的IoU损失函数相比&…

linux运行python怎么结束

假如你已经进入到【>>>】&#xff0c;那么输入【quit&#xff08;&#xff09;】&#xff0c;然后按一下回车键即可退出了。 如果是想要关闭窗口的&#xff0c;那么直接在这个窗口上按【ctrld】。

AI项目二十:基于YOLOv8实例分割的DeepSORT多目标跟踪

若该文为原创文章&#xff0c;转载请注明原文出处。 前面提及目标跟踪使用的方法有很多&#xff0c;更多的是Deepsort方法。 本篇博客记录YOLOv8的实例分割deepsort视觉跟踪算法。结合YOLOv8的目标检测分割和deepsort的特征跟踪&#xff0c;该算法在复杂环境下确保了目标的准…