一、基础准备
Android 手把手进阶自定义View(十)- 事件分发机制解析
Android 手把手进阶自定义View(十一)- 手势检测 GestureDetector
Android 手把手进阶自定义View(十二)- 缩放手势检测 ScaleGestureDetector
Android 手把手进阶自定义View(十三)- 滚动计算 Scroller、OverScroller
经过前四篇的学习,我们对View的触摸反馈、手势检测、滑动有了一定的了解,这一篇我们将对这部分内容做一个实践。
二、可放缩的 ImageView
我们要实现的是类似于相册里图片的触摸交互效果,不知道是什么效果的可以打开手机相册看一看。下面我们来一步步分析。
2.1、居中绘制原始图片,并计算内贴边、外贴边的放缩倍数,初始给图片设置为内贴边。
首先我们来看一下什么是内贴边、外贴边,假设我们有一张图片(200px * 200px),三种效果如下图所示:
根据上图我们可以知道:
-
内贴边:将图片宽、高较小的边放缩至对应的屏幕宽或高(对应图片的方向)的大小
-
外贴边:将图片宽、高较大的边放缩至对应的屏幕宽或高(对应图片的方向)的大小
理解它们之后对应的放缩倍数也就好计算了,代码如下(kotlin):
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
//这里确定两个使原图达到内贴边、外贴边的放缩倍数,
if (mBitmap.width / mBitmap.height.toFloat() > width / height.toFloat()) {
mSmallScale = width / mBitmap.width.toFloat()
mBigScale = height / mBitmap.height.toFloat()
} else {
mSmallScale = height / mBitmap.height.toFloat()
mBigScale = width / mBitmap.width.toFloat()
}
//为了更好的展示拖动效果,外贴边放缩大小这里再乘一个放大系数,使得上下左右都能够拖动
mBigScale *= OVER_SCALE_FACTOR
}
2.2、双击放大/缩小
对于双击,我们可以用 GestureDetectorCompat.OnDoubleTapListener 方便的进行检测。这里有个问题需要注意,我们先看一下:
如上图所示的黄色圈圈区域,作图是手指点击位置,右图是点击区域放大后的位置,明显两者相对于屏幕中心的距离变化了。造成这种效果的原因是我们设置了canvas基于屏幕中心进行放缩,我们画个图看一下:
上图中,红色是屏幕中心,两个绿圈圈代表放缩前后的同一个点。所以为了双击的点在放大后还在当前位置,我们就需要对canvas进行 translate 偏移,偏移量的计算也比较容易了,当然为了避免偏移到空白位置(保证canvas始终在图片内部)还需要限制偏移的最大值和最小值:
//GestureDetector.OnDoubleTapListener
override fun onDoubleTap(e: MotionEvent): Boolean {
//双击切换放大、缩小
mIsBigScale = !mIsBigScale
if (mIsBigScale) {
//计算双击点偏移,使双击的点在放大前后位于同一个点
//双击点距离中心 - 放大后的这个点距离中心
setBitmapOffsetPoint(
(e.x - width / 2f) - (e.x - width / 2f) * mBigScale / mSmallScale,
(e.y - height / 2f) - (e.y - height / 2f) * mBigScale / mSmallScale
)
//正向动画
getScaleAnimator().start()
} else {
//反向动画
getScaleAnimator().reverse()
}
return false
}
/**
* 设置图片偏移并检查边界
*/
fun setBitmapOffsetPoint(x: Float, y: Float) {
mCanvasOffsetPoint.x = x
mCanvasOffsetPoint.x = Math.min(mCanvasOffsetPoint.x, mCanvasMaxOffsetPoint.x)
mCanvasOffsetPoint.x = Math.max(mCanvasOffsetPoint.x, mCanvasMinOffsetPoint.x)
mCanvasOffsetPoint.y = y
mCanvasOffsetPoint.y = Math.min(mCanvasOffsetPoint.y, mCanvasMaxOffsetPoint.y)
mCanvasOffsetPoint.y = Math.max(mCanvasOffsetPoint.y, mCanvasMinOffsetPoint.y)
}
//其中
//canvas负向偏移最小值
mCanvasMinOffsetPoint.x = -(mBitmap.width * mBigScale - width) / 2
mCanvasMinOffsetPoint.y = -(mBitmap.height * mBigScale - height) / 2
//canvas正向偏移最大值
mCanvasMaxOffsetPoint.x = (mBitmap.width * mBigScale - width) / 2
mCanvasMaxOffsetPoint.y = (mBitmap.height * mBigScale - height) / 2
没错,上图中还加了放大缩小时的一个过渡动画效果。
2.3、放大后可以滑动
滑动比较容易,我们直接用 GestureDetectorCompat.OnDoubleTapListener 的 onScroll 方法即可,同样也需要对偏移的边界做限制,代码如下:
override fun onScroll(down: MotionEvent, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean {
//大图模式时才可以滑动
if (mIsBigScale) {
//限制滚动边界
setBitmapOffsetPoint(mCanvasOffsetPoint.x - distanceX, mCanvasOffsetPoint.y - distanceY)
invalidate()
}
return false
}
2.4、惯性滑动
惯性滑动也称弹性滑动,即快速滑动后抬起手指,图片仍能滑动一段距离。这个的话需要用到 OverScroller 来计算,我们可以通过 GestureDetectorCompat.OnDoubleTapListener 的 onFling 方法,也可以通过 onTouchEvent 中抬起手指的 UP 事件时用 VelocityTracker 计算的速度来触发 OverScroller 的 fling。这里我们用第一种方式,代码如下:
override fun onFling(e1: MotionEvent?, e2: MotionEvent?, velocityX: Float, velocityY: Float): Boolean {
//大图模式才可以弹性滑动
if (mIsBigScale) {
mScroller.fling(
mCanvasOffsetPoint.x.toInt(), mCanvasOffsetPoint.y.toInt(), velocityX.toInt(), velocityY.toInt(),
mCanvasMinOffsetPoint.x.toInt(), mCanvasMaxOffsetPoint.x.toInt(),
mCanvasMinOffsetPoint.y.toInt(), mCanvasMaxOffsetPoint.y.toInt(),
100, 100
)
invalidate()
}
return false
}
override fun computeScroll() {
//scroller用法
if (mScroller.computeScrollOffset()) {
mCanvasOffsetPoint.x = mScroller.currX.toFloat()
mCanvasOffsetPoint.y = mScroller.currY.toFloat()
invalidate()
}
}
2.5、完整代码
看到这里我们已经基本分析完了这样一个效果的实现,下面我们看下完整的代码:
class ScalableImageView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {
companion object {
//原图大小
val IMAGE_WIDTH = Utils.dp2px(300)
//放大系数
const val OVER_SCALE_FACTOR = 2
}
private var mPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private var mBitmap: Bitmap
//图片绘制起点
private var mBitmapStartPoint = PointF()
//canvas当前偏移
private var mCanvasOffsetPoint = PointF()
//canvas负向偏移最小值
private var mCanvasMinOffsetPoint = PointF()
//canvas正向偏移最大值
private var mCanvasMaxOffsetPoint = PointF()
//是否是大图
private var mIsBigScale = false
//达到内贴边效果的放缩倍数,即小图
private var mSmallScale = 0f
//达到外贴边效果的放缩倍数,即大图
private var mBigScale = 0f
private var mScroller: OverScroller
private var mGestureDetector: GestureDetectorCompat
private var mGestureListener = MyGestureListener()
//属性动画
private lateinit var mScaleAnimator: ObjectAnimator
private var mScaleFraction = 0f
set(value) {
field = value
invalidate()
}
init {
mBitmap = Utils.decodeBitmap(resources, R.drawable.avatar, IMAGE_WIDTH.toInt())
mGestureDetector = GestureDetectorCompat(context, mGestureListener)
mScroller = OverScroller(context)
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
//使得图片初始是居中的
mBitmapStartPoint.x = (width - mBitmap.width) / 2f
mBitmapStartPoint.y = (height - mBitmap.height) / 2f
//这里确定两个使原图达到内贴边、外贴边的放缩倍数,
if (mBitmap.width / mBitmap.height.toFloat() > width / height.toFloat()) {
mSmallScale = width / mBitmap.width.toFloat()
mBigScale = height / mBitmap.height.toFloat()
} else {
mSmallScale = height / mBitmap.height.toFloat()
mBigScale = width / mBitmap.width.toFloat()
}
//为了更好的展示拖动效果,这里再乘一个放大系数,使得上下左右都能够拖动
mBigScale *= OVER_SCALE_FACTOR
//canvas负向偏移最小值
mCanvasMinOffsetPoint.x = -(mBitmap.width * mBigScale - width) / 2
mCanvasMinOffsetPoint.y = -(mBitmap.height * mBigScale - height) / 2
//canvas正向偏移最大值
mCanvasMaxOffsetPoint.x = (mBitmap.width * mBigScale - width) / 2
mCanvasMaxOffsetPoint.y = (mBitmap.height * mBigScale - height) / 2
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
//乘以scaleFraction是为了在缩小时恢复到中点
canvas.translate(mCanvasOffsetPoint.x * mScaleFraction, mCanvasOffsetPoint.y * mScaleFraction)
//放缩倍数
val scale = mSmallScale + (mBigScale - mSmallScale) * mScaleFraction
canvas.scale(scale, scale, width / 2f, height / 2f)
canvas.drawBitmap(mBitmap, mBitmapStartPoint.x, mBitmapStartPoint.y, mPaint)
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent?): Boolean {
return mGestureDetector.onTouchEvent(event)
}
override fun computeScroll() {
//scroller用法
if (mScroller.computeScrollOffset()) {
mCanvasOffsetPoint.x = mScroller.currX.toFloat()
mCanvasOffsetPoint.y = mScroller.currY.toFloat()
invalidate()
}
}
/**
* 获取动画
*/
fun getScaleAnimator(): ObjectAnimator {
if (!this::mScaleAnimator.isInitialized) {
mScaleAnimator = ObjectAnimator.ofFloat(this, "mScaleFraction", 0f, 1f)
}
return mScaleAnimator
}
/**
* 设置图片偏移并检查边界
*/
fun setBitmapOffsetPoint(x: Float, y: Float) {
mCanvasOffsetPoint.x = x
mCanvasOffsetPoint.x = Math.min(mCanvasOffsetPoint.x, mCanvasMaxOffsetPoint.x)
mCanvasOffsetPoint.x = Math.max(mCanvasOffsetPoint.x, mCanvasMinOffsetPoint.x)
mCanvasOffsetPoint.y = y
mCanvasOffsetPoint.y = Math.min(mCanvasOffsetPoint.y, mCanvasMaxOffsetPoint.y)
mCanvasOffsetPoint.y = Math.max(mCanvasOffsetPoint.y, mCanvasMinOffsetPoint.y)
}
inner class MyGestureListener : GestureDetector.OnGestureListener, GestureDetector.OnDoubleTapListener {
override fun onShowPress(e: MotionEvent?) {
}
override fun onSingleTapUp(e: MotionEvent?): Boolean {
return false
}
override fun onDown(e: MotionEvent?): Boolean {
//手指按下时如果还在滚动则停止滚动
if (!mScroller.isFinished) {
mScroller.abortAnimation()
}
//down事件,必须返回true,否则后面的事件就收不到了
return true
}
override fun onFling(e1: MotionEvent?, e2: MotionEvent?, velocityX: Float, velocityY: Float): Boolean {
//大图模式才可以弹性滑动
if (mIsBigScale) {
mScroller.fling(
mCanvasOffsetPoint.x.toInt(), mCanvasOffsetPoint.y.toInt(), velocityX.toInt(), velocityY.toInt(),
mCanvasMinOffsetPoint.x.toInt(), mCanvasMaxOffsetPoint.x.toInt(),
mCanvasMinOffsetPoint.y.toInt(), mCanvasMaxOffsetPoint.y.toInt(),
100, 100
)
invalidate()
}
return false
}
override fun onScroll(down: MotionEvent, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean {
//大图模式时才可以滑动
if (mIsBigScale) {
//限制滚动边界
setBitmapOffsetPoint(mCanvasOffsetPoint.x - distanceX, mCanvasOffsetPoint.y - distanceY)
invalidate()
}
return false
}
override fun onLongPress(e: MotionEvent?) {
}
override fun onDoubleTap(e: MotionEvent): Boolean {
//双击切换放大、缩小
mIsBigScale = !mIsBigScale
if (mIsBigScale) {
//计算双击点偏移,使双击的点在放大前后位于同一个点
//双击点距离中心 - 放大后的这个点距离中心
setBitmapOffsetPoint(
(e.x - width / 2f) - (e.x - width / 2f) * mBigScale / mSmallScale,
(e.y - height / 2f) - (e.y - height / 2f) * mBigScale / mSmallScale
)
//正向动画
getScaleAnimator().start()
} else {
//反向动画
getScaleAnimator().reverse()
}
return false
}
override fun onDoubleTapEvent(e: MotionEvent?): Boolean {
return false
}
override fun onSingleTapConfirmed(e: MotionEvent?): Boolean {
return false
}
}
}
三、双指缩放
在上一节我们实现了一个双击放大、缩小的 ImageView,但是还缺了双指缩放的效果,双指缩放手势我们可以通过 ScaleGestureDetector 来检测。这一节我们来分析一下如何实现。
3.1、接管 onTouchEvent
GestureDetector、ScaleGestureDetector 都可以接管 onTouchEvent 事件,但是同一时间只能接管一个,所以我们可以根据当前是否正在进行缩放手势来决定谁来接管。代码如下:
override fun onTouchEvent(event: MotionEvent?): Boolean {
var result = mScaleGestureDetector.onTouchEvent(event)
//isInProgress 是否正在进行缩放手势
if (!mScaleGestureDetector.isInProgress) {
//根据是否正在进行缩放手势来决定谁来接管onTouchEvent
result = mGestureDetector.onTouchEvent(event)
}
return result
}