在 Android 开发中,查看大图、手势缩放是一个非常高频的需求。在传统的 View 体系中,我们通常会使用 PhotoView 这样的第三方库。而在 Jetpack Compose 中,得益于强大的手势处理 API,我们可以用很少的代码自己实现一个功能完备的缩放组件。
本文将实现一个支持 双指缩放 (Pinch to Zoom) 、单指拖拽 (Pan) 以及 双击放大/复原 (Double Tap) 的图片组件。
1. 核心 API
Compose 提供了两个关键的 Modifier 来处理手势和变换:
Modifier.pointerInput: 用于监听底层的手势事件。我们将主要使用detectTransformGestures来检测缩放和平移,以及detectTapGestures来检测双击。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 中实现图片缩放的核心要点:
- Modifier.graphicsLayer: 高效处理 Scale 和 Translation。
- detectTransformGestures: 一站式获取缩放系数 (zoom) 和位移向量 (pan)。
- State Management: 维护 scale 和 offset 状态。
- 边界计算: 这一步是难点,需要根据当前缩放后的图片尺寸与容器尺寸对比,限制 offset 的范围,防止图片被拖出屏幕。
- 交互细节 : 结合
detectTapGestures实现双击缩放,结合Animatable实现平滑过渡。
通过这些组合,可以构建出一个性能媲美原生 PhotoView 的 Compose 图片组件。