前言
在上一篇博客中,我们已经实现了 ScalableImageView
的双击缩放,以及双向滑动。
现在我们来完成缩放跟随触摸点,并解决恢复成默认缩放时图片跳动的问题,最后完成双指捏撑缩放功能。
实现过程
解决图片跳动
我们之前把重置偏移的操作放在了双击后目标 ScaleMode
为 ScaleMode.ORIGINAL
的分支中。如果图片正处于放大且有偏移的状态,那么在双击恢复时,会让图片偏移瞬间变为 0,导致看起来像是跳了一下。
我们只需不让偏移瞬间改变,让它以动画的形式改变即可。
kotlin
var extraOffsetX = 0f
var extraOffsetY = 0f
override fun onDoubleTap(e: MotionEvent): Boolean {
switchScaleMode()
// ...
if (currentScaleMode == ScaleMode.ORIGINAL){
// 使用动画重置偏移
ObjectAnimator.ofFloat(this, "extraOffsetX", extraOffsetX, 0f).start()
ObjectAnimator.ofFloat(this, "extraOffsetY", extraOffsetY, 0f).start()
}
return false
}
另外,还有一个跳动问题:我们当前的 ObjectAnimator
对象的起始值和结束值是固定的。在缩放的动画过程中,如果用户再次双击,那么之前的动画会被取消,新的动画会从一个固定的预设值开始,这也会导致跳动。
所以,我们应该以当前的实时 scale
值作为动画的起点,动态地创建动画。
kotlin
override fun onDoubleTap(e: MotionEvent): Boolean {
// 记录动画开始前的状态
val oldScale = scale
val oldExtraOffsetX = extraOffsetX
val oldExtraOffsetY = extraOffsetY
switchScaleMode()
val targetScale = getCurrentScale()
// 从当前实时 scale 值开始动画
getFloatAnimator(this, "scale", oldScale, targetScale).start()
if (currentScaleMode == ScaleMode.ORIGINAL) {
// 从当前实时偏移值开始动画
getFloatAnimator(this, "extraOffsetX", oldExtraOffsetX, 0f).start()
getFloatAnimator(this, "extraOffsetY", oldExtraOffsetY, 0f).start()
}
return false
}
/**
* 获取Float属性动画
*/
private fun getFloatAnimator(
target: Any,
properName: String,
startValue: Float,
endValue: Float,
): ObjectAnimator {
return ObjectAnimator.ofFloat(target, properName, startValue, endValue)
}
/**
* 获取当前缩放比
*/
fun getCurrentScale(): Float =
when (currentScaleMode) {
ScaleMode.ORIGINAL -> {
defaultScale
}
ScaleMode.FIT_WIDTH -> {
scaleToFitWidth
}
ScaleMode.FIT_HEIGHT -> {
scaleToFitHeight
}
}
实现缩放跟随触摸点
现在的缩放的轴心固定在了视图中心,并不会跟随触摸点,例如:
触摸点的位置在缩放后,并不与触摸点重合。我们要实现它们重合(缩放跟随),只需添加一个额外的偏移量来抵消这个视觉位移即可。
kotlin
override fun onDoubleTap(e: MotionEvent): Boolean {
// 记录动画开始前的状态
val oldScale = scale
val oldExtraOffsetX = extraOffsetX
val oldExtraOffsetY = extraOffsetY
// 切换模式
switchScaleMode()
// 目标缩放值
val targetScale = getCurrentScale()
// 计算围绕触摸点缩放所需的偏移量变化
val offsetXChange = (e.x - width / 2f) * (1 - targetScale / oldScale)
val offsetYChange = (e.y - height / 2f) * (1 - targetScale / oldScale)
// 目标偏移 = 原始偏移 + 变化量
var targetExtraOffsetX = oldExtraOffsetX + offsetXChange
var targetExtraOffsetY = oldExtraOffsetY + offsetYChange
// 特殊处理:如果回到原始状态或是FIT_WIDTH状态,重置目标偏移量
if (currentScaleMode == ScaleMode.ORIGINAL || currentScaleMode == ScaleMode.FIT_WIDTH) {
targetExtraOffsetX = 0f
targetExtraOffsetY = 0f
}
// 从当前实时 scale 值开始动画
getFloatAnimator(this, "scale", oldScale, targetScale).start()
// 从当前实时偏移值开始动画
getFloatAnimator(this, "extraOffsetX", oldExtraOffsetX, targetExtraOffsetX).start()
getFloatAnimator(this, "extraOffsetY", oldExtraOffsetY, targetExtraOffsetY).start()
return false
}
现在的放大效果就会很跟手了。不过还有问题没解决,设置了这个偏移后,可能会导致图片超界,出现空白区域。
我们还要对它做边界修正。我们把这个修正过程抽取成一个 fixOffset
方法。
kotlin
override fun onDoubleTap(e: MotionEvent): Boolean {
// 记录动画前的初始状态
val oldScale = scale
val oldExtraOffsetX = extraOffsetX
val oldExtraOffsetY = extraOffsetY
// 切换模式并获取目标缩放值
switchScaleMode()
val targetScale = getCurrentScale()
// 计算目标偏移量
var targetExtraOffsetX = oldExtraOffsetX - (e.x - width / 2f) * (targetScale / oldScale - 1)
var targetExtraOffsetY = oldExtraOffsetY - (e.y - height / 2f) * (targetScale / oldScale - 1)
// 对目标偏移量进行边界修正
val maxExtraOffsetX = (image.width * targetScale - width).coerceAtLeast(0f) / 2f
val maxExtraOffsetY = (image.height * targetScale - height).coerceAtLeast(0f) / 2f
val (fixedX, fixedY) = fixOffset(targetExtraOffsetX, targetExtraOffsetY, maxExtraOffsetX, maxExtraOffsetY)
targetExtraOffsetX = fixedX
targetExtraOffsetY = fixedY
// 特殊处理:如果回到原始或FIT_WIDTH状态,重置目标偏移量
if (currentScaleMode == ScaleMode.ORIGINAL || currentScaleMode == ScaleMode.FIT_WIDTH) {
targetExtraOffsetX = 0f
targetExtraOffsetY = 0f
}
// 统一启动所有动画
getFloatAnimator(this, "extraOffsetX", oldExtraOffsetX, targetExtraOffsetX).start()
getFloatAnimator(this, "extraOffsetY", oldExtraOffsetY, targetExtraOffsetY).start()
getFloatAnimator(this, "scale", oldScale, targetScale).start()
return true
}
/**
* 修正偏移
*/
private fun fixOffset(
offsetX: Float,
offsetY: Float,
maxExtraOffsetX: Float,
maxExtraOffsetY: Float,
): Pair<Float, Float> {
val offsetXFixed = offsetX.coerceIn(-maxExtraOffsetX, maxExtraOffsetX)
val offsetYFixed = offsetY.coerceIn(-maxExtraOffsetY, maxExtraOffsetY)
return offsetXFixed to offsetYFixed
}
重构代码
在实现双指捏撑缩放之前,我们先来重构一下 ScalableImageView
。
目前 ScalableImageView
直接实现了多个接口,类中代码有些混乱,我们可以将每个接口的实现都抽取到一个个内部类中,让结构更加清晰。
以 Runable
为例:
kotlin
private val processFlingRunnable = ProcessFlingRunnable()
override fun onFling(...): Boolean {
// ...
processFlingRunnable.processFling()
return false
}
/**
* 执行惯性滑动动画
*/
inner class ProcessFlingRunnable : Runnable {
fun processFling() {
if (!overScroller.computeScrollOffset()) {
return
}
extraOffsetX = overScroller.currX.toFloat()
extraOffsetY = overScroller.currY.toFloat()
invalidate()
postOnAnimation(this)
}
override fun run() {
processFling()
}
}
GestureDetector.OnGestureListener
, GestureDetector.OnDoubleTapListener
的实现可以通过继承 GestureDetector.SimpleOnGestureListener
类来合并,因为它实现了这两个接口。
完整代码如下:
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
enum class ScaleMode {
ORIGINAL, // 原始大小
FIT_WIDTH, // 适应宽度
FIT_HEIGHT // 适应高度
}
private val image: Bitmap by lazy {
getBitmap(resources, R.drawable.avator, imageSize.toInt())
}
// 默认缩放
private val defaultScale = 1f
// 水平宽度填满容器的缩放比
private var scaleToFitWidth = 0f
// 垂直高度填满容器的缩放比
private var scaleToFitHeight = 0f
private var currentScaleMode = ScaleMode.ORIGINAL
private val gestureListener = GestureListener()
private val processFlingRunnable = ProcessFlingRunnable()
private val gestureDetector = GestureDetectorCompat(context, gestureListener).apply {
setOnDoubleTapListener(gestureListener)
}
// 缩放
var scale = defaultScale
set(value) {
field = value
invalidate()
}
// 额外偏移
var extraOffsetX = 0f
var extraOffsetY = 0f
private val overScroller = OverScroller(context)
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
// 调整缩放比
scaleToFitWidth = width / imageSize
scaleToFitHeight = height / imageSize
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
return gestureDetector.onTouchEvent(event)
}
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
)
}
/**
* 修正偏移
*/
private fun fixOffset(
offsetX: Float,
offsetY: Float,
maxExtraOffsetX: Float,
maxExtraOffsetY: Float,
): Pair<Float, Float> {
val offsetXFixed = offsetX.coerceIn(-maxExtraOffsetX, maxExtraOffsetX)
val offsetYFixed = offsetY.coerceIn(-maxExtraOffsetY, maxExtraOffsetY)
return offsetXFixed to offsetYFixed
}
/**
* 切换缩放模式
*/
fun switchScaleMode() {
currentScaleMode = when (currentScaleMode) {
ScaleMode.ORIGINAL -> ScaleMode.FIT_WIDTH
ScaleMode.FIT_WIDTH -> ScaleMode.FIT_HEIGHT
ScaleMode.FIT_HEIGHT -> ScaleMode.ORIGINAL
}
}
/**
* 获取当前缩放比
*/
fun getCurrentScale(): Float =
when (currentScaleMode) {
ScaleMode.ORIGINAL -> {
defaultScale
}
ScaleMode.FIT_WIDTH -> {
scaleToFitWidth
}
ScaleMode.FIT_HEIGHT -> {
scaleToFitHeight
}
}
/**
* 获取Float属性动画
*/
private fun getFloatAnimator(
target: Any,
properName: String,
startValue: Float,
endValue: Float,
): ObjectAnimator {
return ObjectAnimator.ofFloat(target, properName, startValue, endValue)
}
/**
* 执行惯性滑动动画
*/
inner class ProcessFlingRunnable : Runnable {
/**
* 执行惯性滑动动画
*/
fun processFling() {
if (!overScroller.computeScrollOffset()) {
return
}
extraOffsetX = overScroller.currX.toFloat()
extraOffsetY = overScroller.currY.toFloat()
invalidate()
postOnAnimation(this)
}
override fun run() {
processFling()
}
}
/**
* 双击、滑动、惯性滑动手势监听器
*/
inner class GestureListener : GestureDetector.SimpleOnGestureListener() {
private val target = this@ScalableImageView
override fun onDown(e: MotionEvent): Boolean {
// 停止惯性滑动
overScroller.forceFinished(true)
// 消费事件
return true
}
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(),
)
processFlingRunnable.processFling()
return false
}
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
extraOffsetY -= distanceY
fixOffset(
extraOffsetX, extraOffsetY,
(image.width * scale - width).coerceAtLeast(0f) / 2,
(image.height * scale - height).coerceAtLeast(0f) / 2
).apply {
extraOffsetX = first
extraOffsetY = second
}
// 记得刷新
invalidate()
return false
}
override fun onDoubleTap(e: MotionEvent): Boolean {
// 动画前的初始状态
val oldScale = scale
val oldExtraOffsetX = extraOffsetX
val oldExtraOffsetY = extraOffsetY
// 切换缩放模式
switchScaleMode()
// 目标缩放值
val targetScale = getCurrentScale()
// 目标偏移量
var targetExtraOffsetX =
oldExtraOffsetX - (e.x - width / 2f) * (targetScale / oldScale - 1)
var targetExtraOffsetY =
oldExtraOffsetY - (e.y - height / 2f) * (targetScale / oldScale - 1)
// 边界修正,确保最大偏移量不小于0
val maxExtraOffsetX = (image.width * targetScale - width).coerceAtLeast(0f) / 2f
val maxExtraOffsetY = (image.height * targetScale - height).coerceAtLeast(0f) / 2f
fixOffset(
targetExtraOffsetX,
targetExtraOffsetY,
maxExtraOffsetX,
maxExtraOffsetY
).apply {
targetExtraOffsetX = first
targetExtraOffsetY = second
}
// 特殊处理:如果回到原始状态或是FIT_WIDTH状态,重置目标偏移量
if (currentScaleMode == ScaleMode.ORIGINAL || currentScaleMode == ScaleMode.FIT_WIDTH) {
targetExtraOffsetX = 0f
targetExtraOffsetY = 0f
}
// 统一启动动画
getFloatAnimator(target, "extraOffsetX", oldExtraOffsetX, targetExtraOffsetX).start()
getFloatAnimator(target, "extraOffsetY", oldExtraOffsetY, targetExtraOffsetY).start()
getFloatAnimator(target, "scale", oldScale, targetScale).start()
return true
}
}
}
双指缩放
双指缩放需要用到 ScaleGestureDetector
。
kotlin
class ScalableImageView(context: Context, attrs: AttributeSet?) : View(context, attrs) {
// ...
private val scaleGestureListener = ScaleListener()
private val scaleGestureDetector = ScaleGestureDetector(context, scaleGestureListener)
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
scaleGestureDetector.onTouchEvent(event)
// 如果缩放检测器没有处理事件,再把事件给双击/拖动检测器
if (!scaleGestureDetector.isInProgress) {
gestureDetector.onTouchEvent(event)
}
return true
}
inner class ScaleListener : ScaleGestureDetector.SimpleOnScaleGestureListener() {
// 捏撑进行中的回调
override fun onScale(detector: ScaleGestureDetector): Boolean {
return true
}
// 捏撑开始的回调
override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
// 表示消费事件
return true
}
// 捏撑结束的回调
override fun onScaleEnd(detector: ScaleGestureDetector) {
return
}
}
}
注意:
ScaleGestureDetectorCompat
并不是ScaleGestureDetector
的兼容版本。
接下来我们来完成 onScale()
回调。
调用 ScaleGestureDetector.getScaleFactor()
可以得到一个缩放比例,表示了相对缩放开始时的缩放系数。如果方法返回 true
,表示此次缩放结束,缩放比例会重置为 1f
。
也就是说,如果该回调一直返回 false
,那么 ScaleGestureDetector.getScaleFactor()
代表的一直是相对于缩放开始时的缩放系数;如果一直返回 true
,则始终代表了相对于上一个事件的缩放比例。
我们只需这样即可:
kotlin
inner class ScaleListener : ScaleGestureDetector.SimpleOnScaleGestureListener() {
var initialScale = 0f
override fun onScale(detector: ScaleGestureDetector): Boolean {
scale = initialScale * detector.scaleFactor
// 添加缩放的边界限制
val maxScale = scaleToFitHeight * 2f
val minScale = defaultScale * 0.5f
scale = scale.coerceIn(minScale, maxScale)
invalidate()
return false
}
override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
initialScale = scale
// 表示消费事件
return true
}
override fun onScaleEnd(detector: ScaleGestureDetector) {
return
}
}
现在缩放并不跟手,和之前类似,只需加上一个偏移量即可。缩放中心点可以通过 ScaleGestureDetector.getFocusX()
、ScaleGestureDetector.getFocusY()
获取。
kotlin
override fun onScale(detector: ScaleGestureDetector): Boolean {
val oldScale = scale
// 计算新的目标缩放值
var targetScale = initialScale * detector.scaleFactor
// 添加缩放的边界限制
val maxScale = scaleToFitHeight * 2f
val minScale = defaultScale * 0.5f
targetScale = targetScale.coerceIn(minScale, maxScale)
// 计算偏移量
val offsetXChange = (detector.focusX - width / 2f) * (1 - targetScale / oldScale)
val offsetYChange = (detector.focusY - height / 2f) * (1 - targetScale / oldScale)
// 应用新的缩放值和偏移量
scale = targetScale
extraOffsetX += offsetXChange
extraOffsetY += offsetYChange
// 修正边界,防止移出屏幕
val maxExtraOffsetX = (image.width * scale - width).coerceAtLeast(0f) / 2f
val maxExtraOffsetY = (image.height * scale - height).coerceAtLeast(0f) / 2f
val (fixedX, fixedY) = fixOffset(
extraOffsetX,
extraOffsetY,
maxExtraOffsetX,
maxExtraOffsetY
)
extraOffsetX = fixedX
extraOffsetY = fixedY
invalidate()
return false
}
引入双指缩放后,为了能够切换到双击缩放状态,并增加对手动模式滑动的支持。
需要修改一下之前的代码:
kotlin
class ScalableImageView(context: Context, attrs: AttributeSet?) : View(context, attrs) {
enum class ScaleMode {
ORIGINAL, // 原始大小
FIT_WIDTH, // 适应宽度
FIT_HEIGHT, // 适应高度
// 手动缩放
MANUAL
}
private var lastScaleMode = ScaleMode.ORIGINAL
/**
* 切换缩放模式
*/
fun switchScaleMode() {
currentScaleMode = when (currentScaleMode) {
ScaleMode.ORIGINAL -> ScaleMode.FIT_WIDTH
ScaleMode.FIT_WIDTH -> ScaleMode.FIT_HEIGHT
ScaleMode.FIT_HEIGHT -> ScaleMode.ORIGINAL
else -> ScaleMode.entries[lastScaleMode.ordinal]
}
}
/**
* 获取当前缩放比
*/
fun getCurrentScale(): Float =
when (currentScaleMode) {
ScaleMode.ORIGINAL -> {
defaultScale
}
ScaleMode.FIT_WIDTH -> {
scaleToFitWidth
}
ScaleMode.FIT_HEIGHT -> {
scaleToFitHeight
}
ScaleMode.MANUAL -> {
scale
}
}
/**
* 双击、滑动、惯性滑动手势监听器
*/
inner class GestureListener : GestureDetector.SimpleOnGestureListener() {
// ...
override fun onFling(
e1: MotionEvent?,
e2: MotionEvent,
velocityX: Float,
velocityY: Float,
): Boolean {
// 默认缩放和FIT_WIDTH缩放下,禁止滑动
// ...
if (currentScaleMode == ScaleMode.MANUAL && scale <= scaleToFitWidth) {
return false
}
val maxExtraOffsetX = (image.width * scale - width).coerceAtLeast(0f) / 2f
val maxExtraOffsetY = (image.height * scale - height).coerceAtLeast(0f) / 2f
// 惯性滑动
overScroller.fling(
extraOffsetX.toInt(), // 起始位置
extraOffsetY.toInt(),
velocityX.toInt(), // 起始速度
velocityY.toInt(),
(-maxExtraOffsetX).toInt(), // 滑动的边界
maxExtraOffsetX.toInt(),
(-maxExtraOffsetY).toInt(),
maxExtraOffsetY.toInt()
)
// 启动动画
postOnAnimation(processFlingRunnable)
return false
}
override fun onScroll(
e1: MotionEvent?,
e2: MotionEvent,
distanceX: Float,
distanceY: Float,
): Boolean {
// 默认缩放和FIT_WIDTH缩放下,禁止滑动
// ...
if (currentScaleMode == ScaleMode.MANUAL && scale <= scaleToFitWidth) {
return false
}
// ...
return false
}
}
inner class ScaleListener : ScaleGestureDetector.SimpleOnScaleGestureListener() {
override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
initialScale = scale
lastScaleMode =
if (currentScaleMode == ScaleMode.MANUAL) lastScaleMode else currentScaleMode
currentScaleMode = ScaleMode.MANUAL
// 表示消费事件
return true
}
}
}
至此,一个可缩放 ImageView
就完成了。