この記事はAndroid Advent Calendar 2023の21日目の記事です。
Modifier.clickable()は、Jetpack ComposeのUIコンポーネントをクリック(タップ)可能にするAPIです。Composeでアプリを作るうえでなくてはならないModifier.clickable()ですが、引数にonClickのラムダを渡すこと以外はあまりよく知りませんでした。そこでこの記事では、Modifier.clickable()の仕様を確認して、どのように実装されているのかコードを眺めてみたいと思います。
環境
Compose 1.6.0-beta03で確認しています。
Compose 1.5から1.6でModifier.Node対応が進んでいるので、1.5以前のコードはこれから紹介するコードとはかなり違っている思います。また、今後の1.6.xでも変更が加わる可能性があることをご承知おきください。
Modifier.clickable()の定義
はじめに、Modifier.clickable()の定義を確認しておきます。
Modifier.clickable()には引数の異なる2つのオーバーロードが存在します。onClickラムダだけを渡したときに使っているのは、引数が4つの方です。
fun Modifier.clickable(
enabled: Boolean = true,
onClickLabel: String? = null,
role: Role? = null,
onClick: () -> Unit
)
fun Modifier.clickable(
interactionSource: MutableInteractionSource,
indication: Indication?,
enabled: Boolean = true,
onClickLabel: String? = null,
role: Role? = null,
onClick: () -> Unit
)
引数が4つのModifier.clickable()は、デフォルト値を指定して、もう一方の引数が6つのModifier.clickable()を呼び出しています。
fun Modifier.clickable(
enabled: Boolean = true,
onClickLabel: String? = null,
role: Role? = null,
onClick: () -> Unit
) = composed {
Modifier.clickable(
enabled = enabled,
onClickLabel = onClickLabel,
onClick = onClick,
role = role,
indication = LocalIndication.current,
interactionSource = remember { MutableInteractionSource() }
)
}
ここで、Modifier.composed()が使われています。デフォルト値の指定のためにrememberを使うことによって、Modifierが状態を持つことになるため、Modifier.composed()が使われているようです。
Modifier.Nodeの移行が進んでも、こういうところにModifier.composed()が残るというのが意外な発見でした。Modifier.composed()を完全に排除しようとすると、ModifierNodeの実装が複雑になるので、このようにModifier.composed()も部分的に残しているのだろうと想像しました。
次は引数について確認していきます。
enabledとonClickは自明だと思うので、それ以外の引数の役割を確認していきます。
roleとonClickLabel
roleとonClickLabelはアクセシビリティのためのパラメータです。トークバックを有効にした時に、音声で画面の機能を説明するために使われます。
以下のように、空っぽのBoxにModifier.clickable()を適用した場合で確認してみます。
@Composable
fun ClickableSample() {
Box(
modifier = Modifier
.size(300.dp, 100.dp)
.background(color = MaterialTheme.colorScheme.secondaryContainer)
.clickable(
onClickLabel = "更新する",
role = Role.Button,
) {
println("** Click!! **")
},
)
}
このように、role=Role.Button, onClickLabel=”画面を更新する”とすると、「ボタン。画面を更新するにはダブルタップします」と読み上げられます。(トークバックを有効にしている場合、ダブルタップが通常のタップと同じ扱いになります。)
role=null, onClickLabel=nullの場合は、「ラベルなし。有効にするにはダブルタップします。」と読み上げられます。これでは、ダブルタップすると何が有効になるのか分かりません。
実際のアプリではBoxの中に何らかのアイコンやテキストが入っていることが多いので、それらが読み上げられることによって、何のボタンか分かることも多いと思います。とはいえ、Modifier.clickableを使う時にはアクセシビリティのことも思い出すように気をつけたいものです。
なお、RoleにはButtonの他に、CheckBoxやSwitchなどがあります。Material3のButtonなどを使っている場合は初めから適切なRoleが設定されているのですが、BoxなどをModifier.clickableでボタンとして使う場合などは、自分で適切にRoleを指定するのが望ましいと言えます。
以下のページの中ほどに、role = Role.Checkboxを指定したサンプルコードが掲載されています。
また、onClickLabelは「〇〇する」という動詞で定義する良さそうです。読み上げ文の「にはダブルタップします」の部分が定型文なので、「〇〇する」と定義しておくことで、読み上げが自然な日本語になります。onClickLabelに「更新ボタン」のようなボタンの名前を定義するよりも、「画面を更新する」のように、ボタンの挙動や目的を説明したほうが、アクセシビリティが向上しそうです。
interactionSource
interactionSourceは、コンポーネントで発生したユーザーのInteractionを受け取ることができるストリームです。Interactionは、指が触れた(PressInteraction.Press)、離れた(PressInteraction.Release)などのユーザーの操作です。InteractionSourceの内部にはInteractionのFlowを持っていて、外部に対してはcollectIsPressedAsState()のような関数でStateを公開しています。interactionSourceを使うと、コンポーネントの操作状態に合わせて外観をカスタマイズしたりできます。
以下のページに簡単なサンプルコードが記載されています。
これを参考にして、次のようなコードを実行してみました。
@Composable
fun ClickableSample() {
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
Box(
modifier = Modifier
.size(300.dp, 100.dp)
.background(color = MaterialTheme.colorScheme.secondaryContainer)
.clickable(
interactionSource = interactionSource,
indication = LocalIndication.current,
) {
println("** Click!! **")
},
) {
Text(text = if (isPressed) "Pressed" else "Not Pressed")
}
}
実行結果は次のとおりです。ボタンを押している間はPressedと表示されていることがわかります。他にも、色を変えたり、ボタンの大きさを変えたり、いろいろ工夫できそうです。
indication
indicationは、操作状態に応じた視覚効果を指定する際に使います。デフォルトではCompositionLocalのLocalIndicationが指定されているので、MaterialTheme内で使った場合はRippleEffectが設定されます。RippleEffectを無効にしたい場合は、nullを指定します。また、RippleEffect以外にも、操作に合わせたアニメーション効果を作って適用することができます。
以下の記事がとても参考になります。
Modifier.clickable()の実装
ここからは、Modifier.clickable()の実装を見ていきます。
fun Modifier.clickable() {
Modifier
.indication(interactionSource, indication)
.hoverable(enabled = enabled, interactionSource = interactionSource)
.focusableInNonTouchMode(enabled = enabled, interactionSource = interactionSource)
.then(ClickableElement(interactionSource, enabled, onClickLabel, role, onClick))
}
Modifier.clickable()は、4つのModifierの組み合わせで構成されていることが分かります。
まずは、名前からして一番の主役っぽいClickableElementから見ていこうと思いますが、その前に簡単にModifier.Nodeの仕組みを説明します。
Modifier.Nodeの超概要
Modifier.Nodeは、ModifierNodeElementとのペアで実装されます。UIコンポーネント側でModifierチェーンを構成するのはModifierNodeElementです。ModifierNodeElementは実行時にModifier.Nodeオブジェクトを作成します。実際にUIを描画したり、ジェスチャーを検出したりするのはModifier.Nodeの役割です。
以降の図ではModifierNodeElementは省略し、Modifier.Nodeのみ図示します。
Modifier.Nodeは、いろいろな定義済みのインターフェースを実装したり、別の定義済みのDelegatableNodeに委任したりすることで、比較的簡単に実装することができるようになっています。(とはいってもそれなりに複雑なコードになりがちですが。)そのため、実装しているインターフェースと、delegate関数を使って委任している部分を見ると、どんな機能を組み合わせてその機能を実現しているのかがおおよそ分かります。
ClickableNode
さて、Modifier.clickable()の実装に戻りましょう。
fun Modifier.clickable() {
Modifier
.indication(interactionSource, indication)
.hoverable(enabled = enabled, interactionSource = interactionSource)
.focusableInNonTouchMode(enabled = enabled, interactionSource = interactionSource)
.then(ClickableElement(interactionSource, enabled, onClickLabel, role, onClick))
}
名前から分かる通り、ClickableElementがModifier.clickableの主役です。実際の処理はClickableNodeに書かれているので、確認していきます。
ClickableNodeはAbstractClickableNodeのサブクラスになっていて、AbstractClickableNodeはPointerInputModifierNodeとKeyInputModifierNodeを実装しています。このことから、ジェスチャー検出処理とキーイベント検出処理が実装されていることが予想できます。
private class ClickableNode() : AbstractClickableNode()
private sealed class AbstractClickableNode()
: DelegatingNode(), PointerInputModifierNode, KeyInputModifierNode
AbstractClickableNodeという抽象化レイヤーを挟んでいるのは、Modifier.clickable()とModifier.combinedClickableで実装を共通化するためのようです。おかげで少しコードが追いづらいですが、じっくり読んでいきます。
PointerInputModifierNodeの実装
PointerInputModifierNodeは、コンポーネント上で発生したタッチやジェスチャーのイベントを検出する役割を担います。AbstractClickableNodeでPointerInputModifierNodeのonPointerEvent()とonCancelPointerInput()をオーバーライドしていますが、実際の処理はClickablePointerInputNodeという別のNodeに委任しています。delegate()は処理を委任するための関数です。
private sealed class AbstractClickableNode()
: DelegatingNode(), PointerInputModifierNode, KeyInputModifierNode {
abstract val clickablePointerInputNode: AbstractClickablePointerInputNode
override fun onPointerEvent(
pointerEvent: PointerEvent,
pass: PointerEventPass,
bounds: IntSize
) {
clickablePointerInputNode.onPointerEvent(pointerEvent, pass, bounds)
}
override fun onCancelPointerInput() {
clickablePointerInputNode.onCancelPointerInput()
}
}
private class ClickableNode() : AbstractClickableNode() {
override val clickablePointerInputNode = delegate(
ClickablePointerInputNode(
enabled = enabled,
interactionSource = interactionSource,
onClick = onClick,
interactionData = interactionData
)
)
}
ClickablePointerInputNodeを見ると、クリック(タップ)検出処理の実体がありました。タップを検出したときにonClickコールバックを呼び出しています。また、InteractionSourceにInteractionを発行しています。
private class ClickablePointerInputNode() : AbstractClickablePointerInputNode() {
override suspend fun PointerInputScope.pointerInput() {
interactionData.centreOffset = size.center.toOffset()
detectTapAndPress(
onPress = { offset ->
if (enabled) {
handlePressInteraction(offset)
}
},
onTap = { if (enabled) onClick() }
)
}
}
ところで、ClickablePointerInputNodeのPointerInputScope.pointerInput()はどこから呼ばれるかというと、ClickablePOinterInputNodeの親クラスのAbstractClickablePointerInputNodeが委任しているSuspendingPointerInputModifierNodeから呼ばれます。
private sealed class AbstractClickablePointerInputNode()
: DelegatingNode(),
ModifierLocalModifierNode,
CompositionLocalConsumerModifierNode,
PointerInputModifierNode {
private val pointerInputNode = delegate(
SuspendingPointerInputModifierNode { pointerInput() }
)
}
SuspendingPointerInputModifierNodeは、PointerInputModifierNodeを実装するときによく使われる便利クラスで、PointerInputScopeのスロットを提供してくれるので、ここにジェスチャー検出処理を書くことができます。
また、AbstractClickablePointerInputNodeはModifierLocalModifierNodeとCompositionLocalConsumerModifierNodeという2つのインターフェースも実装しています。これらを使って、コンポーネントがScrollableコンポーネントの上に配置されているかどうかを検出し、Scrollableの場合はPressInteractionの発行をすこし遅らせているようでした(コードは省略)。
KeyInputModifierNodeの実装
さて、いったんAbstractClickableNodeに戻ります。
AbstractClickableNodeはKeyInputModifierNodeも実装しています。
onKeyEvent()の中で、onClickコールバックを呼び出していました。また、InteractionSourceにInteractionを発行しています。このコードは、例えばBluetoothキーボードでAndroid端末を操作した時などに実行されます。
private sealed class AbstractClickableNode()
: DelegatingNode(), PointerInputModifierNode, KeyInputModifierNode {
override fun onKeyEvent(event: KeyEvent): Boolean {
return when {
...
enabled && event.isClick -> {
interactionData.currentKeyPressInteractions.remove(event.key)?.let {
coroutineScope.launch {
interactionSource.emit(PressInteraction.Release(it))
}
}
onClick()
true
}
...
}
}
}
InteractionSourceの後始末
AbstractClickableNodeでは上記の他に、InteractionSourceの後始末も行っています。具体的には、Press Interactionを発行した後に指がコンポーネント外に移動した場合などに、Cancel Interactionを発行しています。この処理によって、interactionSourceを利用する側で、クリックがキャンセルされたことを検出できています。
private sealed class AbstractClickableNode()
: DelegatingNode(), PointerInputModifierNode, KeyInputModifierNode {
override fun onDetach() {
disposeInteractionSource()
}
protected fun disposeInteractionSource() {
interactionData.pressInteraction?.let { oldValue ->
val interaction = PressInteraction.Cancel(oldValue)
interactionSource.tryEmit(interaction)
}
interactionData.currentKeyPressInteractions.values.forEach {
interactionSource.tryEmit(PressInteraction.Cancel(it))
}
interactionData.pressInteraction = null
interactionData.currentKeyPressInteractions.clear()
}
}
ClickableSemanticsNode
ClickableNodeの実装で見逃してはいけないものがもう一つあります。ClickableSemanticsNodeです。
private class ClickableNode()
: AbstractClickableNode(interactionSource, enabled, onClickLabel, role, onClick) {
override val clickableSemanticsNode = delegate(
ClickableSemanticsNode(
enabled = enabled,
role = role,
onClickLabel = onClickLabel,
onClick = onClick,
onLongClick = null,
onLongClickLabel = null
)
)
}
ClickableSemanticsNodeはSemanticsModifierNodeを実装しています。今回は詳細実装は追いかけませんが、Modifier.clickable()に設定したroleやonClickLabelは、ここで読み上げ用のAPIに渡されます。
private class ClickableSemanticsNode(
private var enabled: Boolean,
private var onClickLabel: String?,
private var role: Role?,
private var onClick: () -> Unit,
private var onLongClickLabel: String?,
private var onLongClick: (() -> Unit)?,
) : SemanticsModifierNode, Modifier.Node()
ClickableNode まとめ
以上で、ClickableNodeの処理を概ね把握できました。改めて整理すると、以下のような処理が実装されていました。
- タップイベントを検出してonClickコールバックを呼び出し、Interactionを発行する
- キーイベントを検出してonClickコールバックを呼び出し、Interactionを発行する
- ジェスチャーがキャンセルされたときにInteractionSourceにキャンセルのInteractionを発行する
- roleとonClickLabelを読み上げ機能に設定する
Modifier.clickableのその他の処理
さて、もう一度Modifier.clickableの実装を見ましょう。
fun Modifier.clickable() {
Modifier
.indication(interactionSource, indication)
.hoverable(enabled = enabled, interactionSource = interactionSource)
.focusableInNonTouchMode(enabled = enabled, interactionSource = interactionSource)
.then(ClickableElement(interactionSource, enabled, onClickLabel, role, onClick))
}
ここまでで、ClickableElementについて説明しました。残りのindication(), hoverable(), focusableInNonTouchMode()も同じ粒度で説明する気力が無いので、これらは概要だけにとどめます。
Modifier.indication()の機能は、IndicationModifierがDrawModifierを実装して、draw()メソッドでタップ時の視覚効果を描画しています。
private class IndicationModifier(
val indicationInstance: IndicationInstance
) : DrawModifier {
override fun ContentDrawScope.draw() {
with(indicationInstance) {
drawIndication()
}
}
}
Modifier.hoverable()の機能は、HoverableNodeが担当しています。HoverableNodeもPointerInputModifierNodeを実装していて、マウスポインタがコンポーネントの上に来た時にコンポーネントの外観を変更する処理を実装しています。Android端末にBluetoothマウスを接続した場合などにこのコードが実行されます。
private class HoverableNode() : PointerInputModifierNode, Modifier.Node()
最後にModifier.focusableInNonTouchMode()ですが、これはインプットモードを判定し、キーボードのTabキーでフォーカスを当てられるようになっています。内部ではModifier.focusable()を呼び出していて、FocusableNodeがさらにいろいろなModifierNodeを実装したり委任したりしていて、こちらも追いかけるとかなり深そうです。
終わり
というわけでこの記事では、Modifier.clickable()の定義の確認と、内部の実装を見てきました。簡単に使えるModifier.clickable()ですが、内部では非常にたくさんの処理を行っていることが分かりました。ここまで長文にお付き合いくださり、ありがとうございました。