鸿蒙开发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 = 200
  build() {
    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 = 200
  build() {
    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:string
  price:number
  img:Resource
  discount:number

  constructor(name:string,img:Resource,price:number,discount?:number) {
    this.name = name
    this.img = img
    this.price = price
    this.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:string

  build() {
    // 顶部标题
    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:string
  age:number

  constructor(name,age) {
    this.name = name
    this.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 = 1
  name:string
  finish:boolean

  constructor() {
    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.length
    this.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 = val
                  this.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: number

  build() {
    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 = 1
  name:string
  finish:boolean

  constructor() {
    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.length
    this.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 = val
                    this.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 = 0

  onPageShow(){
    // 调用子组件的方法
    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: string

  build(){
    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 = 1
  name:string
  finish:boolean

  constructor() {
    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.length
    this.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为父组件的this
                handleTaskChange: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:Task
  handleTaskChange: ()=>void

  build() {
    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 = val
          this.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: string
  title: string

  constructor(url, title) {
    this.url = url
    this.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: RouterItem
  rid: number

  build() {
    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:string

  build() {
    // 顶部标题
    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 = 20

  build() {
    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.speed
                this.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.speed
                this.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.speed
              this.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.speed
              this.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 = 120
  centerY: number = 120

  // 大小圆的半径
  maxRadius: number = 100
  radius: number = 20

  // 摇杆小圆球的初始位置
  @State positionX: number = this.centerX
  @State positionY: number = this.centerY

  // 角度正弦和余弦
  sin: number = 0
  cos: number = 0

  taskId: number = 1
  scaleTaskId: number = 1

  @State fishScale:number = 1

  build() {
    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.centerX
            this.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.cos
          this.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].x
        let y = event.touches[0].y
        // 2.计算手指与中心点坐标的差值
        let vx = x - this.centerX
        let 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: string
  title: string

  constructor(url, title) {
    this.url = url
    this.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: RouterItem
  rid: number

  build() {
    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.UIAbilityContext

  build() { 
    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.bundleName
                abilityName:"DocumentAbility", // 要跳转到目标ability名称
                moduleName:"entry", // 当前的模块名称
                parameters:{
                  instanceKey: this.index // 传过去的key
                }
              }

              // 跳转到一个新的Ability
              this.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.bundleName
                  abilityName:"DocumentAbility", // 要跳转到目标ability名称
                  moduleName:"entry", // 当前的模块名称
                  parameters:{
                    instanceKey: id // 传过去的key
                  }
                }
                // 跳转到一个新的Ability
                this.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.UIAbilityContext

  onPageShow(){
    let abilityInfo = this.context
    console.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.bundleName
            abilityName:"EntryAbility", // 要跳转到目标ability名称
            moduleName:"entry", // 当前的模块名称
          }
          // 跳转到一个新的Ability
          this.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: string
  title: string

  constructor(url, title) {
    this.url = url
    this.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: RouterItem
  rid: number

  build() {
    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: number
  name: string
  images: string[]
  area: string
  address: string
  avgPrice: number
  comments: number
  score: number
  openHours: string
}

ResponseInfo.ts

class responseData{
  total: number
  totalPages: number
  rows: any[]
}

export default class ResponseInfo{
  code: number
  data: responseData
}
model

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

在该文件夹下新增

ShopModel.ts

import http from '@ohos.net.http'
import ResponseInfo from '../viewModel/ResponseInfo'

class ShopModel {
  pageNo: number = 1
  pageSize: number = 3
  baseUrl: 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 = false

  aboutToAppear() {
    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 = true
          ShopModel.pageNo++
          this.getShopList()
          console.log("触底加载")
        }
      })

    }
    .width('100%')
    .height('100%')
    .backgroundColor("#ececec")
  }

  getShopList() {
    ShopModel.getListFun().then(res => {
      const shops = res.data.rows
      shops.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: ShopInfo

  build() {
    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 = 1
  pageSize: number = 3

  getListFun() {
    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:number

  fontSizeMap: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

  // 页面加载成功后,从缓存中读取fontSize
  async 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 = 0

  build() {
    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: number

  build() {
    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 = res
      console.log("test-tag:查询数据",JSON.stringify(this.taskList))
      this.handleTaskChange()
    })
  }

  handleTaskChange() {
    this.taskTotal = this.taskList.length
    this.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为父组件的this
              handleTaskChange: 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) => void

  build() {
    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: Task
  handleTaskChange: () => void

  build() {
    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 = val
          this.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
      }
      // 执行SQL
      rdbStore.executeSql(sql)
      // 保存rdb
      this.rdbStore = rdbStore
      console.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 = null

  async aboutToAppear(){
    // 获取资源管理器
    let rm = getContext(this).resourceManager;
    // 读取图片
    let file = await rm.getMediaContent($r('app.media.xiaomi14'))
    // 创建PixelMap
    image.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 = 0
  progressMaxValue: number = 100
  // 任务状态
  @State state: DownloadState = DownloadState.NOT_BEGIN
  // 下载的文件名
  filename: string = '圣诞星.mp4'
  // 模拟下载的任务的id
  taskId: number = -1
  // 通知id
  notificationId: number = 999
  isSupport: boolean = false

  async aboutToAppear(){
    // 1.判断当前系统是否支持进度条模板
    // 注意:进度条模板名称固定 downloadTemplate
    this.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 = 0
    this.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.准备消息request
    let 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: WantAgent
  
  async aboutToAppear(){
    // 1.判断当前系统是否支持进度条模板
    // 注意:进度条模板名称固定 downloadTemplate
    this.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, // 开启一个Ability
      wantAgentFlags:[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.准备消息request
    let 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: CustomDialogController
  confirm:()=>void
  cancel:()=>void

  build() {
    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: CustomDialogController
  private 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:00
    const 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开始的,所以需要+1
    const day = date.getDate();

    // 格式化月和日,如果不足两位数,前面补0
    const 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_CARBON
  recommendProtein:number = CommonConstants.RECOMMEND_PROTEIN
  recommendFat:number = CommonConstants.RECOMMEND_FAT

  build() {
    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 = false

  onPanelShow(){
    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:()=>void

  build() {
    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: () => void
  onChangeAmount: (amount) => void
  gridList: 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 = 999
      this.value = "999"
    } else {
      this.amount = amount
      this.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 = 1

  onPanelShow(){
    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:number

  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 * 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_SM


  aboutToAppear(){
    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_SM

  controller: 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
  }
  // 获取不同的分类以及分类对应的list
  getGroupList(isFood: boolean) { 
    // 根据是否是食物切换显示不同的类型列表
    let categories = isFood ? FoodCategories : WorkoutCategories
    let 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 = true

  build() {
    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: RecordItem

  build() {
    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

  • 4
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值