この記事では、Jetpack Composeで画像をピンチジェスチャーでズームする処理について説明します。
単純にズームするだけならとても簡単に実現できるのですが、操作性を考慮すると意外なほど奥が深いので、何回かに分けて紹介します。今回はその1・基本編です。
基本の実装
まずはもっともシンプルに画像をズームするサンプルです。このサンプルで基本の考え方を理解しましょう。
@Composable
fun ZoomImageSample() {
var scale by remember { mutableStateOf(1f) }
Image(
painter = painterResource(id = R.drawable.bird),
contentDescription = "Zoomable bird image",
contentScale = ContentScale.Fit,
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTransformGestures { _, _, zoom, _ ->
scale *= zoom
}
}
.graphicsLayer {
scaleX = scale
scaleY = scale
}
)
}
画像のズームは大きく分けて2つの処理で実現します。1つ目がズームジェスチャーの検出、2つ目が画像の表示倍率の変更です。
ズームジェスチャーの検出
ズームジェスチャーの検出はModifier.pointerInput
を使います。pointerInput
のラムダはPointerInputScope
がレシーバになっていて、タッチイベントを扱うことができます。このラムダ内でdetectTransformGestures
を使ってジェスチャーを検出します。
detectTransformGestures
のラムダは、ジェスチャーを検出した時に呼び出されるonGesture
コールバックです。コールバックの第3引数がズームの変化量になっています。onGestureが前回呼び出された時と比べて拡大されていれば1より大きな値が、縮小されていれば1より小さな値が渡されます。これを現在のscale
に掛けて、新しいscale
の値を得ます。ここで、scale
は現在の画像の表示倍率を保持するための変数です。scale
の変更をトリガに表示を更新する必要があるため、MutableState
になっています。
なお、ズームジェスチャーの検出はModifier.transformable
でも可能ですが、こちらは1本指のジェスチャーを受け付けないため、このあとドラッグジェスチャーに対応する際に面倒になります。そのためこの記事ではModifier.pointerInput
を使います。
表示倍率の変更
画像の表示倍率の変更はModifier.graphicsLayer
で実現できます。graphicsLayer
の引数は、GraphicsLayerScope
をレシーバに受け取るラムダです。このスコープでscaleX
とscaleY
を変更すると、縦横の表示倍率を変更できます。
なお、graphicsLayer
には、ラムダを引数にとるタイプと、個別のパラメータを引数にとるタイプがあります。後者はパラメータが変更されるたびに再コンポジションが発生するので、パフォーマンスに影響が出る場合があります。とくに理由がなければラムダタイプを使う方が良いです。
ちなみに、表示倍率の変更はModifier.scale
でも実現できますが、処理効率とコードの見通しの観点から、この記事ではgraphicsLayer
を使います。処理効率については、先ほど説明したパラメータを引数にとるgraphicsLayer
と同様です。Modifier.scale
を使うと、scale
が変更されるたびに再コンポジションが発生します。コードの見通しについては、このあと画像の表示位置も変更するようにする際に、graphicsLayer
を使えば処理を一か所にまとめられるので見通しが良くなります。
画像の移動
次は、ズームに加えて上下左右に画像をドラッグで移動できるようにします。
@Composable
fun ZoomImageSample() {
var scale by remember { mutableStateOf(1f) }
var offset by remember { mutableStateOf(Offset.Zero) }
Image(
painter = painterResource(id = R.drawable.bird),
contentDescription = "Zoomable bird image",
contentScale = ContentScale.Fit,
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTransformGestures { _, pan, zoom, _ ->
scale *= zoom
offset += pan
}
}
.graphicsLayer {
scaleX = scale
scaleY = scale
translationX = offset.x
translationY = offset.y
}
)
}
ドラッグジェスチャーは、先ほどのdetectTransformGestures
のonGesture
コールバックで検出できます。onGesture
の第2引数はpanです。panは、onGesture
が前回呼び出されたときからどれだけジェスチャーが平行移動したかをOffset
で表しています。これを現在のoffset
に足して、新しいoffsetの値を算出します。ここでoffset
はscale
と同様にMutableStateになっています。offsetの変更が表示更新のトリガになります。
表示する画像位置の変更は、graphicsLayer
のラムダ内でGraphicsLayerScope
のtransitionX
とtransitionY
を変更すると実現できます。
状態の分離
ここで一旦、scaleとoffsetの管理を別クラスに切り出して、UIと状態管理を分離します。この先、scaleとoffsetに対する処理が複雑になっていくのに備えるためです。
@Stable
class ZoomState {
private var _scale = mutableStateOf(1f)
val scale: Float
get() = _scale.value
private var _offsetX = mutableStateOf(0f)
val offsetX: Float
get() = _offsetX.value
private var _offsetY = mutableStateOf(0f)
val offsetY: Float
get() = _offsetY.value
fun applyGesture(pan: Offset, zoom: Float) {
_scale.value *= zoom
_offsetX.value += pan.x
_offsetY.value += pan.y
}
}
ZoomState
クラスに_scale
、_offsetX
、_offsetY
の3つのMutableStateを持たせます。それぞれ、クラス外部からはread onlyなFloatプロパティとして見えるようにgetterを作成しておきます。
applyGesture
はジェスチャー検出時に呼び出す関数です。この関数でpanとzoomの変化量を受け取り、クラス内部のプロパティを更新します。
@Composable
fun rememberZoomState() = remember { ZoomState() }
ZoomState
クラスをremember変数として取得するrememberZoomState
関数も作成しておきます。
@Composable
fun ZoomImageSample() {
val zoomState = rememberZoomState()
Image(
painter = painterResource(id = R.drawable.bird),
contentDescription = "Zoomable bird image",
contentScale = ContentScale.Fit,
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTransformGestures { _, pan, zoom, _ ->
zoomState.applyGesture(pan, zoom)
}
}
.graphicsLayer {
scaleX = zoomState.scale
scaleY = zoomState.scale
translationX = zoomState.offsetX
translationY = zoomState.offsetY
}
)
}
UI側では、rememberZoomState
関数を使ってzoomState
を作成し、ジェスチャー検出時はapplyGesture
を呼び出します。graphicsLayer
でセットする値は、zoomState
のプロパティを参照します。
制約条件の追加
さて、ここまでで基本的なジェスチャーの検出と表示への反映はできているのですが、このままだと無限に拡大縮小できてしまったり、画像を完全に画面外に追い出したりできてしまいます。そこで、scaleとoffsetに適切に制約条件を加えます。
倍率の制約
まずは倍率に制約を加えます。ここでは、初期状態の倍率を1として、1から5までの倍率に制限するようにしてみます。
class ZoomState(private val maxScale: Float) {
fun applyGesture(pan: Offset, zoom: Float) {
_scale.value = (_scale.value * zoom).coerceIn(1f, maxScale)
_offsetX.value += pan.x
_offsetY.value += pan.y
}
}
fun rememberZoomState(maxScale: Float) = remember { ZoomState(maxScale) }
fun ZoomImageSample() {
val zoomState = rememberZoomState(maxScale = 5f)
Image( ... )
}
まず、ZoomState
クラスに最大倍率を表すmaxScale
プロパティを追加します。そしてapplyGesture
で_scale
を変更する際に、coerceIn
で_scale
の値を1からmaxScale
の範囲に収まるようにします。
これに合わせてrememberZoomState
関数も変更し、UI側からmaxScale = 5f
を指定します。
これで、画像の倍率を制限することができました。
位置の制約
最後に、画像の端が画面の端より内側に移動しないように、offsetにも制約を加えます。
offsetの制約条件を算出するには、画像サイズと、コンポーザブル要素のサイズ(画面上でレイアウトした時のサイズ。Viewのサイズという方が分かりやすいかも。)が必要になります。まずはそれらを取得する部分を説明します。
fun ZoomImageSample() {
val zoomState = rememberZoomState(maxScale = 5f)
val painter = painterResource(id = R.drawable.bird)
zoomState.setImageSize(painter.intrinsicSize)
Image(
painter = painter,
modifier = Modifier
.onSizeChanged { size ->
zoomState.setLayoutSize(size.toSize())
}
.pointerInput(Unit) { ... }
.graphicsLayer {}
...
)
}
画像サイズはPainter.intrinsicSize
で取得できます。これをsetImageSize
でZoomState
に渡します。setImageSize
の中身は後述します。
サンプルでは画像リソースを使っているので、初回コンポーズの時点で画像サイズが確定していますが、非同期に画像を表示する場合は初回コンポーズ時点では画像サイズが確定していないかもしれません。ですが何らかのコールバック関数などで画像サイズを取得できると思います。例えばCoilのAsyncImage
では、onSuccess
コールバックのAsyncImagePainter.State.Success
からPainter
にアクセス可能です。
レイアウトのサイズは、Modifier.onSizeChanged
で取得できます。onSizeChanged
のラムダは、レイアウトが変化するたびに呼び出されるので、画面が回転したりサイズが変更されたりしても対応できます。こちらもsetLayoutSize
でZoomState
に渡します。
ZoomState
のsetImageSize
とsetLayoutSize
の実装は下記のようになります。
class ZoomState(private val maxScale: Float) {
private var imageSize = Size.Zero
fun setImageSize(size: Size) {
imageSize = size
updateFitImageSize()
}
private var layoutSize = Size.Zero
fun setLayoutSize(size: Size) {
layoutSize = 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)
}
}
}
それぞれプロパティに値を保存した後で、updateFitImageSize
でfitImageSize
を更新します。fitImageSize
は、画像をレイアウトにフィットさせるように拡大または縮小した時のサイズです。画像サイズとレイアウトサイズのアスペクト比によって、widthまたはheightの比例計算で算出します。下の図は画像がレイアウトよりも横長の場合を表しています。
class ZoomState(private val maxScale: Float) {
fun applyGesture(pan: Offset, zoom: Float) {
_scale.value = (_scale.value * zoom).coerceIn(1f, maxScale)
val boundX = max((fitImageSize.width * _scale.value - layoutSize.width), 0f) / 2f
_offsetX.value = (_offsetX.value + pan.x).coerceIn(-boundX, boundX)
val boundY = max((fitImageSize.height * _scale.value - layoutSize.height), 0f) / 2f
_offsetY.value = (_offsetY.value + pan.y).coerceIn(-boundY, boundY)
}
}
applyGesture
では、fitImageSize
とlayoutSize
を元に、offsetの取りうる値を決めます。
下の図はoffsetとfitImageSize、layoutSizeの関係をx軸について図示したものです。
fitImageSizeにscaleを掛けると、現在の画像の表示サイズになります。offsetの原点はレイアウトの中心点です。画像の左端がレイアウトの左端よりも右側にあるためには、
offset <= (fitImageSize.width * scale) / 2 - layoutSize.width / 2
の関係が成り立っている必要があります。画像の右端については、符号を入れかえて同様に考えることができます。
ただし、画像の幅がレイアウトの幅よりも小さい場合(画像がレイアウトよりも縦長の場合)は、横方向には動かしたくないので、offsetXは常にゼロにします。コード中のmax()
で判定している部分がこれに該当します。
y軸についても同じように値の範囲を決めます。
これで、画像の移動範囲を定めることができました。
最終コードと次回予告
最後にソースコード全体を掲載しておきます。適切に制約を入れつつ、ズームと移動ができるようになりました。
@Stable
class ZoomState(private val maxScale: Float) {
private var _scale = mutableStateOf(1f)
val scale: Float
get() = _scale.value
private var _offsetX = mutableStateOf(0f)
val offsetX: Float
get() = _offsetX.value
private var _offsetY = mutableStateOf(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)
}
}
fun applyGesture(pan: Offset, zoom: Float) {
_scale.value = (_scale.value * zoom).coerceIn(1f, maxScale)
val boundX = max((fitImageSize.width * _scale.value - layoutSize.width), 0f) / 2f
_offsetX.value = (_offsetX.value + pan.x).coerceIn(-boundX, boundX)
val boundY = max((fitImageSize.height * _scale.value - layoutSize.height), 0f) / 2f
_offsetY.value = (_offsetY.value + pan.y).coerceIn(-boundY, boundY)
}
}
@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)
Image(
painter = painter,
contentDescription = "Zoomable bird image",
contentScale = ContentScale.Fit,
modifier = Modifier
.fillMaxSize()
.onSizeChanged { size ->
zoomState.setLayoutSize(size.toSize())
}
.pointerInput(Unit) {
detectTransformGestures { _, pan, zoom, _ ->
zoomState.applyGesture(pan = pan, zoom = zoom)
}
}
.graphicsLayer {
scaleX = zoomState.scale
scaleY = zoomState.scale
translationX = zoomState.offsetX
translationY = zoomState.offsetY
}
)
}
以上で基本的な画像のズームは完成です。ただこのサンプルには、ドラッグジェスチャーで指を離した時のフリング(慣性)動作がありません。そのため、画像を拡大してドラッグした時に使いづらさを感じます。次回はこの点を改善していきます。