在实际项目中遇到了一个需求:图片支持双击放大缩小,放大的情况下支持滑动,还支持双指放大收缩,这其实就类似手机相册浏览图片时的功能。这里想到的是自定义View,拿到图片资源后(不论是本地还是网上资源最终都能转为bitmap)在onDraw方法中使用canvas.drawBitmap去绘制图片,具体代码如下:
class AwesomeImageView @JvmOverloads constructor(context: Context, attributeSet: AttributeSet) :
View(context, attributeSet), Runnable {
private var bitmap: Bitmap = BitmapFactory.decodeResource(context.resources, R.drawable.ic_fizz)
private var paint: Paint = Paint(Paint.ANTI_ALIAS_FLAG)
//bitmap绘制的时候的初试偏移值,因为我们需要把bitmap绘制到view的中心处,而不是左上角
private var originalOffsetX = 0f
private var originalOffsetY = 0f
//用于canvas.translate
private var offsetX = 0f
private var offsetY = 0f
//缩放比例
private var hugeScale = 0f
private var littleScale = 0f
//双击、单指滑动、惯性滑动相关
private var gestureListener = MyGestureDetectorListener()
private var gestureDetector: GestureDetector = GestureDetector(context, gestureListener)
//双指缩放相关
private var scaleGestureListener = MyScaleGestureListener()
private var scaleGestureDetector: ScaleGestureDetector = ScaleGestureDetector(context, scaleGestureListener)
private var isHugeModel = false//当前是放大还是缩小
private val OVER_SCALE_FACTOR = 1.5f
private var objectAnimator: ObjectAnimator? = null
private var initialScale = 0f
private var canScroller = true
private var currentScale = 0f
set(value) {
field = value
invalidate()
}
private val overScroller = OverScroller(context)
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
originalOffsetX = (w - bitmap.width) / 2f
originalOffsetY = (h - bitmap.height) / 2f
//说明bitmap高更接近view边缘
if (w.toFloat() / h >= bitmap.width.toFloat() / bitmap.height) {
//计算出缩放比例,这里的OVER_SCALE_FACTOR是为了在手指滑动的时候上下左右都能滑动所以刻意把 hugeScale 的缩放比例增大
hugeScale = (w.toFloat() / bitmap.width) * OVER_SCALE_FACTOR
littleScale = h.toFloat() / bitmap.height
} else {
littleScale = w.toFloat() / bitmap.width
hugeScale = (h.toFloat() / bitmap.height) * OVER_SCALE_FACTOR
}
currentScale = littleScale
}
override fun onDraw(canvas: Canvas?) {
//不能一步到位直接移动,还是需要根据动画的完成度来移动,否则会闪
val translateRatio = (currentScale - littleScale) / (hugeScale - littleScale)
canvas?.translate(offsetX * translateRatio, offsetY * translateRatio)
canvas?.scale(currentScale, currentScale, width / 2f, height / 2f)
canvas?.drawBitmap(bitmap, originalOffsetX, originalOffsetY, paint)
}
inner class MyGestureDetectorListener : GestureDetector.SimpleOnGestureListener(){
override fun onDown(e: MotionEvent?): Boolean {
//返回true表示消费当前事件,否则当前事件序列的后续事件会收不到
return true
}
/**
* 处理单指滑动
*/
override fun onScroll(
down: MotionEvent?,//最初的down事件,被保留下来了
currentEvent: MotionEvent?,
distanceX: Float,//上一个event和当前event x的距离,旧的减新的
distanceY: Float
): Boolean {
if (isHugeModel && canScroller) {
//这里之所以是-=而不是+=是因为canvas.translate移动的不是坐标点而是画布
//比如这个场景bitmap向右下方移动实际上就是画布向右下移动,所以从canvas.translate的角度来看offsetX和offsetY都得是正数
//而旧值减新值无论distanceX还是distanceY都是负,所以是-=
offsetX -= distanceX
offsetY -= distanceY
//这里是为了防止无限滑动,把整个bitmap都滑出屏幕了,这肯定是我们不想要的
//不信的话可以试试注释掉下面这段代码......
offsetX = offsetX.coerceAtMost(bitmap.width / 2 * hugeScale - width / 2)
offsetX = offsetX.coerceAtLeast(-(bitmap.width / 2 * hugeScale - width / 2))
offsetY = offsetY.coerceAtMost(bitmap.height / 2 * hugeScale - height / 2)
offsetY = offsetY.coerceAtLeast(-(bitmap.height / 2 * hugeScale - height / 2))
invalidate()
}
return false
}
/**
* 类似于recyclerView的惯性滑动
*/
override fun onFling(
down: MotionEvent?,
currentEvent: MotionEvent?,
velocityX: Float,//x方向的速度
velocityY: Float
): Boolean {
overScroller.fling(
offsetX.toInt(),//起始点就是在onScroll后的offsetX和offsetY
offsetY.toInt(),
velocityX.toInt(),
velocityY.toInt(),
-(bitmap.width * hugeScale / 2 - width / 2).toInt(),//X最小的移动范围
(bitmap.width * hugeScale / 2 - width / 2).toInt(),//X最大的移动范围
-(bitmap.height * hugeScale / 2 - height / 2).toInt(),
(bitmap.height * hugeScale / 2 - height / 2).toInt(), 80, 80
)
postOnAnimation(this@AwesomeImageView)
return false
}
/**
* 支持双击放大缩小
*/
override fun onDoubleTap(e: MotionEvent?): Boolean {
isHugeModel = !isHugeModel
if (isHugeModel) {
//之所以start?和final?需要这样算,是因为这终究是基于view的中心点的缩放
//要是直接使用startX = e.x那算出来的距离就不对了,除非这是基于canvas的坐标原点放大
//重点------>我们能实现在点击处放大是因为在缩放前把坐标点给移动了
e?.let {
val startX = e.x - width / 2
val startY = e.y - height / 2
val finalX = startX * hugeScale / littleScale
val finalY = startY * hugeScale / littleScale
//这里之所以是start?-final?,比如放大后的位置x、y点都大于初始位置
//那在绘制bitmap之前肯定就得把canvas的坐标点往相反的方向移动,这样才能保证点击的位置放大后还在原地
offsetX = startX - finalX
offsetY = startY - finalY
}
getAnimator().start()
} else {
getAnimator().reverse()
}
return false
}
}
/**
* 支持双指缩放
*/
private inner class MyScaleGestureListener :ScaleGestureDetector.OnScaleGestureListener{
//如果返回 true 则表示当前缩放事件已经被处理,检测器会重新积累缩放因子,返回 false 则会继续积累缩放因子
override fun onScale(detector: ScaleGestureDetector?): Boolean {
//detector.scaleFactor是从1开始的 1.1 1.2 1.3等等
//所以我们可以直接做乘法
currentScale = initialScale * (detector?.scaleFactor ?: 1f)
currentScale = currentScale.coerceAtLeast(littleScale * 0.4f)
currentScale = currentScale.coerceAtMost(hugeScale * 2)
invalidate()
return false
}
//如果返回 false 则表示不使用当前这次缩放手势
override fun onScaleBegin(detector: ScaleGestureDetector?): Boolean {
initialScale = currentScale
canScroller = false
return true
}
//缩放结束
override fun onScaleEnd(detector: ScaleGestureDetector?) {
canScroller = true
}
}
fun getAnimator(): ObjectAnimator {
if (objectAnimator == null) {
/**
* 重点------》为了实现多点触控这里的value值不再使用0和1,而是直接使用具体的缩放比例,这样在动画过程中拿到的值就是当前bitmap真正的缩放比例
* 这也是为什么onScale方法中的代码可以直接 currentScale = initialScale * (detector?.scaleFactor ?: 1f)
*/
objectAnimator = ObjectAnimator.ofFloat(this, "currentScale", littleScale, hugeScale)
objectAnimator!!.duration = 150
}
return objectAnimator!!
}
override fun onTouchEvent(event: MotionEvent?): Boolean {
//这里目前的写法还有冲突,可以根据当前手势自行调用scaleGestureDetector或者gestureDetector的onTouchEvent,而不是一起调用
scaleGestureDetector.onTouchEvent(event)
return gestureDetector.onTouchEvent(event)
}
override fun run() {
if (overScroller.computeScrollOffset()) {
offsetX = overScroller.currX.toFloat()
offsetY = overScroller.currY.toFloat()
invalidate()
postOnAnimation(this)
}
}
}
回过头来看其实onDraw中绘制相关的代码很少,核心还是依靠 GestureDetector对手势进行检测再做具体操作。本例主要在于思路,是一个大体的流程,还有很多细节没优化。