HarmonyOS 应用开发进阶案例(十一):实现简易动效

0 阅读22分钟

本案例利用ArkUI组件不仅可以实现属性变化引起的属性动画,也可以实现父组件状态变化引起子组件产生动画效果,这种动画为显式动画。

案例实战

案例效果截图

案例运用到的知识点

  1. 核心知识点
  • 显式动画:提供全局animateTo显式动画接口来指定由于闭包代码导致的状态变化插入过渡动效。
  • 属性动画:组件的某些通用属性变化时,可以通过属性动画实现渐变过渡效果,提升用户体验。支持的属性包括width、height、backgroundColor、opacity、scale、rotate、translate等。
  • Slider:滑动条组件,通常用于快速调节设置值,如音量调节、亮度调节等应用场景。
  1. 其他知识点
  • ArkTS 语言基础
  • V2版状态管理:@ComponentV2/@Local/@Param/AppStorageV2
  • 渲染控制:ForEach
  • 自定义组件和组件生命周期
  • 自定义构建函数@Builder
  • @Extend:定义扩展样式
  • Navigation导航组件与router路由导航
  • 内置组件:Swiper/Stack/Column/Row/Image/Grid/List
  • 常量与资源分类的访问
  • MVVM模式

代码结构

├──entry/src/main/ets                // 代码区
│  ├──common
│  │  └──constants
│  │     └──Const.ets                // 常量类
│  ├──entryability
│  │  └──EntryAbility.ets            // 程序入口类
│  ├──pages
│  │  └──Index.ets                   // 动效页面入口
│  ├──view
│  │  ├──AnimationWidgets.ets        // 动画组件
│  │  ├──CountController.ets         // 图标数量控制组件
│  │  └──IconAnimation.ets           // 图标属性动画组件
│  └──viewmodel
│     ├──IconItem.ets                // 图标类
│     ├──Point.ets                   // 图标坐标类
│     └──IconsModel.ets              // 图标数据模型
└──entry/src/main/resources          // 资源文件

公共文件与资源

本案例涉及到的常量类代码如下:

  1. 通用常量类
// entry/src/main/ets/common/constants/Constants.ets
const IMAGE_ARR = [
  $r('app.media.badge1'),
  $r('app.media.badge2'),
  $r('app.media.badge3'),
  $r('app.media.badge4'),
  $r('app.media.badge5'),
  $r('app.media.badge6')
]

export default class Common {
  static readonly IMAGE_RESOURCE: Resource[] = IMAGE_ARR
  static readonly IMAGES_TOTAL: number = IMAGE_ARR.length
  static readonly IMAGES_MIN: number = 3
  static readonly ROTATE_ANGLE_360: number = 360
  static readonly DELAY_10: number = 10
  static readonly DEFAULT_FULL_WIDTH: string = '100%'
  static readonly DEFAULT_FULL_HEIGHT: string = '100%'
  static readonly ICON_WIDTH: number = 58
  static readonly ICON_HEIGHT: number = 58

  static readonly CONTROLLER_WIDTH: string = '97vp'
  static readonly CONTROLLER_HEIGHT: string = '336vp'
  static readonly FONT_WEIGHT_500: number = 500

  static readonly OPACITY_06: number = 0.6
  static readonly OFFSET_RADIUS: number = 145

  static readonly INIT_SCALE: number = 0.75
  static readonly DURATION_500: number = 500
  static readonly TEMPO: number = 0.68

  static readonly SCALE_RATIO: number = 1.25
  static readonly DURATION_1000: number = 1000
}

本案例涉及到的资源文件如下:

  1. string.json
// entry/src/main/resources/base/element/string.json
{
  "string": [
    {
      "name": "module_desc",
      "value": "description"
    },
    {
      "name": "EntryAbility_desc",
      "value": "ArkUI动画"
    },
    {
      "name": "EntryAbility_label",
      "value": "ArkUI动画"
    },
    {
      "name": "please_click_button",
      "value": "请点击按钮"
    },
    {
      "name": "count",
      "value": "数量"
    }
  ]
}

2. color.json

// entry/src/main/resources/base/element/color.json
{
  "color": [
    {
      "name": "start_window_background",
      "value": "#FFFFFF"
    },
    {
      "name": "white",
      "value": "#FFFFFF"
    },
    {
      "name": "bgColor",
      "value": "#f1f3f5"
    },
    {
      "name": "fontGrayColor",
      "value": "#182431"
    },
    {
      "name": "SliderSelectColor",
      "value": "#007DFF"
    }
  ]
}

3. float.json

// entry/src/main/resources/base/element/float.json
{
  "float": [
    {
      "name": "size_4",
      "value": "4vp"
    },
    {
      "name": "size_5",
      "value": "5vp"
    },
    {
      "name": "size_12",
      "value": "12vp"
    },
    {
      "name": "size_14",
      "value": "14vp"
    },
    {
      "name": "size_15",
      "value": "15vp"
    },
    {
      "name": "size_16",
      "value": "16vp"
    },
    {
      "name": "size_20",
      "value": "20vp"
    },
    {
      "name": "size_24",
      "value": "24vp"
    },
    {
      "name": "size_48",
      "value": "48vp"
    },
    {
      "name": "size_64",
      "value": "64vp"
    },
    {
      "name": "size_100",
      "value": "100vp"
    }
  ]
}

其他资源请到源码中获取。

页面入口

import { AnimationWidgets } from '../view/AnimationWidgets'
import { CountController } from '../view/CountController'
import Common from '../common/constants/Const'
import { IconsModel } from '../viewmodel/IconsModel'

@Entry
@Component
struct Index {
  @State quantity: number = Common.IMAGES_MIN
  @Provide iconModel: IconsModel 
    = new IconsModel(this.quantity, Common.OFFSET_RADIUS)

  build() {
    Column() {
      // 动画组件
      AnimationWidgets({
        quantity: $quantity
      })

      // 图标数量控制组件
      CountController({
        quantity: $quantity
      })
    }
    .width(Common.DEFAULT_FULL_WIDTH)
    .height(Common.DEFAULT_FULL_HEIGHT)
    .backgroundColor($r('app.color.bgColor'))
  }
}

动画组件

  1. 组件入口
// entry/src/main/ets/viewmodel/AnimationWidgets.ets
// 图标数据模型,用于生成和管理图标数组
import { IconsModel } from '../viewmodel/IconsModel'
// 单个图标的动画组件
import { IconAnimation } from './IconAnimation'
// 通用常量定义
import Common from '../common/constants/Const'
// 图标项的数据结构定义
import IconItem from '../viewmodel/IconItem'

@ComponentV2
export struct AnimationWidgets {
  // 控制动画状态的标志,点击后切换 true/false
  @Local mainFlag: boolean = false

  // 图标数量,默认使用最小数量
  @Param quantity: number = Common.IMAGES_MIN

  // 图标数据模型,初始化时传入图标数量与偏移半径
  @Consumer() iconModel: IconsModel
    = new IconsModel(this.quantity, Common.OFFSET_RADIUS)

  // 监听图标数量变化时,重新生成图标数组
  @Monitor('quantity')
  onQuantityChange() {
    this.iconModel.addImage(this.quantity)
  }

  // 组件即将显示时执行,初始化图标数组
  aboutToAppear() {
    this.onQuantityChange()
  }

  // 执行动画方法,切换 mainFlag 的值,并触发旋转和平移动画
  animate() {
    animateTo(
      {
        delay: Common.DELAY_10, // 动画延迟开始时间
        tempo: Common.TEMPO, // 动画节奏
        iterations: 1, // 动画循环次数
        duration: Common.DURATION_500, // 动画持续时间
        curve: Curve.Smooth, // 动画曲线
        playMode: PlayMode.Normal // 播放模式
      }, () => {
      this.mainFlag = !this.mainFlag // 切换动画状态
    })
  }

  build() {
    // 外部 Stack 容器,居中显示所有元素
    Stack() {
      // 图标动画区域
      Stack() {
        // 遍历图标数组,创建多个 IconAnimation 子组件
        ForEach(this.iconModel.imagerArr, (item: IconItem) => {
          IconAnimation({
            item: item, // 当前图标数据
            mainFlag: this.mainFlag // 动画状态标志传递给子组件
          })
        }, (item: IconItem) => JSON.stringify(item.index))
      }
      .width(Common.DEFAULT_FULL_WIDTH)
      .height(Common.DEFAULT_FULL_HEIGHT)
      .rotate({
        x: 0,
        y: 0,
        z: 1,
        // 根据动画状态旋转360°
        angle: this.mainFlag ? Common.ROTATE_ANGLE_360 : 0
      })

      // 中心按钮图片,控制动画触发
      Image(this.mainFlag ? $r('app.media.imgActive') : $r('app.media.imgInit'))
        .width($r('app.float.size_64')) // 设置图片宽度
        .height($r('app.float.size_64')) // 设置图片高度
        .objectFit(ImageFit.Contain) // 保持图像比例缩放
        .scale({
          x: this.mainFlag ? Common.INIT_SCALE : 1,
          y: this.mainFlag ? Common.INIT_SCALE : 1
        }) // 动画时缩放
        .onClick(() => {
          this.iconModel.reset() // 重置图标数组
          this.animate() // 启动动画
        })

      // 提示文本,提示用户点击按钮
      Text($r('app.string.please_click_button'))
        .fontSize($r('app.float.size_16'))
        .opacity(Common.OPACITY_06)
        .fontColor($r('app.color.fontGrayColor'))
        .fontWeight(Common.FONT_WEIGHT_500)
        .margin({
          top: $r('app.float.size_100')
        })
    }
    .width(Common.DEFAULT_FULL_WIDTH)
    .layoutWeight(1)
  }
}

2. 生成和管理图标

// entry/src/main/ets/viewmodel/IconsModel.ets
import Common from '../common/constants/Const'
import IconItem from './IconItem' // 图标项的数据结构
import Point from './Point' // 二维坐标点类

// 2π 常量,用于计算圆周角度
const TWO_PI: number = 2 * Math.PI

@ObservedV2
export class IconsModel {
  // 图标数组,每个图标包含 id、资源路径、点击状态、坐标
  @Trace public imagerArr: Array<IconItem> = []

  // 当前图标数量
  @Trace private num: number = Common.IMAGES_MIN

  // 图标在圆形轨迹上的半径(偏移距离)
  @Trace private radius: number

  // 构造函数,初始化图标数量和半径,同时生成初始图标数据
  constructor(num: number, radius: number) {
    this.radius = radius
    this.addImage(num)
  }

  // 添加图标数量为 num,如果数量变化则生成或裁剪图标项
  public addImage(num: number) {
    this.num = num

    // 如果已有图标数量与目标一致,则不需要更新
    if (this.imagerArr.length === num) {
      return
    }

    // 如果现有图标比目标多,则裁剪多余图标
    if (this.imagerArr.length > num) {
      this.imagerArr.splice(num, this.imagerArr.length - num)
    } else {
      // 如果现有图标比目标少,则生成新图标项
      for (let i = this.imagerArr.length; i < num; i++) {
        const point = this.genPointByIndex(i) // 计算图标坐标
        this.imagerArr.push(new IconItem(
          i,
          Common.IMAGE_RESOURCE[i],
          false,
          point
        ))
      }
    }

    // 刷新所有图标的位置
    this.refreshPoint(num)
  }

  // 根据最新数量刷新每个图标的位置坐标
  public refreshPoint(num: number) {
    for (let i = 0; i < num; i++) {
      this.imagerArr[i].point = this.genPointByIndex(i)
    }
  }

  // 根据索引生成圆形分布坐标点
  public genPointByIndex(index: number): Point {
    // 计算圆周上的 x 坐标
    const x = this.radius * Math.cos(TWO_PI * index / this.num)
    // 计算圆周上的 y 坐标
    const y = this.radius * Math.sin(TWO_PI * index / this.num)
    return new Point(x, y)
  }

  // 重置所有图标的点击状态为 false
  public reset() {
    for (let i = 0; i < this.num; i++) {
      if (this.imagerArr[i].clicked) {
        this.imagerArr[i].clicked = false
      }
    }
  }
}

3. 图标项的数据结构

// entry/src/main/ets/viewmodel/IconItem.ets
import Point from './Point'

@ObservedV2
export default class IconItem {
  @Trace index: number = 0
  @Trace clicked: boolean = false
  @Trace image: Resource = $r('app.media.badge1')
  @Trace point: Point = new Point(0, 0)

  constructor(index: number, image: Resource, clicked: boolean, point: Point) {
    this.index = index
    this.image = image
    this.clicked = clicked
    this.point = point
  }
}

4. 二维坐标点类

// entry/src/main/ets/viewmodel/Points.ets
export default class Point {
  public x: number = 0
  public y: number = 0

  constructor(x: number, y: number) {
    this.x = x
    this.y = y
  }
}

5. 单个图标的动画组件

// entry/src/main/ets/view/IconAnimation.ets
import IconItem from '../viewmodel/IconItem' // 引入图标项模型
import Common from '../common/constants/Const'

@ComponentV2
export struct IconAnimation {
  // 是否启动主动画标志位,控制图标是否位移到目标点
  @Param mainFlag: boolean = false

  // 当前图标项参数,包含坐标、图片资源、点击状态等
  @Param item: IconItem = new Object() as IconItem

  // 构建组件 UI
  build() {
    // 渲染图片组件
    Image(this.item.image)
      .width(Common.ICON_WIDTH)
      .height(Common.ICON_HEIGHT)
        // 图片缩放方式:适应容器大小
      .objectFit(ImageFit.Contain)
        // 根据 mainFlag 判断是否将图标平移到圆形目标点位置,否则保持在初始点(0,0)
      .translate(this.mainFlag
        ? { x: this.item.point.x, y: this.item.point.y }
        : { x: 0, y: 0 }
      )
        // 如果图标被点击,绕 y 轴旋转 360 度,否则不旋转
      .rotate({
        x: 0,
        y: 1,
        z: 0,
        angle: this.item.clicked ? Common.ROTATE_ANGLE_360 : 0
      })
        // 如果图标被点击,则缩小一定比例,否则恢复原始大小
      .scale(this.item.clicked
        ? { x: Common.SCALE_RATIO, y: Common.SCALE_RATIO }
        : { x: 1, y: 1 })
        // 如果图标被点击,降低不透明度,否则保持全不透明
      .opacity(this.item.clicked ? Common.OPACITY_06 : 1)
        // 点击时切换点击状态(即点击后执行动画)
      .onClick(() => {
        this.item.clicked = !this.item.clicked
      })
        // 应用动画效果配置:延迟、持续时间、动画节奏、执行次数、播放模式
      .animation(
        {
          delay: Common.DELAY_10,
          duration: Common.DURATION_1000,
          iterations: 1,
          curve: Curve.Smooth,
          playMode: PlayMode.Normal
        }
      )
  }
}

图标数量控制组件

import Common from '../common/constants/Const'

// 扩展 Text 控件,统一字体样式设置
@Extend(Text) function textStyle () {
  .fontSize($r('app.float.size_16'))
  .fontWeight(Common.FONT_WEIGHT_500)
}

// 定义 CountController 控件组件
@ComponentV2
export struct CountController {
  // 图标数量参数,默认为最小数量
  @Param quantity: number = Common.IMAGES_MIN

  // 数量变化事件,用于将滑块值同步回父组件
  @Event $quantity: (value: number) => void = () => {}

  build() {
    // 使用垂直布局容器
    Column() {
      Row() {
        // 显示“数量”文字
        Text($r('app.string.count'))
          .textStyle()

        // 显示当前数量值,取整数
        Text(this.quantity.toFixed(0))
          .textStyle()
      }
      .justifyContent(FlexAlign.SpaceBetween)
      .width(Common.DEFAULT_FULL_WIDTH)
      .margin({
        top: $r('app.float.size_4')
      })

      // 滑块控件 Slider
      Slider({
        value: this.quantity, // 当前值
        min: Common.IMAGES_MIN, // 最小值
        max: Common.IMAGES_TOTAL, // 最大值
        step: 1, // 步进为 1
        style: SliderStyle.InSet // 样式为嵌入式
      })
        .blockColor(Color.White)
        .selectedColor($r('app.color.SliderSelectColor'))
        .margin({
          top: $r('app.float.size_5') 
        })
        .showSteps(true)
        .trackThickness($r('app.float.size_20'))
        .onChange((value: number) => {
          this.$quantity(value)
        })
    }
    .height(Common.CONTROLLER_WIDTH)
    .padding({
      top: $r('app.float.size_12'),
      bottom: $r('app.float.size_12'),
      left: $r('app.float.size_16'),
      right: $r('app.float.size_16')
    })
    .margin({
      bottom: $r('app.float.size_48')
    })
    .width(Common.CONTROLLER_HEIGHT)
    .borderRadius($r('app.float.size_24'))
    .backgroundColor($r('app.color.white'))
  }
}
    • 按键事件是指通过连接和使用外设键盘操作时所响应的事件。
  • 焦点事件:通过以上方式控制组件焦点的能力和响应的事件。
  • 拖拽事件:由触屏事件和键鼠事件发起,包括手指/手写笔长按组件拖拽和鼠标拖拽。
  • 事件分发:描述触控类事件(不包括按键,焦点)响应链的命中收集过程。

手势事件由绑定手势方法和绑定的手势组成,绑定的手势可以分为单一手势和组合手势两种类型,根据手势的复杂程度进行区分。

  • 绑定手势方法:用于在组件上绑定单一手势或组合手势,并声明所绑定的手势的响应优先级。
  • 单一手势:手势的基本单元,是所有复杂手势的组成部分。
  • 组合手势:由多个单一手势组合而成,可以根据声明的类型将多个单一手势按照一定规则组合成组合手势,并进行使用。

附加知识点:交互事件

通用事件按照触发类型来分类,包括触屏事件、键鼠事件、焦点事件和拖拽事件。

  • 触屏事件:手指或手写笔在触屏上的单指或单笔操作。
  • 键鼠事件:包括外设鼠标或触控板的操作事件和外设键盘的按键事件。
    • 鼠标事件是指通过连接和使用外设鼠标/触控板操作时所响应的事件。
    • 按键事件是指通过连接和使用外设键盘操作时所响应的事件。
  • 焦点事件:通过以上方式控制组件焦点的能力和响应的事件。
  • 拖拽事件:由触屏事件和键鼠事件发起,包括手指/手写笔长按组件拖拽和鼠标拖拽。
  • 事件分发:描述触控类事件(不包括按键,焦点)响应链的命中收集过程。

手势事件由绑定手势方法和绑定的手势组成,绑定的手势可以分为单一手势和组合手势两种类型,根据手势的复杂程度进行区分。

  • 绑定手势方法:用于在组件上绑定单一手势或组合手势,并声明所绑定的手势的响应优先级。
  • 单一手势:手势的基本单元,是所有复杂手势的组成部分。
  • 组合手势:由多个单一手势组合而成,可以根据声明的类型将多个单一手势按照一定规则组合成组合手势,并进行使用。

事件分发

事件分发是指ArkUI收到用户操作生成的触控事件,通过触摸测试,将触控事件分发至各个组件形成事件的过程。

触控事件是触摸测试的输入,根据用户操作方式的不同,可以划分为Touch类触控事件和Mouse类触控事件。

  • Touch类触控事件指触摸生成的触控事件,输入源包含:finger(手指在屏幕滑动)、pen(手写笔在屏幕滑动)、mouse(鼠标操作)、touchpad(触控板操作),可以触发触摸事件、点击事件、拖拽事件和手势事件。
  • Mouse类触控事件指鼠标操作生成的触控事件,输入源包含:mouse(鼠标操作)、touchpad(触控板操作)、joystick(手柄操作),可以触发触摸事件、点击事件、拖拽事件、手势事件和鼠标事件。

不论是Touch类触控事件还是Mouse类触控事件,最后触发的事件均是通过触摸测试决定最终所分发到的组件。触摸测试决定了ArkUI事件响应链生成、触控事件分发以及组件绑定事件的触发。

触屏事件

触屏事件指当手指/手写笔在组件上按下、滑动、抬起时触发的回调事件。包括点击事件、拖拽事件和触摸事件。触摸事件原理图如下:

点击事件

点击事件是指通过手指或手写笔做出一次完整的按下和抬起动作。当发生点击事件时,会触发以下回调函数:

onClick(event: (event?: ClickEvent) => void)

event参数提供点击事件相对于窗口或组件的坐标位置,以及发生点击的事件源。

例如通过按钮的点击事件控制图片的显示和隐藏。

@Entry
@Component
struct IfElseTransition {
  @State flag: boolean = true;
  @State btnMsg: string = 'show';

  build() {
    Column() {
      Button(this.btnMsg).width(80).height(30).margin(30)
        .onClick(() => {
          if (this.flag) {
            this.btnMsg = 'hide';
          } else {
            this.btnMsg = 'show';
          }
          // 点击Button控制Image的显示和消失
          this.flag = !this.flag;
        })
      if (this.flag) {
        Image($r('app.media.icon')).width(200).height(200)
      }
    }.height('100%').width('100%')
  }
}

触摸事件

当手指或手写笔在组件上触碰时,会触发不同动作所对应的事件响应,包括按下(Down)、滑动(Move)、抬起(Up)事件:

onTouch(event: (event?: TouchEvent) => void)
  • event.type为TouchType.Down:表示手指按下。
  • event.type为TouchType.Up:表示手指抬起。
  • event.type为TouchType.Move:表示手指按住移动。
  • event.type为TouchType.Cancel:表示打断取消当前手指操作。

触摸事件可以同时多指触发,通过event参数可获取触发的手指位置、手指唯一标志、当前发生变化的手指和输入的设备源等信息。

@Entry
@Component
struct TouchExample {
  @State text: string = ''
  @State eventType: string = ''
  build() {
    Column() {
      Button('Touch').height(50).width(200).margin(20)
        .onTouch((event?: TouchEvent) => {
          if(event){
            if (event.type === TouchType.Down) {
              this.eventType = 'Down'
            }
            if (event.type === TouchType.Up) {
              this.eventType = 'Up'
            }
            if (event.type === TouchType.Move) {
              this.eventType = 'Move'
            }
            this.text = 'TouchType:' + this.eventType
              + '\nDistance between touch point and touch element:\nx: '
              + event.touches[0].x + '\n' + 'y: ' + event.touches[0].y
              + '\nComponent globalPos:('+ event.target.area.globalPosition.x+','
              + event.target.area.globalPosition.y + ')\nwidth:'
              + event.target.area.width + '\nheight:' + event.target.area.height
          }
        })
      Text(this.text)
    }.width('100%').padding(30)
  }
}

键鼠事件

键鼠事件指键盘,鼠标外接设备的输入事件。

  1. 鼠标事件

支持的鼠标事件包含通过外设鼠标、触控板触发的事件。

鼠标事件可触发以下回调:

名称描述
onHover(event: (isHover: boolean) => void)鼠标进入或退出组件时触发该回调。isHover:表示鼠标是否悬浮在组件上,鼠标进入时为true, 退出时为false。
onMouse(event: (event?: MouseEvent) => void)当前组件被鼠标按键点击时或者鼠标在组件上悬浮移动时,触发该回调,event返回值包含触发事件时的时间戳、鼠标按键、动作、鼠标位置在整个屏幕上的坐标和相对于当前组件的坐标。

当组件绑定onHover回调时,可以通过hoverEffect属性设置该组件的鼠标悬浮态显示效果。

鼠标事件数据流:

鼠标事件传递到ArkUI之后,会先判断鼠标事件是否是左键的按下/抬起/移动,然后做出不同响应:

  • 是:鼠标事件先转换成相同位置的触摸事件,执行触摸事件的碰撞测试、手势判断和回调响应。接着去执行鼠标事件的碰撞测试和回调响应。
  • 否:事件仅用于执行鼠标事件的碰撞测试和回调响应。
  1. 按键事件

按键事件数据流:

按键事件由外设键盘等设备触发,经驱动和多模处理转换后发送给当前获焦的窗口,窗口获取到事件后,会尝试分发三次事件。三次分发的优先顺序如下,一旦事件被消费,则跳过后续分发流程。

  1. 首先分发给ArkUI框架用于触发获焦组件绑定的onKeyPreIme回调和页面快捷键。
  2. 再向输入法分发,输入法会消费按键用作输入。
  3. 再次将事件发给ArkUI框架,用于响应系统默认Key事件(例如走焦),以及获焦组件绑定的onKeyEvent回调。

因此,当某输入框组件获焦,且打开了输入法,此时大部分按键事件均会被输入法消费。例如字母键会被输入法用来往输入框中输入对应字母字符、方向键会被输入法用来切换选中备选词。如果在此基础上给输入框组件绑定了快捷键,那么快捷键会优先响应事件,事件也不再会被输入法消费。

按键事件到ArkUI框架之后,会先找到完整的父子节点获焦链。从叶子节点到根节点,逐一发送按键事件。

Web组件的KeyEvent流程与上述过程有所不同。对于Web组件,不会在onKeyPreIme返回false时候,去匹配快捷。而是第三次按键派发中,Web对于未消费的KeyEvent会通过ReDispatch重新派发回ArkUI。在ReDispatch中再执行匹配快捷键等操作。

焦点事件

  • 焦点:指向当前应用界面上唯一的一个可交互元素,当用户使用键盘、电视遥控器、车机摇杆/旋钮等非指向性输入设备与应用程序进行间接交互时,基于焦点的导航和交互是重要的输入手段。
  • 焦点链:在应用的组件树形结构中,当一个组件获得焦点时,从根节点到该组件节点的整条路径上的所有节点都会被视为处于焦点状态,形成一条连续的焦点链。
  • 走焦:指焦点在应用内的组件之间转移的行为。这一过程对用户是透明的,但开发者可以通过监听onFocus(焦点获取)和onBlur(焦点失去)事件来捕捉这些变化。
  1. 获焦事件

获焦事件回调,绑定该接口的组件获焦时,回调响应。

onFocus(event: () => void)

2. 失焦事件

失焦事件回调,绑定该接口的组件失焦时,回调响应。

onBlur(event:() => void)

onFocus和onBlur两个接口通常成对使用,来监听组件的焦点变化。

@Entry
@ComponentV2
struct FocusEventExample {
  @Local oneButtonColor: Color = Color.Gray
  @Local twoButtonColor: Color = Color.Gray
  @Local threeButtonColor: Color = Color.Gray

  build() {
    Column({ space: 20 }) {
      // 通过外接键盘的上下键可以让焦点在三个按钮间移动,
      // 按钮获焦时颜色变化,失焦时变回原背景色
      Button('First Button')
        .width(260)
        .height(70)
        .backgroundColor(this.oneButtonColor)
        .fontColor(Color.Black)
          // 监听第一个组件的获焦事件,获焦后改变颜色
        .onFocus(() => {
          this.oneButtonColor = Color.Green
        })
          // 监听第一个组件的失焦事件,失焦后改变颜色
        .onBlur(() => {
          this.oneButtonColor = Color.Gray
        })

      Button('Second Button')
        .width(260)
        .height(70)
        .backgroundColor(this.twoButtonColor)
        .fontColor(Color.Black)
          // 监听第二个组件的获焦事件,获焦后改变颜色
        .onFocus(() => {
          this.twoButtonColor = Color.Green
        })
          // 监听第二个组件的失焦事件,失焦后改变颜色
        .onBlur(() => {
          this.twoButtonColor = Color.Grey
        })

      Button('Third Button')
        .width(260)
        .height(70)
        .backgroundColor(this.threeButtonColor)
        .fontColor(Color.Black)
          // 监听第三个组件的获焦事件,获焦后改变颜色
        .onFocus(() => {
          this.threeButtonColor = Color.Green
        })
          // 监听第三个组件的失焦事件,失焦后改变颜色
        .onBlur(() => {
          this.threeButtonColor = Color.Gray 
        })
    }.width('100%').margin({ top: 20 })
  }
}

上述示例包含以下3步:

  • 应用打开,按下TAB键激活走焦,“First Button”显示焦点态样式:组件外围有一个蓝色的闭合框,onFocus回调响应,背景色变成绿色。
  • 按下TAB键,触发走焦,“Second Button”获焦,onFocus回调响应,背景色变成绿色;“First Button”失焦,onBlur回调响应,背景色变回灰色。
  • 按下TAB键,触发走焦,“Third Button”获焦,onFocus回调响应,背景色变成绿色;“Second Button”失焦,onBlur回调响应,背景色变回灰色。

拖拽事件

拖拽事件提供了一种通过鼠标或手势触屏传递数据的机制,即从一个组件位置拖出(drag)数据并将其拖入(drop)到另一个组件位置,以触发响应。在这一过程中,拖出方提供数据,而拖入方负责接收和处理数据。这一操作使用户能够便捷地移动、复制或删除指定内容。

手势事件

本节主要介绍手势事件相关内容。涵盖绑定手势方法,如常规、带优先级和并行手势绑定;阐述单一手势,像点击、长按等;还介绍由多种单一手势组合而成的组合手势,帮助开发者实现丰富的交互效果。

绑定手势方法

通过给各个组件绑定不同的手势事件,并设计事件的响应方式,当手势识别成功时,ArkUI框架将通过事件回调通知组件手势识别的结果。

  1. gesture(常规手势绑定方法)
.gesture(gesture: GestureType, mask?: GestureMask)

gesture为通用的一种手势绑定方法,可以将手势绑定到对应的组件上。

例如,可以将点击手势TapGesture通过gesture手势将方法绑定到Text组件上。

@Entry
@Component
struct Index {
  build() {
    Column() {
      Text('Gesture').fontSize(28)
        // 采用gesture手势绑定方法绑定TapGesture
        .gesture(
          TapGesture()
            .onAction(() => {
              console.info('TapGesture is onAction');
            }))
    }
    .height(200)
    .width(250)
  }
}

2. priorityGesture(带优先级的手势绑定方法)

.priorityGesture(gesture: GestureType, mask?: GestureMask)

priorityGesture是带优先级的手势绑定方法,可以在组件上绑定优先识别的手势。

在默认情况下,当父组件和子组件使用gesture绑定同类型的手势时,子组件优先识别通过gesture绑定的手势。当父组件使用priorityGesture绑定与子组件同类型的手势时,父组件优先识别通过priorityGesture绑定的手势。

长按手势时,设置触发长按的最短时间小的组件会优先响应,会忽略priorityGesture设置。

例如,当父组件Column和子组件Text同时绑定TapGesture手势时,父组件以带优先级手势priorityGesture的形式进行绑定时,优先响应父组件绑定的TapGesture。

@Entry
@ComponentV2
struct Index {
  build() {
    Column() {
      Text('Gesture').fontSize(28)
        .gesture(
          TapGesture()
            .onAction(() => {
              console.info('Text TapGesture is onAction')
            }))
    }
    .height(200)
    .width(250)
    // 设置为priorityGesture时,
    // 点击文本区域会忽略Text组件的TapGesture手势事件,
    // 优先响应父组件Column的TapGesture手势事件
    .priorityGesture(
      TapGesture()
        .onAction(() => {
          console.info('Column TapGesture is onAction')
        }), GestureMask.IgnoreInternal)
  }
}

3. parallelGesture(并行手势绑定方法)

.parallelGesture(gesture: GestureType, mask?: GestureMask)

parallelGesture是并行的手势绑定方法,可以在父子组件上绑定可以同时响应的相同手势。

在默认情况下,手势事件为非冒泡事件,当父子组件绑定相同的手势时,父子组件绑定的手势事件会发生竞争,最多只有一个组件的手势事件能够获得响应。而当父组件绑定了并行手势parallelGesture时,父子组件相同的手势事件都可以触发,实现类似冒泡效果。

@Entry
@ComponentV2
struct Index {
  build() {
    Column() {
      Text('Gesture').fontSize(28)
        .gesture(
          TapGesture()
            .onAction(() => {
              console.info('Text TapGesture is onAction')
            }))
    }
    .height(200)
    .width(250)
    // 设置为parallelGesture时,
    // 点击文本区域会同时响应父组件Column和子组件Text的TapGesture手势事件
    .parallelGesture(
      TapGesture()
        .onAction(() => {
          console.info('Column TapGesture is onAction')
        }), GestureMask.Normal)
  }
}

单一手势

  1. 点击手势(TapGesture)
TapGesture(value?:{count?:number, fingers?:number})

点击手势支持单次点击和多次点击,拥有两个可选参数:

  • count:声明该点击手势识别的连续点击次数。默认值为1,若设置小于1的非法值会被转化为默认值。如果配置多次点击,上一次抬起和下一次按下的超时时间为300毫秒。
  • fingers:用于声明触发点击的手指数量,最小值为1,最大值为10,默认值为1。当配置多指时,若第一根手指按下300毫秒内未有足够的手指数按下则手势识别失败。

以在Text组件上绑定双击手势(count值为2的点击手势)为例:

@Entry
@ComponentV2
struct Index {
  @Local value: string = ""

  build() {
    Column() {
      Text('Click twice').fontSize(28)
        .gesture(
          // 绑定count为2的TapGesture
          TapGesture({ count: 2 })
            .onAction((event: GestureEvent|undefined) => {
              if(event){
                this.value = JSON.stringify(event.fingerList[0])
              }
            }))
      Text(this.value)
    }
    .height(200)
    .width(250)
    .padding(20)
    .border({ width: 3 })
    .margin(30)
  }
}

  1. 长按手势(LongPressGesture)
LongPressGesture(value?:{fingers?:number, repeat?:boolean, duration?:number})

长按手势用于触发长按手势事件,拥有三个可选参数:

  • fingers:用于声明触发长按手势所需要的最少手指数量,最小值为1,最大值为10,默认值为1。
  • repeat:用于声明是否连续触发事件回调,默认值为false。
  • duration:用于声明触发长按所需的最短时间,单位为毫秒,默认值为500。

以在Text组件上绑定可以重复触发的长按手势为例:

@Entry
@Component
struct Index {
  @State count: number = 0

  build() {
    Column() {
      Text('LongPress OnAction:' + this.count).fontSize(28)
        .gesture(
          // 绑定可以重复触发的LongPressGesture
          LongPressGesture({ repeat: true })
            .onAction((event: GestureEvent|undefined) => {
              if(event){
                if (event.repeat) {
                  this.count++
                }
              }
            })
            .onActionEnd(() => {
              this.count = 0
            })
        )
    }
    .height(200)
    .width(250)
    .padding(20)
    .border({ width: 3 })
    .margin(30)
  }
}

  1. 拖动手势(PanGesture)
PanGesture(value?:{ fingers?:number, direction?:PanDirection, distance?:number})

拖动手势用于触发拖动手势事件,滑动达到最小滑动距离(默认值为5vp)时拖动手势识别成功,拥有三个可选参数:

  • fingers:用于声明触发拖动手势所需要的最少手指数量,最小值为1,最大值为10,默认值为1。
  • direction:用于声明触发拖动的手势方向,此枚举值支持逻辑与(&)和逻辑或(|)运算。默认值为Pandirection.All。
  • distance:用于声明触发拖动的最小拖动识别距离,单位为vp,默认值为5。

以在Text组件上绑定拖动手势为例,可以通过在拖动手势的回调函数中修改组件的布局位置信息来实现组件的拖动:

@Entry
@ComponentV2
struct Index {
  @Local offsetX: number = 0
  @Local offsetY: number = 0
  @Local positionX: number = 0
  @Local positionY: number = 0
  
  build() {
    Column() {
      Text('PanGesture Offset:\nX: ' + this.offsetX + '\n' 
           + 'Y: ' + this.offsetY)
        .fontSize(28)
        .height(200)
        .width(300)
        .padding(20)
        .border({ width: 3 })
          // 在组件上绑定布局位置信息
        .translate({ x: this.offsetX, y: this.offsetY, z: 0 })
        .gesture(
          // 绑定拖动手势
          PanGesture()
            .onActionStart((event: GestureEvent|undefined) => {
              console.info('Pan start')
            })
              // 当触发拖动手势时,根据回调函数修改组件的布局位置信息
            .onActionUpdate((event: GestureEvent|undefined) => {
              if(event){
                this.offsetX = this.positionX + event.offsetX
                this.offsetY = this.positionY + event.offsetY
              }
            })
            .onActionEnd(() => {
              this.positionX = this.offsetX
              this.positionY = this.offsetY
            })
        )
    }
    .height(200)
    .width(250)
  }
}

  1. 捏合手势(PinchGesture)
PinchGesture(value?:{fingers?:number, distance?:number})

捏合手势用于触发捏合手势事件,拥有两个可选参数:

  • fingers:用于声明触发捏合手势所需要的最少手指数量,最小值为2,最大值为5,默认值为2。
  • distance:用于声明触发捏合手势的最小距离,单位为vp,默认值为5。

以在Column组件上绑定三指捏合手势为例,可以通过在捏合手势的函数回调中获取缩放比例,实现对组件的缩小或放大:

@Entry
@ComponentV2
struct Index {
  @Local scaleValue: number = 1
  @Local pinchValue: number = 1
  @Local pinchX: number = 0
  @Local pinchY: number = 0
  
  build() {
    Column() {
      Column() {
        Text('PinchGesture scale:\n' + this.scaleValue)
        Text('PinchGesture center:\n(' + this.pinchX + ',' + this.pinchY + ')')
      }
      .height(200)
      .width(300)
      .border({ width: 3 })
      .margin({ top: 100 })
      // 在组件上绑定缩放比例,可以通过修改缩放比例来实现组件的缩小或者放大
      .scale({ x: this.scaleValue, y: this.scaleValue, z: 1 })
      .gesture(
        // 在组件上绑定三指触发的捏合手势
        PinchGesture({ fingers: 3 })
          .onActionStart((event: GestureEvent|undefined) => {
            console.info('Pinch start')
          })
            // 当捏合手势触发时,可以通过回调函数获取缩放比例,从而修改组件的缩放比例
          .onActionUpdate((event: GestureEvent|undefined) => {
            if(event){
              this.scaleValue = this.pinchValue * event.scale
              this.pinchX = event.pinchCenterX
              this.pinchY = event.pinchCenterY
            }
          })
          .onActionEnd(() => {
            this.pinchValue = this.scaleValue
            console.info('Pinch end')
          })
      )
    }
  }
}

  1. 旋转手势(RotationGesture)
RotationGesture(value?:{fingers?:number, angle?:number})

旋转手势用于触发旋转手势事件,拥有两个可选参数:

fingers:用于声明触发旋转手势所需要的最少手指数量,最小值为2,最大值为5,默认值为2。

angle:用于声明触发旋转手势的最小改变度数,单位为deg,默认值为1。

以在Text组件上绑定旋转手势实现组件的旋转为例,可以通过在旋转手势的回调函数中获取旋转角度,从而实现组件的旋转:

@Entry
@ComponentV2
struct Index {
  @Local angle: number = 0
  @Local rotateValue: number = 0

  build() {
    Column() {
      Text('RotationGesture angle:' + this.angle).fontSize(28)
        // 在组件上绑定旋转布局,可以通过修改旋转角度来实现组件的旋转
        .rotate({ angle: this.angle })
        .gesture(
          RotationGesture()
            .onActionStart((event: GestureEvent|undefined) => {
              console.info('RotationGesture is onActionStart')
            })
              // 当旋转手势生效时,通过旋转手势的回调函数获取旋转角度,从而修改组件的旋转角度
            .onActionUpdate((event: GestureEvent|undefined) => {
              if(event){
                this.angle = this.rotateValue + event.angle
              }
              console.info('RotationGesture is onActionEnd')
            })
              // 当旋转结束抬手时,固定组件在旋转结束时的角度
            .onActionEnd(() => {
              this.rotateValue = this.angle
              console.info('RotationGesture is onActionEnd')
            })
            .onActionCancel(() => {
              console.info('RotationGesture is onActionCancel')
            })
        )
        .height(200)
        .width(300)
        .padding(20)
        .border({ width: 3 })
        .margin(100)
    }
  }
}

  1. 滑动手势(SwipeGesture)
SwipeGesture(value?:{fingers?:number, direction?:SwipeDirection, speed?:number})

滑动手势用于触发滑动事件,当滑动速度大于100vp/s时可以识别成功,拥有三个可选参数:

  • fingers:用于声明触发滑动手势所需要的最少手指数量,最小值为1,最大值为10,默认值为1。
  • direction:用于声明触发滑动手势的方向,此枚举值支持逻辑与(&)和逻辑或(|)运算。默认值为SwipeDirection.All。
  • speed:用于声明触发滑动的最小滑动识别速度,单位为vp/s,默认值为100。

以在Column组件上绑定滑动手势实现组件的旋转为例:

@Entry
@ComponentV2
struct Index {
  @Local rotateAngle: number = 0
  @Local speed: number = 1

  build() {
    Column() {
      Column() {
        Text("SwipeGesture speed\n" + this.speed)
        Text("SwipeGesture angle\n" + this.rotateAngle)
      }
      .border({ width: 3 })
      .width(300)
      .height(200)
      .margin(100)
      // 在Column组件上绑定旋转,通过滑动手势的滑动速度和角度修改旋转的角度
      .rotate({ angle: this.rotateAngle })
      .gesture(
        // 绑定滑动手势且限制仅在竖直方向滑动时触发
        SwipeGesture({ direction: SwipeDirection.Vertical })
          // 当滑动手势触发时,获取滑动的速度和角度,实现对组件的布局参数的修改
          .onAction((event: GestureEvent|undefined) => {
            if(event){
              this.speed = event.speed
              this.rotateAngle = event.angle
            }
          })
      )
    }
  }
}

组合手势

组合手势由多种单一手势组合而成,通过在GestureGroup中使用不同的GestureMode来声明该组合手势的类型,支持顺序识别、并行识别和互斥识别三种类型。

GestureGroup(mode:GestureMode, gesture:GestureType[])
  • mode:为GestureMode枚举类。用于声明该组合手势的类型。
  • gesture:由多个手势组合而成的数组。用于声明组合成该组合手势的各个手势。
  1. 顺序识别

顺序识别组合手势对应的GestureMode为Sequence。顺序识别组合手势将按照手势的注册顺序识别手势,直到所有的手势识别成功。当顺序识别组合手势中有一个手势识别失败时,后续手势识别均失败。顺序识别手势组仅有最后一个手势可以响应onActionEnd。

以一个由长按手势和拖动手势组合而成的连续手势为例:

在一个Column组件上绑定了translate属性,通过修改该属性可以设置组件的位置移动。然后在该组件上绑定LongPressGesture和PanGesture组合而成的Sequence组合手势。当触发LongPressGesture时,更新显示的数字。当长按后进行拖动时,根据拖动手势的回调函数,实现组件的拖动。

@Entry
@ComponentV2
struct Index {
  @Local offsetX: number = 0
  @Local offsetY: number = 0
  @Local count: number = 0
  @Local positionX: number = 0
  @Local positionY: number = 0
  @Local borderStyles: BorderStyle = BorderStyle.Solid

  build() {
    Column() {
      Text('sequence gesture\n' + 'LongPress onAction:' 
        + this.count + '\nPanGesture offset:\nX: ' 
        + this.offsetX + '\n' + 'Y: ' + this.offsetY).fontSize(28)
    }.margin(10)
    .borderWidth(1)
    // 绑定translate属性可以实现组件的位置移动
    .translate({ x: this.offsetX, y: this.offsetY, z: 0 })
    .height(250)
    .width(300)
    //以下组合手势为顺序识别,当长按手势事件未正常触发时不会触发拖动手势事件
    .gesture(
      // 声明该组合手势的类型为Sequence类型
      GestureGroup(GestureMode.Sequence,
        // 该组合手势第一个触发的手势为长按手势,且长按手势可多次响应
        LongPressGesture({ repeat: true })
          // 当长按手势识别成功,增加Text组件上显示的count次数
          .onAction((event: GestureEvent|undefined) => {
            if(event){
              if (event.repeat) {
                this.count++
              }
            }
            console.info('LongPress onAction')
          })
          .onActionEnd(() => {
            console.info('LongPress end')
          }),
        // 当长按之后进行拖动,PanGesture手势被触发
        PanGesture()
          .onActionStart(() => {
            this.borderStyles = BorderStyle.Dashed
            console.info('pan start')
          })
            // 当该手势被触发时,根据回调获得拖动的距离,
            // 修改该组件的位移距离从而实现组件的移动
          .onActionUpdate((event: GestureEvent|undefined) => {
            if(event){
              this.offsetX = (this.positionX + event.offsetX)
              this.offsetY = this.positionY + event.offsetY
            }
            console.info('pan update')
          })
          .onActionEnd(() => {
            this.positionX = this.offsetX
            this.positionY = this.offsetY
            this.borderStyles = BorderStyle.Solid
          })
      )
        .onCancel(() => {
          console.log("sequence gesture canceled")
        })
    )
  }
}

2. 并行识别

并行识别组合手势对应的GestureMode为Parallel。并行识别组合手势中注册的手势将同时进行识别,直到所有手势识别结束。并行识别手势组合中的手势进行识别时互不影响。

以在一个Column组件上绑定点击手势和双击手势组成的并行识别手势为例,由于单击手势和双击手势是并行识别,因此两个手势可以同时进行识别,二者互不干涉。

@Entry
@ComponentV2
struct Index {
  @Local count1: number = 0
  @Local count2: number = 0
  
  build() {
    Column() {
      Text('Parallel gesture\n' + 'tapGesture count is 1:' + this.count1 
           + '\ntapGesture count is 2:' + this.count2 + '\n').fontSize(28)
    }
    .height(200)
    .width('100%')
    // 以下组合手势为并行并别,单击手势识别成功后,
    // 若在规定时间内再次点击,双击手势也会识别成功
    .gesture(
      GestureGroup(GestureMode.Parallel,
        TapGesture({ count: 1 })
          .onAction(() => {
            this.count1++
          }),
        TapGesture({ count: 2 })
          .onAction(() => {
            this.count2++
          })
      )
    )
  }
}

  1. 互斥识别

互斥识别组合手势对应的GestureMode为Exclusive。互斥识别组合手势中注册的手势将同时进行识别,若有一个手势识别成功,则结束手势识别,其他所有手势识别失败。

以在一个Column组件上绑定单击手势和双击手势组合而成的互斥识别组合手势为例。若先绑定单击手势后绑定双击手势,由于单击手势只需要一次点击即可触发而双击手势需要两次,每次的点击事件均被单击手势消费而不能积累成双击手势,所以双击手势无法触发。若先绑定双击手势后绑定单击手势,则触发双击手势不触发单击手势。

@Entry
@ComponentV2
struct Index {
  @Local count1: number = 0
  @Local count2: number = 0
  
  build() {
    Column() {
      Text('Exclusive gesture\n' + 'tapGesture count is 1:' + this.count1 
           + '\ntapGesture count is 2:' + this.count2 + '\n').fontSize(28)
    }
    .height(200)
    .width('100%')
    //以下组合手势为互斥并别,单击手势识别成功后,双击手势会识别失败
    .gesture(
      GestureGroup(GestureMode.Exclusive,
        TapGesture({ count: 1 })
          .onAction(() => {
            this.count1++
          }),
        TapGesture({ count: 2 })
          .onAction(() => {
            this.count2++
          })
      )
    )
  }
}

✋ 需要参加鸿蒙认证的请点击 鸿蒙认证链接