今回は画像ズームシリーズその4です。前回の「その3」まででHorizontalPagerの上に配置した画像をズームすることができるようになりました。今回はこれまでの内容を実装したModifier関数を自作することによって、UIコンポーザブルをすっきりとシンプルに記述できるようにします。
今回もソースコードは前回の結果をベースに変更を加えていきますので、まだの方は先に「その1」「その2」「その3」をご覧いただくことをお勧めします。
今回のゴール
今回はのゴールは、できるだけシンプルなUIのコードで、前回と同じ動作を実現することです。前回実装したズーム動作の動画を再掲しておきます。HorizontalPagerのスワイプ操作と、画像のズームや移動を両立させました。
そして、HorizontalPagerの上に配置したZoomImageSample
コンポーザブルの実装は以下のようになっていました。UI要素としてはImage
を置いているだけにもかかわらず、Modifier
の記述が非常に長く、読みづらいコードになってしまっています。今回はModifier関数を自作して、コードをすっきりさせていきます。
Modifier関数の作り方
Modifier関数は、Modifier
インターフェースの拡張関数として定義します。メソッドチェーンで記述できるように、戻り値もModifier
にします。
fun Modifier.myModifierFunction(): Modifier
Modifier関数を自作する方法は、その関数が「状態を持つ」かどうかによって異なります。「状態を持つ」というのは、「remember
を使っている」と言い換えてひとまずは問題ないでしょう。今回のズーム処理は、アニメーションを実現するためにrememberCoroutineScope
を使うので、状態を持つということになります。
状態を持つModifier関数は、Modifier.composedを使って実装します。remember
はComposable関数なので、Composable関数内部でしか使えません。一方でModifier関数はComposable関数ではないので、remember
を直接書くことはできません。この橋渡しをするのが、Modifier.composed
です。composed
の引数factory
は、Composable関数のラムダなので、ここにremember
を含む処理を書くことができます。
fun Modifier.composed(
inspectorInfo: InspectorInfo.() -> Unit = NoInspectorInfo,
factory: @Composable Modifier.() -> Modifier
): Modifier
inspectorInfo
については後で簡単に説明します。
Modifier.zoomableを作る
それでは、ZoomImageSample
に書いていた画像のズーム処理を、Modifier関数に実装します。関数名は、clickable
やdraggable
といった公式のModifierに倣って、zoomable
としました。引数にZoomState
を受け取ります。
fun Modifier.zoomable(zoomState: ZoomState): Modifier = composed(
inspectorInfo = debugInspectorInfo {
name = "zoomable"
properties["zoomState"] = zoomState
}
) {
val scope = rememberCoroutineScope()
Modifier
.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
}
}
composed
の引数factory
に、もともとZoomImageSample
に書いていた画像のズームに関する処理を書いています。アニメーションに必要なCoroutineScope
は、rememberCoroutineScope
で取得しています。その下でModifierを作成しています。onSizeChanged
とpointerInput
とgraphicsLayer
を実装しています。これらの内容は前回と同じです。作成したModifierがそのまま、戻り値になります。
Modifier.zoomableを使う
Modifier.zoomable
を使う側のZoomImageSample
は以下のようになります。
@Composable
fun ZoomImageSample(painter: Painter, isVisible: Boolean) {
val zoomState = rememberZoomState(maxScale = 5f)
zoomState.setImageSize(painter.intrinsicSize)
LaunchedEffect(isVisible) {
zoomState.reset()
}
Image(
painter = painter,
contentDescription = "Zoomable bird image",
contentScale = ContentScale.Fit,
modifier = Modifier
.fillMaxSize()
.clipToBounds()
.zoomable(zoomState)
)
}
reset
やsetImageSize
を呼び出す必要があるので、ZoomState
の定義はZoomImageSample
の中に残しています。が、それでもで非常にスッキリしました。Modifierが短く書けることで、UI構成が一目で分かりやすくなりますね。
inspectorInfoについて(おまけ)
最後に、composed
の引数のinspectorInfo
について簡単に説明します。
fun Modifier.zoomable(zoomState: ZoomState): Modifier = composed(
inspectorInfo = debugInspectorInfo {
name = "zoomable"
properties["zoomState"] = zoomState
}
) {
/* ... */
}
debugInspectorInfo
のname
とproperties
に値を入れておくと、Android StudioのLayout Inspectorで値を確認することができます。Attributesタブのmodifierの下にzoomableが表示され、その下にちゃんとzoomStateがあります。zoomStateの各プロパティの値も確認できます。うまく使えばデバッグするときに便利そうです。