Jetpack Composeで画像をズームする(4) Modifier関数を自作しよう
今回は画像ズームシリーズその4です。前回の「その3」まででHorizontalPagerの上に配置した画像をズームすることができるようになりました。今回はこれまでの内容を実装したModifier関数を自作することによって、UIコンポーザブルをすっきりとシンプルに記述できるようにします。
今回もソースコードは前回の結果をベースに変更を加えていきますので、まだの方は先に「その1」「その2」「その3」をご覧いただくことをお勧めします。
今回のゴール
今回はのゴールは、できるだけシンプルなUIのコードで、前回と同じ動作を実現することです。前回実装したズーム動作の動画を再掲しておきます。HorizontalPagerのスワイプ操作と、画像のズームや移動を両立させました。

そして、HorizontalPagerの上に配置したZoomImageSampleコンポーザブルの実装は以下のようになっていました。UI要素としてはImageを置いているだけにもかかわらず、Modifierの記述が非常に長く、読みづらいコードになってしまっています。今回はModifier関数を自作して、コードをすっきりさせていきます。

Modifier関数の作り方
Modifier関数は、Modifierインターフェースの拡張関数として定義します。メソッドチェーンで記述できるように、戻り値もModifierにします。
fun Modifier.myModifierFunction(): ModifierModifier関数を自作する方法は、その関数が「状態を持つ」かどうかによって異なります。「状態を持つ」というのは、「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): ModifierinspectorInfoについては後で簡単に説明します。
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は以下のようになります。
@Composablefun 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の各プロパティの値も確認できます。うまく使えばデバッグするときに便利そうです。
