文章

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
}

不过 OverScrollerValueAnimator 类似,它们都只是一个计算器,不会和 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
}

这个实现还有许多许多细节问题没有处理,不过到此为止已经足够综合一下之前学习的知识了。