Android 原生图像浏览器 Demo
一个使用原生 Android API(无第三方库)实现的图像浏览器示例,支持:
- 横向滑动查看多张图片(ViewPager)
- 图片加载(支持本地路径和网络 URL)
- 手势缩放(双指缩放、双击放大/还原)
- 拖动图片(平移)
项目特点
-
零依赖:为了提高学习 Android 图像处理这方面的能力,不依赖任何第三库(如 Glide、Coil、PhotoView 等)
-
基础能力覆盖广:项目实现图片加载、缓存策略预留、手势识别与 Matrix 缩放
-
学习思路清晰:完成项目,对掌握 Android 原生
Canvans + Matrix +Gesture
技术有大幅度提升 -
支持网络与本地路径:兼容文件路径、http / https 图片
项目结构
imagebrowser/
├── ZoomImageView.kt # 支持缩放/拖拽的自定义 ImageView
├── ImageLoader.kt # 原生多线程图片加载工具(支持本地+网络)
├── ImagePagerAdapter.kt # ViewPager 适配器,绑定图片到 ZoomImageView
├── MainActivity.kt # 演示入口,加载图片并初始化滑动
├── res/
│ ├── layout/activity_main.xml
│ └── drawable/ic_broken_image.png # 加载失败备用图
└── AndroidManifest.xml
技术要点
1. ViewPager
1.1 简介
ViewPager
是 Android 中用于实现页面滑动切换效果的控件,通常用于图片轮播、引导页、Tab+Fragment 页面等功能。它通过适配器 Adapter 提供数据视图,并管理滑动行为。
从 AndroidX 开始,官方推荐使用 ViewPager2
,这是 ViewPager
的增强版。
1.2 ViewPager 与 ViewPager2 区别
特性 | ViewPager | ViewPager2 |
---|---|---|
基于组件 | android.view.ViewGroup | RecyclerView |
垂直方向滑动支持 | 不支持 | 支持 |
RTL 支持 | 不完整 | 完整支持 |
Fragment 生命周期控制 | 不够灵活 | 更好地集成 FragmentStateAdapter |
DiffUtil 支持 | 不支持 | 支持 |
1.3 基础使用示例
ViewPager使用
activity_main.xml
<androidx.viewpager.widget.ViewPager
android:id="@+id/viewPager"
android:layout_width="match_parent"
android:layout_height="match_parent" />
MainActivity
class MainActivity : AppCompatActivity() {
private lateinit var viewPager: ViewPager
private lateinit var adapter: MyPagerAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
viewPager = findViewById(R.id.viewPager)
val views = listOf(
layoutInflater.inflate(R.layout.page_one, null),
layoutInflater.inflate(R.layout.page_two, null),
layoutInflater.inflate(R.layout.page_three, null)
)
adapter = MyPagerAdapter(views)
viewPager.adapter = adapter
}
class MyPagerAdapter(private val views: List<View>) : PagerAdapter() {
override fun instantiateItem(container: ViewGroup, position: Int): Any {
container.addView(views[position])
return views[position]
}
override fun destroyItem(container: ViewGroup, position: Int, `object`: Any) {
container.removeView(`object` as View)
}
override fun getCount() = views.size
override fun isViewFromObject(view: View, obj: Any) = view == obj
}
}
ViewPager2使用
activity_main.xml
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/viewPager2"
android:layout_width="match_parent"
android:layout_height="match_parent" />
MyPagerAdapter
class MyPagerAdapter(private val items: List<String>) : RecyclerView.Adapter<MyPagerAdapter.ViewHolder>() {
inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val text: TextView = itemView.findViewById(R.id.textView)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.page_item, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.text.text = items[position]
}
override fun getItemCount() = items.size
}
MainActivity
class MainActivity : AppCompatActivity() {
private lateinit var viewPager2: ViewPager2
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
viewPager2 = findViewById(R.id.viewPager2)
val data = listOf("第一页", "第二页", "第三页")
viewPager2.adapter = MyPagerAdapter(data)
viewPager2.orientation = ViewPager2.ORIENTATION_HORIZONTAL
}
}
对比二者的使用,不难发现,ViewPager2 相比 ViewPage 使用起来更加方便、简介
1.4 总结
特性 | ViewPager | ViewPager2 |
---|---|---|
是否推荐使用 | 不推荐 | 推荐 |
支持垂直滑动 | 无 | 支持 |
与 Fragment 适配性 | 一般 | 非常好 |
与 RecyclerView 接轨 | 无 | 原生集成 |
2. BitmapFactory
2.1 Bitmap 与 BitmapFactory 简介
-
Bitmap:Android 内存中的位图像素矩阵,呈 ARGB/RGB 等格式。
-
BitmapFactory:静态工具类,负责将多种数据源(资源文件、文件路径、网络流、字节数组…)**解码(decode)**为
Bitmap
对象。 -
解码过程可通过
BitmapFactory.Options
精细控制尺寸、像素格式、缩放、复用等行为。
2.2 常见解码方法
方法 | 典型场景 | 备注 |
---|---|---|
decodeResource(res, id, opts?) | 读取 res/drawable 、mipmap 图片 | 最常用;支持密度自动缩放 |
decodeFile(path, opts?) | 本地文件、缓存目录 | 速度快;需自行处理密度 |
decodeStream(input, rect?, opts?) | 网络流、Assets、ContentProvider | 最灵活;可边下边解码 |
decodeByteArray(data, offset, length, opts?) | 内存中已有字节流(例如 Base64) | 便于网络协议、IPC |
decodeFileDescriptor(fd, rect?, opts?) | 共享文件句柄、大文件 | 适用 ContentResolver.openFileDescriptor() |
2.3 BitmapFactory.Options
详解
关键字段 | 作用 | 兼容性 |
---|---|---|
inJustDecodeBounds | 仅测量图片,不分配像素内存 | API 1+ |
outWidth / outHeight | 输出宽高(px) | 只读 |
inSampleSize | 设置缩放倍数 (≥1, 2 的幂效果最佳) | API 1+ |
inPreferredConfig | 目标像素格式:ARGB_8888 / RGB_565 / ALPHA_8 / RGBA_F16 | API 1+ (RGBA_F16 26+) |
inScaled | 是否按密度缩放(默认 true ) | API 1+ |
inDensity / inTargetDensity | 原图 / 目标屏幕密度 | API 1+ |
inMutable | 请求可变 Bitmap ,便于后续编辑 | API 11+ |
inBitmap | 复用已存在 Bitmap 内存 | API 11+ |
inPremultiplied | 是否使用预乘 Alpha | API 19+ |
inPreferredColorSpace | 建议色彩空间 (sRGB , DisplayP3 …) | API 26+ |
outMimeType | 解码出的 MIME,如 "image/png" | 只读 |
2.4 参考资料
- Android 官方文档 - BitmapFactory | API reference | Android Developers
3. Matrix
3.1 Matrix 概述
在 Android 的 android.graphics
包中,Matrix
类是一个 3×3 矩阵,用于在 2D 空间中进行几何变换。它支持以下基本变换:
-
平移 (Translate)
-
缩放 (Scale)
-
旋转 (Rotate)
-
错切 (Skew)
这些变换可以组合使用,创建复杂的变换效果。Matrix 广泛应用于:
-
图像处理
-
Canvas 绘图
-
View 动画
-
自定义 View 的布局变换
3.2 Matrix 结构
Matrix 使用 9 个浮点值表示的 3×3 矩阵:
[ scaleX skewX transX ]
[ skewY scaleY transY ]
[ 0 0 1 ]
实际存储为 9 元素的 float 数组:
val values = FloatArray(9)
matrix.getValues(values)
3.3 Matrix 数字原理
在研究 Android Matrix(矩阵)前,我们得对矩阵有一定的了解,尤其是在 Android 中的矩阵,它是数学中的 仿射矩阵,它把 一组点或图形 从 原始坐标系 映射到 变换后的新坐标。
-
什么是仿射矩阵 (Affine Matrix)
仿射变换 (Affine Transformation) 是一种图形变换,保留直线性和比例性 (直线变直线,平行变平行),它可以实现:
-
平移(Translation)
-
缩放(Scaling)
-
旋转(Rotation)
-
错切(Skew / Shear)
这些变化可以用一个 3x3 的矩阵 来统一表示,这个矩阵就叫 仿射矩阵
-
基本变换矩阵
-
平移矩阵
[ 1 0 dx ] [ 0 1 dy ] [ 0 0 1 ]
-
缩放矩阵
[ sx 0 0 ] [ 0 sy 0 ] [ 0 0 1 ]
-
旋转矩阵(角度为 0)
[ cosθ -sinθ 0 ] [ sinθ cosθ 0 ] [ 0 0 1 ]
-
错切矩阵
[ 1 kx 0 ] [ ky 1 0 ] [ 0 0 1 ]
3.4 矩阵乘法
变换通过矩阵乘法组合:
Result = MatrixA × MatrixB
注意顺序:矩阵乘法不满足交换律,A × B ≠ B × A
3.5 Matrix 核心操作
-
预乘
preXXX()
方法:新变换先于当前变换执行matrix.preScale(0.5f, 0.5f) // 先缩放 matrix.preRotate(45f) // 后旋转
-
后乘
postXXX()
方法:新变换后于当前变换执行matrix.postRotate(45f) // 先旋转 matrix.postScale(0.5f, 0.5f) // 后缩放
-
设置方法
直接设置特定变换:
matrix.setScale(2f, 2f) // 重置为缩放矩阵 matrix.setRotate(30f, px, py) // 围绕点(px,py)旋转
3.6 Matrix 常用方法
-
基本变换方法
方法 描述 setTranslate(dx, dy)
设置平移变换 postTrasnslate(dx, dy)
后乘平移 preTranslate(dx, dy)
预乘平移 setScale(sx, sy, px, py)
设置缩放(以(px, py) 为中心) postScale(sx, sy, px, py)
后乘缩放 setRotate(degress, px, py)
设置旋转 postRotate(degress, px, py)
后乘旋转 setSkew(kx, ky, px, py)
设置错切 -
使用方法
方法 描述 reset)
重置为单位矩阵 setConcat(A, B)
设置为 A × B invert(inverse)
求逆矩阵 mapPoints(src, dst)
变换点坐标 mapRect(rect)
变换矩形区域 -
状态检查
方法 描述 isIdentity()
是否单位矩阵 isAffine()
是否是仿射变换 rectStaysRect()
矩形变换后是否仍为矩形
3.7 Matrix 示例
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp"
tools:context=".MainActivity">
<!-- 显示变换后的图像 -->
<ImageView
android:id="@+id/imageView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:contentDescription="Transformed image"
android:scaleType="matrix"
android:src="@mipmap/ic_launcher" />
<!-- 控制面板 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingTop="16dp">
<!-- 缩放控制 -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="缩放比例" />
<SeekBar
android:id="@+id/scaleSeekBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:max="200"
android:progress="100" />
<!-- 旋转控制 -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="旋转角度" />
<SeekBar
android:id="@+id/rotateSeekBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:max="360"
android:progress="0" />
<!-- 平移控制 -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="X轴平移" />
<SeekBar
android:id="@+id/translateXSeekBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:max="200"
android:progress="100" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Y轴平移" />
<SeekBar
android:id="@+id/translateYSeekBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:max="200"
android:progress="100" />
<!-- 重置按钮 -->
<Button
android:id="@+id/resetButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="重置变换" />
</LinearLayout>
</LinearLayout>
MainActivity
class MainActivity : AppCompatActivity() {
private lateinit var imageView: ImageView
private val matrix = Matrix()
private var originalMatrix: Matrix? = null
// 当前变换参数
private var scaleFactor = 1.0f
private var rotationDegrees = 0f
private var translateX = 0f
private var translateY = 0f
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
imageView = findViewById(R.id.imageView)
val scaleSeekBar: SeekBar = findViewById(R.id.scaleSeekBar)
val rotateSeekBar: SeekBar = findViewById(R.id.rotateSeekBar)
val translateXSeekBar: SeekBar = findViewById(R.id.translateXSeekBar)
val translateYSeekBar: SeekBar = findViewById(R.id.translateYSeekBar)
val resetButton: Button = findViewById(R.id.resetButton)
// 保存初始矩阵状态(用于重置)
imageView.post {
originalMatrix = Matrix(imageView.imageMatrix)
}
// 设置缩放监听器
scaleSeekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
scaleFactor = progress / 100f
applyTransformations()
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {
}
override fun onStopTrackingTouch(seekBar: SeekBar?) {
}
})
// 设置旋转监听器
rotateSeekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
rotationDegrees = progress.toFloat()
applyTransformations()
}
override fun onStopTrackingTouch(seekBar: SeekBar?) {
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {
}
})
// 设置 x 轴平移监听器
translateXSeekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
// 映射到 [-100, 100] 范围
translateX = (progress - 100).toFloat()
applyTransformations()
}
override fun onStopTrackingTouch(seekBar: SeekBar?) {
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {
}
})
// 设置Y轴平移监听器
translateYSeekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
// 映射到 [-100, 100] 范围
translateY = (progress - 100).toFloat()
applyTransformations()
}
override fun onStartTrackingTouch(seekBar: SeekBar) {}
override fun onStopTrackingTouch(seekBar: SeekBar) {}
})
// 重置
resetButton.setOnClickListener {
scaleFactor = 1.0f
rotationDegrees = 0f
translateX = 0f
translateY = 0f
findViewById<SeekBar>(R.id.scaleSeekBar).progress = 100
findViewById<SeekBar>(R.id.rotateSeekBar).progress = 0
findViewById<SeekBar>(R.id.translateXSeekBar).progress = 100
findViewById<SeekBar>(R.id.translateYSeekBar).progress = 100
originalMatrix?.let {
imageView.imageMatrix = it
}
}
}
private fun applyTransformations() {
// 重置矩阵
originalMatrix?.let { matrix.set(it) }
// 获取 ImageView 中心点作为变换中心点
val centerX = imageView.width / 2f
val centerY = imageView.height / 2f
// 应用变换(顺序很重要)
matrix.postScale(scaleFactor, scaleFactor, centerX, centerY) // 以中心点缩放
matrix.postRotate(rotationDegrees, centerX, centerY) // 以中心点旋转
matrix.postTranslate(translateX, translateY) // 平移
// 应用变换到 ImageView
imageView.imageMatrix = matrix
// 调试输出矩阵值
debugMatrixValues()
}
private fun debugMatrixValues() {
val values = FloatArray(9)
matrix.getValues(values)
val matrixString = """
Matrix Values:
[${values[0]}, ${values[1]}, ${values[2]}]
[${values[3]}, ${values[4]}, ${values[5]}]
[${values[6]}, ${values[7]}, ${values[8]}]
""".trimIndent()
Log.d("MainActivity", matrixString)
}
}
4. Gesture Detection
4.1 什么是 Gesture(手势)
在 Android 中,Gesture 是用户与触摸屏交互的一种方式。比如点击、长按、滑动、双击、捏合等,这些动作统称为手势。Android 提供了强大的手势检测 API 来简化手势的识别和响应。
4.2 主要类和接口说明
类 / 接口 | 说明 |
---|---|
GestureDetector | 核心类,用于识别常见的单指手势(点击、长按、滑动、双击等) |
GestureDetector.OnGestureListener | 接口,处理常规手势 |
GestureDetector.OnDoubleTapListener | 接口,处理双击相关手势 |
ScaleGestureDetector | 用于识别缩放手势(两个手指捏合/放大) |
MotionEvent | 表示用户在屏幕上的一次触摸事件 |
4.4 GestureDetector 使用流程
-
实例化
GestrueDetector
-
实现
OnGestureListener
接口 -
将触摸事件传递给
GestureDetector
4.5 常见的手势与回调方法
方法 | 对应手势 | 说明 |
---|---|---|
onDown() | 按下 | 必须返回 true ,否则无法触发其他手势 |
onSingleTapUp() | 单击抬起 | 单击事件触发点 |
onLongPress() | 长按 | 手指长时间不动 |
onScroll() | 滑动 | 手指滑动事件 |
onFling() | 快速滑动 | 手指滑动后快速释放 |
onDoubleTap() | 双击 | 两次点击间隔小于一定时间 |
onScale() | 缩放 | 两指捏合或放大时触发(用于 ScaleGestureDetector ) |
4.6 示例
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#EEEEEE">
</FrameLayout>
MainActivity
class MainActivity : AppCompatActivity(),
GestureDetector.OnGestureListener,
GestureDetector.OnDoubleTapListener {
private lateinit var gestureDetector: GestureDetector // 单指手势
private lateinit var scaleGestureDetector: ScaleGestureDetector // 双指缩放
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// 初始化单指手势检测器
gestureDetector = GestureDetector(this, this).apply {
setOnDoubleTapListener(this@MainActivity)
}
// 初始化双指缩放检测器
scaleGestureDetector = ScaleGestureDetector(
this,
object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
override fun onScale(detector: ScaleGestureDetector): Boolean {
// 缩放因子
val scaleFactor = detector.scaleFactor
showToast("缩放比例:$scaleFactor")
return true
}
}
)
}
/** 把所有触摸事件都交给两个检测器 */
override fun onTouchEvent(event: MotionEvent): Boolean {
scaleGestureDetector.onTouchEvent(event) // 先处理缩放
gestureDetector.onTouchEvent(event) // 再处理单指/双击等
return super.onTouchEvent(event)
}
// ─────────────── OnGestureListener ───────────────
override fun onDown(e: MotionEvent): Boolean {
showToast("按下")
return true
}
override fun onShowPress(e: MotionEvent) {
showToast("按下但未抬起")
}
override fun onSingleTapUp(e: MotionEvent): Boolean {
showToast("单击")
return true
}
override fun onScroll(
e1: MotionEvent?,
e2: MotionEvent,
distanceX: Float,
distanceY: Float
): Boolean {
showToast("滑动 distanceX=$distanceX, distanceY=$distanceY")
return true
}
override fun onLongPress(e: MotionEvent) {
showToast("长按")
}
override fun onFling(
e1: MotionEvent?,
e2: MotionEvent,
velocityX: Float,
velocityY: Float
): Boolean {
showToast("快速滑动 velocityX=$velocityX, velocityY=$velocityY")
return true
}
// ─────────────── OnDoubleTapListener ───────────────
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
showToast("确认单击")
return true
}
override fun onDoubleTap(e: MotionEvent): Boolean {
showToast("双击")
return true
}
override fun onDoubleTapEvent(e: MotionEvent): Boolean {
showToast("双击过程中的其他事件")
return true
}
// ─────────────── 工具方法 ───────────────
private fun showToast(msg: String) {
Toast.makeText(this, msg, Toast.LENGTH_SHORT).show()
}
}
5. 图像预览器 Demo
接下来我们开始写一个用纯原生的 图像预览器 Demo,全部代码基于 Android SDK + Android X 官方支持库,不引人任何三方依赖(如 Coil、PhotoView等)。
核心功能:
-
本地 / 网络图片加载(
BitmapFactory
+ 线程池) -
横向滑动浏览(
ViewPager2
– AndroidX 官方组件) -
双指缩放、拖拽和平移(自定义
ZoomImageView
,使用Matrix
+ScaleGestureDetector
/GestureDetector
)
5.1 简易图片加载器
ImageLoader
object ImageLoader { // 单例设计
// 固定4线程的IO线程池
private val ioPool = Executors.newFixedThreadPool(4)
// 主线程Handler(用于切回UI线程)
private val uiHandler = Handler(Looper.getMainLooper())
/**
* 加载图片方法
* @param urlOrPath 支持两种格式:
* - http/https开头的网络URL
* - 本地文件路径
* @param onResult 回调函数(在主线程执行):
* - 成功时返回Bitmap
* - 失败时返回null(当前实现会抛出异常但未在回调中体现)
*/
fun load(urlOrPath: String, onResult: (Bitmap?) -> Unit) {
// 将任务提交到IO线程池
ioPool.execute {
val bitmap = try {
// 判断加载类型
if (urlOrPath.startsWith("http")) {
// 网络图片加载
val stream = URL(urlOrPath).openStream() // 可能抛出MalformedURLException/IOException
BitmapFactory.decodeStream(stream) // 可能返回null(当流不是有效图片时)
} else {
// 本地文件加载(注意:未检查文件权限)
BitmapFactory.decodeFile(urlOrPath) // 可能返回null(文件不存在/非图片)
}
} catch (e: Exception) {
// 捕获所有异常(建议:区分异常类型处理)
e.printStackTrace()
null // 异常时返回null(但当前回调会强转导致崩溃!)
}
// 切回主线程回调
uiHandler.post {
onResult(bitmap as Bitmap) // 强制转换不安全!
}
}
}
}
这里只是提供简易的代码,代码中含有很多风险,例如线程池为实现关闭逻辑;线程池数量是固定的,需要动态配置;未处理线程池满/拒绝策略;网络加载图片未设置超时、缓存、重定向等等,这些需要用户在实际应用中去处理,作者就懒得写了。
5.2 自定义缩放 ImageView
ZoomImageView
class ZoomImageView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : AppCompatImageView(context, attrs) {
// 矩阵计算临时存储数组(用于getValues)
private val matrixValues = FloatArray(9)
// 实际控制图片变换的矩阵(所有操作基于此矩阵)
private val imageMatrixInternal = Matrix()
// 当前缩放比例(1f为原始大小)
private var scale = 1f
// 最小缩放比例(根据图片和视图大小动态计算)
private var minScale = 1f
// 最大缩放比例(固定为minScale的4倍)
private var maxScale = 4f
// 记录上次触摸点坐标(用于计算移动距离)
private val lastPoint = PointF()
// 拖拽状态标志(true表示正在拖拽)
private var isDragging = false
// 冲突解决相关变量
private var isScaling = false // 是否正在缩放
private var canIntercept = false // 是否允许父容器拦截事件
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop // 系统认定的最小滑动距离
/**
* 边界检查方法(防止图片被拖出可视区域)
* 处理三种情况:
* 1. 图片小于视图 → 居中显示
* 2. 图片左/上边缘超出 → 拉回边界
* 3. 图片右/下边缘超出 → 拉回边界
*/
private fun checkBounds() {
val drawable = drawable ?: return
val values = FloatArray(9)
imageMatrixInternal.getValues(values)
val scaleX = values[Matrix.MSCALE_X] // X轴缩放值
val scaleY = values[Matrix.MSCALE_Y] // Y轴缩放值
val transX = values[Matrix.MTRANS_X] // X轴平移值
val transY = values[Matrix.MTRANS_Y] // Y轴平移值
val dWidth = drawable.intrinsicWidth * scaleX // 图片当前显示宽度
val dHeight = drawable.intrinsicHeight * scaleY // 图片当前显示高度
val viewWidth = width // 视图宽度
val viewHeight = height // 视图高度
var deltaX = 0f // 需要修正的X轴位移
var deltaY = 0f // 需要修正的Y轴位移
// 水平边界检查
when {
// 图片宽度小于视图宽度 → 居中显示
dWidth < viewWidth -> deltaX = (viewWidth - dWidth) / 2 - transX
// 图片左边缘超出视图左边界 → 向右拉回
transX > 0 -> deltaX = -transX
// 图片右边缘超出视图右边界 → 向左拉回
transX + dWidth < viewWidth -> deltaX = viewWidth - (transX + dWidth)
}
// 垂直边界检查(逻辑同水平方向)
when {
dHeight < viewHeight -> deltaY = (viewHeight - dHeight) / 2 - transY
transY > 0 -> deltaY = -transY
transY + dHeight < viewHeight -> deltaY = viewHeight - (transY + dHeight)
}
// 应用修正
if (deltaX != 0f || deltaY != 0f) {
imageMatrixInternal.postTranslate(deltaX, deltaY)
imageMatrix = imageMatrixInternal
invalidate() // 触发重绘
}
}
// 缩放手势检测器(处理双指缩放)
private val scaleDetector = ScaleGestureDetector(context,
object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
override fun onScale(detector: ScaleGestureDetector): Boolean {
val scaleFactor = detector.scaleFactor // 本次缩放因子
val newScale = scale * scaleFactor // 计算新缩放比例
// 限制缩放范围在[minScale, maxScale]之间
if (newScale in minScale..maxScale) {
scale = newScale
// 以双指中心点为缩放锚点
imageMatrixInternal.postScale(
scaleFactor,
scaleFactor,
detector.focusX,
detector.focusY
)
imageMatrix = imageMatrixInternal
checkBounds() // 缩放后检查边界
invalidate() // 重绘视图
}
return true
}
})
// 手势检测器(处理双击事件)
private val gestureDetector = GestureDetector(context,
object : GestureDetector.SimpleOnGestureListener() {
// 双击切换最大/最小缩放
override fun onDoubleTap(e: MotionEvent): Boolean {
// 计算目标缩放值(当前小于中间值则放大,否则缩小)
val targetScale = if (scale < (minScale + maxScale) / 2) maxScale else minScale
val factor = targetScale / scale // 计算缩放因子
scale = targetScale
// 以点击点为中心缩放
imageMatrixInternal.postScale(factor, factor, e.x, e.y)
imageMatrix = imageMatrixInternal
checkBounds() // 边界检查
invalidate() // 重绘
return true
}
})
init {
scaleType = ScaleType.MATRIX // 必须设置为MATRIX才能自定义变换
}
/**
* 触摸事件处理(核心逻辑)
* 处理三种操作:
* 1. 双指缩放 → 由scaleDetector处理
* 2. 双击 → 由gestureDetector处理
* 3. 单指拖动 → 直接计算位移
*/
override fun onTouchEvent(event: MotionEvent): Boolean {
// 优先传递事件给手势检测器
scaleDetector.onTouchEvent(event)
gestureDetector.onTouchEvent(event)
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
// 记录按下坐标
lastPoint.set(event.x, event.y)
isDragging = true
canIntercept = true // 允许父容器拦截事件(初始状态)
// 请求父容器不拦截事件(独占触摸流)
parent.requestDisallowInterceptTouchEvent(true)
}
MotionEvent.ACTION_MOVE -> {
if (event.pointerCount == 1) { // 单指移动
val dx = abs(event.x - lastPoint.x) // X轴移动距离
val dy = abs(event.y - lastPoint.y) // Y轴移动距离
/**
* 冲突解决关键逻辑:
* 当以下条件满足时,允许父容器(如ViewPager2)拦截事件:
* 1. 当前允许拦截(canIntercept=true)
* 2. 水平移动距离超过阈值(touchSlop)
* 3. 水平移动距离大于垂直移动距离(判断为水平滑动)
* 4. 当前未放大(scale <= minScale)
*/
if (canIntercept && dx > touchSlop && dx > dy && scale <= minScale) {
parent.requestDisallowInterceptTouchEvent(false)
canIntercept = false
return false // 放弃当前手势
}
// 正常处理平移(非缩放状态或正在拖拽)
if (isDragging || !scaleDetector.isInProgress) {
imageMatrixInternal.postTranslate(
event.x - lastPoint.x, // X轴位移
event.y - lastPoint.y // Y轴位移
)
imageMatrix = imageMatrixInternal
lastPoint.set(event.x, event.y) // 更新坐标
invalidate() // 重绘
}
}
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
isDragging = false
checkBounds() // 手指抬起后修正边界
canIntercept = false
}
MotionEvent.ACTION_POINTER_UP -> {
// 多指操作时强制独占事件(防止父容器干扰)
parent.requestDisallowInterceptTouchEvent(true)
canIntercept = false
}
}
return true
}
/**
* 视图尺寸变化回调(首次布局或尺寸改变时触发)
* 关键作用:计算初始缩放比例并使图片居中显示
*/
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
drawable?.let {
val viewW = w.toFloat() // 视图新宽度
val viewH = h.toFloat() // 视图新高度
val dWidth = it.intrinsicWidth.toFloat() // 图片原始宽度
val dHeight = it.intrinsicHeight.toFloat() // 图片原始高度
if (dWidth <= 0 || dHeight <= 0) return@let // 无效尺寸跳过
// 计算最小缩放比例(适应视图)
val scaleX = viewW / dWidth
val scaleY = viewH / dHeight
minScale = min(scaleX, scaleY).coerceAtLeast(0.1f) // 防止除零
maxScale = minScale * 4 // 最大缩放为最小缩放的4倍
scale = minScale // 重置当前缩放
// 初始化矩阵变换
imageMatrixInternal.reset()
// 先缩放
imageMatrixInternal.postScale(minScale, minScale)
// 后平移(居中显示)
val dx = (viewW - dWidth * minScale) / 2
val dy = (viewH - dHeight * minScale) / 2
imageMatrixInternal.postTranslate(dx, dy)
imageMatrix = imageMatrixInternal
invalidate() // 重绘
}
}
/**
* 垂直滚动检查(用于嵌套滚动容器)
* @return true表示当前方向可以滚动(还有内容可显示)
*/
override fun canScrollVertically(direction: Int): Boolean {
val drawable = drawable ?: return false
imageMatrixInternal.getValues(matrixValues)
val transY = matrixValues[Matrix.MTRANS_Y] // 当前垂直平移量
val scaleY = matrixValues[Matrix.MSCALE_Y] // 当前垂直缩放值
val imageHeight = drawable.intrinsicHeight * scaleY // 图片显示高度
val viewHeight = height.toFloat() // 视图高度
return when {
// 向上滚动检查:顶部是否有隐藏内容(transY < 0表示有内容在上方)
direction < 0 -> transY < 0
// 向下滚动检查:底部是否有隐藏内容
direction > 0 -> transY + imageHeight > viewHeight
else -> false
}
}
/**
* 水平滚动检查(解决与ViewPager2的滑动冲突关键)
* 核心逻辑:仅在放大状态下消费水平滑动事件
*/
override fun canScrollHorizontally(direction: Int): Boolean {
return if (scale > minScale) {
// 放大时走默认检查逻辑(根据平移量判断)
super.canScrollHorizontally(direction)
} else {
// 未放大时返回false,让父容器(ViewPager2)处理水平滑动
false
}
}
}
emmmm, 作者能力有限,这个view也是研究了半天才写出来的,结合着ViewPager使用时的效果并不是很好,因为滑动冲突相关这一块处理的还不是很流畅,希望有大佬能优化一下,并且在评论区分享一下方法;单独使用这个组件的话还是很丝滑的。
ImagePagerAdapter
class ImagePagerAdapter(
private val data: List<String> // 数据源:支持网络URL或本地路径的字符串集合
) : RecyclerView.Adapter<ImagePagerAdapter.VH>() { // 继承自RecyclerView.Adapter
/**
* ViewHolder内部类(持有ZoomImageView实例)
* 关键点:直接使用ZoomImageView作为itemView,省去XML布局
*/
inner class VH(val view: ZoomImageView) : RecyclerView.ViewHolder(view) // 参数为ZoomImageView实例
/**
* 创建ViewHolder时初始化ZoomImageView
* 注意:此处动态创建View,而非通过XML inflate
*/
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
val zoomImageView = ZoomImageView(parent.context).apply {
// 设置全屏布局参数(关键:必须MATCH_PARENT才能正常显示)
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, // 宽度填满父容器
ViewGroup.LayoutParams.MATCH_PARENT // 高度填满父容器
)
// 必须设置为MATRIX缩放类型才能支持ZoomImageView的缩放功能
scaleType = ImageView.ScaleType.MATRIX
}
return VH(zoomImageView) // 返回持有ZoomImageView的ViewHolder
}
/**
* 绑定数据到ViewHolder
* 潜在问题:未处理图片加载时的占位图/错误图显示逻辑
*/
override fun onBindViewHolder(holder: VH, position: Int) {
// 使用ImageLoader异步加载图片(注意:未实现加载取消逻辑)
ImageLoader.load(data[position]) { bmp: Bitmap? ->
if (bmp != null) {
// 加载成功:设置Bitmap到ZoomImageView
holder.view.setImageBitmap(bmp)
} else {
// 加载失败:显示默认背景(建议:改用专门的error placeholder)
holder.view.setImageResource(R.drawable.ic_launcher_background)
}
}
// 注意:此处未保存加载任务,滑动时可能导致图片错位(RecyclerView复用问题)
}
/** 返回数据总量 */
override funnPageLimit配合)
// 4. 未处理Bitmap回收逻辑(大量高清图可能导致OOM)
}
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<!-- <androidx.viewpager2.widget.ViewPager2-->
<!-- android:id="@+id/viewPager"-->
<!-- android:layout_width="match_parent"-->
<!-- android:layout_height="match_parent"-->
<!-- app:layout_constraintTop_toTopOf="parent"-->
<!-- app:layout_constraintBottom_toBottomOf="parent"/>-->
<com.example.imagebrowsingexample.ZoomImageView
android:id="@+id/image"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:ignore="MissingConstraints" />
</androidx.constraintlayout.widget.ConstraintLayout>
MainActivity
class MainActivity : AppCompatActivity() {
private lateinit var vb: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
vb = ActivityMainBinding.inflate(layoutInflater).also { setContentView(it.root) }
// val images = listOf(
// "https://picsum.photos/id/1003/800/500",
// "https://picsum.photos/id/1011/800/500",
// "https://picsum.photos/id/1035/800/500"
// )
// vb.viewPager.adapter = ImagePagerAdapter(images)
// vb.viewPager.offscreenPageLimit = 2
ImageLoader.load("https://picsum.photos/id/1003/800/500") { bmp: Bitmap? ->
if (bmp != null) {
// 加载成功:设置Bitmap到ZoomImageView
vb.image.setImageBitmap(bmp)
} else {
// 加载失败:显示默认背景(建议:改用专门的error placeholder)
vb.image.setImageResource(R.drawable.ic_launcher_background)
}
}
}
}