前回の「その1」では、Jetpack Composeを使って画像をズームする基本的な実装を紹介しました。今回はアニメーションを利用してさらに使い勝手を向上させる方法を説明します。「その1」のソースコードをベースに変更を加えていきますので、まだの方は先に「その1」をご覧いただくことをお勧めします。
今回のゴール
今回は2つのアニメーションを追加して、操作性を改善します。
1つ目は、ドラッグ終了時のフリングアニメーションです。指が離れた瞬間にスクロールがピタッと止まるのではなく、だんだんと速度が遅くなるやつです。このアニメーションがあると、画像を拡大して上下左右に移動するときの操作が滑らかになります。
2つ目は、倍率を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
の引数にonGestureStart
とonGestureEnd
の2つのコールバックを追加しています。awaitFirstDown
が最初のタッチの検出部なので、その直後でonGestureStart
を呼び出します。ジェスチャーが続いている間はdo-whileループが回り、指がすべて離れるとループを抜けるので、ループの後ろでonGestureEnd
を呼び出します。
また、onGesture
コールバックの引数にtimeMillis
を追加しています。これがジェスチャーイベントの発生した時刻を表します。イベント発生時刻はPointerInputChange.uptimeMillis
で取得できます。コード中ではevent
がPointerEvent
オブジェクトなので、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
に加えて、第一引数のcentroid
をZoomState.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
を使うように変更しています。snapTo
はAnimatable
のメソッドですが、アニメーションなしで即座に指定した値を反映します。ただし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
}
)
}