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 图片组件。

相关推荐
モンキー・D・小菜鸡儿2 小时前
Android13 新特性与适配指南
gitee·kotlin·安卓新特性
天下无敌笨笨熊7 小时前
kotlin函数式编程
开发语言·数据库·kotlin
QING6188 小时前
Kotlin Flow 去重 (distinctUntilChanged) 详解
kotlin·android studio·android jetpack
QING6188 小时前
Kotlin Flow 节流 (Throttle) 详解
android·kotlin·android jetpack
Kapaseker9 小时前
Context 知多少,组件通联有门道
android·kotlin
Nerve1 天前
FluxImageLoader : 基于Coil3封装的 Android 图片加载库,旨在提供简单、高效且功能丰富的图片加载解决方案
android·android jetpack
儿歌八万首1 天前
Jetpack Compose 实战:打造高性能轮播图 (Carousel) 组件
android·前端·kotlin
QING6181 天前
Kotlin Flow 防抖(Debounce)详解
android·kotlin·android jetpack
QING6181 天前
Kotlin Flow 防抖(Debounce)、节流(Throttle)、去重(distinctUntilChanged) —— 新手指南
android·kotlin·android jetpack