Jetpack Compose 实战:实现手势缩放图片 (Zoomable Image) 组件

在 Android 开发中,查看大图、手势缩放是一个非常高频的需求。在传统的 View 体系中,我们通常会使用 PhotoView 这样的第三方库。而在 Jetpack Compose 中,得益于强大的手势处理 API,我们可以用很少的代码自己实现一个功能完备的缩放组件。

本文将实现一个支持 双指缩放 (Pinch to Zoom)单指拖拽 (Pan) 以及 双击放大/复原 (Double Tap) 的图片组件。

1. 核心 API

Compose 提供了两个关键的 Modifier 来处理手势和变换:

  1. Modifier.pointerInput : 用于监听底层的手势事件。我们将主要使用 detectTransformGestures 来检测缩放和平移,以及 detectTapGestures 来检测双击。
  2. Modifier.graphicsLayer: 用于应用变换(缩放、平移、旋转),它比直接改变宽高等属性性能更好,因为它在 GPU 层面上操作,避免了不必要的重组 (Recomposition) 和重绘。

2. 基础实现:双指缩放与平移

首先,我们需要定义状态变量来保存当前的缩放比例 (scale) 和偏移量 (offset)。

kotlin 复制代码
@Composable
fun SimpleZoomableImage(
    painter: Painter,
    modifier: Modifier = Modifier
) {
    // 状态:缩放比例,默认为 1f
    var scale by remember { mutableFloatStateOf(1f) }
    // 状态:偏移量,默认为 (0, 0)
    var offset by remember { mutableStateOf(Offset.Zero) }

    Box(
        modifier = modifier
            // 裁剪超出边界的内容
            .clip(RectangleShape)
            .pointerInput(Unit) {
                detectTransformGestures { _, pan, zoom, _ ->
                    // 1. 计算新的缩放比例
                    scale *= zoom
                    // 限制最小缩放为 1倍,最大为 3倍
                    scale = scale.coerceIn(1f, 3f)

                    // 2. 计算新的偏移量 (只有放大时才允许拖动)
                    // 注意:这里需要根据 scale 进行累加
                    // 简单的实现可以直接累加 pan
                    if (scale > 1f) {
                        val newOffset = offset + pan
                        // 这里可以添加边界限制逻辑,暂且略过
                        offset = newOffset
                    } else {
                        offset = Offset.Zero
                    }
                }
            }
    ) {
        Image(
            painter = painter,
            contentDescription = null,
            contentScale = ContentScale.Fit,
            modifier = Modifier
                .fillMaxSize()
                .graphicsLayer {
                    // 应用变换
                    scaleX = scale
                    scaleY = scale
                    translationX = offset.x
                    translationY = offset.y
                }
        )
    }
}

3. 进阶功能:双击缩放与回弹动画

为了提供更好的用户体验,我们需要加入动画效果,并处理双击逻辑。当用户双击时,如果当前未放大,则放大到指定倍数;如果已放大,则恢复原状。

我们需要使用 Animatable 来替代简单的 MutableState,以便执行动画。

完整代码实现

下面是一个封装好的 ZoomableImage 组件,整合了双击、手势缩放和边界控制。

kotlin 复制代码
import androidx.compose.foundation.Image
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.unit.IntSize
import kotlinx.coroutines.launch

@Composable
fun ZoomableImage(
    painter: Painter,
    modifier: Modifier = Modifier,
    contentDescription: String? = null,
    maxScale: Float = 3f,
    minScale: Float = 1f
) {
    // 缩放比例动画状态
    var scale by remember { mutableFloatStateOf(1f) }
    // 偏移量动画状态
    var offset by remember { mutableStateOf(Offset.Zero) }
    
    // 用于计算边界
    var size by remember { mutableStateOf(IntSize.Zero) }

    Box(
        modifier = modifier
            .clip(RectangleShape) // 确保图片放大后不画出边界
            .onSizeChanged { size = it }
            .pointerInput(Unit) {
                // 处理双击事件
                detectTapGestures(
                    onDoubleTap = { tapOffset ->
                        // 如果当前已经放大了,则恢复原状
                        if (scale > 1f) {
                            scale = 1f
                            offset = Offset.Zero
                        } else {
                            // 双击放大:通常放大到最大倍数
                            // 这里可以做一个简单的计算,让双击点成为中心
                            // 为了演示简单,我们直接放大,不做复杂的中心点偏移计算
                            scale = maxScale
                        }
                    }
                )
            }
            .pointerInput(Unit) {
                // 处理手势变换(缩放和平移)
                detectTransformGestures { centroid, pan, zoom, _ ->
                    val oldScale = scale
                    val newScale = (scale * zoom).coerceIn(minScale, maxScale)
                    
                    // 1. 更新缩放
                    scale = newScale

                    // 2. 更新偏移量
                    // 为了让缩放过程更自然(以两指中心为基准),需要配合 centroid 计算 offset
                    // 这里的算法简化处理:仅处理平移 pan 和基于缩放的简单位移
                    // 真实的 PhotoView 算法会更复杂,需要考虑 centroid 的位置保持不动
                    
                    // 简单的平移逻辑:
                    var newOffset = offset + pan
                    
                    // 3. 边界限制 (Rubber Banding 简化版)
                    // 计算图片放大后的宽高
                    val imageWidth = size.width * scale
                    val imageHeight = size.height * scale
                    
                    // 计算允许的最大偏移量 (X轴和Y轴)
                    // 如果图片宽度大于容器,允许左右滑动;否则居中(偏移限制为0)
                    val maxOffsetX = ((imageWidth - size.width) / 2).coerceAtLeast(0f)
                    val maxOffsetY = ((imageHeight - size.height) / 2).coerceAtLeast(0f)
                    
                    // 限制 Offset 在 [-max, +max] 之间
                    newOffset = Offset(
                        x = newOffset.x.coerceIn(-maxOffsetX, maxOffsetX),
                        y = newOffset.y.coerceIn(-maxOffsetY, maxOffsetY)
                    )
                    
                    offset = newOffset
                }
            }
    ) {
        Image(
            painter = painter,
            contentDescription = contentDescription,
            contentScale = ContentScale.Fit,
            modifier = Modifier
                .fillMaxSize()
                .align(Alignment.Center)
                .graphicsLayer {
                    scaleX = scale
                    scaleY = scale
                    translationX = offset.x
                    translationY = offset.y
                }
        )
    }
}

4. 优化体验:添加动画

上面的代码是瞬时改变状态的。为了让体验更丝滑(例如双击放大时有过渡动画),我们可以使用 Animatable

kotlin 复制代码
@Composable
fun AnimatedZoomableImage(
    painter: Painter,
    modifier: Modifier = Modifier
) {
    val scale = remember { Animatable(1f) }
    val offset = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
    val scope = rememberCoroutineScope()

    Box(
        modifier = modifier
            .pointerInput(Unit) {
                detectTapGestures(
                    onDoubleTap = {
                        scope.launch {
                            if (scale.value > 1f) {
                                // 动画复原
                                scale.animateTo(1f)
                                offset.animateTo(Offset.Zero)
                            } else {
                                // 动画放大
                                scale.animateTo(3f)
                            }
                        }
                    }
                )
            }
            .pointerInput(Unit) {
                detectTransformGestures { _, pan, zoom, _ ->
                    scope.launch {
                        // 手势过程中通常使用 snapTo 直接设置值,避免动画延迟
                        scale.snapTo((scale.value * zoom).coerceIn(1f, 3f))
                        
                        // 简单的 Pan 处理
                        val newOffset = offset.value + pan
                        offset.snapTo(newOffset)
                    }
                }
            }
    ) {
        // Image ... (使用 scale.value 和 offset.value)
    }
}

5. 总结

在 Compose 中实现图片缩放的核心要点:

  1. Modifier.graphicsLayer: 高效处理 Scale 和 Translation。
  2. detectTransformGestures: 一站式获取缩放系数 (zoom) 和位移向量 (pan)。
  3. State Management: 维护 scale 和 offset 状态。
  4. 边界计算: 这一步是难点,需要根据当前缩放后的图片尺寸与容器尺寸对比,限制 offset 的范围,防止图片被拖出屏幕。
  5. 交互细节 : 结合 detectTapGestures 实现双击缩放,结合 Animatable 实现平滑过渡。

通过这些组合,可以构建出一个性能媲美原生 PhotoView 的 Compose 图片组件。

相关推荐
Kapaseker15 小时前
你不看会后悔的2025年终总结
android·kotlin
alexhilton18 小时前
务实的模块化:连接模块(wiring modules)的妙用
android·kotlin·android jetpack
幽络源小助理1 天前
下载安装AndroidStudio配置Gradle运行第一个kotlin程序
android·开发语言·kotlin
QING6181 天前
SupervisorJob子协程异常处理机制 —— 新手指南
android·kotlin·android jetpack
W个世界1 天前
06-区间与迭代
kotlin
Fate_I_C1 天前
Kotlin 中的 suspend(挂起函数)
android·开发语言·kotlin
凡小烦1 天前
看完你就是古希腊掌管Compose输入框的神!!!
android·kotlin
モンキー・D・小菜鸡儿1 天前
kotlin 斗牛小游戏
kotlin·小游戏
Fate_I_C1 天前
Kotlin 中 `@JvmField` 注解的使用
android·开发语言·kotlin