功能概述
- 显示一张图片:在 onDraw() 方法里绘制指定大小的图片;
- 双击放大/缩小:通过 GestureDetector 监听 onDoubleTap(),触发动画,在小倍数与大倍数之间平滑切换;
- 手势缩放(Pinch) :通过 ScaleGestureDetector,监听双指缩放手势,实时调整缩放倍数;
- 放大后拖动:在放大状态(big)时,可以在图片超出屏幕边缘部分进行拖动,移动可见区域;
- 惯性滚动(Fling) :在手指离开屏幕时,根据滑动速度实现惯性滚动(OverScroller)并做越界保护(不让图片无限滚动出范围)。
-
属性与初始化
paint:用于绘制位图;bitmap:需要绘制的头像;offsetX / offsetY:在放大后进行拖动时的偏移;smallScale / bigScale:图片最小与最大缩放比例;gestureDetector / scaleGestureDetector:分别负责单指和双指手势;scroller:辅助进行惯性滚动(fling)。
-
onSizeChanged()
- 计算并设置 smallScale、bigScale;
- 设置
currentScale初值和属性动画的起、终值。
-
onTouchEvent()
- 优先给 scaleGestureDetector 处理双指缩放;
- 若不在缩放中(
!isInProgress),再交给 gestureDetector 处理单指手势(双击、拖动、fling)。
-
onDraw()
- 根据
currentScale与最小缩放的差值,计算一个scaleFraction; - 先
canvas.translate()再canvas.scale(),最后drawBitmap()。
- 根据
-
fixOffsets()
- 限定拖动偏移量,防止图片被完全拖出控件区域。
-
myGestureListener
- onDoubleTap() :双击放大/缩小的核心逻辑;
- onScroll() :单指拖动图片;
- onFling() :松手后的惯性滚动,使用 OverScroller 来实现平滑滚动。
-
myScaleGestureListener
- onScale() :实时更新缩放倍数,需限制在 [smallScale, bigScale] 范围内;
- onScaleBegin() :在开始缩放时,计算放大中心到控件中心的偏移,让手势焦点更好地对准缩放中心。
-
myFlingRunner
- 通过
postOnAnimation循环回调computeScrollOffset(),不断更新offsetX/offsetY,直到 fling 动画结束。
- 通过
package com.example.layoutlayout.view
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 androidx.core.view.ViewCompat
import com.hencoder.layoutlayout.dp
import com.hencoder.layoutlayout.getAvatar
import kotlin.math.max
import kotlin.math.min
// 图片的固定位图大小(dp 转换为 px 后的值)
private val IMAGE_SIZE = 300.dp.toInt()
// 放大倍数的额外系数,用于确定双击放大到的倍数
private const val EXTRA_SCALE_FACTOR = 1.5f
/**
* 一个可缩放、可拖动的自定义 View,用于展示和手势操作图片。
*/
class ScalableImageView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {
// 画笔,开启抗锯齿
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
// 要展示的位图对象,通过工具函数获得固定大小的头像
private val bitmap = getAvatar(resources, IMAGE_SIZE)
// 图片原始偏移量(让图片默认居中显示)
private var originalOffsetX = 0f
private var originalOffsetY = 0f
// 拖拽偏移量(在放大之后用于拖动图片位置)
private var offsetX = 0f
private var offsetY = 0f
// 最小(初始)缩放倍数和最大(放大后)缩放倍数
private var smallScale = 0f
private var bigScale = 0f
// 手势监听器(主要处理单指手势,如:双击、拖动、fling 等)
private val myGestureListener = MyGestureListener()
// 缩放手势监听器(专门处理双指捏合缩放)
private val myScaleGestureListener = MyScaleGestureListener()
// fling 动画的循环任务
private val myFlingRunner = MyFlingRunner()
// GestureDetector:处理单指相关手势(双击、scroll、fling)
private val gestureDetector = GestureDetectorCompat(context, myGestureListener)
// ScaleGestureDetector:处理双指捏合手势
private val scaleGestureDetector = ScaleGestureDetector(context, myScaleGestureListener)
// 用于区分当前是否处于“放大状态”
private var big = false
/**
* currentScale 表示当前的缩放倍数。自定义 setter 用于在修改时自动重绘。
*/
private var currentScale = 0f
set(value) {
field = value
invalidate()
}
/**
* 利用属性动画在 smallScale 和 bigScale 之间做平滑过渡
* 目标属性为 "currentScale"(通过反射或 setter 更新)
*/
private val scaleAnimator = ObjectAnimator.ofFloat(this, "currentScale", smallScale, bigScale)
// 用于处理惯性滚动(fling)的辅助类
private val scroller = OverScroller(context)
/**
* 当 View 尺寸发生变化时(或首次测量),计算图片居中位置及最小、最大缩放倍数
*/
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
// 让图片居中:根据 View 宽高和图片大小计算居中的偏移量
originalOffsetX = (width - IMAGE_SIZE) / 2f
originalOffsetY = (height - IMAGE_SIZE) / 2f
// 根据图片宽高和控件宽高,先计算最合适的最小缩放和最大缩放
if (bitmap.width / bitmap.height.toFloat() > width / height.toFloat()) {
// 图片更宽时,以宽度为基准,贴满控件宽度
smallScale = width / bitmap.width.toFloat()
bigScale = height / bitmap.height.toFloat() * EXTRA_SCALE_FACTOR
} else {
// 图片更高时,以高度为基准,贴满控件高度
smallScale = height / bitmap.height.toFloat()
bigScale = width / bitmap.width.toFloat() * EXTRA_SCALE_FACTOR
}
// 初始时的缩放倍数设为最小,保证完整显示图片
currentScale = smallScale
// 让动画的起始和目标值跟随计算结果变动
scaleAnimator.setFloatValues(smallScale, bigScale)
}
/**
* 在触摸事件中,先把事件分发给 ScaleGestureDetector 处理(双指缩放)
* 若没有处于缩放中,再交给 GestureDetector 处理(双击、拖动、fling)
*/
override fun onTouchEvent(event: MotionEvent?): Boolean {
scaleGestureDetector.onTouchEvent(event)
if (!scaleGestureDetector.isInProgress) {
gestureDetector.onTouchEvent(event)
}
return true
}
/**
* 在 onDraw 中先根据当前缩放倍数计算偏移,再放大/缩小,最后绘制图片
*/
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// 计算当前缩放相对于最小值的比例(0 ~ 1)
val scaleFraction = (currentScale - smallScale) / (bigScale - smallScale)
// 根据这个比例,让 offsetX、offsetY 在放大过程中逐渐生效
canvas.translate(offsetX * scaleFraction, offsetY * scaleFraction)
// 以 View 的中心为缩放中心点
canvas.scale(currentScale, currentScale, width / 2f, height / 2f)
// 绘制位图。originalOffsetX / Y 用来把原图居中。
canvas.drawBitmap(bitmap, originalOffsetX, originalOffsetY, paint)
}
/**
* 修正 offsetX/offsetY 防止图片在放大后被无限拖动出屏幕边缘
*/
private fun fixOffsets() {
// 最大可偏移量:放大后的图片宽度减去控件宽度的一半
offsetX = min(offsetX, (bitmap.width * bigScale - width) / 2)
offsetX = max(offsetX, -(bitmap.width * bigScale - width) / 2)
// 同理对于 Y 方向
offsetY = min(offsetY, (bitmap.height * bigScale - height) / 2)
offsetY = max(offsetY, -(bitmap.height * bigScale - height) / 2)
}
/**
* 监听单指手势:包含 onDown、onScroll、onFling、onDoubleTap
*/
inner class MyGestureListener : GestureDetector.SimpleOnGestureListener() {
/**
* onDown 返回 true 表示本控件要处理后续事件,否则可能被系统忽略
*/
override fun onDown(e: MotionEvent): Boolean {
return true
}
/**
* 惯性滚动(fling),在大图状态(big = true)才需要
* 通过 OverScroller 计算滚动范围,并启动 HenFlingRunner
*/
override fun onFling(
downEvent: MotionEvent?,
currentEvent: MotionEvent?,
velocityX: Float,
velocityY: Float
): Boolean {
if (big) {
scroller.fling(
offsetX.toInt(), // 起始 X
offsetY.toInt(), // 起始 Y
velocityX.toInt(), // X 方向速度
velocityY.toInt(), // Y 方向速度
(-(bitmap.width * bigScale - width) / 2).toInt(), // 最小 X
((bitmap.width * bigScale - width) / 2).toInt(), // 最大 X
(-(bitmap.height * bigScale - height) / 2).toInt(),// 最小 Y
((bitmap.height * bigScale - height) / 2).toInt() // 最大 Y
)
// 使用兼容写法 postOnAnimation 让界面持续刷新,直到 fling 结束,下一帧调用。
ViewCompat.postOnAnimation(this@ScalableImageView, myFlingRunner)
}
return false
}
/**
* 单指拖动事件。如果当前是大图状态,则可以拖拽 offsetX / offsetY
*/
override fun onScroll(
downEvent: MotionEvent?,
currentEvent: MotionEvent?,
distanceX: Float,
distanceY: Float
): Boolean {
if (big) {
offsetX -= distanceX
offsetY -= distanceY
fixOffsets()
invalidate()
}
return false
}
/**
* 双击事件。用于在大图和小图之间切换。
* 当从小图状态切换到大图时,需要根据点击位置来计算放大后的偏移。
*/
override fun onDoubleTap(e: MotionEvent): Boolean {
big = !big
if (big) {
// 点击位置相对于控件中心点的距离(让双击处放大后更靠近屏幕中心)
offsetX = (e.x - width / 2f) * (1 - bigScale / smallScale)
offsetY = (e.y - height / 2f) * (1 - bigScale / smallScale)
fixOffsets()
// 执行动画,平滑地从 smallScale 放大到 bigScale
scaleAnimator.start()
} else {
// 回到小图状态,反向执行动画
scaleAnimator.reverse()
}
return true
}
}
/**
* 专门用于监听双指捏合手势,实时计算缩放倍数
*/
inner class MyScaleGestureListener : ScaleGestureDetector.OnScaleGestureListener {
/**
* 开始双指缩放时,计算放大中心相对于控件中心的偏移
*/
override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
// 让图片的放大中心以手势焦点为参考
offsetX = (detector.focusX - width / 2f) * (1 - bigScale / smallScale)
offsetY = (detector.focusY - height / 2f) * (1 - bigScale / smallScale)
return true
}
override fun onScaleEnd(detector: ScaleGestureDetector?) {
// 缩放结束,这里可根据需求添加逻辑或动画等
}
/**
* 核心:在缩放中,每次事件都拿到 scaleFactor 来更新 currentScale
* 但需限定在 [smallScale, bigScale] 范围内
*/
override fun onScale(detector: ScaleGestureDetector): Boolean {
val tempCurrentScale = currentScale * detector.scaleFactor
return if (tempCurrentScale < smallScale || tempCurrentScale > bigScale) {
// 超出范围就不再更新
false
} else {
currentScale *= detector.scaleFactor
true
}
}
}
/**
* 在 fling 时使用的循环任务,配合 OverScroller 每帧更新 offsetX/offsetY
*/
inner class MyFlingRunner : Runnable {
override fun run() {
// 如果尚未计算结束,就继续计算并刷新界面
if (scroller.computeScrollOffset()) {
offsetX = scroller.currX.toFloat()
offsetY = scroller.currY.toFloat()
invalidate()
// 再次提交到下一帧,直到 fling 动画结束
ViewCompat.postOnAnimation(this@ScalableImageView, this)
}
}
}
}
运行效果:
总结
- 双击缩放:用
GestureDetector的 onDoubleTap() 搭配ObjectAnimator来控制缩放倍数从smallScale动画到bigScale。 - 双指手势缩放:用
ScaleGestureDetector实时获取缩放比例,边缩放边更新currentScale以实现捏合缩放。 - 放大后拖动:通过 onScroll() 和
offsetX/ offsetY在大图下实现拖动视图,超出边界会被fixOffsets()限制。 - 惯性滚动:用
OverScroller来根据滑动速度进行 fling,监听滚动位置并持续更新偏移量。 - 坐标变换:绘制时通过
canvas.translate和canvas.scale实现图片的移动和缩放。 - 边界检测:用
fixOffsets(),确保在放大状态下,图片内容不会被完全拖出屏幕可视范围。
这整个类组合了 GestureDetector 和 ScaleGestureDetector 的常见用法,再加上属性动画和 OverScroller,使得图片在不同的用户交互(双击、双指捏合、拖动、fling)时有非常流畅直观的体验。