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

この記事をシェア

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

今回のゴール

今回はAccompanistのHorizontalPagerに配置した画像をズームできるようにします。ズームの状態によって、ジェスチャーの使い方を切り替えるのがポイントです。画像の倍率が1の場合や、画像を端までスクロールしてさらにスクロールしようとした場合は、ジェスチャーイベントをHorizontalPagerに伝えて、ページを移動させます。一方で、画像の拡大縮小や、拡大した画像を移動するときのジェスチャーは、HorizontalPagerには伝えないようにする必要があります。

HorizontalPagerを導入

まずは前回までに作成したズーム可能な画像のコンポーザブルをHorizontalPagerに配置します。

@OptIn(ExperimentalPagerApi::class)
@Composable
fun ZoomImageSampleOnPager() {
    val resources = listOf(R.drawable.bird, R.drawable.bird2, R.drawable.bird3)
    HorizontalPager(count = resources.size) { page ->
        val painter = painterResource(id = resources[page])
        val isVisible by remember {
            derivedStateOf {
                val offset = calculateCurrentOffsetForPage(page)
                (-1.0f < offset) and (offset < 1.0f)
            }
        }
        ZoomImageSample(painter, isVisible)
    }
}

HorizontalPagerに、前回までに作成したZoomImageSampleを配置しています。前回と違うのは、ZoomImageSampleが2つの引数をとるようになっていることです。1つ目はImageコンポーザブルに渡すPainterです。

2つ目の引数isVisibleは、ページが見えているかどうかの状態を表すBoolean値です。HorizontalPagerは、画面に見えているページの両隣りもコンポジションを行います。普段はあまり意識する必要はないのですが、今回は画面から見えなくなった時点でズーム状態をリセットしたいので、この引数を用意しています。画面に表示されているかどうかの判定は、calculateCurrentOffsetForPageで取得できるoffsetが-1から+1の範囲に収まっているかどうかで判断できます。ただし、offsetはHorizontalPagerを少しスクロールするだけで変化するので、そのままだと再コンポジションが高頻度で発生します。これを防ぐため、derivedStateOfを使ってisVisibleのtrue/falseが変化した時だけ再コンポジションが発生するようにしています。

ZoomImageSampleの変更

ZoomImageSampleは以下のようになります。ハイライトしている部分が前回から変更した部分です。

@Composable
fun ZoomImageSample(painter: Painter, isVisible: Boolean) {
    val zoomState = rememberZoomState(maxScale = 5f)
    zoomState.setImageSize(painter.intrinsicSize)
    val scope = rememberCoroutineScope()
    LaunchedEffect(isVisible) {
        zoomState.reset()
    }
    Image(
        painter = painter,
        contentDescription = "Zoomable bird image",
        contentScale = ContentScale.Fit,
        modifier = Modifier
            .fillMaxSize()
            .clipToBounds()
            .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
            }
    )
}

ポイントは、23-24行目です。前回までは、detectTransformGesturesがジェスチャーイベントを検出すると無条件にzoomState.applyGestureを呼び出していました。それを今回はzoomState.canConsumeGestureを呼び出し、ジェスチャーイベントをImageコンポーザブルがハンドリングするか、HorizontalPagerに伝えるかを判定するように変更しています。canConsumeGestureがtrueを返した場合だけ、zoomState.applyGestureを呼び出し、画像のズームや移動を行います。また34行目で、canConsumeGestureの結果は、onGestureの戻り値として呼び出し元に返しています。後で説明しますが、onGestureの呼び出し元では、trueが返ってきた場合のみジェスチャーイベントを消費するように変更します。これによって、ジェスチャーを画像のズームに使うか、ページ送りに使うかを切り替えています。

その他の変更点は簡単に説明します。

6-8行目では、isVisibleが変化した時にzoomState.resetを呼び出して、画像のズーム状態をリセットしています。resetの中身は後述します。

15行目のclipToBoundsは、拡大した画像が隣のページにはみ出さないようにするために指定しています。clipToBoundsを指定しないと、図のように隣のページの画像と重なってしまいます。

また21行目では、ジェスチャー開始イベントをzoomStateに伝えています。zoomState.startGestureの中身も後述します。

detectTransformGesturesの変更

ジェスチャーの検出は、前回カスタマイズしたPointerInputScope.detectTransformGesturesで行いますが、今回はさらにカスタマイズを加えます。(関数が長いので一部省略しています。ソースコード全体を確認したい場合はこの記事の最後を見てください。)

suspend fun PointerInputScope.detectTransformGestures(
    panZoomLock: Boolean = false,
    onGesture: (centroid: Offset, pan: Offset, zoom: Float, rotation: Float, timeMillis: Long) -> Boolean,
    onGestureStart: () -> Unit = {},
    onGestureEnd: () -> Unit = {},
) {
    forEachGesture {
        awaitPointerEventScope {
            /* ... */
            awaitFirstDown(requireUnconsumed = false)
            onGestureStart()
            do {
                val event = awaitPointerEvent()
                val canceled = event.changes.fastAny { it.isConsumed }
                if (!canceled) {
                    /* ... */
                    if (pastTouchSlop) {
                        val centroid = event.calculateCentroid(useCurrent = false)
                        val effectiveRotation = if (lockedToPanZoom) 0f else rotationChange
                        if (effectiveRotation != 0f ||
                            zoomChange != 1f ||
                            panChange != Offset.Zero
                        ) {
                            val isConsumed = onGesture(
                                centroid,
                                panChange,
                                zoomChange,
                                effectiveRotation,
                                event.changes[0].uptimeMillis
                            )
                            if (isConsumed) {
                                event.changes.fastForEach {
                                    if (it.positionChanged()) {
                                        it.consume()
                                    }
                                }
                            }
                        }
                    }
                }
            } while (!canceled && event.changes.fastAny { it.pressed })
            onGestureEnd()
        }
    }
}

ジェスチャーイベントを親のコンポーネントに伝播させるかどうかは、PointerInputChange.consumeを呼び出すかどうかで決まります。前回までのdetectTransformGesturesの実装では、常にconsumeを呼び出していたため、HorizontalPagerにジェスチャーイベントが伝わらず、ページ送り操作ができません。かといってconsumeを呼び出すのをやめると、画像のズームや移動とページ送りが同時に動いてしまいます。

これを適切に処理するため、onGestureがBoolean値を返すように変更しています。先ほど説明したように、onGestureの利用側でジェスチャーを使用する場合はtrueを返します。trueが返ってきた場合は、PointerInputChange.consumeを呼び出し、ジェスチャーイベントが親のコンポーザブルに伝播しないようにします。これによってHorizontalPagerにジェスチャーが伝わらなくなります。逆にonGestureがfalseを返した場合はconsumeを呼び出さないので、ジェスチャーイベントが親コンポーザブルに伝播し、HorizontalPagerのページ送りが動作することになります。

ZoomStateの変更

最後に画像のズーム状態を管理するZoomStateクラスの変更です。

ジェスチャー消費判定

まずはジェスチャーを消費するかどうかの判定を実装します。

@Stable
class ZoomState(private val maxScale: Float) {

    private var shouldConsumeEvent: Boolean? = null

    fun startGesture() {
        shouldConsumeEvent = null
    }

    fun canConsumeGesture(pan: Offset, zoom: Float): Boolean {
        return shouldConsumeEvent ?: run {
            var consume = true
            if (zoom == 1f) { // One finger gesture
                if (scale == 1f) {  // Not zoomed
                    consume = false
                } else {
                    val ratio = (abs(pan.x) / abs(pan.y))
                    if (ratio > 3) {   // Horizontal drag
                        if ((pan.x < 0) && (_offsetX.value == _offsetX.lowerBound)) {
                            // Drag R to L when right edge of the content is shown.
                            consume = false
                        }
                        if ((pan.x > 0) && (_offsetX.value == _offsetX.upperBound)) {
                            // Drag L to R when left edge of the content is shown.
                            consume = false
                        }
                    }
                }
            }
            shouldConsumeEvent = consume
            consume
        }
    }
}

shouldConsumeEventという変数を用意し、ジェスチャー開始時に呼び出されるstartGestureでnullに初期化しています。canConsumeGestureはパンやズームが変化するたびに呼ばれますが、ジェスチャーを消費するかどうかはジェスチャー開始時に決まるので、そのジェスチャーで最初に呼ばれたとき(shouldConsumeEventがnullのとき)に判定を行ってshouldConsumeEventにtrueかfalseを代入し、以降の呼び出しではshouldConsumeEventの値をそのまま返しています。

ジェスチャーを消費するかどうかは以下のルールで判定しています。

  • 2本(以上の)指のジェスチャー(ズームジェスチャー)は常に消費する。
  • 1本指ジェスチャーの場合、
    • 倍率が1の場合は消費しない
    • 画像の右端が表示された状態で、画像をさらに左に移動しようとした場合は消費しない
    • 画像の左端が表示された状態で、画像をさらに右に移動しようとした場合は消費しない
    • 上記に当てはまらない場合は消費する

「消費する」と判定した場合はcanConsumeGestureがtrueを返すので、HorizontalPagerにはイベントが伝わらなくなります。

ズーム状態のリセット

最後に、ズーム状態をリセットするメソッドを実装します。HorizontalPagerのページが切り替わったときに呼び出しているやつです。

@Stable
class ZoomState(private val maxScale: Float) {
    suspend fun reset() = coroutineScope {
        launch { _scale.snapTo(1f) }
        _offsetX.updateBounds(0f, 0f)
        launch { _offsetX.snapTo(0f) }
        _offsetY.updateBounds(0f, 0f)
        launch { _offsetY.snapTo(0f) }
    }
}

snapToを使ってscaleとoffsetをデフォルト状態に設定しています。offsetに対してはupdateBoundsも忘れずにデフォルト状態を設定します。そうしないと、指を離したときにフリング動作が発生してしまいます。

以上でHorizontalPagerに配置した画像をズームすることができるようになりました。

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

suspend fun PointerInputScope.detectTransformGestures(
    panZoomLock: Boolean = false,
    onGesture: (centroid: Offset, pan: Offset, zoom: Float, rotation: Float, timeMillis: Long) -> Boolean,
    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
                        ) {
                            val isConsumed = onGesture(
                                centroid,
                                panChange,
                                zoomChange,
                                effectiveRotation,
                                event.changes[0].uptimeMillis
                            )
                            if (isConsumed) {
                                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)
        }
    }

    suspend fun reset() = coroutineScope {
        launch { _scale.snapTo(1f) }
        _offsetX.updateBounds(0f, 0f)
        launch { _offsetX.snapTo(0f) }
        _offsetY.updateBounds(0f, 0f)
        launch { _offsetY.snapTo(0f) }
    }

    private var shouldConsumeEvent: Boolean? = null

    fun startGesture() {
        shouldConsumeEvent = null
    }

    fun canConsumeGesture(pan: Offset, zoom: Float): Boolean {
        return shouldConsumeEvent ?: run {
            var consume = true
            if (zoom == 1f) { // One finger gesture
                if (scale == 1f) {  // Not zoomed
                    consume = false
                } else {
                    val ratio = (abs(pan.x) / abs(pan.y))
                    if (ratio > 3) {   // Horizontal drag
                        if ((pan.x < 0) && (_offsetX.value == _offsetX.lowerBound)) {
                            // Drag R to L when right edge of the content is shown.
                            consume = false
                        }
                        if ((pan.x > 0) && (_offsetX.value == _offsetX.upperBound)) {
                            // Drag L to R when left edge of the content is shown.
                            consume = false
                        }
                    }
                }
            }
            shouldConsumeEvent = consume
            consume
        }
    }

    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) }

@OptIn(ExperimentalPagerApi::class)
@Composable
fun ZoomImageSampleOnPager() {
    val resources = listOf(R.drawable.bird, R.drawable.bird2, R.drawable.bird3)
    HorizontalPager(count = resources.size) { page ->
        val painter = painterResource(id = resources[page])
        val isVisible by remember {
            derivedStateOf {
                val offset = calculateCurrentOffsetForPage(page)
                (-1.0f < offset) and (offset < 1.0f)
            }
        }
        ZoomImageSample(painter, isVisible)
    }
}

@Composable
fun ZoomImageSample(painter: Painter, isVisible: Boolean) {
    val zoomState = rememberZoomState(maxScale = 5f)
    zoomState.setImageSize(painter.intrinsicSize)
    val scope = rememberCoroutineScope()
    LaunchedEffect(isVisible) {
        zoomState.reset()
    }
    Image(
        painter = painter,
        contentDescription = "Zoomable bird image",
        contentScale = ContentScale.Fit,
        modifier = Modifier
            .fillMaxSize()
            .clipToBounds()
            .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
            }
    )
}
この記事をシェア