Jetpack Composeで画像をズームする(4) Modifier関数を自作しよう

この記事をシェア

今回は画像ズームシリーズその4です。前回の「その3」まででHorizontalPagerの上に配置した画像をズームすることができるようになりました。今回はこれまでの内容を実装したModifier関数を自作することによって、UIコンポーザブルをすっきりとシンプルに記述できるようにします。

今回もソースコードは前回の結果をベースに変更を加えていきますので、まだの方は先に「その1」「その2」「その3」をご覧いただくことをお勧めします。

今回のゴール

今回はのゴールは、できるだけシンプルなUIのコードで、前回と同じ動作を実現することです。前回実装したズーム動作の動画を再掲しておきます。HorizontalPagerのスワイプ操作と、画像のズームや移動を両立させました。

そして、HorizontalPagerの上に配置したZoomImageSampleコンポーザブルの実装は以下のようになっていました。UI要素としてはImageを置いているだけにもかかわらず、Modifierの記述が非常に長く、読みづらいコードになってしまっています。今回はModifier関数を自作して、コードをすっきりさせていきます。

Modifier関数の作り方

Modifier関数は、Modifierインターフェースの拡張関数として定義します。メソッドチェーンで記述できるように、戻り値もModifierにします。

fun Modifier.myModifierFunction(): Modifier

Modifier関数を自作する方法は、その関数が「状態を持つ」かどうかによって異なります。「状態を持つ」というのは、「rememberを使っている」と言い換えてひとまずは問題ないでしょう。今回のズーム処理は、アニメーションを実現するためにrememberCoroutineScopeを使うので、状態を持つということになります。

状態を持つModifier関数は、Modifier.composedを使って実装します。rememberはComposable関数なので、Composable関数内部でしか使えません。一方でModifier関数はComposable関数ではないので、rememberを直接書くことはできません。この橋渡しをするのが、Modifier.composedです。composedの引数factoryは、Composable関数のラムダなので、ここにrememberを含む処理を書くことができます。

fun Modifier.composed(
    inspectorInfo: InspectorInfo.() -> Unit = NoInspectorInfo,
    factory: @Composable Modifier.() -> Modifier
): Modifier

inspectorInfoについては後で簡単に説明します。

Modifier.zoomableを作る

それでは、ZoomImageSampleに書いていた画像のズーム処理を、Modifier関数に実装します。関数名は、clickabledraggableといった公式のModifierに倣って、zoomableとしました。引数にZoomStateを受け取ります。

fun Modifier.zoomable(zoomState: ZoomState): Modifier = composed(
    inspectorInfo = debugInspectorInfo {
        name = "zoomable"
        properties["zoomState"] = zoomState
    }
) {
    val scope = rememberCoroutineScope()
    Modifier
        .onSizeChanged { size ->
            zoomState.setLayoutSize(size.toSize())
        }
        .pointerInput(Unit) {
            detectTransformGestures(
                onGestureStart = { zoomState.startGesture() },
                onGesture = { centroid, pan, zoom, _, timeMillis ->
                    val canConsume = zoomState.canConsumeGesture(pan = pan, zoom = zoom)
                    if (canConsume) {
                        scope.launch {
                            zoomState.applyGesture(
                                pan = pan,
                                zoom = zoom,
                                position = centroid,
                                timeMillis = timeMillis,
                            )
                        }
                    }
                    canConsume
                },
                onGestureEnd = {
                    scope.launch {
                        zoomState.endGesture()
                    }
                }
            )
        }
        .graphicsLayer {
            scaleX = zoomState.scale
            scaleY = zoomState.scale
            translationX = zoomState.offsetX
            translationY = zoomState.offsetY
        }
}

composedの引数factoryに、もともとZoomImageSampleに書いていた画像のズームに関する処理を書いています。アニメーションに必要なCoroutineScopeは、rememberCoroutineScopeで取得しています。その下でModifierを作成しています。onSizeChangedpointerInputgraphicsLayerを実装しています。これらの内容は前回と同じです。作成したModifierがそのまま、戻り値になります。

Modifier.zoomableを使う

Modifier.zoomableを使う側のZoomImageSampleは以下のようになります。

@Composable
fun ZoomImageSample(painter: Painter, isVisible: Boolean) {
    val zoomState = rememberZoomState(maxScale = 5f)
    zoomState.setImageSize(painter.intrinsicSize)
    LaunchedEffect(isVisible) {
        zoomState.reset()
    }
    Image(
        painter = painter,
        contentDescription = "Zoomable bird image",
        contentScale = ContentScale.Fit,
        modifier = Modifier
            .fillMaxSize()
            .clipToBounds()
            .zoomable(zoomState)
    )
}

resetsetImageSizeを呼び出す必要があるので、ZoomStateの定義はZoomImageSampleの中に残しています。が、それでもで非常にスッキリしました。Modifierが短く書けることで、UI構成が一目で分かりやすくなりますね。

inspectorInfoについて(おまけ)

最後に、composedの引数のinspectorInfoについて簡単に説明します。

fun Modifier.zoomable(zoomState: ZoomState): Modifier = composed(
    inspectorInfo = debugInspectorInfo {
        name = "zoomable"
        properties["zoomState"] = zoomState
    }
) {
    /* ... */
}

debugInspectorInfonamepropertiesに値を入れておくと、Android StudioのLayout Inspectorで値を確認することができます。Attributesタブのmodifierの下にzoomableが表示され、その下にちゃんとzoomStateがあります。zoomStateの各プロパティの値も確認できます。うまく使えばデバッグするときに便利そうです。

この記事をシェア