前言
我们现在就来完成一个可以随意滑动的 ScalableImageView
。
准备工作
创建 Extensions.kt
文件,存放待会要用到的工具。
kotlin
val Float.dp
get() = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
this,
Resources.getSystem().displayMetrics
)
val Int.dp
get() = this.toFloat().dp
fun getBitmap(res: Resources, @DrawableRes drawableResId: Int, targetWidth: Int): Bitmap {
val options = BitmapFactory.Options()
options.inJustDecodeBounds = true
BitmapFactory.decodeResource(res, drawableResId, options)
options.inJustDecodeBounds = false
options.inDensity = options.outWidth
options.inTargetDensity = targetWidth
return BitmapFactory.decodeResource(res, drawableResId, options)
}
创建 ScalableImageView
(继承自 View),并重写 onDraw
方法,居中绘制图片。
kotlin
class ScalableImageView(context: Context, attrs: AttributeSet?) : View(context, attrs) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val imageSize = 200.dp
var imageLeft: Float = 0f
var imageTop: Float = 0f
private val image: Bitmap by lazy {
getBitmap(resources, R.drawable.avator, imageSize.toInt())
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
imageLeft = (width - imageSize) / 2
imageTop = (height - imageSize) / 2
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawBitmap(image, imageLeft, imageTop, paint)
}
}
运行效果:
实现步骤
实现图片缩放
我们可以使用 Canvas.scale()
实现缩放,缩放比也很容易得到,如果图片横向填满屏幕宽度,就是屏幕宽度 / 图片宽度;同理,图片纵向填满屏幕高度,缩放比就是屏幕高度 / 图片高度。
kotlin
// 默认缩放
private val defaultScale = 1f
// 水平宽度填满容器的缩放比
private var scaleToFitWidth = 0f
// 垂直高度填满容器的缩放比
private var scaleToFitHeight = 0f
enum class ScaleMode {
ORIGINAL, // 原始大小
FIT_WIDTH, // 适应宽度
FIT_HEIGHT // 适应高度
}
private var currentScaleMode = ScaleMode.ORIGINAL
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
// ...
// 调整缩放比
scaleToFitWidth = width / imageSize
scaleToFitHeight = height / imageSize
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val scale = when (currentScaleMode) {
ScaleMode.ORIGINAL -> defaultScale
ScaleMode.FIT_WIDTH -> scaleToFitWidth
ScaleMode.FIT_HEIGHT -> scaleToFitHeight
}
canvas.scale(
scale,
scale,
width / 2f,
height / 2f
)
// ...
}
ScaleMode
为 FIT_WIDTH
的效果:
ScaleMode
为 FIT_HEIGHT
的效果:
实现双击缩放
我们要实现双击手势的监听,可以使用 Android 提供的 GestureDetector
来完成。
创建这个对象,并实现 GestureDetector.OnGestureListener
接口:
kotlin
class ScalableImageView(context: Context, attrs: AttributeSet?) : View(context, attrs),
GestureDetector.OnGestureListener {
// ...
private val gestureDetector = GestureDetectorCompat(context, this)
// ...
override fun onDown(e: MotionEvent): Boolean {
TODO("Not yet implemented")
}
override fun onFling(
e1: MotionEvent?,
e2: MotionEvent,
velocityX: Float,
velocityY: Float,
): Boolean {
TODO("Not yet implemented")
}
override fun onLongPress(e: MotionEvent) {
TODO("Not yet implemented")
}
override fun onScroll(
e1: MotionEvent?,
e2: MotionEvent,
distanceX: Float,
distanceY: Float,
): Boolean {
TODO("Not yet implemented")
}
override fun onShowPress(e: MotionEvent) {
TODO("Not yet implemented")
}
override fun onSingleTapUp(e: MotionEvent): Boolean {
TODO("Not yet implemented")
}
}
另外,GestureDetector
对象是监测器,需要我们手动挂载到当前 View 上,让它来处理触摸事件。
只需重写 onTouchEvent
方法,然后调用 GestureDetector.onTouchEvent(event)
方法即可。
kotlin
override fun onTouchEvent(event: MotionEvent): Boolean {
return gestureDetector.onTouchEvent(event)
}
我们再来完成重写的各个回调方法。
kotlin
override fun onDown(e: MotionEvent): Boolean {
// 消费事件
return true
}
当 DOWN
事件到来时,onDown()
会被调用,我们返回 true
,表示想要消费这一事件序列。如果这里返回 false
,那么 onScroll
、onFling
等回调将都不会被触发。
kotlin
override fun onShowPress(e: MotionEvent) {
}
这是 View 是否显示按下状态的回调,
kotlin
override fun onSingleTapUp(e: MotionEvent): Boolean {
return false // 返回值表示事件是否被消费
}
这是单击(抬起时)的回调,它的返回值表示是否消费了此次点击事件,返回 true
可以防止点击事件传递给父 View 处理。
kotlin
override fun onFling(
e1: MotionEvent?,
e2: MotionEvent,
velocityX: Float,
velocityY: Float,
): Boolean {
// 快速滑动手势的逻辑处理
// ...
}
override fun onScroll(
e1: MotionEvent?,
e2: MotionEvent,
distanceX: Float,
distanceY: Float,
): Boolean {
return false
}
这两个回调是成对的,onScroll()
是手指移动的回调,e1
表示 DOWN
事件对应的触摸事件,e2
为当前的触摸事件,distanceX
、distanceY
为当前事件与上一次事件之间的距离 (可以为负数,因为计算方式是旧坐标 - 新坐标)。
onFling()
是手指快速滑动后松手的回调,velocityX
、velocityY
表示 X、Y 轴方向的速度,单位是像素每秒。
kotlin
override fun onLongPress(e: MotionEvent) {
}
这是长按的回调。
到现在,我们都还没添加双击手势的判断,而它的判断需要添加 OnDoubleTapListener
。因此,我们让当前 ScalableImageView
实现 GestureDetector.OnDoubleTapListener
接口,并重写三个方法,如下所示:
kotlin
class ScalableImageView(context: Context, attrs: AttributeSet?) : View(context, attrs),
GestureDetector.OnGestureListener, GestureDetector.OnDoubleTapListener {
// ...
private val gestureDetector = GestureDetectorCompat(context, this).apply {
setOnDoubleTapListener(this@ScalableImageView)
}
// ...
override fun onDoubleTap(e: MotionEvent): Boolean {
TODO("Not yet implemented")
}
override fun onDoubleTapEvent(e: MotionEvent): Boolean {
TODO("Not yet implemented")
}
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
TODO("Not yet implemented")
}
}
在
GestureDetectorCompat
的构造函数内部可以看到,如果设置的OnGestureListener
对象实现了OnDoubleTapListener
接口,会自动调用setOnDoubleTapListener
方法,并传入这个对象。所以我们还可以省略setOnDoubleTapListener(this@ScalableImageView)
代码。
我们还是来解释一下这三个回调方法,
kotlin
override fun onDoubleTap(e: MotionEvent): Boolean {
return false // 返回值表示事件是否被消费
}
这是双击的回调,第二次按下与第一次按下的间隔需要少于 DOUBLE_TAP_TIMEOUT
(300ms),大于 DOUBLE_TAP_MIN_TIME
(40ms),最短触发时间是用于防止手抖的。
kotlin
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
return false // 返回值表示事件是否被消费
}
在设置了双击监听器后,第一次点击就不能轻易判断为单击,而是需要进行延迟确认,而 onSingleTapConfirmed()
就是这个单击确认的回调,它与 onDoubleTap()
不会发生冲突。
因此,在开启双击后,就不应该使用 onSingleTapUp()
,因为它不准确;如果关闭双击,就不应该使用 onSingleTapConfirmed()
,因为它会有一个延迟。
kotlin
override fun onDoubleTapEvent(e: MotionEvent): Boolean {
return false // 返回值表示事件是否被消费
}
这个回调用于接收第二次按下的事件序列。
现在,这些回调都搞明白后,我们就开始完成双击缩放图片,代码是这样的:
kotlin
override fun onDoubleTap(e: MotionEvent): Boolean {
switchScaleMode()
return false
}
/**
* 切换缩放模式
*/
fun switchScaleMode() {
currentScaleMode = when (currentScaleMode) {
ScaleMode.ORIGINAL -> ScaleMode.FIT_WIDTH
ScaleMode.FIT_WIDTH -> ScaleMode.FIT_HEIGHT
ScaleMode.FIT_HEIGHT -> ScaleMode.ORIGINAL
}
invalidate() // 刷新
}
运行效果:
现在的缩放是生硬的,我们给缩放加上动画:
kotlin
var scale = defaultScale
set(value) {
field = value
invalidate()
}
private val toFitWidthScaleAnimator: ObjectAnimator by lazy {
ObjectAnimator.ofObject(this, "scale", FloatEvaluator(), defaultScale, scaleToFitWidth)
}
private val toFitHeightScaleAnimator: ObjectAnimator by lazy {
ObjectAnimator.ofObject(this, "scale", FloatEvaluator(), scaleToFitWidth, scaleToFitHeight)
}
private val toDefaultScaleAnimator: ObjectAnimator by lazy {
ObjectAnimator.ofObject(this, "scale", FloatEvaluator(), scaleToFitHeight, defaultScale)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// val scale = when (currentScaleMode) {
// ScaleMode.ORIGINAL -> defaultScale
// ScaleMode.FIT_WIDTH -> scaleToFitWidth
// ScaleMode.FIT_HEIGHT -> scaleToFitHeight
// }
canvas.scale(
scale,
scale,
width / 2f,
height / 2f
)
canvas.drawBitmap(image, imageLeft, imageTop, paint)
}
override fun onDoubleTap(e: MotionEvent): Boolean {
switchScaleMode()
when (currentScaleMode) {
ScaleMode.ORIGINAL -> {
toDefaultScaleAnimator.start()
toFitWidthScaleAnimator.cancel()
toFitHeightScaleAnimator.cancel()
}
ScaleMode.FIT_WIDTH -> {
toFitWidthScaleAnimator.start()
toDefaultScaleAnimator.cancel()
toFitHeightScaleAnimator.cancel()
}
ScaleMode.FIT_HEIGHT -> {
toFitHeightScaleAnimator.start()
toDefaultScaleAnimator.cancel()
toFitWidthScaleAnimator.cancel()
}
}
return false
}
/**
* 切换缩放模式
*/
fun switchScaleMode() {
currentScaleMode = when (currentScaleMode) {
ScaleMode.ORIGINAL -> ScaleMode.FIT_WIDTH
ScaleMode.FIT_WIDTH -> ScaleMode.FIT_HEIGHT
ScaleMode.FIT_HEIGHT -> ScaleMode.ORIGINAL
}
// invalidate() // 刷新
}
运行效果:
实现双向滑动
我们在 onScroll()
回调中处理图片的偏移。这里需要注意一点,因为我们的 Canvas 坐标系经过了放缩,所以在修改偏移时,也要考虑缩放比。
kotlin
private var extraOffsetX = 0f
private var extraOffsetY = 0f
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.scale(
scale,
scale,
width / 2f,
height / 2f
)
canvas.drawBitmap(
image,
imageLeft + extraOffsetX / scale,
imageTop + extraOffsetY / scale,
paint
)
}
override fun onScroll(
e1: MotionEvent?,
e2: MotionEvent,
distanceX: Float,
distanceY: Float,
): Boolean {
// 默认缩放和FIT_WIDTH缩放下,禁止滑动
if (currentScaleMode == ScaleMode.ORIGINAL ||
currentScaleMode == ScaleMode.FIT_WIDTH
) {
return false
}
extraOffsetX -= distanceX
extraOffsetY -= distanceY
// 记得刷新
invalidate()
return false
}
override fun onDoubleTap(e: MotionEvent): Boolean {
switchScaleMode()
when (currentScaleMode) {
ScaleMode.ORIGINAL -> {
// 重置偏移
extraOffsetX = 0f
extraOffsetY = 0f
toDefaultScaleAnimator.start()
toFitWidthScaleAnimator.cancel()
toFitHeightScaleAnimator.cancel()
}
ScaleMode.FIT_WIDTH -> {
toFitWidthScaleAnimator.start()
toDefaultScaleAnimator.cancel()
toFitHeightScaleAnimator.cancel()
}
ScaleMode.FIT_HEIGHT -> {
toFitHeightScaleAnimator.start()
toDefaultScaleAnimator.cancel()
toFitWidthScaleAnimator.cancel()
}
}
return false
}
运行效果:
另外,在滑动时,会超出图片的边界,出现空白区域,我们来处理一下。
在 onScroll()
回调中进行修正:
kotlin
override fun onScroll(
e1: MotionEvent?,
e2: MotionEvent,
distanceX: Float,
distanceY: Float,
): Boolean {
// 默认缩放和FIT_WIDTH缩放下,禁止滑动
if (currentScaleMode == ScaleMode.ORIGINAL ||
currentScaleMode == ScaleMode.FIT_WIDTH
) {
return false
}
// 当前缩放比为scaleToFitHeight
// 修正边界
extraOffsetX -= distanceX
extraOffsetX = min(extraOffsetX, (image.width * scale - width) / 2)
extraOffsetX = max(extraOffsetX, -(image.width * scale - width) / 2)
extraOffsetY -= distanceY
extraOffsetY = min(extraOffsetY, (image.height * scale - height) / 2)
extraOffsetY = max(extraOffsetY, -(image.height * scale - height) / 2)
// 记得刷新
invalidate()
return false
}
这样就行了。
实现惯性滑动
惯性滑动需要用到之前说到的 onFling()
回调。 那么惯性滑动过程中的位移该怎么计算?我们可以通过 OverScroller
来完成。
kotlin
override fun onFling(
e1: MotionEvent?,
e2: MotionEvent,
velocityX: Float,
velocityY: Float,
): Boolean {
// 默认缩放和FIT_WIDTH缩放下,禁止滑动
if (currentScaleMode == ScaleMode.ORIGINAL || currentScaleMode == ScaleMode.FIT_WIDTH) {
return false
}
// 惯性滑动
overScroller.fling(
extraOffsetX.toInt(), // 起始位置,也是起始偏移
extraOffsetY.toInt(),
velocityX.toInt(), // 起始速度
velocityY.toInt(),
(-(image.width * scale - width) / 2f).toInt(), // 滑动的边界
((image.width * scale - width) / 2f).toInt(),
(-(image.height * scale - height) / 2f).toInt(),
((image.height * scale - height) / 2f).toInt(),
)
return false
}
知道惯性滑动的物理模型的话,以上这些参数应该不难填。然后我们可以调用 computeScrollOffset()
方法,获取当前时刻的计算结果。
因为在惯性滑动的过程中,并没有相应的回调。所以需要我们手动不断获取计算结果,修改属性值进行动画,像这样:
kotlin
override fun onFling(
e1: MotionEvent?,
e2: MotionEvent,
velocityX: Float,
velocityY: Float,
): Boolean {
// 默认缩放和FIT_WIDTH缩放下,禁止滑动
// ...
// 惯性滑动
// ...
processFling()
return false
}
/**
* 执行惯性滑动动画
*/
fun processFling() {
// computeScrollOffset 返回 false 表示动画结束
if (!overScroller.computeScrollOffset()){ // 计算当前滑动位置
// 递归结束
return
}
extraOffsetX = overScroller.currX.toFloat()
extraOffsetY = overScroller.currY.toFloat()
invalidate()
// 在下一帧继续执行,形成动画
postOnAnimation {
processFling()
}
}
另外,因为使用 Lambda 会不断创建对象,消耗性能。最佳实践是让当前 ScalableImageView
实现 Runnable
接口,然后将当前对象传入即可。
kotlin
class ScalableImageView(context: Context, attrs: AttributeSet?) : View(context, attrs),
GestureDetector.OnGestureListener, GestureDetector.OnDoubleTapListener, Runnable {
// ...
/**
* 执行惯性滑动动画
*/
fun processFling() {
// ...
postOnAnimation(this)
}
override fun run() {
processFling()
}
// ...
}
其实 fling()
还有一个重载方法,我们可以额外填入 overX
和 overY
参数,这两个参数表示过度滑动的偏移量。
例如快速向上滑动微信、QQ 的聊天列表,列表滑到底时,并不是立马停止的,而是会有一个回弹效果。
我们简单设置一下:
kotlin
// 惯性滑动
overScroller.fling(
extraOffsetX.toInt(), // 起始位置,也是起始偏移
extraOffsetY.toInt(),
velocityX.toInt(), // 起始速度
velocityY.toInt(),
(-(image.width * scale - width) / 2f).toInt(), // 滑动的边界
((image.width * scale - width) / 2f).toInt(),
(-(image.height * scale - height) / 2f).toInt(),
((image.height * scale - height) / 2f).toInt(),
50.dp.toInt(),
0
)
运行效果:
我们还可以使用 Scroller
来进行惯性滑动,不过 OverScroller
是 Scroller
的升级版,增加了对边界回弹(Overscroll)和精细的物理效果,我们更多会使用 OverScroller
。
而且 Scroller
惯性滑动时,一定不会超出边界,导致即使很用力滑动,滑动速度也并不会增加,使用它可能会降低用户交互体验。