前言
在view中我们可以重写onTouchEvent 来自定义点击事件,但是MotionEvent 给我们的选择太少,无法满足一些个性化的需求,比如双击,惯性滑动等等,所以我们引入GestureDetectorCompat 监听器来实现一些额外的功能
一、GestureDetectorCompat是什么?
GestureDetectorCompat,翻译系为手势检测器,类似于外挂,钩子,把你在屏幕上的触摸和点击截取到,去替代默认的super.onTouchEvent(event) ,而是走我们在手势检测器中自定义的触摸效果
二、使用步骤
1.定义一个GestureDetectorCompat的实例
代码如下:
private val gestureDetectorCompat = GestureDetectorCompat(context, this)
第二个参数为listener ,让view实现GestureDetector.OnGestureListener 接口,我们就可以直接填入this,但是要去重写抽象方法。
override fun onDown(e: MotionEvent?): Boolean {
TODO("Not yet implemented")
}
override fun onShowPress(e: MotionEvent?) {
TODO("Not yet implemented")
}
override fun onSingleTapUp(e: MotionEvent?): Boolean {
TODO("Not yet implemented")
}
override fun onScroll(
e1: MotionEvent?,
e2: MotionEvent?,
distanceX: Float,
distanceY: Float
): Boolean {
TODO("Not yet implemented")
}
override fun onLongPress(e: MotionEvent?) {
TODO("Not yet implemented")
}
override fun onFling(
e1: MotionEvent?,
e2: MotionEvent?,
velocityX: Float,
velocityY: Float
): Boolean {
TODO("Not yet implemented")
}
2.重写方法,实现自定义效果
①想让检测器消费一系列的触摸事件,那么就要在重写方法onDown 中去返回true,那么后续的一系列触摸过程才能让手势检测器获取
代码如下:
override fun onDown(e: MotionEvent?): Boolean {
return true
}
②既然我们要去实现双击效果,那么就得再给手势检测器设置一个监听
private val gestureDetectorCompat = GestureDetectorCompat(context, this).apply {
setOnDoubleTapListener(this@ScalableImageView)
}
setOnDoubleTapListener 的参数是传入一个listener,那么我们还是填入view,让view去重写接口GestureDetector.OnDoubleTapListener 的方法即可 重写的方法:
override fun onSingleTapConfirmed(e: MotionEvent?): Boolean {
TODO("Not yet implemented")
}
override fun onDoubleTap(e: MotionEvent?): Boolean {
TODO("Not yet implemented")
}
override fun onDoubleTapEvent(e: MotionEvent?): Boolean {
TODO("Not yet implemented")
}
简化: 追踪GestureDetectorCompat 的构造函数的源码我们可以看到,内部帮我们判断了listener的类型,如果是GestureDetector.OnDoubleTapListene 的实现类,那么就会帮我们去执行setOnDoubleTapListener 方法,不用我们再去配置,所以代码可以简化为:
private val gestureDetectorCompat = GestureDetectorCompat(context, this)
③实现双击变大变小
效果:
④重写onDoubleTap
目的是双击实现图片的放大缩小,所以我们做一个动画,控制图片的大小
private var scaleFraction = 0f
set(value) {
field = value
invalidate()
}
private val animator by lazy { ObjectAnimator.ofFloat(this, "scaleFraction", 0f, 1f) }
在ondraw 中,拿到要缩放的比例系数,实现从小图片到大图片
val scale = smallScale + (bigScale - smallScale) * scaleFraction
scale(scale, scale, width / 2f, height / 2f)
drawBitmap(bitmap, originalOffsetX, originalOffsetY, paint)
所以在实现双击的方法中,从小到大就是正常播放动画,从大到小就是反向播放动画
override fun onDoubleTap(e: MotionEvent?): Boolean {
if (isBig) {
animator.reverse()
} else {
animator.start()
}
isBig = !isBig
return true
}
④实现惯性滑动
核心是重写onFling 方法 创建一个OverScroller 对象
private val overScroller = OverScroller(context)
OverScroller 的作用:控制一个点在一定范围内的惯性滑动 如图所示 那么如何控制一张图片的惯性移动呢? 把图片在大框中的移动等价为触摸点在小框中的移动,触摸点在x,y轴上移动的位移,同步到图片在xy轴上的偏移,那么就可以实现图片的惯性 说白了就是把你的触摸点控制在一个小框的范围内,就可以把图片控制在大框内 实现步骤: ①:定义两个变量作为小圆在x轴上移动的偏移,y轴上移动的偏移
private var offsetX = 0f
private var offsetY = 0f
②:重写onFling 方法
override fun onFling(
e1: MotionEvent?,
e2: MotionEvent?,
velocityX: Float,
velocityY: Float
): Boolean {
overScroller.fling(
offsetX.toInt(),
offsetY.toInt(),
velocityX.toInt(),
velocityY.toInt(),
(-(bitmap.width * bigScale - width) / 2f).toInt(),
((bitmap.width * bigScale - width) / 2f).toInt(),
(-(bitmap.height * bigScale - height) / 2f).toInt(),
((bitmap.height * bigScale - height) / 2f).toInt(), 40.dp.toInt(), 40.dp.toInt()
)
postOnAnimation(this)
return false
}
第一个参数和第二个参数:手指点击下去时的位置 第三第四个参数:手指用力滑动时,在两个方向上的速度 第五第六个参数:包围触摸点的小框的范围 最后两个参数指滑出小框边界时,超出又恢复的范围 如下图所示的效果 总结:把触摸点的位移同步给图片 ③实现流畅的惯性滑动 让view实现Runnable 接口,重写run 方法,按帧去更新滑动
override fun run() {
if (overScroller.computeScrollOffset()) {
offsetX = overScroller.currX.toFloat()
offsetY = overScroller.currY.toFloat()
invalidate()
postOnAnimation(this)
}
}
④在onFling 中使用run 方法
postOnAnimation(this)
总结
使用了一个手势检测器去检测一些额外的手势,如双击,惯性滑动等等,再去重写手势检测器的抽象方法,实现这些手势该有的反馈
完整代码
package com.lbj23.customview.customview
import android.animation.ObjectAnimator
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.util.AttributeSet
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View
import android.widget.OverScroller
import androidx.core.view.GestureDetectorCompat
import com.lbj23.customview.R
import com.lbj23.customview.dp
import com.lbj23.customview.getAvatar
import kotlin.math.max
import kotlin.math.min
private const val EXTRA_SCALE = 1.5f
class ScalableImageView(context: Context?, attrs: AttributeSet?) : View(context, attrs),
GestureDetector.OnGestureListener, GestureDetector.OnDoubleTapListener, Runnable {
private val gestureDetectorCompat = GestureDetectorCompat(context, this).apply {
setIsLongpressEnabled(false)
}
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val imageSize = 120.dp.toInt()
private val bitmap = getAvatar(resources, R.drawable.test, imageSize)
private var originalOffsetX = 0f
private var originalOffsetY = 0f
private var offsetX = 0f
private var offsetY = 0f
private var bigScale = 0f
private var smallScale = 0f
private var isBig = false
private var scaleFraction = 0f
set(value) {
field = value
invalidate()
}
private val animator by lazy { ObjectAnimator.ofFloat(this, "scaleFraction", 0f, 1f) }
private val overScroller = OverScroller(context)
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.apply {
if (isBig) {
translate(offsetX, offsetY)
}
val scale = smallScale + (bigScale - smallScale) * scaleFraction
scale(scale, scale, width / 2f, height / 2f)
drawBitmap(bitmap, originalOffsetX, originalOffsetY, paint)
}
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
originalOffsetX = (width - bitmap.width) / 2f
originalOffsetY = (height - bitmap.height) / 2f
if (width / height.toFloat() < bitmap.width / bitmap.height.toFloat()) {
smallScale = width / bitmap.width.toFloat()
bigScale = (height / bitmap.height.toFloat()) * EXTRA_SCALE
} else {
smallScale = height / bitmap.height.toFloat()
bigScale = (width / bitmap.width.toFloat()) * EXTRA_SCALE
}
}
override fun onTouchEvent(event: MotionEvent?): Boolean {
return gestureDetectorCompat.onTouchEvent(event)
}
override fun onDown(e: MotionEvent?): Boolean {
return true
}
override fun onShowPress(e: MotionEvent?) {
}
override fun onSingleTapUp(e: MotionEvent?): Boolean {
return false
}
override fun onScroll(
e1: MotionEvent?,
e2: MotionEvent?,
distanceX: Float,
distanceY: Float
): Boolean {
if (isBig) {
offsetX -= distanceX
offsetX = min((bitmap.width * bigScale - width) / 2f, offsetX)
offsetX = max(-(bitmap.width * bigScale - width) / 2f, offsetX)
offsetY -= distanceY
offsetY = min((bitmap.height * bigScale - height) / 2f, offsetY)
offsetY = max(-(bitmap.height * bigScale - height) / 2f, offsetY)
invalidate()
}
return true
}
override fun onLongPress(e: MotionEvent?) {
}
override fun onFling(
e1: MotionEvent?,
e2: MotionEvent?,
velocityX: Float,
velocityY: Float
): Boolean {
overScroller.fling(
offsetX.toInt(),
offsetY.toInt(),
velocityX.toInt(),
velocityY.toInt(),
(-(bitmap.width * bigScale - width) / 2f).toInt(),
((bitmap.width * bigScale - width) / 2f).toInt(),
(-(bitmap.height * bigScale - height) / 2f).toInt(),
((bitmap.height * bigScale - height) / 2f).toInt(), 40.dp.toInt(), 40.dp.toInt()
)
postOnAnimation(this)
return false
}
override fun run() {
if (overScroller.computeScrollOffset()) {
offsetX = overScroller.currX.toFloat()
offsetY = overScroller.currY.toFloat()
invalidate()
postOnAnimation(this)
}
}
override fun onSingleTapConfirmed(e: MotionEvent?): Boolean {
return false
}
override fun onDoubleTap(e: MotionEvent?): Boolean {
if (isBig) {
animator.reverse()
} else {
animator.start()
}
isBig = !isBig
return true
}
override fun onDoubleTapEvent(e: MotionEvent?): Boolean {
return false
}
}
进阶一:优化代码
①.不要继续用view去实现那些接口了,写内部类去实现接口 ②.不用 GestureDetector.OnGestureListener, GestureDetector.OnDoubleTapListener ,继承 GestureDetector.SimpleOnGestureListener() ,后者是一个实现类,实现了前面两个接口,所以我们继承后者后之后只需要重写我们需要用到的回调方法即可 GestureDetector.SimpleOnGestureListener() 实现了GestureDetector.OnGestureListener,GestureDetector.OnDoubleTapListener ③.优化后内部类的代码
inner class ChildGestureDetector : GestureDetector.SimpleOnGestureListener() {
override fun onDoubleTap(e: MotionEvent): Boolean {
if (isBig) {
animator.reverse()
} else {
offsetX = -(e.x - width / 2) * (bigScale / smallScale - 1)
offsetY = -(e.y - height / 2) * (bigScale / smallScale - 1)
fixOffset()
animator.start()
}
isBig = !isBig
return true
}
override fun onFling(
e1: MotionEvent?,
e2: MotionEvent?,
velocityX: Float,
velocityY: Float
): Boolean {
overScroller.fling(
offsetX.toInt(),
offsetY.toInt(),
velocityX.toInt(),
velocityY.toInt(),
(-(bitmap.width * bigScale - width) / 2f).toInt(),
((bitmap.width * bigScale - width) / 2f).toInt(),
(-(bitmap.height * bigScale - height) / 2f).toInt(),
((bitmap.height * bigScale - height) / 2f).toInt(), 40.dp.toInt(), 40.dp.toInt()
)
postOnAnimation(gestureAction)
return false
}
override fun onDown(e: MotionEvent?): Boolean {
return true
}
override fun onScroll(
e1: MotionEvent?,
e2: MotionEvent?,
distanceX: Float,
distanceY: Float
): Boolean {
if (isBig) {
offsetX -= distanceX
offsetY -= distanceY
fixOffset()
invalidate()
}
return true
}
}
进阶二—>手指点哪里放大到哪里
①:思路 我们点击框1的中心(黄色小点),那么框1放大后,中心就跑到绿色小点去了,我们想要的效果是点到小框的中心,小框变大框了,中心还在我们手指点击的位置,如图所示,把框2拉倒框3的位置 ②:代码实现
关键是我画框的三行代码 点击变大后,距离图片几何中心为1距离的黑色小点跑到了最上面的绿色小点 (1距离+2距离)/1距离=大图片/小图片 所以,距离2=1距离*(大图片/小图片-1) 以为是把图片拉回来,offset = -距离2 最后再对offset的值进行矫正,防止拉的太多,导致出现白边效果 实现效果:
进阶三,捏撑效果
相册中的图片都有一个两指控制图片大小的功能 实现 ①:设置一个缩放系数去取代scaleFraction ,用于记录实时的缩放比例,而不是固定的从零到一的一个进度值。
private var currentScale = 0f
set(value) {
field = value
invalidate()
}
②:currentScale 值动态变化的时候要去重新绘制图片,onDraw 就要做修改
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.apply {
val scaleFraction = (currentScale - smallScale) / (bigScale - smallScale)
translate(offsetX * scaleFraction, offsetY * scaleFraction)
scale(currentScale, currentScale, width / 2f, height / 2f)
drawBitmap(bitmap, originalOffsetX, originalOffsetY, paint)
}
}
进度值就为(最新比例-最小比例)/(大图片-小图片),由此可看出我们是要实现由smallScale ?bigScale 的大小变化,由双指控制 ③:currentScale 在每次view尺寸变化时去更新 最后一行:由于bigScale , smallScale时动态变化的,所以要给动画也重新赋参数值 动画就是尺寸变化时的动画
private val animator = ObjectAnimator.ofFloat(this, "currentScale", smallScale, bigScale)
④:用ScaleGestureDetector 去替代手势检测器 声明
private val childScaleGestureListener = ChildScaleGestureListener()
private val scaleGestureDetector = ScaleGestureDetector(context, childScaleGestureListener)
listener的回调函数:
inner class ChildScaleGestureListener : ScaleGestureDetector.OnScaleGestureListener {
override fun onScale(detector: ScaleGestureDetector): Boolean {
currentScale *= detector.scaleFactor
currentScale = currentScale.coerceAtLeast(smallScale).coerceAtMost(bigScale)
return true
}
override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
offsetX = -(detector.focusX - width / 2) * (bigScale / smallScale - 1)
offsetY = -(detector.focusY - height / 2) * (bigScale / smallScale - 1)
return true
}
override fun onScaleEnd(detector: ScaleGestureDetector?) {
}
}
onScale 中,返回true,detector.scaleFactor 就是实时的比例系数,否则就是开始捏撑时到现在的比例系数变化 如果想要捏撑跟手,则在开始捏撑前给图片重新设置偏移,原理和双击时一致
offsetX = -(detector.focusX - width / 2) * (bigScale / smallScale - 1)
offsetY = -(detector.focusY - height / 2) * (bigScale / smallScale - 1)
detector.focusX :捏撑时两指的中心位置 ⑥:效果 ⑦:优化 一:限制缩放的范围
override fun onScale(detector: ScaleGestureDetector): Boolean {
currentScale *= detector.scaleFactor
currentScale = currentScale.coerceAtLeast(smallScale).coerceAtMost(bigScale)
return true
}
给缩放系数设置范围,介于smallScale 和bigScale 之间
二:实现捏撑到最大或者最小,继续捏撑,然后再反向捏撑,图像不是立马变大或者变小,而是达到最大后者最小的那个临界值后才开始变化 现在是立马变大变小 改进:
override fun onScale(detector: ScaleGestureDetector): Boolean {
val tmpCurScale = currentScale *detector.scaleFactor
if(tmpCurScale<smallScale||tmpCurScale>bigScale){
return false
}else{
currentScale = tmpCurScale
return true
}
}
如果滑动超过了界限就不消费这次滑动,保留detector.scaleFactor 值 滑动达到了图片可以缩放的两指临界点后消费事件然后更新detector.scaleFactor 值 效果
捏撑和双击一起工作
override fun onTouchEvent(event: MotionEvent?): Boolean {
scaleGestureDetector.onTouchEvent(event)
if (!scaleGestureDetector.isInProgress) {
gestureDetectorCompat.onTouchEvent(event)
}
return true
}
两个检测器都去执行,如果进入捏撑状态,手势检测器就不执行了
最终的完整代码
package com.lbj23.customview.customview
import android.animation.ObjectAnimator
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.util.AttributeSet
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.ScaleGestureDetector
import android.view.View
import android.widget.OverScroller
import androidx.core.view.GestureDetectorCompat
import com.lbj23.customview.R
import com.lbj23.customview.dp
import com.lbj23.customview.getAvatar
import kotlin.math.max
import kotlin.math.min
private const val EXTRA_SCALE = 1.5f
class ScalableImageView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {
private val childGestureDetector = ChildGestureDetector()
private val gestureDetectorCompat = GestureDetectorCompat(context, childGestureDetector)
private val gestureAction = GestureAction()
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val imageSize = 120.dp.toInt()
private val bitmap = getAvatar(resources, R.drawable.test, imageSize)
private var originalOffsetX = 0f
private var originalOffsetY = 0f
private var offsetX = 0f
private var offsetY = 0f
private var bigScale = 0f
private var smallScale = 0f
private var isBig = false
private var currentScale = 0f
set(value) {
field = value
invalidate()
}
private val animator = ObjectAnimator.ofFloat(this, "currentScale", smallScale, bigScale)
private val overScroller = OverScroller(context)
private val childScaleGestureListener = ChildScaleGestureListener()
private val scaleGestureDetector = ScaleGestureDetector(context, childScaleGestureListener)
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.apply {
val scaleFraction = (currentScale - smallScale) / (bigScale - smallScale)
translate(offsetX * scaleFraction, offsetY * scaleFraction)
scale(currentScale, currentScale, width / 2f, height / 2f)
drawBitmap(bitmap, originalOffsetX, originalOffsetY, paint)
}
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
originalOffsetX = (width - bitmap.width) / 2f
originalOffsetY = (height - bitmap.height) / 2f
if (width / height.toFloat() < bitmap.width / bitmap.height.toFloat()) {
smallScale = width / bitmap.width.toFloat()
bigScale = (height / bitmap.height.toFloat()) * EXTRA_SCALE
} else {
smallScale = height / bitmap.height.toFloat()
bigScale = (width / bitmap.width.toFloat()) * EXTRA_SCALE
}
currentScale = smallScale
animator.setFloatValues(smallScale, bigScale)
}
override fun onTouchEvent(event: MotionEvent?): Boolean {
scaleGestureDetector.onTouchEvent(event)
if (!scaleGestureDetector.isInProgress) {
gestureDetectorCompat.onTouchEvent(event)
}
return true
}
private fun fixOffset() {
offsetX = min((bitmap.width * bigScale - width) / 2f, offsetX)
offsetX = max(-(bitmap.width * bigScale - width) / 2f, offsetX)
offsetY = min((bitmap.height * bigScale - height) / 2f, offsetY)
offsetY = max(-(bitmap.height * bigScale - height) / 2f, offsetY)
}
inner class ChildGestureDetector : GestureDetector.SimpleOnGestureListener() {
override fun onDoubleTap(e: MotionEvent): Boolean {
if (isBig) {
animator.reverse()
} else {
offsetX = -(e.x - width / 2) * (bigScale / smallScale - 1)
offsetY = -(e.y - height / 2) * (bigScale / smallScale - 1)
fixOffset()
animator.start()
}
isBig = !isBig
return true
}
override fun onFling(
e1: MotionEvent?,
e2: MotionEvent?,
velocityX: Float,
velocityY: Float
): Boolean {
overScroller.fling(
offsetX.toInt(),
offsetY.toInt(),
velocityX.toInt(),
velocityY.toInt(),
(-(bitmap.width * bigScale - width) / 2f).toInt(),
((bitmap.width * bigScale - width) / 2f).toInt(),
(-(bitmap.height * bigScale - height) / 2f).toInt(),
((bitmap.height * bigScale - height) / 2f).toInt(), 40.dp.toInt(), 40.dp.toInt()
)
postOnAnimation(gestureAction)
return false
}
override fun onDown(e: MotionEvent?): Boolean {
return true
}
override fun onScroll(
e1: MotionEvent?,
e2: MotionEvent?,
distanceX: Float,
distanceY: Float
): Boolean {
if (isBig) {
offsetX -= distanceX
offsetY -= distanceY
fixOffset()
invalidate()
}
return true
}
}
inner class GestureAction : Runnable {
override fun run() {
if (overScroller.computeScrollOffset()) {
offsetX = overScroller.currX.toFloat()
offsetY = overScroller.currY.toFloat()
invalidate()
postOnAnimation(this)
}
}
}
inner class ChildScaleGestureListener : ScaleGestureDetector.OnScaleGestureListener {
override fun onScale(detector: ScaleGestureDetector): Boolean {
val tmpCurScale = currentScale *detector.scaleFactor
if(tmpCurScale<smallScale||tmpCurScale>bigScale){
return false
}else{
currentScale = tmpCurScale
return true
}
}
override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
offsetX = -(detector.focusX - width / 2) * (bigScale / smallScale - 1)
offsetY = -(detector.focusY - height / 2) * (bigScale / smallScale - 1)
return true
}
override fun onScaleEnd(detector: ScaleGestureDetector?) {
}
}
}
|