今回は画像ズームシリーズその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
}
)
}