每个版本背后、都是无数与Bugs战斗的昼夜
总算是赶在五一节前、把「历史地图」4.5.3版本(鸿蒙、iOS、Android)都提上了应用市场,不知已经更新了的你有没有察觉到新版本带来的变化。
其实这是一个细细碎碎的优化版本:没有新功能、全都是藏在角落里的小提升(或许iOS界面的“液态玻璃”风格全面适配会感知度强一点),并且各平台的提升点还各不相同,看你们用下来都能找到多少处……
举一个栗子、就比如鸿蒙手机上,在「全网找图」中不是可以对找到的历史地图放大查看嘛?老版本中的效果是这样的:
而在新版本上则是这样的:
看出来区别没?留意缩放时两根手指的中心点位置(公众号上图片有压缩、可能看的不明显,最好是能够手机上亲自体验一下)、是不是发现老版本两指中心点的位置随着缩放是在不断变化的、而新版本则可以始终保持稳定;另一方面双击图片放大,新版本可以点哪里就以哪里作为中心点放大、老版本则是始终以图片的中心位置放大。
不要小看这样一个小小的优化点,实现过程中整整是变换了六次方案才最终实现目前这样一个比较满意的效果。
一、六次方案探索
鸿蒙 6.0(API 20)为 Scroll 组件新增了 minZoomScale、maxZoomScale、zoomScale、enableBouncesZoom 等缩放属性,看起来是最官方的方案。https://developer.huawei.com/consumer/cn/doc/harmonyos-references/ts-container-scroll#maxzoomscale20
Scroll组件新增支持设置手势缩放的大小比例控制 ▼Scroll(this.scroller) { Image(this.imageUri) .objectFit(ImageFit.Contain) .width('100%')}.scrollable(ScrollDirection.FREE).minZoomScale(1.0).maxZoomScale(3.0).enableBouncesZoom(true).zoomScale(this.zoomScale).onDidZoom((scale: number) => { this.currentZoomLevel = scale})
而实际的效果是在图片中心区域的缩放表现良好,但在边缘区域双指捏合时,中心点会发生跳变,这是不可接受的。 在经过各种参数调整无果后得出结论:Scroll 内置缩放的偏移量计算在边界约束下存在缺陷,并且没法通过配置解决。既然 Scroll 的偏移量计算有问题,那是不是可以在 onDidZoom 每帧回调中用正确的公式覆盖它?.onDidZoom((scale: number) => { if (this.isPinchActive) { // 计算正确的偏移量 let contentX = (this.pinchCenterX + startOffsetX) / startScale let contentY = (this.pinchCenterY + startOffsetY) / startScale let targetOffsetX = contentX * scale - this.pinchCenterX let targetOffsetY = contentY * scale - this.pinchCenterY this.scroller.scrollTo({ xOffset: targetOffsetX, yOffset: targetOffsetY, animation: false }) }})
经过尝试发现,Scroll 内部也在管理偏移量。每帧的执行顺序是:Scroll 先应用自己的偏移量 → 修正代码覆盖它。两者在“打架”,导致中心区域反而出现抖动,边缘区域问题依旧。 也就是说,与 Scroll 的内部偏移管理“抢控制权”这条路行不通。又去一顿翻文档,然后发现、根据 ArkUI 文档,.priorityGesture() 的优先级高于组件内置手势,于是尝试用 PinchGesture 拦截双指手势,阻止 Scroll 处理缩放。 Scroll(this.scroller) { Image(this.imageUri)}.priorityGesture( PinchGesture({ fingers: 2 }) .onActionUpdate((event: GestureEvent) => { let newScale = this.pinchStartScale * event.scale this.zoomScale = newScale this.scroller.scrollTo({ ... }) }))
实际发现 priorityGesture 并没有完全阻止 Scroll 的内置缩放行为。通过日志发现 Scroll 仍然在内部处理缩放,并将 zoomScale 钳制到 minScale,导致双指缩小完全无效(scale 始终为 1.0)。 换句话说,Scroll 的内置缩放手势无法被 priorityGesture 完全屏蔽。 方案 4:自定义 scale + translate(错误偏移公式)经过上面尝试,决定彻底放弃 Scroll 组件,使用 Stack + Image + scale/translate 变换来完全自定义实现。 Image(this.imageUri) .scale({ x: this.scaleValue, y: this.scaleValue, centerX: this.viewWidth / 2, centerY: this.viewHeight / 2 }) .translate({ x: this.offsetX, y: this.offsetY })
let contentX = (pinchCenterX - startOffsetX) / startScalelet contentY = (pinchCenterY - startOffsetY) / startScalelet newOffsetX = pinchCenterX - contentX * newScalelet newOffsetY = pinchCenterY - contentY * newScale
这个公式适用于 Scroll 的坐标系(偏移量从左上角计算),但 scale 变换的 centerX/centerY 改变了坐标原点。结果双击放大时偏移量计算为大负数(如 -174, -396),图片一下跑出了屏幕。 这说明scale(centerX, centerY) + translate 的坐标系与 Scroll 的偏移量坐标系完全不同,不能混用公式。 方案 5:scale 不带 centerX/centerY好吧,centerX/centerY 导致坐标系混乱,那就不指定它,让 scale 从左上角 (0,0) 缩放。 Image(this.imageUri) .scale({ x: this.scaleValue, y: this.scaleValue }) // 无 centerX/Y .translate({ x: this.offsetX, y: this.offsetY })
ImageFit.Contain 虽然让图片视觉居中,但 scale 从 (0,0) 缩放导致图片从左上角“生长”。缩放时图片向右下扩展,偏移量为负数,图片向左上角跑出屏幕。嗯,又发现了 scale 不带 centerX/centerY 与 ImageFit.Contain 的居中效果不兼容。 方案 6:scale(centerX/Y) + 正确偏移公式又再度回到方案 4,但重新推导 scale(centerX, centerY) + translate 坐标系下的正确偏移量公式。关键推导如下:screenPos = (contentPos - center) * scale + center + offset
保持焦点位置不变(screenPos 不随 scale 变化): dx = focusX - centerXdy = focusY - centerYnewOffsetX = dx - (dx - startOffsetX) / startScale * newScalenewOffsetY = dy - (dy - startOffsetY) / startScale * newScale
- 中心缩放(focusX = centerX):dx = 0,newOffset = 0 - 0 = 0 ✓
- 缩小到 1x:newOffset = dx - dx * 1 = 0 ✓
完美运行!双指中心点在缩放过程中始终稳定,无论在中心还是边缘区域。二、最终实现方案
private calcNewOffset(focusX: number, focusY: number, newScale: number, startScale: number, startOffsetX: number, startOffsetY: number): number[] { let cx = this.viewWidth / 2 let cy = this.viewHeight / 2 let dx = focusX - cx let dy = focusY - cy let newOX = dx - (dx - startOffsetX) / startScale * newScale let newOY = dy - (dy - startOffsetY) / startScale * newScale return [newOX, newOY]}
双指中心点追踪,PinchGesture 不提供双指中心点坐标,需通过 onTouch 预先捕获:PinchGesture({ fingers: 2 }) .onActionStart(() => { this.pinchStartScale = this.scaleValue this.pinchStartOffsetX = this.offsetX this.pinchStartOffsetY = this.offsetY }) .onActionUpdate((event: GestureEvent) => { let newScale = this.pinchStartScale * event.scale newScale = Math.max(this.minScale, Math.min(this.maxScale, newScale)) let offsets = this.calcNewOffset(this.pinchCenterX, this.pinchCenterY, newScale, this.pinchStartScale, this.pinchStartOffsetX, this.pinchStartOffsetY) this.scaleValue = newScale this.offsetX = this.clampOffsetX(newScale, offsets[0]) this.offsetY = this.clampOffsetY(newScale, offsets[1]) })
PinchGesture({ fingers: 2 }) .onActionStart(() => { this.pinchStartScale = this.scaleValue this.pinchStartOffsetX = this.offsetX this.pinchStartOffsetY = this.offsetY }) .onActionUpdate((event: GestureEvent) => { let newScale = this.pinchStartScale * event.scale newScale = Math.max(this.minScale, Math.min(this.maxScale, newScale)) let offsets = this.calcNewOffset(this.pinchCenterX, this.pinchCenterY, newScale, this.pinchStartScale, this.pinchStartOffsetX, this.pinchStartOffsetY) this.scaleValue = newScale this.offsetX = this.clampOffsetX(newScale, offsets[0]) this.offsetY = this.clampOffsetY(newScale, offsets[1]) })
private clampOffsetX(scale: number, ox: number): number { let maxOX = Math.max(0, this.viewWidth * (scale - 1) / 2) return Math.max(-maxOX, Math.min(ox, maxOX))}
双击缩放动画,使用 setInterval(16ms/帧)手动驱动动画,同时插值缩放和偏移量:private runAnimation(startScale: number, targetScale: number, startOffsetX: number, startOffsetY: number, targetOffsetX: number, targetOffsetY: number, duration: number) { let startTime = Date.now() this.animTimer = setInterval(() => { let progress = Math.min((Date.now() - startTime) / duration, 1) let ep = this.easeInOut(progress) this.scaleValue = startScale + (targetScale - startScale) * ep this.offsetX = startOffsetX + (targetOffsetX - startOffsetX) * ep this.offsetY = startOffsetY + (targetOffsetY - startOffsetY) * ep if (progress >= 1) { clearInterval(this.animTimer) this.animTimer = -1 } }, 16)}
三、踩坑总结
| | | |
|---|
Scroll 边缘漂移 | 边缘区域缩放中心跳变 | Scroll 内部偏移量计算缺陷 | 官方组件需测试边界场景 |
onDidZoom 修正冲突 | 中心区域抖动 | 与 Scroll 内部偏移管理“打架” | 不能与组件抢控制权 |
priorityGesture 失效 | 双指缩小完全无效 | Scroll 仍内部处理缩放 | 优先手势 ≠ 禁用内置手势 |
坐标系混用 | 偏移量为大负数 | Scroll 公式与 scale(center) 不兼容 | 不同坐标系公式不能混用 |
scale 无 center | 图片从左上角“生长” | ImageFit.Contain 居中与 (0,0) 缩放不匹配 | 变换锚点必须与布局匹配 |
onZoomStop 不触发 | 双击缩放居中调整从未执行 | 程序化修改 zoomScale 不触发 onZoomStop | 不要假设回调在所有场景触发 |
四、写在最后
这个看似简单的“图片双指缩放”功能,经历了六次方案迭代才最终解决。核心教训是:- 官方组件不一定完美:鸿蒙 Scroll 的内置缩放在边缘区域存在缺陷,不能盲目依赖
- 坐标系是根本:scale(centerX, centerY) + translate 的坐标系与 Scroll 的偏移量坐标系完全不同,必须重新推导公式
- 控制权不能分享:当需要完全掌控某个行为时,不能与组件内部“抢控制权”,必须完全接管
- 日志是生命线:每次方案失败时,详细的日志分析是定位根因的关键
最终方案虽然代码量不大(约 200 行),但每一行都经过了反复的验证,也是希望这篇文章能帮助正在鸿蒙上实现类似功能的开发者少走一点儿弯路。