Android杂谈(二):缩放图片

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 区别
特性ViewPagerViewPager2
基于组件android.view.ViewGroupRecyclerView
垂直方向滑动支持不支持支持
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 总结
特性ViewPagerViewPager2
是否推荐使用不推荐推荐
支持垂直滑动支持
与 Fragment 适配性一般非常好
与 RecyclerView 接轨原生集成

2. BitmapFactory

2.1 Bitmap 与 BitmapFactory 简介
  • Bitmap:Android 内存中的位图像素矩阵,呈 ARGB/RGB 等格式。

  • BitmapFactory:静态工具类,负责将多种数据源(资源文件、文件路径、网络流、字节数组…)**解码(decode)**为 Bitmap 对象。

  • 解码过程可通过 BitmapFactory.Options 精细控制尺寸、像素格式、缩放、复用等行为。

2.2 常见解码方法
方法典型场景备注
decodeResource(res, id, opts?)读取 res/drawablemipmap 图片最常用;支持密度自动缩放
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_F16API 1+ (RGBA_F16 26+)
inScaled是否按密度缩放(默认 trueAPI 1+
inDensity / inTargetDensity原图 / 目标屏幕密度API 1+
inMutable请求可变 Bitmap,便于后续编辑API 11+
inBitmap复用已存在 Bitmap 内存API 11+
inPremultiplied是否使用预乘 AlphaAPI 19+
inPreferredColorSpace建议色彩空间 (sRGB, DisplayP3…)API 26+
outMimeType解码出的 MIME,如 "image/png"只读
2.4 参考资料

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. 平移矩阵

    [ 1  0  dx ]
    [ 0  1  dy ]
    [ 0  0  1  ]
    
  2. 缩放矩阵

    [ sx  0   0 ]
    [ 0   sy  0 ]
    [ 0   0   1 ]
    
  3. 旋转矩阵(角度为 0)

    [ cosθ  -sinθ  0 ]
    [ sinθ  cosθ   0 ]
    [ 0     0      1 ]
    
  4. 错切矩阵

    [ 1   kx  0 ]
    [ ky  1   0 ]
    [ 0   0   1 ]
    
3.4 矩阵乘法

变换通过矩阵乘法组合:

Result = MatrixA × MatrixB

注意顺序:矩阵乘法不满足交换律,A × B ≠ B × A

3.5 Matrix 核心操作
  1. 预乘

    preXXX() 方法:新变换先于当前变换执行

    matrix.preScale(0.5f, 0.5f)  // 先缩放
    matrix.preRotate(45f)        // 后旋转
    
  2. 后乘

    postXXX() 方法:新变换后于当前变换执行

    matrix.postRotate(45f)       // 先旋转
    matrix.postScale(0.5f, 0.5f) // 后缩放
    
  3. 设置方法

    直接设置特定变换:

    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 使用流程
  1. 实例化 GestrueDetector

  2. 实现 OnGestureListener 接口

  3. 将触摸事件传递给 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)
            }
        }
    }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值