ArkUI-状态管理
ArkUI中状态管理需要用到多个不同装饰器:
- @State
- @Prop 和 @Link
- @Provide 和 @Consume
- @Observed 和 @ObjectLink
@State装饰器
在声明式UI中,是以状态来驱动视图更新的:
- 状态(@State):驱动视图更新的数据(被@state装饰器标记的变量)。
- 视图(View):页面UI,基于UI描述来渲染得到的用户界面。
视图使用状态变量后,状态变量发生改变,则视图内容跟随改变。
注意:
- @State 装饰器修饰的变量必须先初始化,不允许不赋值。****
- @State 支持Object、class、string、number、boolean、enum类型以及这些类型的数组。
- 嵌套类型以及数组中的对象属性无法触发视图更新。
-
- 嵌套类型:如一个对象中属性更改时,视图会收到通知。但是如果对象中的某个属性也是对象,这个属性对象内的属性被修改时,我们不会收到视图更新通知。
export class PersonBean{
name:string;
age:number;
eat:EatBean;
}
export class EatBean{
food:string;
drink:string;
}
import { EatBean, PersonBean } from '../bean/PersonBean';
@Entry
@Component
struct Index4 {
@State private mMessage: PersonBean = { name: '张三', age: 100, eat: { food: '米饭', drink: '可乐' } }
build() {
Column() {
Text(this.mMessage.name + ":" + this.mMessage.age + ":" + this.mMessage.eat.food + ":" + this.mMessage.eat.drink)
.onClick((event) => {
this.mMessage.name = "王五"
this.mMessage.age = 18
//当同时修改对象属性以及子对象属性时,会由对象属性触发视图更新,子对象的修改结果也被顺带更新到了视图中。
//但如果只修改子对象属性,那么则不会触发视图更新。
// this.mMessage.eat.drink = "雪碧"
})
.fontSize(50)
}.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.width('100%')
.height('100%')
}
}
-
- 数组中的对象属性:数组内对象的属性被修改时,不会触发视图更新。
这个问题可以用其他装饰器来解决,下面会提到。
实践:下方代码中,我们在点击时会发现,修改数组元素对象中的属性,并没触发视图更新。
- 数组中的对象属性:数组内对象的属性被修改时,不会触发视图更新。
@State private mPersonList: PersonBean[] = [
{ name: '张三', age: 100, eat: { food: '米饭', drink: '可乐' } },
{ name: '李四', age: 20, eat: { food: '面条', drink: '奶茶' } },
]
Text(this.mPersonList[0].name + ":" + this.mPersonList[0].age + ":" + this.mPersonList[0].eat.food + ":" + this.mPersonList[0].eat.drink)
.onClick((event) => {
this.mPersonList[0].name = "王五"
this.mPersonList[0].age = 18
this.mPersonList[0].eat.drink = "雪碧"
// this.mPersonList[0]={ name: '李四', age: 20, eat: { food: '面条', drink: '奶茶' } }
})
.fontSize(50)
任务统计案例-@State
需求:
- 统计任务进度:展示任务总数与完成数。
- 任务新增:支持点击按钮新增任务。
- 任务列表:支持勾选完成、左滑删除、任务新增。
需求分解:
- 任务进度模块:UI、统计任务总数与完成数。
- 任务列表:任务新增。
- 任务属性:完成状态、任务名称。
开始:
- 设计任务的Bean类
export class TaskBean{
static id:number=1//任务ID,唯一标识
id:number=TaskBean.id++;//自己这里留存一份ID
name:string=`任务${this.id}`//任务名称
state:number=0//任务状态 0未完成,1完成。
}
- 创建所需要的状态成员变量
//任务总数量
@State private mTotalTaskCount:number=0;
//任务完成数
@State private mFinishTaskCount:number=0;
//任务数组
@State private mTaskList:TaskBean[]=[];
- 创建公共样式函数(任务完成的样式):
//任务完成的公共样式
@Extend(Text) function finishedTaskStyle(){
.decoration({type:TextDecorationType.LineThrough})//删除线
.fontColor('#B1B2B166')
}
- 绘制任务进度模块UI,并且在视图中绑定状态成员:
build() {
Column() {
Row({space:30}) {
Text('任务进度')
.fontSize(30)
.fontWeight(FontWeight.Bold)
Row(){
Text(this.mFinishTaskCount.toString())
.fontSize(30)
.fontColor('#36d')
Text("/"+this.mTotalTaskCount.toString())
.fontSize(30)
}
}
.width('93%')
.height(200)
.margin({ top: 30 })
.justifyContent(FlexAlign.Center)
.backgroundColor('#ffffff')
.borderRadius(20)
}.width('100%')
.height('100%')
.backgroundColor('#c6c6c6')
}
此时完成效果:
- 在这里会发现我们还需要一个环形的进度条,这个在官方文档可以查到:
Progress(options: {value: number, total?: number, type?: ProgressType})
参数名 | 参数类型 | 必填 | 参数描述 |
---|---|---|---|
value | number | 是 | 指定当前进度值。设置小于0的数值时置为0,设置大于total的数值时置为total。从API version 9开始,该接口支持在ArkTS卡片中使用。 |
total | number | 否 | 指定进度总长。默认值:100从API version 9开始,该接口支持在ArkTS卡片中使用。 |
type 8+ | ProgressType | 否 | 指定进度条类型。默认值:ProgressType.Linear从API version 9开始,该接口支持在ArkTS卡片中使用。 |
styledeprecated | ProgressStyle | 否 | 指定进度条样式。该参数从API version8开始废弃,建议使用type替代。默认值:ProgressStyle.Linear |
ProgressType枚举说明
从API version 9开始,该接口支持在ArkTS卡片中使用。
名称 | 描述 |
---|---|
Linear | 线性样式。从API version9开始,高度大于宽度的时候自适应垂直显示。 |
Ring8+ | 环形无刻度样式,环形圆环逐渐显示至完全填充效果。 |
Eclipse8+ | 圆形样式,显示类似月圆月缺的进度展示效果,从月牙逐渐变化至满月。 |
ScaleRing8+ | 环形有刻度样式,显示类似时钟刻度形式的进度展示效果。从API version9开始,刻度外圈出现重叠的时候自动转换为环形无刻度进度条。 |
Capsule8+ | 胶囊样式,头尾两端圆弧处的进度展示效果与Eclipse相同;中段处的进度展示效果与Linear相同。高度大于宽度的时候自适应垂直显示。 |
所以进度表我们则使用:
Progress({ value: this.mFinishTaskCount, total: this.mTotalTaskCount, type: ProgressType.Ring })
.width(100)
.height(100)
- 但是仅仅使用Row、Column容器的话,我们的进度文本无法和圆环进度条重叠。所以需要使用另一个特殊容器:Stack堆叠容器
使用起来很简单,设置好重叠组件的内部对齐方式,并包裹需要重叠的组件。
- 添加 新增任务按钮 模块
Button('新建任务')
.type(ButtonType.Capsule)
.fontSize(30)
.padding({ left:20,top:10,right:20,bottom:10, })
.width('60%')
.onClick((event)=>{
//设置点击事件,像数组中新增任务
this.mTaskList[this.mTotalTaskCount++]=new TaskBean()
})
- 添加List列表
List(){
ForEach(this.mTaskList,(item:TaskBean,index)=>{
ListItem(){//这里不要把ListItem忘记了,这是个必须要的标记
//item绘制
}
})
}
- 绘制ListItem基本布局,我们发现item中有一个勾选栏,这个官方也有提供,叫做CheckBox。
List({ space: 10 }) {
ForEach(this.mTaskList, (item: TaskBean, index) => {
ListItem() {
Row() {
Text(item.name)
.fontSize(26)
.fontWeight(FontWeight.Bold)
.margin({ left: 20 })
Text(item.name)
.fontSize(26)
.fontWeight(FontWeight.Bold)
.margin({ left: 20 })
Checkbox().select(item.state == 1)
.margin(40)
.onChange((onchange)=>{
//更新当前任务状态
item.state=onchange?1:0
//更新已完成的任务数量
this.handleEndTaskCount()
})
}
.width('93%')
.height(100)
.justifyContent(FlexAlign.SpaceBetween)
.backgroundColor('#ffffff')
.borderRadius(20)
}
.width('100%')
})
}
.layoutWeight(1)
.listDirection(Axis.Vertical) //列表方向:默认垂直
.alignListItem(ListItemAlign.Center) //列表内item项交叉轴居中
//更新完成的任务总数
handleEndTaskCount(){
this.mFinishTaskCount=this.mTaskList.filter(item=> item.state==1).length
}
- ListItem要支持左滑删除,这个功能,在ListItem中有提供对应函数:
通过swipeAction函数可以设置ListItem的左滑/右滑展示的组件。内部三个参数,第一个参数是右滑/下滑的布局组件,第二个参数是左滑/上滑的布局组件,第三个参数是滑动距离识别效果。
左右滑动的形参类型 CustomBuilder ,所以我们需要传入自定义构建函数作为实参来构建View。
首先是自定义构建函数:在这里我们是创建的局部函数,因为这样更方便去操作列表的数据数组。
/**
* ListItem的左滑删除组件
*/
@Builder ListItemDelView(index: number) {
Button({ type: ButtonType.Circle }) {
Image($r('app.media.delete'))
.width(35)
.height(35)
.fillColor(Color.White)
}
.width(60)
.height(60)
.margin({ right: 35 })
.onClick((event) => {
//删除
this.mTaskList.splice(index, 1); //从index索引开始,删除一个元素
this.mTotalTaskCount--
this.handleEndTaskCount()
})
}
启用左滑删除:
List(){
//....
ListItem(){...}.swipeAction({ end: this.ListItemDelView(index), edgeEffect: SwipeEdgeEffect.Spring })
}
到现在为止,功能基本完成了,除了一项“任务完成时,任务文本显示删除线”;因为目前只使用了@State装饰器,没法满足需求,在后面的装饰器学习中我们再把这个功能补上。@State装饰器无法监听到数组内部元素的属性变化。
@Prop @Link @Provide @Consume装饰器
@Prop 和 @Link 装饰器
当父子组件之间需要数据同步时,可以使用@Prop 和 @Link 装饰器:
- 父子组件:组件嵌套,父组件内部嵌套其他组件。
- 数据同步:组件在引用的过程中,就存在数据的传递操作,比如子组件想要获取父组件的某些参数,并且需要实时监听,那么就需要数据同步。
而父子组件的数据同步,仅靠@State装饰器是没法实现的,所以需要使用@Prop和@Link。
我们继续使用前面的 任务统计案例 来进行@Prop和@Link的使用。
- 由于先前的代码没有对UI进行封装,所以现在先将 任务进度卡片、任务列表 进行简单封装成自定义组件,通过构造函数来传递父组件的参数:
@Component
export struct TaskScheduleCard {
//任务总数量
@State private mTotalTaskCount: number = 0;
//任务完成数
@State private mFinishTaskCount: number = 0;
build() {
Row({ space: 30 }) {
Text('任务进度')
.fontSize(30)
.fontWeight(FontWeight.Bold)
Stack({ alignContent: Alignment.Center }) {
Progress({ value: this.mFinishTaskCount, total: this.mTotalTaskCount, type: ProgressType.Ring })
.width(100)
.height(100)
Row() {
Text(this.mFinishTaskCount.toString())
.fontSize(30)
.fontColor('#36d')
Text("/" + this.mTotalTaskCount.toString())
.fontSize(30)
}
}
}
.width('93%')
.height(200)
.margin({ top: 30 })
.justifyContent(FlexAlign.Center)
.backgroundColor('#ffffff')
.borderRadius(20)
}
}
import { TaskBean } from '../bean/TaskBean';
@Component
export struct TaskListView {
//任务总数量
@State private mTotalTaskCount: number = 0;
//任务完成数
@State private mFinishTaskCount: number = 0;
//任务数组
@State private mTaskList: TaskBean[] = [];
build() {
List({ space: 10 }) {
ForEach(this.mTaskList, (item: TaskBean, index) => {
ListItem() {
Row() {
Text(item.name)
.fontSize(26)
.fontWeight(FontWeight.Bold)
.margin({ left: 20 })
Checkbox().select(item.state == 1)
.margin(40)
.onChange((onchange) => {
//更新当前任务状态
item.state = onchange ? 1 : 0
//更新已完成的任务数量
this.handleEndTaskCount()
})
}
.width('93%')
.height(100)
.justifyContent(FlexAlign.SpaceBetween)
.backgroundColor('#ffffff')
.borderRadius(20)
}
.width('100%')
.swipeAction({ end: this.ListItemDelView(index), edgeEffect: SwipeEdgeEffect.Spring })
})
}
.layoutWeight(1)
.listDirection(Axis.Vertical) //列表方向:默认垂直
.alignListItem(ListItemAlign.Center) //列表内item项交叉轴居中
}
/**
* ListItem的左滑删除组件
*/
@Builder ListItemDelView(index: number) {
Button({ type: ButtonType.Circle }) {
Image($r('app.media.delete'))
.width(35)
.height(35)
.fillColor(Color.White)
}
.width(60)
.height(60)
.margin({ right: 35 })
.onClick((event) => {
//删除
this.mTaskList.splice(index, 1); //从index索引开始,删除一个元素
this.mTotalTaskCount--
this.handleEndTaskCount()
})
}
//更新完成的任务总数
handleEndTaskCount() {
this.mFinishTaskCount = this.mTaskList.filter(item => item.state == 1).length
}
}
TaskScheduleCard({mFinishTaskCount:this.mFinishTaskCount,mTotalTaskCount:this.mTotalTaskCount})
TaskListView({mTotalTaskCount:this.mTotalTaskCount,mFinishTaskCount:this.mFinishTaskCount,mTaskList:this.mTaskList})
- 完成UI封装后,发现我们需要的几个参数都是来自父组件的;并且在父组件传递值时,编译器会报错不允许将父组件的@State变量赋值给子组件的@State变量
;如果不使用@State 那么我们子组件的数据同步就有了问题。特别是两个组件共用一个父组件的成员变量时,如果自己手动去做数据同步,那么也会带来很大麻烦。
所以此时就需要借助@Prop和@Link了。 - @Prop和@Link它们之间存在不同:单向同步和双向同步
比如父组件有一个被@State修饰的成员变量,那么我们在子组件中也定义一个相同的成员用来绑定对应父组件的成员数据;
如果使用@Prop修饰,那么当父组件的成员修改时,子组件的该成员也会被修改而发出视图更新通知,反之,子组件修改自身成员时,父组件的对应成员变量不会被修改,更不会触发视图更新;
如果使用@Link,那么不论修改子组件或是父组件的成员,它们都会同步更新,并且发出视图更新通知。
@Prop | @Link | |
---|---|---|
同步类型 | 单向同步 | 双向同步 |
实现原理 | 父组件的@State变量,在传递给@Prop的子组件成员时,会被进行单项绑定。 当父组件的该变量修改时,父组件会把这个最新值传给子组件的变量,实现数据同步,从而触发子组件的视图更新。 | 如果父组件成员使用@State装饰器,而子组件使用@Link装饰器时,会进行双向绑定。双向绑定其实是子组件成员得到的是父组件成员变量值的引用,父组件成员和子组件成员持有的都是同一个数据的引用,从而实现了双向绑定。 |
支持的数据类型 | - 仅支持string, number, boolean,enum类型 |
- 允许父子之间的类型不一样:如果父组件有个@State变量是对象类型,子组件想要监听对象内部的某个基本类型数据的变化,那么就把对象内部的属性传给子组件绑定。
- 不支持数组和any。 | - 父子类型必须一致:数组、对象、string、number、boolean、enum等
- 数组中元素的增、删、替换会引起刷新
- 嵌套类型以及数组中的对象属性无法触发视图更新。 和@State的条件一样 | | 使用方法 | 在创建子组件时,父组件将当前的@State变量赋值给子组件对应的@Prop变量就能实现绑定。 子组件的后代组件绑定方式:当子组件需要将父组件的@State变量赋值给子组件的后代组件时,也只需要把子组件的@Prop变量赋值给后代组件,层层传值即可。 | 在创建子组件时,父组件将当前的@State变量的引用传递给子组件对应的@Link变量从而实现双向绑定。引用的传递:当我们传递值时,通常使用 this.xxx 来传递当前类中的某个成员的值。而传递成员的引用,则是通过使用 'xxx,省略 this. 。子组件的后代组件绑定方式:因为@Link绑定是绑定的引用,所以父组件给子组件的对应变量赋值的是父组件成员的引用,即 xxx。 | | 初始化方式 | 声明时@Prop成员时不允许对其初始化 | 父组件传递,禁止子组件初始化。 |
- 所以,我们只需要把子组件中需要使用到父组件数据的成员,使用@Prop进行绑定,而数组则使用@Link进行绑定。
注意,我们的进度统计是只做了对数据的读取,所以使用@Prop进行单向同步是没问题;但是我们的任务列表组件是支持左滑删除的,这就涉及到对数组和任务数量等信息的操作了,因此列表组件需要使用@Link进行双向绑定。这样就完成了父子组件之间的数据同步。
@Component
export struct TaskScheduleCard {
//任务总数量
@Prop mTotalTaskCount: number
//任务完成数
@Prop mFinishTaskCount: number
build(){.....}
}
@Component
export struct TaskListView {
//任务总数量
@Link mTotalTaskCount: number
//任务完成数
@Link mFinishTaskCount: number
//任务数组
@Link mTaskList: TaskBean[]
build(){.....}
}
TaskScheduleCard({mFinishTaskCount:this.mFinishTaskCount,mTotalTaskCount:this.mTotalTaskCount})
TaskListView({mTotalTaskCount:$mTotalTaskCount,mFinishTaskCount:$mFinishTaskCount,mTaskList:$mTaskList})
@Provide 和 @Consume 装饰器
@Provide 和 @Consume的用法非常简单,它们和@Prop、@Link的作用类似;最大的区别就是@Provide和@Consume支持跨组件传递。
前面的@Prop@Link装饰器都是需要一层层去传递,爷爷类传给父亲类,父亲类传给孙子类。
而@Provide和@Consume不用,它可以直接将爷爷类的成员传递给孙子类使用。
具体用法:
- 我们继续使用前面任务管理案例,将@Prop和@Link更换为 @Provide 和 @Consume
- 父组件将@State装饰器更换为@Provide。
//任务总数量
@Provide mTotalTaskCount: number
//任务完成数
@Provide mFinishTaskCount: number
//任务数组
@Provide mTaskList: TaskBean[]
TaskScheduleCard()
TaskListView()
- 其他组件将需要绑定的成员,使用@Consume装饰器。
需要保证成员名与父组件@Provide 成员同名、同数据类型。
@Component
export struct TaskScheduleCard {
//任务总数量
@Consume mTotalTaskCount: number
//任务完成数
@Consume mFinishTaskCount: number
build(){.....}
}
@Component
export struct TaskListView {
//任务总数量
@Consume mTotalTaskCount: number
//任务完成数
@Consume mFinishTaskCount: number
//任务数组
@Consume mTaskList: TaskBean[]
build(){.....}
}
- 然后,就没有然后了,完成。
@Prop @Link 与 @Provide @Consume在使用上的选择
- @Provide与@Consume是一起组合使用的。
- @Prop、@Link则是与@State进行组合使用。
- 因为@Provide的方式实现了跨组件,并且值传递也是隐式的(不需要我们手动去传递);所以内部的性能消耗会比@Prop、@Link更大;
因此,通常建议尽可能使用@Prop或@Link。 - 如果只是读取父组件数据进行显示,那么使用@Prop。如果还需要做修改,那么使用@Link。
用最小的权限完成所需功能。
@Observed和@ObjectLink
@Observed 和 @ObjectLink 装饰器用于在涉及嵌套对象或数组元素为对象时的场景中进行双向数据同步。
看这个介绍就知道它能够弥补@State装饰器的不支持 同步嵌套对象 和 数组元素对象内部属性 的缺陷。
通过这节的学习,我们就可以完善任务统计案例的任务完成后实时显示删除线。
使用方式
- 设置@Observed:给Class类头部添加 @Observed ,如果Class内部嵌套了其他对象,那么则对这个对象所属的Class类头部继续添加 @Observed装饰器,如:
@Observed
class Person{
parma1:string
action:Action
}
@Observed
class Action{
parma1:number
parma2:number
}
- 给子组件添加@ObjectLink,并由父组件传参绑定:
@Component
struct Son{
//绑定了一个Person对象
@ObjectLink person:Person
build(){
//..使用person
}
}
//构建一个Person对象
let mPerson=new Person()
build{
//传递对象给子组件
Son({person:this.mPerson})
}
这样就完成了简单的@ObjectLink 和 @Observed 使用。
进阶用法
那么如果传递的是个 存储对象的数组,该怎么绑定呢?
也很简单,数组继续使用@State 或 @Provide 装饰器,子组件则使用@Prop、@Link、@Consume绑定数组。
之后重要的来了,我们定义一个成员变量并使用@ObjectLink装饰,数据类型为数组中元素的Bean类型;
//使用Observed
@Observed
export class TaskBean{
id:number=(id++);//自己这里留存一份ID
name:string=`任务${this.id}`//任务名称
state:number=0//任务状态 0未完成,1完成。
}
let id:number=1
@Entry
@Component
struct Index5 {
//任务数组
@State private mTaskList: TaskBean[] = []
build() {
Column({ space: 20 }) {
//传递数组mTaskList
TaskListView({mTaskInfo:this.mTaskInfo,mTaskList:$mTaskList})
.layoutWeight(1)
}
}
}
接着我们将数组中的对象抽出,并赋值给我们的 @ObjectLink 装饰的成员变量,这样一来,该@ObjectLink装饰的成员变量就与数组中指定元素完成了数据绑定。
@Component
export struct TaskListView {
//任务数组
@Link mTaskList: TaskBean[]
build() {
Column() {
List({ space: 10 }) {
ForEach(this.mTaskList, (item: TaskBean, index) => {
ListItem() {
//item是数组内部的一个元素,我们把这个元素传给了子组件。
//注意,此处还传递了个方法名:this.handleEndTaskCount
TaskListItemView({ item: item, onTaskChange: this.handleEndTaskCount
.bind(this)
})
}
.width('100%')
.swipeAction({ end: this.ListItemDelView(index), edgeEffect: SwipeEdgeEffect.Spring })
})
}
.listDirection(Axis.Vertical) //列表方向:默认垂直
.alignListItem(ListItemAlign.Center) //列表内item项交叉轴居中
}
}
}
@Component
export struct TaskListItemView{
//此处被父组件赋值后,就完成了数据绑定。
@ObjectLink item : TaskBean
//回调函数
onTaskChange:() => void
build(){
Row() {
if (this.item.state == 1) {
Text(this.item.name)
.fontSize(26)
.fontWeight(FontWeight.Bold)
.margin({ left: 20 })
.finishedTaskStyle()
} else {
Text(this.item.name)
.fontSize(26)
.fontWeight(FontWeight.Bold)
.margin({ left: 20 })
}
Checkbox().select(this.item.state == 1)
.margin(40)
.onChange((onchange) => {
//更新当前任务状态
this.item.state = onchange ? 1 : 0
//更新已完成的任务数量
// this.handleEndTaskCount()
this.onTaskChange()
})
}
.width('93%')
.height(100)
.justifyContent(FlexAlign.SpaceBetween)
.backgroundColor('#ffffff')
.borderRadius(20)
}
}
//任务完成的公共样式
@Extend(Text) function finishedTaskStyle() {
.decoration({ type: TextDecorationType.LineThrough }) //删除线
.fontColor('#B1B2B166')
}
方法传递
ListItem() {
//item是数组内部的一个元素,我们把这个元素传给了子组件。
//注意,此处还传递了个方法名:this.handleEndTaskCount
TaskListItemView({ item: item, onTaskChange: this.handleEndTaskCount.bind(this)})
}
在上面的例子中,我们发现在给ListItem传递Item对象时,还传递了一个 handleEndTaskCount 函数的名称,这个用法属于方法传递。
当子组件想要调用当前组件内部的某个函数时,通常可以采用方法传递的方式。
使用方法:
- 首先在子View声明一个函数:
// 表示 onTaskChange 是个无参无返回值的函数。
onTaskChange:() => void
- 当子View的状态发生变化时,则可以调用onTaskChange触发事件。
Checkbox().select(this.item.state == 1)
.margin(40)
.onChange((onchange) => {
//更新当前任务状态
this.item.state = onchange ? 1 : 0
//更新已完成的任务数量
// this.handleEndTaskCount()
this.onTaskChange()
})
- 在主View中进行方法传递,把要被子View调用的函数名传递进去,就实现了方法的传递。
TaskListItemView({ item: item, onTaskChange: this.handleEndTaskCount})
- 但是,要注意,handleEndTaskCount函数中使用到了this关键字、以及主View类中的其他变量:
//更新完成的任务总数
handleEndTaskCount() {
this.mTaskInfo.mFinishTaskCount = this.mTaskList.filter(item => item.state == 1).length
}
而在我们将方法传递给子View后,方法中的this指向会从原先的主View,切换到调用端(即 子View);而this指向的变量,在子View中是找不到的,这样的话会引起程序崩溃。
所以,我们还需要在传递函数名时,在尾部加上.bind(this),即表示指定该函数内部使用的this指向当前主View对象,即:
TaskListItemView({ item: item, onTaskChange: this.handleEndTaskCount.bind(this)})
这样便完成了方法传递。
ArkUI-页面路由Router
页面路由是为了实现应用程序内部不同页面之间的跳转和数据传递的。
页面栈
- HarmonyOS的页面栈和安卓的类似,旧页面在栈底,新页面在栈顶,总是先显示栈顶的页面。
我们借助页面栈的逻辑来实现页面的返回、跳转等功能。 - 页面栈的最大容量是32个页面,使用 router.clear() 可以清空页面栈中的历史页面,释放内存。
当然,我们要尽可能手动控制页面栈的数量,而不是纯粹的调用clear函数去清空页面栈。 - Router有两种页面跳转模式,分别是:
-
- router.pushUrl():目标页不会替换当前页,而是压入页面栈(f放到栈顶),因此可以用 router.back() 返回当前页。
- router.replaceUrl():目标页会替换当前页,当前页会被销毁并释放资源,无法返回当前页。
- 推荐使用 pushUrl() 方式。
- Router有两种页面实例创建模式,分别是:
-
- Standard:标准实例模式,每次跳转都会新建一个目标页并压入栈底。默认就是这个模式。
- Single:单实例模式,如果目标页已经在栈中,则离栈最近的同Url页面会被移动到栈顶并重新加载。
使用Router
- 首先在页面导入HarmonyOS提供的Router模块:
import router from '@ohos.router'
- 然后利用router实现跳转:
//跳转指定页面,此处以pushUrl为例,和replaceUrl的参数是一样的。
router.pushUrl({
url: 'pages/RouterPage2', //要打开的目标页面路径
params: { id: 1688 } //传递的参数,可选
},
router.RouterMode.Standard,//设置启动模式,枚举
err => {//异常相应回调函数:错误码 ①100001:内部错误,可能是渲染错误 ②100002:路由地址错误 ③100003:路由栈中页面超过32个。
//路由跳转失败处理
console.log('路由失败')
}
)
- 我们在配置目标页面的url使用的是文件的目录地址,但是router此时是跳转不了的,必须去 base/profile/main_pages.json 文件中添加新建的page页面路径,才可跳转。
注:我们新建的每个入口页面都必须要在 main_pages.json 文件中进行注册。否则无法跳转。
{
"src": [
"pages/RouterPage1",
"pages/RouterPage2",
"pages/Index5",
"pages/Index4",
"pages/Index3",
"pages/Index2",
"pages/Index"
]
}
- 跳转到的新页面可以读取上一个页面中传递过来的参数、返回等操作:
//获取传递过来的参数
params:any=router.getParams()//有些情况要记得给router.getParams的返回值判断null。
let id=any.id
//也可以: this.mReceptionId = (router.getParams() as any).id
//返回上一页
router.back()
//返回到指定页,并携带参数
router.back({
url:'pages/RouterPage1',
params:{id:10023}
})
showAlertBeforeBackPage
这个函数是当用户点击返回页面时,会弹出一个页面返回询问对话框。
使用方式:
//开启页面返回询问对话框
router.showAlertBeforeBackPage({
message: '是否要退出当前页?' //自定义的确认文本
});
当我们调用 route.back函数 或 按下返回键 退出本页时,应用便会弹出该确认框。
ArkUI-动画
在ArkUI中,分为属性动画、显式动画、组件转场动画三种。
属性动画
属性动画是通过设置组件的animation属性来给组件添加动画,当组件的width、height、Opacity、backgroundColor、scale、rotate、translate等属性变更时,可以实现渐变效果。
Text(this.message)
.fontSize(50)
.fontWeight(FontWeight.Bold)
.position({
x:500,//x轴坐标
y:500//y轴坐标
})
.rotate({
angle:30,//旋转角度
centerX:'50%',//旋转中心横坐标: 50%为以自身中心点作为x坐标
centerY:'50%',//旋转中心纵坐标:50%以自身中心点作为y坐标
})
.animation({
duration:3000,//动画的持续时间
curve:Curve.EaseInOut,//设置动画曲线
})
注意:
- 属性动画的执行需要开发人员提供初始值和结束值。
- 需要添加动画效果的属性函数必须在 animation() 之前被调用,否则该属性的变更没有动画效果。
- 页面首次加载后,Build中定义的属性均为动画的起始值,只有在属性变更后,动画才会触发。
所以会用到@State装饰器,通过@State成员更新来触发动画。
animation函数中参数介绍:
名称 | 参数类型 | 是否必填 | 描述 | |
---|---|---|---|---|
duration | number | 否 | 设置动画时长。默认值:1000,单位:毫秒。 | |
tempo | number | 否 | 动画播放速度。数值越大,速度越快。默认值:1 | |
curve | string | Curve | 否 | 设置动画曲线。默认值:Curve.EaseInOut,平滑开始和结束。 |
delay | number | 否 | 设置动画延迟执行的时长。默认值:0,单位:毫秒。 | |
iterations | number | 否 | 设置播放次数。默认值:1,取值范围: [-1,+oo)。说明:设置为-1时表示无限次播放。 | |
playMode | PlayMode | 否 | 设置动画播放模式,默认播放完成后从头开始播放。默认值:PlayMode.Normal | |
onFinish | ()=>void | 否 | 状态回调,动画播放完成时触发。 |
案例:使用属性动画实现小鱼移动
需求分析
- UI方面:
-
- GIF中有一只小鱼,使用Image组件。
- 背景是一张图片,Image
- 有上下左右按钮,使用Button 嵌套Image
- 逻辑方面:按下方向键时,触发小鱼移动。
-
- 监听上下左右键的touch事件。
- 小鱼移动需要使用到动画来过渡。
逐个实现
- UI方面简单,小鱼和背景图都是用Image组件。
- 上下左右键的布局如果使用Row、Column的话,嵌套会比较严重,所以在这里使用RelativeContainer相对布局
RelativeContainer() {
//上
this.BuildDirectionKey($r('app.media.xiangshang'), 'top_btn', {
top: { anchor: '__container__', align: VerticalAlign.Top },
middle: { anchor: '__container__', align: HorizontalAlign.Center },
}, (event: TouchEvent) => {
//向上
switch (event.type) {
case TouchType.Down:
this.clickBtn(ClickBtnMode.TOP)
break
case TouchType.Cancel, TouchType.Up:
this.cancelBtn()
break
}
})
}
@Builder BuildDirectionKey(res: ResourceStr, id: string, alignRules: AlignRuleOption, onTouch: (event: TouchEvent) => void) {
Button() {
Image(res)
}
.width(80)
.height(80)
.type(ButtonType.Normal)
.backgroundColor(null)
.id(id)
.alignRules(alignRules)
.onTouch((event) => {
onTouch(event)
})
}
- 接下来是按下方向键小鱼移动,那么需要先监听方向键的touch事件,设置onTouch事件监听,并且通过Event.type来获取触摸情况,我们这里监听按下、抬起、取消事件就行。
.onTouch((event)=>{
switch (event.type) {
case TouchType.Down:
this.clickBtn(ClickBtnMode.TOP)
break
case TouchType.Cancel, TouchType.Up:
this.cancelBtn()
break
}
})
- 当按键按下时,我们需要通过属性动画来让小鱼移动,那么先对组件添加动画属性,并且将position属性的x、y更换为使用成员变量,之后只需要mX或mY或mScale发生变化,那么组件的属性就会改变从而触发属性动画:
@State mX: number = 1
@State mY: number = 1
@State mScale:number|string=0
this.animDuration=100
Image($r('app.media.yu'))
.id('yu_img')
.width(150)
.height(150)
.backgroundImageSize(ImageSize.Cover)
.position({
x: this.mX,
y: this.mY
})
.rotate({
angle:this.mScale,
centerY:'50%',
centerX:'50%',
})
.alignRules({
left: { anchor: '__container__', align: HorizontalAlign.Start },
top: { anchor: '__container__', align: VerticalAlign.Top },
})
.animation({
duration: this.animDuration
})
- 按钮按下时,我们根据按下的方向键,来修改组件对应的X或Y坐标:
this.mSetUp=33
switch (mode) {
case ClickBtnMode.TOP:
if (this.mY - this.mSetUp >= 0) {
this.mY -= this.mSetUp
}
break
case ClickBtnMode.BOTTOM:
if (this.mY + this.mSetUp + 150 < px2vp(display.getDefaultDisplaySync().height)) {
this.mY += this.mSetUp
}
break
//...
}
- 此时我们的效果只是按下会触发小鱼移动,但没有实现一直长按移动,因为按钮长按下去只会触发一次的down事件,所以我们需要使用定时器,在手指按下到抬起之间,我们每隔100毫秒,就让小鱼执行一次移动的属性动画。同时别忘了及时关闭定时器。
setInterval(function(){
//执行操作
}.bind(this), this.animDuration);
- 需求完成,gitee-使用属性动画实现小鱼移动
定时器 Timer
定时器大家并不陌生,定时器用于执行定时任务,在ArkTS中,定时器常用的函数有两个:
- setInterval:设置定时任务;重复调用一个函数,在每次调用之间具有固定的时间延迟。
注意,setInterval在调用后会返回一个intervalID,这个ID是当前任务的唯一标识。
this.intervalID=setInterval(function(){
//执行操作
}.bind(this), 1000);
- clearInterval:取消定时任务,传入intervalID即可取消指定的定时任务。
clearInterval(this.intervalID)
显式动画
显式动画是通过animateTo函数来修改组件属性,实现组件属性变化时的渐变过渡效果。
显式动画不再需要使用animation函数,而是使用animateTo,不过它们都是通过属性的变化来触发动画效果的,原理一样。
animation函数是监听在它之前调用的属性的发生变化时触发动画效果,而animateTo则是在函数内部去指定动画效果的属性(如时长duration),以及涉及到动画效果的属性值。
declare function animateTo(value: AnimateParam, event: () => void): void;
参数介绍:
- value: AnimateParam:动画的属性,如时长、次数、播放模式等。
- event: () => void:箭头函数,在这里进行组件属性值的修改,就会被施加上动画效果。
如下代码:我有一个Image组件,我在声明组件UI时定义了一个angle旋转角度,它绑定了成员变量mRotate。
那么正常情况下我们去修改mRotate值,是没有动画效果的;但此时如果在animateTo的箭头函数中去修改mRotate,那么该成员变量所对应的组件属性就会被施加上动画效果。
@State mRotate:number |string=0
build(){
Image($r('app.media.yu'))
.id('yu_img')
.width(150)
.height(150)
.rotate({
angle:this.mRotate,
centerY:'50%',
centerX:'50%',
})
}
onclick=>(){
animateTo({duration:1000,playMode:PlayMode.Normal,curve:Curve.EaseInOut},()=>{
this.mScale=180
})
}
组件转场动画
组件转场动画是指组件在插入和移除时的过渡动画,通过组件的transition属性来配置。
Text("(*´▽`)ノノ")
.transition({
type: TransitionType.All,
translate: { x: 100, y: 100, z: 50 },
rotate: { centerX: '50%', centerY: '50%', angle: 180 },
scale: { centerX: '50%', centerY: '50%', },
})
参数名称 | 参数类型 | 是否必填 | 参数描述 | |||
---|---|---|---|---|---|---|
type | TransitionType | 否 | 类型,默认包括组件新增和删除。默认是ALL | |||
opacity | number | 否 | 不透明度,为插入时起点和删除时终点的值。默认:1,取值范围:[0,1] | |||
translate | {x?:number | string,y?:number | string,z?:number | string} | 否 | 平移效果动画;作为组件插入时动画起点,和删除时动画终点的值。x:横向的平移距离y:纵向的平移距离z:竖向的平移距离 |
scale | {x?:number,y?:number,z?:number,centerX?:number | string,centerY?:number | string} | 否 | 缩放效果动画;作为组件插入时动画起点,和删除时动画终点的值。x:横向放大倍数(或缩小比例)y:纵向放大倍数(或缩小比例)z:当前为二维显示,该参数无效centerX、centerY指缩放中心点,centerX和centerY默认值是 '50%'。中心点为0时,默认的时组件的左上角。 | |
rotate | {x?:number,y?:number,z?:number,angle:number,centerX?:number | string,centerY?:number | string} | 否 | 旋转效果动画;作为组件插入时动画起点,和删除时动画终点的值。angle是旋转的角度,其他参数与scale类似。 |
组件转场动画仅靠transition属性来是不会触发的,因为只有当组件被添加/移除的时候才会触发转场动画。那么我们就要给需要添加转场动画的组件做一个添加/移除的操作,并且通过显式动画animateTo来触发。
首先是在Build()中,对该组件的添加删除做判断控制。
@State isShowView:boolean=false
build(){
if(this.isShowView){
Text("(*´▽`)ノノ").transition({})
}
}
之后通过animateTo显式动画来操作isShowImage变量,从而控制Text的可见性,继而触发转场动画。
animateTo({duration:3000},()=>{
this.isShowImage=!this.isShowImage;
})
完善小鱼移动游戏案例
完成三种动画的学习后,我们继续回到案例:使用属性动画实现小鱼移动,接下来将小鱼游戏的方向控制做成摇杆的。
- 首先是摇杆布局,一个父布局+一个小圆,使用重叠布局就行。
Stack() {
Text()
.width(50)
.height(50)
.backgroundColor('#c6c6c6')
.borderRadius(25)
.align(Alignment.Center)
.shadow({radius:5})
}
.alignContent(Alignment.Center)
.width(200)
.height(200)
.backgroundColor('#80a2a2a2')
.borderRadius(100)
.id('rl_btn')
.margin(30)
.alignRules({
bottom: { anchor: '__container__', align: VerticalAlign.Bottom }
})
- 实现摇杆拖动效果,这里会用到勾股定理和触摸事件。
-
- 首先监听手指的移动事件,当手指按下或移动时,摇杆跟随手指移动。当我们松开时,摇杆归位。
Stack().onTouch((event) => {
switch (event.type) {
case TouchType.Down:
case TouchType.Move:
//相对位置:x,y
let touchX = event.touches[0].x
let touchY = event.touches[0].y
//更新摇杆位置和移动
this.updateJoystick(touchX, touchY, 25)
break
case TouchType.Cancel:
case TouchType.Up:
//停止移动,还原摇杆拖动位置。
this.resetJoystick()
break
}
})
-
-
手指移动时,还需要考虑到摇杆的小圆球不能超出圆形操纵区域,超出的话则按照最大的距离移动。
所以这里要用到正/余玄函数,通过event事件可以拿到手指在大圆圈的相对位置。
我们计算出圆心,之后在计算得到手指的X、Y坐标在圆心的相对坐标。
之后根据a平方+b平方=c平方,得出手指到圆心的距离;再拿手指到圆心的距离与圆半价进行比较,如果小于等于半径,则是在操作范围内;如果大于半径,则要做处理,计算出当前与圆心的角度下的,缩短r的值到半径大小后,x、y的坐标值。
-
updateJoystick(touchX: number, touchY: number) {
//小圆球圆心在大圆球操作栏的相对位置
let centerX = (200) / 2
let centerY = (200) / 2
//计算手指触摸区域到圆心的距离
let offsetX = touchX - centerX
let offsetY = touchY - centerY
//根据c^2 = a^2+b^2,得出手指触摸区域到圆心的距离的平方
let r2=offsetX * offsetX + offsetY * offsetY
//开平方根,得到手指到圆心的距离
let r = Math.sqrt(r2)
//最大的可操作距离为 100,这里应该做常量,我没做。
let radius = 100
if (r < radius) {
//如果手指的操作区域在圆球内,正常移动
this.mJoystickX = touchX - (50 / 2)
this.mJoystickY = touchY - (50 / 2)
}else {
//超出了则按照最大值来,并且借助同角度下, 即使不同半径,但它们的sin或cos始终是一样的,所以使用 maxX/maxR=(touchX/touchR) 推导出 maxX=maxR*(touchX/touchR)
this.mJoystickX=centerX+(radius*(offsetX/r))-(50 / 2)
this.mJoystickY=centerY+(radius*(offsetY/r))-(50 / 2)
}
}
- 优化小鱼移动,之前是上下左右,现在是支持360度方向,所以要重新调整下移动的预判。
-
- 借助摇杆的x、y坐标来计算摇杆偏移量,将偏移量运用到小鱼的移动
- 当手指按下后,设置定时器,实时的计算并指定每次移动的目标点,并每次执行动画。
this.mCurrentIntervalId = setInterval(function(){
//我们这里根据摇杆的推动距离来决定移动速度,所以只需要讲摇杆位移的x和y给到小鱼去相加,就可以了。
let resultX=this.mX+(this.mJoystickX-100+25)*this.mSetUp
let resultY=this.mY+(this.mJoystickY-100+25)*this.mSetUp
if (resultX>=0 && resultX<=px2vp(display.getDefaultDisplaySync().width)-150) {
//x还有位置位移。
this.mX=resultX
}
if (resultY>=0 && resultY<=px2vp(display.getDefaultDisplaySync().height)-150) {
//y还有位置位移。
this.mY=resultY
}
}.bind(this), this.animDuration);
- 计算遥控杆的角度,从而调整小鱼的角度。
在这里我们先通过Math.atan2函数得到手指与遥控杆中心连线 与 X轴正半轴的夹角;
单位是弧度,所以要转换成角度。注意,得到的角度和我们组件的旋转动画的角度属性起始位置是有差异的。
得到的角度,正半轴是从底部逆时针到顶部,为0180度;负半轴从底部到顶部逆时针是 0负180度。
而我们组件的rotate旋转角度属性有两种方式:
① 以X轴正半轴为0,顺时针从0~360度;
② 又或是以X轴正半轴为0,顺时针到负的x半轴 180度,接着以x轴正半轴为0度,逆时针到负的x轴半轴为-180度。
我这里则是将角度转换为方式二方式给组件使用,并且通过显示动画来修改小鱼朝向。
//调整小鱼的方向,需要计算角度
//计算手指与中心点连线 和 x轴正半轴的夹角,单位是弧度
let radian=Math.atan2(this.mJoystickX-100+25,this.mJoystickY-100+25)
//弧度= 角度*PI/180 角度= 弧度*180/PI
let angle=radian*180/Math.PI
//通过显式动画触发
animateTo({duration:6,curve:Curve.Linear},()=>{
if (this.mJoystickX - 100 + 25 > 0) {
//正X半轴
console.log(`正X半轴,angle:${angle}`)
this.mRotate=90-angle
}else {
//负X半轴
console.log(`负X半轴,angle:${angle}`)
this.mRotate=90+Math.abs(angle)
}
})
最终效果:
git地址:完善小鱼游戏-新增全向遥控杆