ZoomIn (Out) できるLazyColumnを作ってみる


- ピンチインアウト
- ダブルタップ

で、ZoomIn (Out) する部品を考える

### コード

```kotlin
@Composable
fun ZoomableContent(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    val scale = remember { Animatable(1f) }
    val offset = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
    val constraintSize = remember { mutableStateOf(Size.Zero) }
    val scope = rememberCoroutineScope()

    val transformableState = rememberTransformableState { zoomChange, offsetChange, _ ->
        // ピンチインアウトは3倍が上限
        val newScale = (scale.value * zoomChange).coerceIn(1f, 3f)
        // コンテンツがズームインされた時（newScale > 1f）中央から最大どれだけ移動できるかを計算: (constraintSize.value.width * (newScale - 1)) / 2
        val maxOffsetX = if (newScale > 1f) (constraintSize.value.width * (newScale - 1)) / 2 else 0f
        val maxOffsetY = if (newScale > 1f) (constraintSize.value.height * (newScale - 1)) / 2 else 0f
        // 移動量が画面幅を超えないようにOffsetを制限
        val newOffsetX = (offset.value.x + offsetChange.x).coerceIn(-maxOffsetX, maxOffsetX)
        val newOffsetY = (offset.value.y + offsetChange.y).coerceIn(-maxOffsetY, maxOffsetY)
        scope.launch { scale.snapTo(newScale) }
        scope.launch { offset.snapTo(Offset(newOffsetX, newOffsetY)) }
    }

    val updateDoubleTapZoomOffset = { doubleTapOffset: Offset ->
        // ダブルタップは1.5倍が上限
        val newScale = if (scale.value > 1f) 1f else 1.5f
        val layoutWidth = constraintSize.value.width
        val layoutHeight = constraintSize.value.height

        val maxOffsetX = (layoutWidth * (newScale - 1)) / 2
        val maxOffsetY = (layoutHeight * (newScale - 1)) / 2

        val scaledFocalX = (doubleTapOffset.x - offset.value.x) * newScale
        val scaledFocalY = (doubleTapOffset.y - offset.value.y) * newScale
        val newOffset = Offset(
            x = (layoutWidth / 2 - scaledFocalX).coerceIn(-maxOffsetX, maxOffsetX),
            y = (layoutHeight / 2 - scaledFocalY).coerceIn(-maxOffsetY, maxOffsetY)
        )
        scope.launch { scale.animateTo(newScale, tween()) }
        scope.launch { offset.animateTo(newOffset, tween()) }
    }

    BoxWithConstraints(
        modifier = modifier
            .transformable(transformableState)
            .pointerInput(Unit) {
                detectTapGestures(
                    onDoubleTap = { doubleTapOffset ->
                        updateDoubleTapZoomOffset(doubleTapOffset)
                    }
                )
            }
            .graphicsLayer(
                scaleX = scale.value,
                scaleY = scale.value,
                translationX = offset.value.x,
                translationY = offset.value.y
            )
    ) {
        constraintSize.value = Size(constraints.maxWidth.toFloat(), constraints.maxHeight.toFloat())
        content()
    }
}
```
<br>

### 解説

https://developer.android.com/jetpack/compose/touch-input/pointer-input/multi-touch

`Modifier.transformable` でピンチインアウトのScaleとオフセットが取れるので `transformableState` でよしなに計算する

<br>

https://developer.android.com/jetpack/compose/touch-input/pointer-input/tap-and-press#double-tap

`Modifier.pointerInput()`の`detectTapGestures`でダブルタップのOffsetがとれるので、`updateDoubleTapZoomOffset` でよしなに計算する

<br>

```kotlin
.graphicsLayer(
    scaleX = scale.value,
    scaleY = scale.value,
    translationX = offset.value.x,
    translationY = offset.value.y
)
```
graphcsLayerに適応する


### 結果

|singleItem|clickableItem|fullSizeItem|
|:--:|:--:|:--:|
|[![singleItem](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/700755/60d0626e-1da6-c55e-71cb-5313d3ee5c7a.gif)](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/700755/60d0626e-1da6-c55e-71cb-5313d3ee5c7a.gif)|[![clickableItem](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/700755/e27306af-fad8-0d46-49c7-21401baaa0c3.gif)](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/700755/e27306af-fad8-0d46-49c7-21401baaa0c3.gif)|![fullSizeItem](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/700755/41db7bf1-2f57-c482-5050-8a20dbb7918f.gif)



課題

- 子要素のタップがあると、イベントが吸われやすい
- LazyColumnが縦スクロールを検知してくれたスクロールだけフリングが効く
- Zoomの伝搬みたいな、子要素にもZoomできるContentがあった場合どうなるかみてない

いずれも結構なカスタムが必要な気がする。

### 感想
公式ドキュメントがめっちゃヒントをくれていた🤪