Jetpack Composeで画像をズームする その1

この記事をシェア

この記事では、Jetpack Composeで画像をピンチジェスチャーでズームする処理について説明します。

単純にズームするだけならとても簡単に実現できるのですが、操作性を考慮すると意外なほど奥が深いので、何回かに分けて紹介します。今回はその1・基本編です。

基本の実装

まずはもっともシンプルに画像をズームするサンプルです。このサンプルで基本の考え方を理解しましょう。

@Composable
fun ZoomImageSample() {
    var scale by remember { mutableStateOf(1f) }
    Image(
        painter = painterResource(id = R.drawable.bird),
        contentDescription = "Zoomable bird image",
        contentScale = ContentScale.Fit,
        modifier = Modifier
            .fillMaxSize()
            .pointerInput(Unit) {
                detectTransformGestures { _, _, zoom, _ ->
                    scale *= zoom
                }
            }
            .graphicsLayer {
                scaleX = scale
                scaleY = scale
            }
    )
}

画像のズームは大きく分けて2つの処理で実現します。1つ目がズームジェスチャーの検出、2つ目が画像の表示倍率の変更です。

ズームジェスチャーの検出

ズームジェスチャーの検出はModifier.pointerInputを使います。pointerInputのラムダはPointerInputScopeがレシーバになっていて、タッチイベントを扱うことができます。このラムダ内でdetectTransformGesturesを使ってジェスチャーを検出します。

detectTransformGesturesのラムダは、ジェスチャーを検出した時に呼び出されるonGestureコールバックです。コールバックの第3引数がズームの変化量になっています。onGestureが前回呼び出された時と比べて拡大されていれば1より大きな値が、縮小されていれば1より小さな値が渡されます。これを現在のscaleに掛けて、新しいscaleの値を得ます。ここで、scaleは現在の画像の表示倍率を保持するための変数です。scaleの変更をトリガに表示を更新する必要があるため、MutableStateになっています。

なお、ズームジェスチャーの検出はModifier.transformableでも可能ですが、こちらは1本指のジェスチャーを受け付けないため、このあとドラッグジェスチャーに対応する際に面倒になります。そのためこの記事ではModifier.pointerInputを使います。

表示倍率の変更

画像の表示倍率の変更はModifier.graphicsLayerで実現できます。graphicsLayerの引数は、GraphicsLayerScopeをレシーバに受け取るラムダです。このスコープでscaleXscaleYを変更すると、縦横の表示倍率を変更できます。

なお、graphicsLayerには、ラムダを引数にとるタイプと、個別のパラメータを引数にとるタイプがあります。後者はパラメータが変更されるたびに再コンポジションが発生するので、パフォーマンスに影響が出る場合があります。とくに理由がなければラムダタイプを使う方が良いです。

ちなみに、表示倍率の変更はModifier.scaleでも実現できますが、処理効率とコードの見通しの観点から、この記事ではgraphicsLayerを使います。処理効率については、先ほど説明したパラメータを引数にとるgraphicsLayerと同様です。Modifier.scaleを使うと、scaleが変更されるたびに再コンポジションが発生します。コードの見通しについては、このあと画像の表示位置も変更するようにする際に、graphicsLayerを使えば処理を一か所にまとめられるので見通しが良くなります。

画像の移動

次は、ズームに加えて上下左右に画像をドラッグで移動できるようにします。

@Composable
fun ZoomImageSample() {
    var scale by remember { mutableStateOf(1f) }
    var offset by remember { mutableStateOf(Offset.Zero) }
    Image(
        painter = painterResource(id = R.drawable.bird),
        contentDescription = "Zoomable bird image",
        contentScale = ContentScale.Fit,
        modifier = Modifier
            .fillMaxSize()
            .pointerInput(Unit) {
                detectTransformGestures { _, pan, zoom, _ ->
                    scale *= zoom
                    offset += pan
                }
            }
            .graphicsLayer {
                scaleX = scale
                scaleY = scale
                translationX = offset.x
                translationY = offset.y
            }
    )
}

ドラッグジェスチャーは、先ほどのdetectTransformGesturesonGestureコールバックで検出できます。onGestureの第2引数はpanです。panは、onGestureが前回呼び出されたときからどれだけジェスチャーが平行移動したかをOffsetで表しています。これを現在のoffsetに足して、新しいoffsetの値を算出します。ここでoffsetscaleと同様にMutableStateになっています。offsetの変更が表示更新のトリガになります。

表示する画像位置の変更は、graphicsLayerのラムダ内でGraphicsLayerScopetransitionXtransitionYを変更すると実現できます。

状態の分離

ここで一旦、scaleとoffsetの管理を別クラスに切り出して、UIと状態管理を分離します。この先、scaleとoffsetに対する処理が複雑になっていくのに備えるためです。

@Stable
class ZoomState {
    private var _scale = mutableStateOf(1f)
    val scale: Float
        get() = _scale.value

    private var _offsetX = mutableStateOf(0f)
    val offsetX: Float
        get() = _offsetX.value

    private var _offsetY = mutableStateOf(0f)
    val offsetY: Float
        get() = _offsetY.value

    fun applyGesture(pan: Offset, zoom: Float) {
        _scale.value *= zoom
        _offsetX.value += pan.x
        _offsetY.value += pan.y
    }
}

ZoomStateクラスに_scale_offsetX_offsetYの3つのMutableStateを持たせます。それぞれ、クラス外部からはread onlyなFloatプロパティとして見えるようにgetterを作成しておきます。

applyGestureはジェスチャー検出時に呼び出す関数です。この関数でpanとzoomの変化量を受け取り、クラス内部のプロパティを更新します。

@Composable
fun rememberZoomState() = remember { ZoomState() }

ZoomStateクラスをremember変数として取得するrememberZoomState関数も作成しておきます。

@Composable
fun ZoomImageSample() {
    val zoomState = rememberZoomState()
    Image(
        painter = painterResource(id = R.drawable.bird),
        contentDescription = "Zoomable bird image",
        contentScale = ContentScale.Fit,
        modifier = Modifier
            .fillMaxSize()
            .pointerInput(Unit) {
                detectTransformGestures { _, pan, zoom, _ ->
                    zoomState.applyGesture(pan, zoom)
                }
            }
            .graphicsLayer {
                scaleX = zoomState.scale
                scaleY = zoomState.scale
                translationX = zoomState.offsetX
                translationY = zoomState.offsetY
            }
    )
}

UI側では、rememberZoomState関数を使ってzoomStateを作成し、ジェスチャー検出時はapplyGestureを呼び出します。graphicsLayerでセットする値は、zoomStateのプロパティを参照します。

制約条件の追加

さて、ここまでで基本的なジェスチャーの検出と表示への反映はできているのですが、このままだと無限に拡大縮小できてしまったり、画像を完全に画面外に追い出したりできてしまいます。そこで、scaleとoffsetに適切に制約条件を加えます。

倍率の制約

まずは倍率に制約を加えます。ここでは、初期状態の倍率を1として、1から5までの倍率に制限するようにしてみます。

class ZoomState(private val maxScale: Float) {
    fun applyGesture(pan: Offset, zoom: Float) {
        _scale.value = (_scale.value * zoom).coerceIn(1f, maxScale)
        _offsetX.value += pan.x
        _offsetY.value += pan.y
    }
}

fun rememberZoomState(maxScale: Float) = remember { ZoomState(maxScale) }

fun ZoomImageSample() {
    val zoomState = rememberZoomState(maxScale = 5f)
    Image( ... )
}

まず、ZoomStateクラスに最大倍率を表すmaxScaleプロパティを追加します。そしてapplyGesture_scaleを変更する際に、coerceIn_scaleの値を1からmaxScaleの範囲に収まるようにします。

これに合わせてrememberZoomState関数も変更し、UI側からmaxScale = 5fを指定します。

これで、画像の倍率を制限することができました。

位置の制約

最後に、画像の端が画面の端より内側に移動しないように、offsetにも制約を加えます。

offsetの制約条件を算出するには、画像サイズと、コンポーザブル要素のサイズ(画面上でレイアウトした時のサイズ。Viewのサイズという方が分かりやすいかも。)が必要になります。まずはそれらを取得する部分を説明します。

fun ZoomImageSample() {
    val zoomState = rememberZoomState(maxScale = 5f)
    val painter = painterResource(id = R.drawable.bird)
    zoomState.setImageSize(painter.intrinsicSize)
    Image(
        painter = painter,
        modifier = Modifier
            .onSizeChanged { size ->
                zoomState.setLayoutSize(size.toSize())
            }
            .pointerInput(Unit) { ... }
            .graphicsLayer {}
        ...
    )
}

画像サイズはPainter.intrinsicSizeで取得できます。これをsetImageSizeZoomStateに渡します。setImageSizeの中身は後述します。

サンプルでは画像リソースを使っているので、初回コンポーズの時点で画像サイズが確定していますが、非同期に画像を表示する場合は初回コンポーズ時点では画像サイズが確定していないかもしれません。ですが何らかのコールバック関数などで画像サイズを取得できると思います。例えばCoilのAsyncImageでは、onSuccessコールバックのAsyncImagePainter.State.SuccessからPainterにアクセス可能です。

レイアウトのサイズは、Modifier.onSizeChangedで取得できます。onSizeChangedのラムダは、レイアウトが変化するたびに呼び出されるので、画面が回転したりサイズが変更されたりしても対応できます。こちらもsetLayoutSizeZoomStateに渡します。

ZoomStatesetImageSizesetLayoutSizeの実装は下記のようになります。

class ZoomState(private val maxScale: Float) {
    private var imageSize = Size.Zero
    fun setImageSize(size: Size) {
        imageSize = size
        updateFitImageSize()
    }

    private var layoutSize = Size.Zero
    fun setLayoutSize(size: Size) {
        layoutSize = size
        updateFitImageSize()
    }

    private var fitImageSize = Size.Zero
    private fun updateFitImageSize() {
        if ((imageSize == Size.Zero) || (layoutSize == Size.Zero)) {
            fitImageSize = Size.Zero
            return
        }

        val imageAspectRatio = imageSize.width / imageSize.height
        val layoutAspectRatio = layoutSize.width / layoutSize.height

        fitImageSize = if (imageAspectRatio > layoutAspectRatio) {
            imageSize * (layoutSize.width / imageSize.width)
        } else {
            imageSize * (layoutSize.height / imageSize.height)
        }
    }
}

それぞれプロパティに値を保存した後で、updateFitImageSizefitImageSizeを更新します。fitImageSizeは、画像をレイアウトにフィットさせるように拡大または縮小した時のサイズです。画像サイズとレイアウトサイズのアスペクト比によって、widthまたはheightの比例計算で算出します。下の図は画像がレイアウトよりも横長の場合を表しています。

class ZoomState(private val maxScale: Float) {
    fun applyGesture(pan: Offset, zoom: Float) {
        _scale.value = (_scale.value * zoom).coerceIn(1f, maxScale)

        val boundX = max((fitImageSize.width * _scale.value - layoutSize.width), 0f) / 2f
        _offsetX.value = (_offsetX.value + pan.x).coerceIn(-boundX, boundX)

        val boundY = max((fitImageSize.height * _scale.value - layoutSize.height), 0f) / 2f
        _offsetY.value = (_offsetY.value + pan.y).coerceIn(-boundY, boundY)
    }
}

applyGestureでは、fitImageSizelayoutSizeを元に、offsetの取りうる値を決めます。

下の図はoffsetとfitImageSize、layoutSizeの関係をx軸について図示したものです。

fitImageSizeにscaleを掛けると、現在の画像の表示サイズになります。offsetの原点はレイアウトの中心点です。画像の左端がレイアウトの左端よりも右側にあるためには、

offset <= (fitImageSize.width * scale) / 2 - layoutSize.width / 2

の関係が成り立っている必要があります。画像の右端については、符号を入れかえて同様に考えることができます。

ただし、画像の幅がレイアウトの幅よりも小さい場合(画像がレイアウトよりも縦長の場合)は、横方向には動かしたくないので、offsetXは常にゼロにします。コード中のmax()で判定している部分がこれに該当します。

y軸についても同じように値の範囲を決めます。

これで、画像の移動範囲を定めることができました。

最終コードと次回予告

最後にソースコード全体を掲載しておきます。適切に制約を入れつつ、ズームと移動ができるようになりました。

@Stable
class ZoomState(private val maxScale: Float) {
    private var _scale = mutableStateOf(1f)
    val scale: Float
        get() = _scale.value

    private var _offsetX = mutableStateOf(0f)
    val offsetX: Float
        get() = _offsetX.value

    private var _offsetY = mutableStateOf(0f)
    val offsetY: Float
        get() = _offsetY.value

    private var layoutSize = Size.Zero
    fun setLayoutSize(size: Size) {
        layoutSize = size
        updateFitImageSize()
    }

    private var imageSize = Size.Zero
    fun setImageSize(size: Size) {
        imageSize = size
        updateFitImageSize()
    }

    private var fitImageSize = Size.Zero
    private fun updateFitImageSize() {
        if ((imageSize == Size.Zero) || (layoutSize == Size.Zero)) {
            fitImageSize = Size.Zero
            return
        }

        val imageAspectRatio = imageSize.width / imageSize.height
        val layoutAspectRatio = layoutSize.width / layoutSize.height

        fitImageSize = if (imageAspectRatio > layoutAspectRatio) {
            imageSize * (layoutSize.width / imageSize.width)
        } else {
            imageSize * (layoutSize.height / imageSize.height)
        }
    }

    fun applyGesture(pan: Offset, zoom: Float) {
        _scale.value = (_scale.value * zoom).coerceIn(1f, maxScale)

        val boundX = max((fitImageSize.width * _scale.value - layoutSize.width), 0f) / 2f
        _offsetX.value = (_offsetX.value + pan.x).coerceIn(-boundX, boundX)

        val boundY = max((fitImageSize.height * _scale.value - layoutSize.height), 0f) / 2f
        _offsetY.value = (_offsetY.value + pan.y).coerceIn(-boundY, boundY)
    }
}

@Composable
fun rememberZoomState(maxScale: Float) = remember { ZoomState(maxScale) }

@Composable
fun ZoomImageSample() {
    val painter = painterResource(id = R.drawable.bird)
    val zoomState = rememberZoomState(maxScale = 5f)
    zoomState.setImageSize(painter.intrinsicSize)
    Image(
        painter = painter,
        contentDescription = "Zoomable bird image",
        contentScale = ContentScale.Fit,
        modifier = Modifier
            .fillMaxSize()
            .onSizeChanged { size ->
                zoomState.setLayoutSize(size.toSize())
            }
            .pointerInput(Unit) {
                detectTransformGestures { _, pan, zoom, _ ->
                    zoomState.applyGesture(pan = pan, zoom = zoom)
                }
            }
            .graphicsLayer {
                scaleX = zoomState.scale
                scaleY = zoomState.scale
                translationX = zoomState.offsetX
                translationY = zoomState.offsetY
            }
    )
}

以上で基本的な画像のズームは完成です。ただこのサンプルには、ドラッグジェスチャーで指を離した時のフリング(慣性)動作がありません。そのため、画像を拡大してドラッグした時に使いづらさを感じます。次回はこの点を改善していきます。

この記事をシェア