一、基礎準備
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
}