Android 简单 ScalableImageView 的实现
学习了 Canvas 高级绘制 和 Android 事件分发机制,是时候做个小 Demo 来练手了。今天做的是 ScalableImageView,要实现的功能如下:
- 默认图片居中,以
CENTER_INSIDE
的方式显示。 - 双击缩放,放大到比
CENTER_CROP
略大。要适配双击的位置。 - 放大后可以滑动显示。
- 支持捏撑手势缩放
这是一个 Demo 性质的项目,不会处理细节问题。
代码比较简单,就不上传完整源码了,主要记录各个需求实现的思路。
图片居中显示
这个需求没什么好说的,注意一下计算缩放倍数的时候要考虑图片与 View 的长宽比。通常算法如下:
if (w.toFloat() / h > bitmap.width.toFloat() / bitmap.height) {
smallScale = h.toFloat() / bitmap.height
bigScale = w.toFloat() / bitmap.width * EXTRA_SCALE_FACTOR
} else {
smallScale = w.toFloat() / bitmap.width
bigScale = h.toFloat() / bitmap.height * EXTRA_SCALE_FACTOR
}
双击缩放
手势检测
需要 GestureDetectorCompat
来识别双击手势。它的监听器有几个回调事件,其中 onDown()
必须重写,和 onTouchEvent()
类似,只有 onDown()
返回 true 才可以收到后续的事件,否则何谈识别手势。
与双击有关的事件回调有两个:
onDoubleTap()
真正的双击回调,在第二次按下的时候触发。onDoubleTapEvent()
在一个双击事件序列中,第二次按下开始,以及之后的其他事件。
缩放动画
我们已经计算了普通状态与放大状态对应的缩放比例,实现动画的方式有好几种。比较直接的是把这两个比例分别作为动画的起始与结束值,但这样不方便后续动态修改缩放比例(比如捏撑手势),也不方便与其他属性(比如位移)共用一个动画。因此这里选择单独定义个属性 scaleFraction
表示缩放动画的进度,取值范围是 [0, 1]。
绘制的时候记得根据动画进度来计算实时缩放比例,相关代码如下:
private var scaleFraction = 0f
set(value) {
field = value
invalidate()
}
private val scaleAnimator by lazy { ObjectAnimator.ofFloat(this, "scaleFraction", 0f, 1f) }
override fun onDraw(canvas: Canvas) {
val scale = smallScale + (bigScale - smallScale) * scaleFraction // 根据动画进度实时计算比例
canvas.scale(scale, scale, width / 2f, height / 2f)
canvas.drawBitmap(bitmap, originalOffsetX, originalOffsetY, paint)
}
override fun onDoubleTap(e: MotionEvent): Boolean {
isBig = !isBig
if (isBig)
scaleAnimator.start()
else
scaleAnimator.reverse()
return true
}
适配双击位置
大部分的图片浏览器都会根据双击的位置来确定图片缩放的中心,从而显得比较自然。
我们当然也可以这么做,但缺点是缩放后坐标系就变了,给后续的其他操作挖坑。所以这里还是把 View 中点作为缩放中心,手动调整图片的位置。所谓「点击位置作为缩放中心」,本质目的就是「点击位置处的图片保持不动,向四周放大」。
如上图,黑色是 View,灰色是 View 中线,绿色是原始图片,橙色是放大后图片。则图片中某个点在放大后相对于 View 的位置改变了。如果用户就是双击这地方放大的,我们希望它的位置保持不变。所以只要计算出橙点相对于绿点的位移就可以修正(相当于手动把放大后的图片往左下角平移,直到两点重合)。
不难看出绿点(双击点)相对于 View 中点的位移是 e.x - width / 2f
,而橙点相对于 View 中点的位移是 (e.x - width / 2f) * (bigScale / smallScale)
,两者相减横坐标的差值就算出来了,同理可得纵坐标的差值,别忘记要往反方向移动才能修正,所以加个负号,最终结果如下:
override fun onDoubleTap(e: MotionEvent): Boolean {
isBig = !isBig
if (isBig) {
offsetX = (e.x - width / 2f) * (1 - bigScale / smallScale)
offsetY = (e.y - height / 2f) * (1 - bigScale / smallScale)
scaleAnimator.start()
} else {
scaleAnimator.reverse()
}
return true
}
把我们的修正偏移应用到绘制代码里,为了实现动画,把上面写的动画的进度也计算进去。这样一举两得,不仅实现了动画,回到原始大小的时候也不需要重置偏移,因为此时动画进度是 0 自然也不会有偏移了。结果如下:
override fun onDraw(canvas: Canvas) {
val scale = smallScale + (bigScale - smallScale) * scaleFraction
// 添加修正偏移,计算动画进度
canvas.translate(offsetX * scaleFraction, offsetY * scaleFraction)
canvas.scale(scale, scale, width / 2f, height / 2f)
canvas.drawBitmap(bitmap, originalOffsetX, originalOffsetY, paint)
}
多次进行坐标变换容易晕,建议按照Canvas 高级绘制里讲的方法“倒过来思考”。
滑动显示
拖动
GestureDetectorCompat
也有 onScroll()
回调,在其中修改 offset 属性就行,没什么难的。唯一要注意的是 onScroll()
中 distanceX/distanceY
是用旧位置减新位置得到的,即向右滑动手指距离为负。
override fun onScroll(e1: MotionEvent, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean {
if (!isBig) return false
offsetX -= distanceX
offsetY -= distanceY
invalidate()
return true
}
这样已经实现拖动了,但最好再加一个边界限制,当图片边缘已到达 View 边缘时就不再允许拖动了,以免出现白边。算法并不复杂,就是看放大后的图片,每个方向比 View 大出多少像素,这个就是最大拖动范围。
private fun fixOffsets() {
offsetX = min(offsetX, (bitmap.width * bigScale - width) / 2f)
offsetX = max(offsetX, -(bitmap.width * bigScale - width) / 2f)
offsetY = min(offsetY, (bitmap.height * bigScale - height) / 2f)
offsetY = max(offsetY, -(bitmap.height * bigScale - height) / 2f)
}
惯性
现在手一抬起拖动就会立即停止,非常生硬。通常来说应该有一个惯性让图继续移动一段距离。
onFling()
回调告知我们手抬起时不同轴向的速度,OverScroller
可以根据初始速度、初始位置、位置边界等参数,计算后面任意时刻的位置。启动计算很简单,传一下对应参数就行了,其中位置边界和上面 fixOffsets()
值是一样的:
override fun onFling(e1: MotionEvent, e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean {
if (!isBig) return false
scroller.fling(
offsetX.toInt(),
offsetY.toInt(),
velocityX.toInt(),
velocityY.toInt(),
(-(bitmap.width * bigScale - width) / 2).toInt(), // 和 fixOffsets() 里的边界一样
((bitmap.width * bigScale - width) / 2).toInt(),
(-(bitmap.height * bigScale - height) / 2).toInt(),
((bitmap.height * bigScale - height) / 2).toInt()
)
return true
}
不过 OverScroller
和 ValueAnimator
类似,它们都只是一个计算器,不会和 View 交互。我们得定期让它计算最新的值,再刷新 View。这里的「定期」不是说要写个定时器,因为我们难以确定界面的刷新率,频率太高浪费,频率太低掉帧。最好用系统的 API postOnAnimation()
,它通常会在下一帧重绘时执行,正好符合需求。
override fun onFling(e1: MotionEvent, e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean {
// ...
postOnAnimation(flingRunner)
return true
}
private val flingRunner = object : Runnable {
override fun run() {
if (scroller.computeScrollOffset()) { // 只在惯性未停止时继续刷新
offsetX = scroller.currX.toFloat()
offsetY = scroller.currY.toFloat()
invalidate()
postOnAnimation(this)
}
}
}
捏撑手势
手势检测
捏撑手势需要用 ScaleGestureDetector
来检测。它的监听器中有个方法叫 onScale()
,与之前的手势监听器不同,前者除了 onDown()
外其他回调的返回值都没有实际意义。但这一个方法的返回值决定了下一次回调的参考基准。如果返回 true 消费了事件,那么下一次回调的缩放比例就按照上一次手指位置为基准。如果返回 false 那么下一次回调的比例按照第一次按下时的手指位置为基准。
建立来说,引入一个 currentScale
变量就可以了,不过记得把它和之前的 scaleFraction
结合起来,让后者操作前者,前者作为绘制的直接依据。捏撑代码如下:
override fun onScale(detector: ScaleGestureDetector): Boolean {
currentScale *= detector.scaleFactor
return true
}
不同手势检测器的合并
现在 View 的 onTouchEvent()
中需要调用两个手势检测器,那势必要排个序。排序的原则是,有可能在有歧义的触摸事件中胜出的手势优先级高。比如双击手势触发的过程中不太可能与捏撑混淆(即没有歧义),但捏撑触发的过程中有可能和移动混淆(多指移动)。所以应该先调用捏撑检测:
override fun onTouchEvent(event: MotionEvent): Boolean {
scaleDetector.onTouchEvent(event)
if (!scaleDetector.isInProgress)
gestureDetector.onTouchEvent(event)
return true
}
这个实现还有许多许多细节问题没有处理,不过到此为止已经足够综合一下之前学习的知识了。