ArkUI 常见手势:手指一动,卡片就该乖乖听话
ArkUI 常见手势:手指一动,卡片就该乖乖听话
今天我们用 onTouch 做一个常见手势小实验:单指移动、双击缩放、单指旋转、双指旋转和双指捏合缩放。
如果把这些动作全靠多个 Gesture 堆到同一个节点上,新手很容易先被冲突绕进去。所以这一篇我们直接读触点:一个手指看位置变化,两个手指看距离和角度变化。操作对象用本地的 icon_huawei.svg,这样你能很直观看到图片怎么跟着手指走。
参考文档:
- https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-interaction-development-guide-overview
先选模式
单指移动和单指旋转都会走 TouchType.Move,所以 Demo 里先给它们拆成两个模式。
Button({ type: ButtonType.Capsule }) {
Text('单指移动')
}
.onClick(() => {
this.updateMode(CommonGestureMode.Move, '移动模式:单指拖动图片')
})
这一步很简单,但很关键。不要让同一个单指移动同时承担“拖动”和“旋转”两个含义,用户会猜不出来页面到底想让他干什么。
用 onTouch 接住触点
核心事件只有一个:
.onTouch((event: TouchEvent) => {
this.handleTouch(event)
})
TouchEvent 里有 type、touches 和 changedTouches。我们根据 Down、Move、Up 分别记录起点、计算变化、收尾提示。
这里还有一个容易踩的点:手势区域外面如果套了 Scroll,拖图片时页面也会跟着滚。Demo 里会在触摸图片区域时临时关闭外层滚动,松手后再恢复;双指操作结束后,也不会让残留的单指继续接管移动,必须重新按下才开始新的单指手势。
双指同时算缩放和旋转
双指场景里,距离变化就是缩放,连线角度变化就是旋转。
let currentDistance: number = this.distanceBetween(first, second)
let currentAngle: number = this.angleBetween(first, second)
this.scaleValue = this.clamp(this.startPinchScale * currentDistance / this.startPinchDistance, 0.65, 2.2)
this.rotateAngle = this.startRotateAngle + currentAngle - this.startPinchAngle
这不是手势识别框架的替代品,只是一个能看懂原理的入门写法。你要做图片编辑器、地图、画布编辑这类功能时,先理解这个计算过程会轻松很多。
你平时最常遇到的是哪类手势:图片预览、地图缩放,还是卡片拖动?这会决定我们下一篇是继续讲冲突处理,还是先把动画反馈补上。
完整示例代码
文件位置:entry/src/main/ets/pages/arkUI/interaction/ArkUICommonGestureDemo.ets
enum CommonGestureMode {
Move,
Rotate,
Transform
}
@Entry
@Component
struct ArkUICommonGestureDemo {
@State mode: CommonGestureMode = CommonGestureMode.Move
@State translateX: number = 0
@State translateY: number = 0
@State scaleValue: number = 1
@State rotateAngle: number = 0
@State feedbackText: string = '选择一个模式后,在华为 SVG 图标上动手试试'
@State gestureAreaActive: boolean = false
private baseTranslateX: number = 0
private baseTranslateY: number = 0
private touchStartX: number = 0
private touchStartY: number = 0
private startRotateAngle: number = 0
private startTouchAngle: number = 0
private startPinchDistance: number = 0
private startPinchAngle: number = 0
private startPinchScale: number = 1
private lastTapTime: number = 0
private isMultiTouching: boolean = false
private waitForFreshSingleTouch: boolean = false
private showTip(message: string): void {
this.getUIContext().getPromptAction().showToast({
message: message,
duration: 1200,
bottom: 120
})
}
private clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value))
}
private angleFromCenter(touch: TouchObject): number {
return Math.atan2(touch.y - 150, touch.x - 150) * 180 / Math.PI
}
private distanceBetween(first: TouchObject, second: TouchObject): number {
let dx: number = second.x - first.x
let dy: number = second.y - first.y
return Math.sqrt(dx * dx + dy * dy)
}
private angleBetween(first: TouchObject, second: TouchObject): number {
return Math.atan2(second.y - first.y, second.x - first.x) * 180 / Math.PI
}
private updateMode(mode: CommonGestureMode, text: string): void {
this.mode = mode
this.feedbackText = text
this.showTip(text)
}
private resetTransform(): void {
this.translateX = 0
this.translateY = 0
this.scaleValue = 1
this.rotateAngle = 0
this.baseTranslateX = 0
this.baseTranslateY = 0
this.startPinchScale = 1
this.lastTapTime = 0
this.isMultiTouching = false
this.waitForFreshSingleTouch = false
this.gestureAreaActive = false
this.feedbackText = '已经重置:图片回到原始位置、大小和角度'
this.showTip('手势效果已重置')
}
private handleDoubleTap(): void {
let nextScale: number = this.scaleValue > 1.05 ? 1 : 1.35
this.scaleValue = nextScale
this.startPinchScale = nextScale
this.feedbackText = `双击缩放:当前缩放 ${nextScale.toFixed(2)}`
this.showTip(nextScale > 1 ? '双击放大' : '双击还原')
}
private beginSingleTouch(touch: TouchObject): void {
this.isMultiTouching = false
this.waitForFreshSingleTouch = false
this.touchStartX = touch.x
this.touchStartY = touch.y
this.baseTranslateX = this.translateX
this.baseTranslateY = this.translateY
this.startRotateAngle = this.rotateAngle
this.startTouchAngle = this.angleFromCenter(touch)
this.feedbackText = this.mode === CommonGestureMode.Rotate ? '单指旋转开始:围着图片中心转动' : '单指触摸开始'
}
private beginMultiTouch(first: TouchObject, second: TouchObject): void {
this.isMultiTouching = true
this.waitForFreshSingleTouch = true
this.lastTapTime = 0
this.startPinchDistance = this.distanceBetween(first, second)
this.startPinchAngle = this.angleBetween(first, second)
this.startPinchScale = this.scaleValue
this.startRotateAngle = this.rotateAngle
this.feedbackText = '双指变换开始:距离控制缩放,连线角度控制旋转'
}
private handleTouchStart(event: TouchEvent): void {
this.gestureAreaActive = true
if (event.touches.length === 1) {
let touch: TouchObject = event.touches[0]
this.beginSingleTouch(touch)
}
if (event.touches.length >= 2) {
let first: TouchObject = event.touches[0]
let second: TouchObject = event.touches[1]
this.beginMultiTouch(first, second)
}
}
private handleTouchMove(event: TouchEvent): void {
this.gestureAreaActive = true
if (event.touches.length >= 2) {
let first: TouchObject = event.touches[0]
let second: TouchObject = event.touches[1]
if (!this.isMultiTouching) {
this.beginMultiTouch(first, second)
}
let currentDistance: number = this.distanceBetween(first, second)
let currentAngle: number = this.angleBetween(first, second)
if (this.startPinchDistance > 0) {
this.scaleValue = this.clamp(this.startPinchScale * currentDistance / this.startPinchDistance, 0.65, 2.2)
}
this.rotateAngle = this.startRotateAngle + currentAngle - this.startPinchAngle
this.feedbackText = `双指变换:缩放 ${this.scaleValue.toFixed(2)},旋转 ${Math.round(this.rotateAngle)}°`
return
}
if (event.touches.length === 1) {
if (this.isMultiTouching || this.waitForFreshSingleTouch) {
this.feedbackText = '双指操作刚结束:先松手,再重新开始单指移动或旋转'
return
}
let touch: TouchObject = event.touches[0]
if (this.mode === CommonGestureMode.Move) {
this.translateX = this.baseTranslateX + touch.x - this.touchStartX
this.translateY = this.baseTranslateY + touch.y - this.touchStartY
this.feedbackText = `单指移动:x ${Math.round(this.translateX)},y ${Math.round(this.translateY)}`
} else if (this.mode === CommonGestureMode.Rotate) {
this.rotateAngle = this.startRotateAngle + this.angleFromCenter(touch) - this.startTouchAngle
this.feedbackText = `单指旋转:${Math.round(this.rotateAngle)}°`
}
}
}
private handleTouchEnd(event: TouchEvent): void {
this.gestureAreaActive = false
if (this.isMultiTouching || this.waitForFreshSingleTouch) {
this.isMultiTouching = false
this.waitForFreshSingleTouch = true
this.lastTapTime = 0
this.baseTranslateX = this.translateX
this.baseTranslateY = this.translateY
this.startPinchScale = this.scaleValue
this.startRotateAngle = this.rotateAngle
this.feedbackText = `双指操作结束:x ${Math.round(this.translateX)},y ${Math.round(this.translateY)},缩放 ${this.scaleValue.toFixed(2)},旋转 ${Math.round(this.rotateAngle)}°`
this.showTip('双指操作结束')
return
}
if (event.changedTouches.length === 1) {
let now: number = Date.now()
if (now - this.lastTapTime < 280) {
this.lastTapTime = 0
this.handleDoubleTap()
return
}
this.lastTapTime = now
}
this.baseTranslateX = this.translateX
this.baseTranslateY = this.translateY
this.startPinchScale = this.scaleValue
this.startRotateAngle = this.rotateAngle
this.feedbackText = `手势结束:x ${Math.round(this.translateX)},y ${Math.round(this.translateY)},缩放 ${this.scaleValue.toFixed(2)},旋转 ${Math.round(this.rotateAngle)}°`
this.showTip('手势结束')
}
private handleTouch(event: TouchEvent): void {
event.stopPropagation()
if (event.type === TouchType.Down) {
this.handleTouchStart(event)
} else if (event.type === TouchType.Move) {
this.handleTouchMove(event)
} else if (event.type === TouchType.Up) {
this.handleTouchEnd(event)
} else if (event.type === TouchType.Cancel) {
this.gestureAreaActive = false
this.isMultiTouching = false
this.waitForFreshSingleTouch = false
this.feedbackText = '触摸被系统取消,当前变换保持不变'
this.showTip('触摸已取消')
}
}
@Builder
sectionTitle(title: string, desc: string) {
Column({ space: 6 }) {
Text(title)
.fontSize(19)
.fontWeight(FontWeight.Bold)
.fontColor('#111827')
Text(desc)
.fontSize(13)
.fontColor('#6B7280')
.lineHeight(20)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
build() {
Scroll() {
Column({ space: 22 }) {
Column({ space: 8 }) {
Text('ArkUI 常见手势')
.fontSize(28)
.fontWeight(FontWeight.Bold)
.fontColor('#111827')
Text('手指一动,卡片就该乖乖听话')
.fontSize(16)
.fontColor('#4B5563')
Text('这一页不用多个 Gesture 硬叠在一个节点上,而是用 onTouch 统一读触点。图片用本地 SVG 资源 icon_huawei,移动、双击、旋转、捏合都围着它做。')
.fontSize(14)
.fontColor('#6B7280')
.lineHeight(22)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
this.sectionTitle('先选模式,再动手', '单指移动和单指旋转都会用到 Move 事件,所以这里用模式把意图分开;双指时同时计算缩放和旋转。')
Row({ space: 8 }) {
Button({ type: ButtonType.Capsule }) {
Text('单指移动')
.fontSize(13)
.fontWeight(this.mode === CommonGestureMode.Move ? FontWeight.Bold : FontWeight.Medium)
.fontColor(this.mode === CommonGestureMode.Move ? '#FFFFFF' : '#475569')
}
.height(36)
.padding({ left: 14, right: 14 })
.backgroundColor(this.mode === CommonGestureMode.Move ? '#111827' : '#FFFFFF')
.border({ width: 1, color: this.mode === CommonGestureMode.Move ? '#111827' : '#CBD5E1' })
.onClick(() => {
this.updateMode(CommonGestureMode.Move, '移动模式:单指拖动图片')
})
Button({ type: ButtonType.Capsule }) {
Text('单指旋转')
.fontSize(13)
.fontWeight(this.mode === CommonGestureMode.Rotate ? FontWeight.Bold : FontWeight.Medium)
.fontColor(this.mode === CommonGestureMode.Rotate ? '#FFFFFF' : '#475569')
}
.height(36)
.padding({ left: 14, right: 14 })
.backgroundColor(this.mode === CommonGestureMode.Rotate ? '#111827' : '#FFFFFF')
.border({ width: 1, color: this.mode === CommonGestureMode.Rotate ? '#111827' : '#CBD5E1' })
.onClick(() => {
this.updateMode(CommonGestureMode.Rotate, '单指旋转模式:围着图片中心转动')
})
Button({ type: ButtonType.Capsule }) {
Text('双指变换')
.fontSize(13)
.fontWeight(this.mode === CommonGestureMode.Transform ? FontWeight.Bold : FontWeight.Medium)
.fontColor(this.mode === CommonGestureMode.Transform ? '#FFFFFF' : '#475569')
}
.height(36)
.padding({ left: 14, right: 14 })
.backgroundColor(this.mode === CommonGestureMode.Transform ? '#111827' : '#FFFFFF')
.border({ width: 1, color: this.mode === CommonGestureMode.Transform ? '#111827' : '#CBD5E1' })
.onClick(() => {
this.updateMode(CommonGestureMode.Transform, '双指变换模式:两指缩放和旋转')
})
}
.width('100%')
Stack({ alignContent: Alignment.Center }) {
Column({ space: 12 }) {
Image($r('app.media.icon_huawei'))
.width(116)
.height(116)
.objectFit(ImageFit.Contain)
Text('双击缩放,单指或双指继续操作')
.fontSize(13)
.fontColor('#475569')
}
.alignItems(HorizontalAlign.Center)
.padding(24)
.borderRadius(24)
.backgroundColor('#FFFFFF')
.shadow({ radius: 18, color: '#0000001A', offsetX: 0, offsetY: 8 })
.translate({ x: this.translateX, y: this.translateY })
.scale({ x: this.scaleValue, y: this.scaleValue })
.rotate({ angle: this.rotateAngle })
}
.width(300)
.height(300)
.alignSelf(ItemAlign.Center)
.borderRadius(28)
.backgroundColor('#E0F2FE')
.border({ width: 1, color: '#BAE6FD' })
.onTouch((event: TouchEvent) => {
this.handleTouch(event)
})
Column({ space: 8 }) {
Text(this.feedbackText)
.fontSize(14)
.fontColor('#111827')
.lineHeight(22)
Text(`当前:x ${Math.round(this.translateX)},y ${Math.round(this.translateY)},缩放 ${this.scaleValue.toFixed(2)},旋转 ${Math.round(this.rotateAngle)}°`)
.fontSize(13)
.fontColor('#64748B')
.lineHeight(20)
}
.width('100%')
.padding(16)
.borderRadius(14)
.backgroundColor('#FFFFFF')
.border({ width: 1, color: '#E5E7EB' })
Button({ type: ButtonType.Capsule }) {
Text('重置图片')
.fontSize(14)
.fontColor('#FFFFFF')
.fontWeight(FontWeight.Bold)
}
.height(40)
.width('100%')
.backgroundColor('#2563EB')
.onClick(() => {
this.resetTransform()
})
}
.width('100%')
.padding(24)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.height('100%')
.scrollable(this.gestureAreaActive ? ScrollDirection.None : ScrollDirection.Vertical)
.backgroundColor('#F8FAFC')
}
}
- 点赞
- 收藏
- 关注作者
评论(0)