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

この記事をシェア

前回の「その1」では、Jetpack Composeを使って画像をズームする基本的な実装を紹介しました。今回はアニメーションを利用してさらに使い勝手を向上させる方法を説明します。「その1」のソースコードをベースに変更を加えていきますので、まだの方は先に「その1」をご覧いただくことをお勧めします。

今回のゴール

今回は2つのアニメーションを追加して、操作性を改善します。

1つ目は、ドラッグ終了時のフリングアニメーションです。指が離れた瞬間にスクロールがピタッと止まるのではなく、だんだんと速度が遅くなるやつです。このアニメーションがあると、画像を拡大して上下左右に移動するときの操作が滑らかになります。

2つ目は、倍率を1より小さくしようとしたときに、指を離すと倍率を1に戻すアニメーションです。このアニメーションがあると、これ以上縮小できないよ、ということが分かりやすくなります。

フリングアニメーション
倍率1に戻すアニメーション

ジェスチャー検出処理のカスタマイズ

では実装していきます。

フリングアニメーションや倍率1に戻すアニメーションを開始するタイミングは、ジェスチャー終了時(指を離したとき)です。そこでまずは、ジェスチャー終了のタイミングを検出できるようにします。また今回は使いませんが、ジェスチャー開始(指を触れたとき)もついでに検出できるようにしておきます。

さらに、ジェスチャーが発生した時刻も取得できるようにします。これはフリングアニメーションを実現するために必要になります。

前回のサンプルでは、ジェスチャー検出にPointerInputScope.detectTransformGesturesを使っていました。今回もこれを使いますが、標準APIのdetectTransformGesturesはジェスチャー開始・終了を通知してくれないので、TransformGestureDetector.ktからdetectTransformGesturesのコードをコピぺして、自分でカスタマイズします。

下記がカスタマイズ後のコードです。ハイライトしている行が変更を加えた部分です。

suspend fun PointerInputScope.detectTransformGestures(
    panZoomLock: Boolean = false,
    onGesture: (centroid: Offset, pan: Offset, zoom: Float, rotation: Float, timeMillis: Long) -> Unit,
    onGestureStart: () -> Unit = {},
    onGestureEnd: () -> Unit = {},
) {
    forEachGesture {
        awaitPointerEventScope {
            var rotation = 0f
            var zoom = 1f
            var pan = Offset.Zero
            var pastTouchSlop = false
            val touchSlop = viewConfiguration.touchSlop
            var lockedToPanZoom = false

            awaitFirstDown(requireUnconsumed = false)
            onGestureStart()
            do {
                val event = awaitPointerEvent()
                val canceled = event.changes.fastAny { it.isConsumed }
                if (!canceled) {
                    val zoomChange = event.calculateZoom()
                    val rotationChange = event.calculateRotation()
                    val panChange = event.calculatePan()

                    if (!pastTouchSlop) {
                        zoom *= zoomChange
                        rotation += rotationChange
                        pan += panChange

                        val centroidSize = event.calculateCentroidSize(useCurrent = false)
                        val zoomMotion = abs(1 - zoom) * centroidSize
                        val rotationMotion = abs(rotation * PI.toFloat() * centroidSize / 180f)
                        val panMotion = pan.getDistance()

                        if (zoomMotion > touchSlop ||
                            rotationMotion > touchSlop ||
                            panMotion > touchSlop
                        ) {
                            pastTouchSlop = true
                            lockedToPanZoom = panZoomLock && rotationMotion < touchSlop
                        }
                    }

                    if (pastTouchSlop) {
                        val centroid = event.calculateCentroid(useCurrent = false)
                        val effectiveRotation = if (lockedToPanZoom) 0f else rotationChange
                        if (effectiveRotation != 0f ||
                            zoomChange != 1f ||
                            panChange != Offset.Zero
                        ) {
                            onGesture(centroid, panChange, zoomChange, effectiveRotation, event.changes[0].uptimeMillis)
                        }
                        event.changes.fastForEach {
                            if (it.positionChanged()) {
                                it.consume()
                            }
                        }
                    }
                }
            } while (!canceled && event.changes.fastAny { it.pressed })
            onGestureEnd()
        }
    }
}

まず、detectTransformGesturesの引数にonGestureStartonGestureEndの2つのコールバックを追加しています。awaitFirstDownが最初のタッチの検出部なので、その直後でonGestureStartを呼び出します。ジェスチャーが続いている間はdo-whileループが回り、指がすべて離れるとループを抜けるので、ループの後ろでonGestureEndを呼び出します。

また、onGestureコールバックの引数にtimeMillisを追加しています。これがジェスチャーイベントの発生した時刻を表します。イベント発生時刻はPointerInputChange.uptimeMillisで取得できます。コード中ではeventPointerEventオブジェクトなので、PointerEvent.changesからPointerInputChangeを取得し、そこからuptimeMillisを取得して、onGestureの引数に渡しています。

以上でdetectTransformGesturesのカスタマイズは完了です。これで、ジェスチャー開始・終了のタイミングと、ジェスチャーイベント発生時刻をUI側で知ることができるようになりました。

コンポーザブル側の変更

次はコンポーザブル側の実装を見ていきます。変更するのはdetectTransformGesturesの引数のラムダだけですので、それ以外は省略してコードを掲載します。

@Composable
fun ZoomImageSample() {
    ...
    val scope = rememberCoroutineScope()
    Image(
        ...
        modifier = Modifier
            ...
            .pointerInput(Unit) {
                detectTransformGestures(
                    onGesture = { centroid, pan, zoom, _, timeMillis ->
                        scope.launch {
                            zoomState.applyGesture(
                                pan = pan,
                                zoom = zoom,
                                position = centroid,
                                timeMillis = timeMillis,
                            )
                        }
                    },
                    onGestureEnd = {
                        scope.launch {
                            zoomState.endGesture()
                        }
                    }
                )
            }
            ...
    )
}

onGestureでは、先ほど追加したtimeMillisに加えて、第一引数のcentroidZoomState.applyGestureに渡します。applyGestureの中身は後で説明します。

centroidは日本語で「重心」です。シングルタッチジェスチャー(ドラッグジェスチャー)の場合は、指が触れている座標を示します。これをZoomState.applyGestureではジェスチャーの「位置 (position) 」として使います。

また、アニメーションを実装する都合上、applyGestureはsuspend関数になるので、コルーチンスコープから呼び出すように変更しています。

onGestureEndでは、ZoomState.endGestureを呼び出します。こちらも中身は後で説明します。suspend関数になるので、コルーチンスコープから呼び出します。

ZoomStateの変更

次に、ZoomStateを変更していきます。最初にフリングアニメーションに対応し、次に倍率1に戻すアニメーションに対応します。

フリングアニメーション

フリングアニメーションはoffsetの変化をアニメーションする処理です。そこでまずはoffsetをMutableStateからAnimatableに変更します。

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

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

Animatableを使うと、フリングのように徐々にスピードを落とすようなアニメーションや、指定した数値に向かって変化していくアニメーションなどを簡単に実現できます。また、Stateの性質も併せ持っているので、値が変化した時に自動的にUIが更新されます。

applyGestureは下記のようになります。

private val velocityTracker = VelocityTracker()
private var shouldFling = true

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

    val boundX = max((fitImageSize.width * _scale.value - layoutSize.width), 0f) / 2f
    _offsetX.updateBounds(-boundX, boundX)
    launch {
        _offsetX.snapTo(_offsetX.value + pan.x)
    }

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

    velocityTracker.addPosition(timeMillis, position)

    if (zoom != 1f) {
        shouldFling = false
    }
}

applyGestureの引数には、ジェスチャー位置を表すpositionと、ジェスチャーイベント発生時刻を表すtimeMillisを追加しています。この2つの値は、VelocityTracker.addPositionに渡します。VelocityTrackerはx-y座標で表される点の移動速度を算出するAPIで、ジェスチャーの速度を算出するのに使えます。ジェスチャーイベントが発生するたびにaddPositionメソッドで位置と時刻をトラッキングさせることで、ジェスチャー終了時に適切な速度のアニメーションを開始できます。

offsetの値を変更する部分は、snapToを使うように変更しています。snapToAnimatableのメソッドですが、アニメーションなしで即座に指定した値を反映します。ただしsnapToはsuspend関数なので、applyGesture全体もsuspend関数にしています。

offsetの取りうる範囲の指定は、前回はcoerceInを使っていましたが、updateBoundsを使うように変更しています。updateBoundsで範囲を指定しておけば、その後のアニメーションがその範囲内で実行されます。したがってsnapToに値を設定するときの範囲チェックは不要です。さらに、フリングアニメーションも指定した範囲からはみ出さないように自動で停止してくれます。

shouldFlingは、ジェスチャー終了時にフリングアニメーションを実行するかどうかのフラグです。ここでは、2本指によるズームジェスチャーが発生した場合(zoom != 1)はフリングアニメーションを無効にしています。これは、思わぬ方向と速度でフリングアニメーションが発生してしまうのを防ぐためです。2本指ジェスチャーが終了するとき、2本の指を離すタイミングがわずかにずれるので、ジェスチャー終了時に一瞬だけ2点タッチから1点タッチになります。その際にpositionが大きく変化するので、VelocityTrackerが算出する速度と方向が意図しないものになってしまうのです。

endGestureは次のようになります。

private val velocityDecay = exponentialDecay<Float>()

suspend fun endGesture() = coroutineScope {
    if (shouldFling) {
        val velocity = velocityTracker.calculateVelocity()
        launch {
            _offsetX.animateDecay(velocity.x, velocityDecay)
        }
        launch {
            _offsetY.animateDecay(velocity.y, velocityDecay)
        }
    }
    shouldFling = true
}

x方向とy方向それぞれのoffsetに対して、animationDecayでアニメーションを開始しています。animationDecayはsuspend関数なので、endGestureもsuspend関数になっています。

animationDecayの第1引数はアニメーションの初速度です。ここで、applyGestureで仕込んでおいたvelocityTrackerを使います。calculateVelocityで速度を算出します。

animationDecayの第2引数はアニメーションの減速カーブです。ここでは標準的なexponentialDecayを使いました。引数を設定すると減速の速さや停止タイミングを調整できます。

まあまあな大工事になりましたが、ここまででフリングアニメーションに対応できました。

倍率1に戻すアニメーション

次は、倍率を1より小さくしようとしたときに1に戻すアニメーションを実装します。変更はZoomStateクラス内だけです。

まずはscaleをoffsetと同様にAnimatableに変更します。

private var _scale = Animatable(1f).apply {
    updateBounds(0.9f, maxScale)
}
val scale: Float
    get() = _scale.value

scaleの範囲は固定なので、Animatableオブジェクト作成と同時にupdateBoundsで範囲を設定しています。最小値が0.9なのは、縮小ジェスチャー実行中は倍率1より一回り小さく表示することを許容し、ジェスチャー終了時に倍率1に戻すことによって、これ以上縮小できないことを分かりやすくするためです。

applyGestureでscaleの値を変更する部分も、offsetと同じようにsnapToに変更します。

suspend fun applyGesture(
    ...
) = coroutineScope {
    launch {
        _scale.snapTo(_scale.value * zoom)
    }
    ...
}

endGestureでは、ジェスチャー終了時にscaleが1未満なら、1に戻すアニメーションを実行します。

suspend fun endGesture() = coroutineScope {
    ...
    if (_scale.value < 1f) {
        launch {
            _scale.animateTo(1f)
        }
    }
}

使うのはanimateToメソッドです。このメソッドを使うと、引数に指定した数値(ここでは1)に到達するまでアニメーションで動かしてくれます。

以上で倍率変更のアニメーションにも対応完了です。

まとめと次回予告

今回は2種類のアニメーションを実装して、画像のズームの操作性を改善しました。これらのアニメーションは実は、次回への伏線になっています。

次回は、これまでに作成したズーム可能な画像コンポーネントを、HorizontalPagerの上に配置します。HorizontalPager上に配置した場合は、ドラッグ操作が画像自体のドラッグなのかPagerのページ送りなのかを判定する必要があります。そこで、画像の端まで表示されている場合はページ送り、そうでない場合は画像のドラッグ、という判定をします。もし今回のアニメーションが実装されていない場合、画像の端までドラッグしたつもりだったのに実は数ピクセル残っていた、とか、倍率を1倍に戻したつもりだったのに指を離す瞬間にちょっとだけ拡大されてしまった、といったことが起こり、ページをスワイプしたいのにできないということが頻発します。そうした事態を避けるためにも、今回のアニメーションは必要なのでした。

最後に、ソースコード全体を掲載しておきます。

suspend fun PointerInputScope.detectTransformGestures(
    panZoomLock: Boolean = false,
    onGesture: (centroid: Offset, pan: Offset, zoom: Float, rotation: Float, timeMillis: Long) -> Unit,
    onGestureStart: () -> Unit = {},
    onGestureEnd: () -> Unit = {},
) {
    forEachGesture {
        awaitPointerEventScope {
            var rotation = 0f
            var zoom = 1f
            var pan = Offset.Zero
            var pastTouchSlop = false
            val touchSlop = viewConfiguration.touchSlop
            var lockedToPanZoom = false

            awaitFirstDown(requireUnconsumed = false)
            onGestureStart()
            do {
                val event = awaitPointerEvent()
                val canceled = event.changes.fastAny { it.isConsumed }
                if (!canceled) {
                    val zoomChange = event.calculateZoom()
                    val rotationChange = event.calculateRotation()
                    val panChange = event.calculatePan()

                    if (!pastTouchSlop) {
                        zoom *= zoomChange
                        rotation += rotationChange
                        pan += panChange

                        val centroidSize = event.calculateCentroidSize(useCurrent = false)
                        val zoomMotion = abs(1 - zoom) * centroidSize
                        val rotationMotion = abs(rotation * PI.toFloat() * centroidSize / 180f)
                        val panMotion = pan.getDistance()

                        if (zoomMotion > touchSlop ||
                            rotationMotion > touchSlop ||
                            panMotion > touchSlop
                        ) {
                            pastTouchSlop = true
                            lockedToPanZoom = panZoomLock && rotationMotion < touchSlop
                        }
                    }

                    if (pastTouchSlop) {
                        val centroid = event.calculateCentroid(useCurrent = false)
                        val effectiveRotation = if (lockedToPanZoom) 0f else rotationChange
                        if (effectiveRotation != 0f ||
                            zoomChange != 1f ||
                            panChange != Offset.Zero
                        ) {
                            onGesture(centroid, panChange, zoomChange, effectiveRotation, event.changes[0].uptimeMillis)
                        }
                        event.changes.fastForEach {
                            if (it.positionChanged()) {
                                it.consume()
                            }
                        }
                    }
                }
            } while (!canceled && event.changes.fastAny { it.pressed })
            onGestureEnd()
        }
    }
}

@Stable
class ZoomState(private val maxScale: Float) {
    private var _scale = Animatable(1f).apply {
        updateBounds(0.9f, maxScale)
    }
    val scale: Float
        get() = _scale.value

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

    private var _offsetY = Animatable(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)
        }
    }

    private val velocityTracker = VelocityTracker()
    private var shouldFling = true

    suspend fun applyGesture(
        pan: Offset,
        zoom: Float,
        position: Offset,
        timeMillis: Long
    ) = coroutineScope {
        launch {
            _scale.snapTo(_scale.value * zoom)
        }

        val boundX = max((fitImageSize.width * _scale.value - layoutSize.width), 0f) / 2f
        _offsetX.updateBounds(-boundX, boundX)
        launch {
            _offsetX.snapTo(_offsetX.value + pan.x)
        }

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

        velocityTracker.addPosition(timeMillis, position)

        if (zoom != 1f) {
            shouldFling = false
        }
    }

    private val velocityDecay = exponentialDecay<Float>()

    suspend fun endGesture() = coroutineScope {
        if (shouldFling) {
            val velocity = velocityTracker.calculateVelocity()
            launch {
                _offsetX.animateDecay(velocity.x, velocityDecay)
            }
            launch {
                _offsetY.animateDecay(velocity.y, velocityDecay)
            }
        }
        shouldFling = true

        if (_scale.value < 1f) {
            launch {
                _scale.animateTo(1f)
            }
        }
    }
}

@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)
    val scope = rememberCoroutineScope()
    Image(
        painter = painter,
        contentDescription = "Zoomable bird image",
        contentScale = ContentScale.Fit,
        modifier = Modifier
            .fillMaxSize()
            .onSizeChanged { size ->
                zoomState.setLayoutSize(size.toSize())
            }
            .pointerInput(Unit) {
                detectTransformGestures(
                    onGesture = { centroid, pan, zoom, _, timeMillis ->
                        scope.launch {
                            zoomState.applyGesture(
                                pan = pan,
                                zoom = zoom,
                                position = centroid,
                                timeMillis = timeMillis,
                            )
                        }
                    },
                    onGestureEnd = {
                        scope.launch {
                            zoomState.endGesture()
                        }
                    }
                )
            }
            .graphicsLayer {
                scaleX = zoomState.scale
                scaleY = zoomState.scale
                translationX = zoomState.offsetX
                translationY = zoomState.offsetY
            }
    )
}
この記事をシェア