在 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)。
@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 組件,整合了雙擊、手勢縮放和邊界控制。
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。
@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 圖片組件。