1、背 景
有朋友留言说,抖音APP中,长按评论按钮触发的快捷表情选择胶囊动画比较好(效果如下图),希望使用鸿蒙ArkTs也实现一个类似的。
本文在鸿蒙ArkTs下也实现一个类似的效果,如下:
首先,核心交互流程与抖音APP保持一致,即:
-
-
长按一个按钮,我们可以在按钮旁边生成一个快捷表情选择胶囊;
-
手指可以快捷的在候选表情上滑动,在选中的表情上停留时,该表情会稍微放大;
-
动画细节上做了一些个人效果,例如:
-
胶囊展示到界面时,候选表情做了一个类似IOS解锁时的类飞入效果;
-
我们在表情胶囊上滑动选择时,整个表情胶囊不会随着动画效果的改变发生宽度抖动。
下面开始介绍如何实现,文末有源代码,有需要的同学自取。
2、问题分析
上述的交互效果其实难点并不在于动画效果,在于onTouch事件的管理,因为,我们在长按评论按钮时,此时系统将会分配事件流给评论按钮消费。
如果我们想在用户不松手移动到其他位置,让其他组件也生效,实现过程将稍微复杂一些。
本文的实现思路是:监听评论按钮的onTouch事件,在事件move过程中,实时获取该事件的发生坐标,基于坐标去判断坐落的表情包位置,从而控制焦点放大效果。
|-------------------------------------------------------|
| 📢📢注意 onTouch事件的调试千万要在真机或者模拟器中执行,在预览器中执行可能会出现非预期的问题。 |
❓我们怎么获取指定组件的坐标呢?
虽然我们通过onTouch事件可以知道用户手指的位置,那我们还有一个问题没解决,就是怎么知道各个表情包的坐标呢?
ArkTs为我们提供了一个API,根据组件ID获取组件实例对象, 通过组件实例对象将获取的坐标位置和大小同步返回给调用方,接口如下:
import { componentUtils } from '@kit.ArkUI';// 调用方式let modePosition:componentUtils.ComponentInfo = componentUtils.getRectangleById("id");
3、布 局
布局比较简单,在本文中,将整个表情胶囊用一个Row包裹,另外,评论图标与表情胶囊整体属于在一个Row中。示意图如下:
为了方便动态插拔,我们将图标资源集合使用一个数组来动态维护,资源类型定义与数组定义如下:
interface CommentIconInfo { source: Resource, id: string;}const commentIcons: Array<CommentIconInfo> = [ { id: 'page_main_icon1', source: $r('app.media.send_you_a_little_red_flower'), }, { id: 'page_main_icon2', source: $r('app.media.powerful'), }, { id: 'page_main_icon3', source: $r('app.media.send_heart'), }, { id: 'page_main_icon4', source: $r('app.media.surprise'), }, ]
布局代码如下:
build() { Column() { Row() { Row() { ForEach(this.commentIcons, (item: CommentIconInfo) => { Image(item.source) .id(item.id) .width(this.selectedId === item.id ? this.selectedSize : this.normalSize) .height(this.selectedId === item.id ? this.selectedSize : this.normalSize) .padding({ left: this.iconMarginLeft }) .animation({ duration: this.animDuration, curve: curves.springMotion() }) .visibility(this.showCommentIconPanel ? Visibility.Visible : Visibility.None) }, (item: CommentIconInfo) => { return `${item.id}` // 复写id生成器 }) } .visibility(this.showCommentIconPanel ? Visibility.Visible : Visibility.None) .backgroundColor(Color.Pink) .width(this.showCommentIconPanel ? this.getRowWidth() : 10) .borderRadius(this.selectedId ? 40 : 20) .padding({ left: this.paddingHoriz, right: this.paddingHoriz }) .animation({ duration: this.animDuration, curve: curves.springMotion() }) SymbolGlyph($r('sys.symbol.ellipsis_message_fill')) .fontSize(60) .renderingStrategy(SymbolRenderingStrategy.MULTIPLE_COLOR) .fontColor([Color.Green]) .margin({ left: 10 }) .onTouch(this.onTouchEvent) } .justifyContent(FlexAlign.End) .width('100%') } .padding(20) .height('100%')}
4、onTouch事件处理
我们在onTouch事件中需要做两个核心事情:
-
控制表情胶囊的显示/隐藏
-
控制用户手指指向的表情包,并聚焦放大显示
代码如下:
private onTouchEvent = (event: TouchEvent) => { if (!event) { return; } // 手指按下0.5s后弹出表情选择面板 if (event.type === TouchType.Down) { this.lastTouchDown = Date.now(); } else if (event.type === TouchType.Up || event.type === TouchType.Cancel) { this.showCommentIconPanel = false; // 松手后,取消显示表情面板(具体消失逻辑可以根据业务需求调整) } else if (!this.showCommentIconPanel) { this.showCommentIconPanel = (Date.now() - this.lastTouchDown) > 500; } this.checkCommentIcons(event);}// 判断用户手指是否选中了某个表情包private checkCommentIcons = (event: TouchEvent) => { const touchInfo = event.touches[0]; this.selectedId = ''; if (!touchInfo) { return; } const windowX = Math.ceil(touchInfo.windowX); // 获取用户手指x坐标 const context = this.getUIContext(); // 检测用户手指x坐标可以命中的表情 this.commentIcons.forEach(iconInfo => { if (event.type === TouchType.Up) { return; } const compInfo = componentUtils.getRectangleById(iconInfo.id); const x = Math.ceil(context.px2vp(compInfo.windowOffset.x)); const width = Math.ceil(context.px2vp(compInfo.size.width)); if (windowX >= x && windowX <= x + width) { this.selectedId = iconInfo.id; // 范围中有表情被选中 } }); this.commentIcons = [...this.commentIcons]; // 低成本刷新数组(浅拷贝)}
5、源代码
示例效果如下:
下方的源代码替换了11、15、19、22行的图片资源后,可以正常运行。代码如下:
import { componentUtils } from '@kit.ArkUI';import { curves } from '@kit.ArkUI';interface CommentIconInfo { source: Resource, id: string;}const commentIcons: Array<CommentIconInfo> = [ { id: 'page_main_icon1', source: $r('app.media.send_you_a_little_red_flower'), }, { id: 'page_main_icon2', source: $r('app.media.powerful'), }, { id: 'page_main_icon3', source: $r('app.media.send_heart'), }, { id: 'page_main_icon4', source: $r('app.media.surprise'), }, ]@Entry@Componentstruct Index { @State selectedSize: number = 60; @State normalSize: number = 35; @State showCommentIconPanel: boolean = false; @State commentIcons: Array<CommentIconInfo> = commentIcons; @State selectedId: string = ''; // 一些本地使用的量 lastTouchDown: number = 0; iconMarginLeft: number = 5; paddingHoriz: number = 10; // 左右padding animDuration: number = 500; private onTouchEvent = (event: TouchEvent) => { if (!event) { return; } // 手指按下0.5s后弹出表情选择面板 if (event.type === TouchType.Down) { this.lastTouchDown = Date.now(); } else if (event.type === TouchType.Up || event.type === TouchType.Cancel) { this.showCommentIconPanel = false; // 松手后,取消显示表情面板(具体消失逻辑可以根据业务需求调整) } else if (!this.showCommentIconPanel) { this.showCommentIconPanel = (Date.now() - this.lastTouchDown) > 500; } this.checkCommentIcons(event); } private checkCommentIcons = (event: TouchEvent) => { const touchInfo = event.touches[0]; this.selectedId = ''; if (!touchInfo) { return; } const windowX = Math.ceil(touchInfo.windowX); // 获取用户手指x坐标 const context = this.getUIContext(); // 检测用户手指x坐标可以命中的表情 this.commentIcons.forEach(iconInfo => { if (event.type === TouchType.Up) { return; } const compInfo = componentUtils.getRectangleById(iconInfo.id); const x = Math.ceil(context.px2vp(compInfo.windowOffset.x)); const width = Math.ceil(context.px2vp(compInfo.size.width)); if (windowX >= x && windowX <= x + width) { this.selectedId = iconInfo.id; // 范围中有表情被选中 } }); this.commentIcons = [...this.commentIcons]; // 低成本刷新数组(浅拷贝) } build() { Column() { Row() { Row() { ForEach(this.commentIcons, (item: CommentIconInfo) => { Image(item.source) .id(item.id) .width(this.selectedId === item.id ? this.selectedSize : this.normalSize) .height(this.selectedId === item.id ? this.selectedSize : this.normalSize) .padding({ left: this.iconMarginLeft }) .animation({ duration: this.animDuration, curve: curves.springMotion() }) .visibility(this.showCommentIconPanel ? Visibility.Visible : Visibility.None) }, (item: CommentIconInfo) => { return `${item.id}` // 复写id生成器 }) } .visibility(this.showCommentIconPanel ? Visibility.Visible : Visibility.None) .backgroundColor(Color.Pink) .width(this.showCommentIconPanel ? this.getRowWidth() : 10) .borderRadius(this.selectedId ? 40 : 20) .padding({ left: this.paddingHoriz, right: this.paddingHoriz }) .animation({ duration: this.animDuration, curve: curves.springMotion() }) SymbolGlyph($r('sys.symbol.ellipsis_message_fill')) .fontSize(60) .renderingStrategy(SymbolRenderingStrategy.MULTIPLE_COLOR) .fontColor([Color.Green]) .margin({ left: 10 }) .onTouch(this.onTouchEvent) } .justifyContent(FlexAlign.End) .width('100%') } .padding(20) .height('100%') } private getRowWidth() { // 防止抖动 const baseWidth = this.paddingHoriz + this.paddingHoriz + this.commentIcons.length * this.normalSize + (this.commentIcons.length - 1) * this.iconMarginLeft; if (this.selectedId) { return baseWidth + this.selectedSize - this.normalSize; } return baseWidth; }}
项目源代码地址:
https://gitee.com/lantingshuxu/harmony-class-room-demos/tree/feat%2FmagicComment/