在 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)。

@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 中實現圖片縮放的核心要點:

  1. Modifier.graphicsLayer: 高效處理 Scale 和 Translation。
  2. detectTransformGestures: 一站式獲取縮放係數 (zoom) 和位移向量 (pan)。
  3. State Management: 維護 scale 和 offset 狀態。
  4. 邊界計算: 這一步是難點,需要根據當前縮放後的圖片尺寸與容器尺寸對比,限制 offset 的範圍,防止圖片被拖出屏幕。
  5. 交互細節: 結合 detectTapGestures 實現雙擊縮放,結合 Animatable 實現平滑過渡。

通過這些組合,可以構建出一個性能媲美原生 PhotoView 的 Compose 圖片組件。