【鸿蒙实战开发】ArkUI-组件动画详解

201 篇文章 0 订阅
201 篇文章 3 订阅

ArkUI为组件提供了通用的属性动画和转场动画能力的同时,还为一些组件提供了默认的动画效果。例如,List的滑动动效,Button的点击动效,是组件自带的默认动画效果。在组件默认动画效果的基础上,开发者还可以通过属性动画和转场动画对容器组件内的子组件动效进行定制。

使用组件默认动画

组件默认动效具备以下功能:

  • 提示用户当前状态,例如用户点击Button组件时,Button组件默认变灰,用户即确定完成选中操作。

  • 提升界面精致程度和生动性。

  • 减少开发者工作量,例如列表滑动组件自带滑动动效,开发者直接调用即可。

更多效果,可以参考组件说明

示例代码和效果如下。

1.  @Entry
2.  @Component
3.  struct ComponentDemo {
4.  build() {
5.  Row() {
6.  Checkbox({ name: 'checkbox1', group: 'checkboxGroup' })
7.  .select(true)
8.  .shape(CheckBoxShape.CIRCLE)
9.  .size({ width: 50, height: 50 })
10.  }
11.  .width('100%')
12.  .height('100%')
13.  .justifyContent(FlexAlign.Center)
14.  }
15.  }

0000000000011111111.20240807205233.31166614314499570075754769766489.gif

打造组件定制化动效

部分组件支持通过属性动画转场动画自定义组件子Item的动效,实现定制化动画效果。例如,Scroll组件中可对各个子组件在滑动时的动画效果进行定制。

  • 在滑动或者点击操作时通过改变各个Scroll子组件的仿射属性来实现各种效果。

  • 如果要在滑动过程中定制动效,可在滑动回调onScroll中监控滑动距离,并计算每个组件的仿射属性。也可以自己定义手势,通过手势监控位置,手动调用ScrollTo改变滑动位置。

  • 在滑动回调onScrollStop或手势结束回调中对滑动的最终位置进行微调。

定制Scroll组件滑动动效示例代码和效果如下。

1.  import { curves, window, display, mediaquery } from '@kit.ArkUI';
2.  import { UIAbility } from '@kit.AbilityKit';

4.  export default class GlobalContext extends AppStorage{
5.  static mainWin: window.Window|undefined = undefined;
6.  static mainWindowSize:window.Size|undefined = undefined;
7.  }
8.  /**
9.  * 窗口、屏幕相关信息管理类
10.  */
11.  export class WindowManager {
12.  private static instance: WindowManager|null = null;
13.  private displayInfo: display.Display|null = null;
14.  private orientationListener = mediaquery.matchMediaSync('(orientation: landscape)');

16.  constructor() {
17.  this.orientationListener.on('change', (mediaQueryResult: mediaquery.MediaQueryResult) => { this.onPortrait(mediaQueryResult) })
18.  this.loadDisplayInfo()
19.  }

21.  /**
22.  * 设置主window窗口
23.  * @param win 当前app窗口
24.  */
25.  setMainWin(win: window.Window) {
26.  if (win == null) {
27.  return
28.  }
29.  GlobalContext.mainWin = win;
30.  win.on("windowSizeChange", (data: window.Size) => {
31.  if (GlobalContext.mainWindowSize == undefined || GlobalContext.mainWindowSize == null) {
32.  GlobalContext.mainWindowSize = data;
33.  } else {
34.  if (GlobalContext.mainWindowSize.width == data.width && GlobalContext.mainWindowSize.height == data.height) {
35.  return
36.  }
37.  GlobalContext.mainWindowSize = data;
38.  }

40.  let winWidth = this.getMainWindowWidth();
41.  AppStorage.setOrCreate<number>('mainWinWidth', winWidth)
42.  let winHeight = this.getMainWindowHeight();
43.  AppStorage.setOrCreate<number>('mainWinHeight', winHeight)
44.  let context:UIAbility = new UIAbility()
45.  context.context.eventHub.emit("windowSizeChange", winWidth, winHeight)
46.  })
47.  }

49.  static getInstance(): WindowManager {
50.  if (WindowManager.instance == null) {
51.  WindowManager.instance = new WindowManager();
52.  }
53.  return WindowManager.instance
54.  }

56.  private onPortrait(mediaQueryResult: mediaquery.MediaQueryResult) {
57.  if (mediaQueryResult.matches == AppStorage.get<boolean>('isLandscape')) {
58.  return
59.  }
60.  AppStorage.setOrCreate<boolean>('isLandscape', mediaQueryResult.matches)
61.  this.loadDisplayInfo()
62.  }

64.  /**
65.  * 切换屏幕方向
66.  * @param ori 常量枚举值:window.Orientation
67.  */
68.  changeOrientation(ori: window.Orientation) {
69.  if (GlobalContext.mainWin != null) {
70.  GlobalContext.mainWin.setPreferredOrientation(ori)
71.  }
72.  }

74.  private loadDisplayInfo() {
75.  this.displayInfo = display.getDefaultDisplaySync()
76.  AppStorage.setOrCreate<number>('displayWidth', this.getDisplayWidth())
77.  AppStorage.setOrCreate<number>('displayHeight', this.getDisplayHeight())
78.  }

80.  /**
81.  * 获取main窗口宽度,单位vp
82.  */
83.  getMainWindowWidth(): number {
84.  return GlobalContext.mainWindowSize != null ? px2vp(GlobalContext.mainWindowSize.width) : 0
85.  }

87.  /**
88.  * 获取main窗口高度,单位vp
89.  */
90.  getMainWindowHeight(): number {
91.  return GlobalContext.mainWindowSize != null ? px2vp(GlobalContext.mainWindowSize.height) : 0
92.  }

94.  /**
95.  * 获取屏幕宽度,单位vp
96.  */
97.  getDisplayWidth(): number {
98.  return this.displayInfo != null ? px2vp(this.displayInfo.width) : 0
99.  }

101.  /**
102.  * 获取屏幕高度,单位vp
103.  */
104.  getDisplayHeight(): number {
105.  return this.displayInfo != null ? px2vp(this.displayInfo.height) : 0
106.  }

108.  /**
109.  * 释放资源
110.  */
111.  release() {
112.  if (this.orientationListener) {
113.  this.orientationListener.off('change', (mediaQueryResult: mediaquery.MediaQueryResult) => { this.onPortrait(mediaQueryResult)})
114.  }
115.  if (GlobalContext.mainWin != null) {
116.  GlobalContext.mainWin.off('windowSizeChange')
117.  }
118.  WindowManager.instance = null;
119.  }
120.  }

122.  /**
123.  * 封装任务卡片信息数据类
124.  */
125.  export class TaskData {
126.  bgColor: Color | string | Resource = Color.White;
127.  index: number = 0;
128.  taskInfo: string = 'music';

130.  constructor(bgColor: Color | string | Resource, index: number, taskInfo: string) {
131.  this.bgColor = bgColor;
132.  this.index = index;
133.  this.taskInfo = taskInfo;
134.  }
135.  }

137.  export const taskDataArr: Array<TaskData> =
138.  [
139.  new TaskData('#317AF7', 0, 'music'),
140.  new TaskData('#D94838', 1, 'mall'),
141.  new TaskData('#DB6B42 ', 2, 'photos'),
142.  new TaskData('#5BA854', 3, 'setting'),
143.  new TaskData('#317AF7', 4, 'call'),
144.  new TaskData('#D94838', 5, 'music'),
145.  new TaskData('#DB6B42', 6, 'mall'),
146.  new TaskData('#5BA854', 7, 'photos'),
147.  new TaskData('#D94838', 8, 'setting'),
148.  new TaskData('#DB6B42', 9, 'call'),
149.  new TaskData('#5BA854', 10, 'music')

151.  ];

153.  @Entry
154.  @Component
155.  export struct TaskSwitchMainPage {
156.  displayWidth: number = WindowManager.getInstance().getDisplayWidth();
157.  scroller: Scroller = new Scroller();
158.  cardSpace: number = 0; // 卡片间距
159.  cardWidth: number = this.displayWidth / 2 - this.cardSpace / 2; // 卡片宽度
160.  cardHeight: number = 400; // 卡片高度
161.  cardPosition: Array<number> = []; // 卡片初始位置
162.  clickIndex: boolean = false;
163.  @State taskViewOffsetX: number = 0;
164.  @State cardOffset: number = this.displayWidth / 4;
165.  lastCardOffset: number = this.cardOffset;
166.  startTime: number|undefined=undefined

168.  // 每个卡片初始位置
169.  aboutToAppear() {
170.  for (let i = 0; i < taskDataArr.length; i++) {
171.  this.cardPosition[i] = i * (this.cardWidth + this.cardSpace);
172.  }
173.  }

175.  // 每个卡片位置
176.  getProgress(index: number): number {
177.  let progress = (this.cardOffset + this.cardPosition[index] - this.taskViewOffsetX + this.cardWidth / 2) / this.displayWidth;
178.  return progress
179.  }

181.  build() {
182.  Stack({ alignContent: Alignment.Bottom }) {
183.  // 背景
184.  Column()
185.  .width('100%')
186.  .height('100%')
187.  .backgroundColor(0xF0F0F0)

189.  // 滑动组件
190.  Scroll(this.scroller) {
191.  Row({ space: this.cardSpace }) {
192.  ForEach(taskDataArr, (item:TaskData, index) => {
193.  Column()
194.  .width(this.cardWidth)
195.  .height(this.cardHeight)
196.  .backgroundColor(item.bgColor)
197.  .borderStyle(BorderStyle.Solid)
198.  .borderWidth(1)
199.  .borderColor(0xAFEEEE)
200.  .borderRadius(15)
201.  // 计算子组件的仿射属性
202.  .scale((this.getProgress(index) >= 0.4 && this.getProgress(index) <= 0.6) ?
203.  {
204.  x: 1.1 - Math.abs(0.5 - this.getProgress(index)),
205.  y: 1.1 - Math.abs(0.5 - this.getProgress(index))
206.  } :
207.  { x: 1, y: 1 })
208.  .animation({ curve: Curve.Smooth })
209.  // 滑动动画
210.  .translate({ x: this.cardOffset })
211.  .animation({ curve: curves.springMotion() })
212.  .zIndex((this.getProgress(index) >= 0.4 && this.getProgress(index) <= 0.6) ? 2 : 1)
213.  }, (item:TaskData) => item.toString())
214.  }
215.  .width((this.cardWidth + this.cardSpace) * (taskDataArr.length + 1))
216.  .height('100%')
217.  }
218.  .gesture(
219.  GestureGroup(GestureMode.Parallel,
220.  PanGesture({ direction: PanDirection.Horizontal, distance: 5 })
221.  .onActionStart((event: GestureEvent|undefined) => {
222.  if(event){
223.  this.startTime = event.timestamp;
224.  }
225.  })
226.  .onActionUpdate((event: GestureEvent|undefined) => {
227.  if(event){
228.  this.cardOffset = this.lastCardOffset + event.offsetX;
229.  }
230.  })
231.  .onActionEnd((event: GestureEvent|undefined) => {
232.  if(event){
233.  let time = 0
234.  if(this.startTime){
235.  time = event.timestamp - this.startTime;
236.  }
237.  let speed = event.offsetX / (time / 1000000000);
238.  let moveX = Math.pow(speed, 2) / 7000 * (speed > 0 ? 1 : -1);

240.  this.cardOffset += moveX;
241.  // 左滑大于最右侧位置
242.  let cardOffsetMax = -(taskDataArr.length - 1) * (this.displayWidth / 2);
243.  if (this.cardOffset < cardOffsetMax) {
244.  this.cardOffset = cardOffsetMax;
245.  }
246.  // 右滑大于最左侧位置
247.  if (this.cardOffset > this.displayWidth / 4) {
248.  this.cardOffset = this.displayWidth / 4;
249.  }

251.  // 左右滑动距离不满足/满足切换关系时,补位/退回
252.  let remainMargin = this.cardOffset % (this.displayWidth / 2);
253.  if (remainMargin < 0) {
254.  remainMargin = this.cardOffset % (this.displayWidth / 2) + this.displayWidth / 2;
255.  }
256.  if (remainMargin <= this.displayWidth / 4) {
257.  this.cardOffset += this.displayWidth / 4 - remainMargin;
258.  } else {
259.  this.cardOffset -= this.displayWidth / 4 - (this.displayWidth / 2 - remainMargin);
260.  }

262.  // 记录本次滑动偏移量
263.  this.lastCardOffset = this.cardOffset;
264.  }
265.  })
266.  ), GestureMask.IgnoreInternal)
267.  .scrollable(ScrollDirection.Horizontal)
268.  .scrollBar(BarState.Off)

270.  // 滑动到首尾位置
271.  Button('Move to first/last')
272.  .backgroundColor(0x888888)
273.  .margin({ bottom: 30 })
274.  .onClick(() => {
275.  this.clickIndex = !this.clickIndex;

277.  if (this.clickIndex) {
278.  this.cardOffset = this.displayWidth / 4;
279.  } else {
280.  this.cardOffset = this.displayWidth / 4 - (taskDataArr.length - 1) * this.displayWidth / 2;
281.  }
282.  this.lastCardOffset = this.cardOffset;
283.  })
284.  }
285.  .width('100%')
286.  .height('100%')
287.  }
288.  }

0000000000011111111.20240807205234.43610367526125933136643726782686.gif

写在最后

●如果你觉得这篇内容对你还蛮有帮助,我想邀请你帮我三个小忙:
●点赞,转发,有你们的 『点赞和评论』,才是我创造的动力。
●关注小编,同时可以期待后续文章ing🚀,不定期分享原创知识。
●更多鸿蒙最新技术知识点,请移步前往小编:https://gitee.com/

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值