Jetpack Composeで画像をズームする その3
今回は画像ズームシリーズその3です。「その1」では基本的な画像ズームの実装を紹介しました。「その2」ではアニメーションを導入して使い勝手を向上させました。今回はHorizontalPagerに配置した画像をズームする方法を紹介します。ソースコードは前回までのものをベースに変更を加えてきますので、まだの方は先に「その1」「その2」をご覧いただくことをお勧めします。
今回のゴール
今回はAccompanistのHorizontalPagerに配置した画像をズームできるようにします。ズームの状態によって、ジェスチャーの使い方を切り替えるのがポイントです。画像の倍率が1の場合や、画像を端までスクロールしてさらにスクロールしようとした場合は、ジェスチャーイベントをHorizontalPagerに伝えて、ページを移動させます。一方で、画像の拡大縮小や、拡大した画像を移動するときのジェスチャーは、HorizontalPagerには伝えないようにする必要があります。

HorizontalPagerを導入
まずは前回までに作成したズーム可能な画像のコンポーザブルをHorizontalPagerに配置します。
@OptIn(ExperimentalPagerApi::class)@Composablefun 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は以下のようになります。
@Composablefun 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 -> // ★1 val canConsume = zoomState.canConsumeGesture(pan = pan, zoom = zoom) if (canConsume) { scope.launch { zoomState.applyGesture( pan = pan, zoom = zoom, position = centroid, timeMillis = timeMillis, ) } } // ★2 canConsume }, onGestureEnd = { scope.launch { zoomState.endGesture() } } ) } .graphicsLayer { scaleX = zoomState.scale scaleY = zoomState.scale translationX = zoomState.offsetX translationY = zoomState.offsetY } )}ポイントは、★1の部分です。前回までは、detectTransformGesturesがジェスチャーイベントを検出すると無条件にzoomState.applyGestureを呼び出していました。それを今回はzoomState.canConsumeGestureを呼び出し、ジェスチャーイベントをImageコンポーザブルがハンドリングするか、HorizontalPagerに伝えるかを判定するように変更しています。canConsumeGestureがtrueを返した場合だけ、zoomState.applyGestureを呼び出し、画像のズームや移動を行います。また★2で、canConsumeGestureの結果は、onGestureの戻り値として呼び出し元に返しています。後で説明しますが、onGestureの呼び出し元では、trueが返ってきた場合のみジェスチャーイベントを消費するように変更します。これによって、ジェスチャーを画像のズームに使うか、ページ送りに使うかを切り替えています。
その他の変更点は簡単に説明します。
LaunchedEffectでは、isVisibleが変化した時にzoomState.resetを呼び出して、画像のズーム状態をリセットしています。resetの中身は後述します。
clipToBoundsは、拡大した画像が隣のページにはみ出さないようにするために指定しています。clipToBoundsを指定しないと、図のように隣のページの画像と重なってしまいます。

またonGestureStartでは、ジェスチャー開始イベントを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クラスの変更です。
ジェスチャー消費判定
まずはジェスチャーを消費するかどうかの判定を実装します。
@Stableclass 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のページが切り替わったときに呼び出しているやつです。
@Stableclass 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() } }}
@Stableclass 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) } } }}
@Composablefun rememberZoomState(maxScale: Float) = remember { ZoomState(maxScale) }
@OptIn(ExperimentalPagerApi::class)@Composablefun 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) }}
@Composablefun 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 } )}