意外と知らないModifier.clickable

この記事は[Android Advent Calendar 2023](https://qiita.com/advent-calendar/2023/android)の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()には引数の異なる２つのオーバーロードが存在します。onClickラムダだけを渡したときに使っているのは、引数が4つの方です。

```Kotlin
fun Modifier.clickable(
    enabled: Boolean = true,
    onClickLabel: String? = null,
    role: Role? = null,
    onClick: () -> Unit
)
```

```Kotlin
fun Modifier.clickable(
    interactionSource: MutableInteractionSource,
    indication: Indication?,
    enabled: Boolean = true,
    onClickLabel: String? = null,
    role: Role? = null,
    onClick: () -> Unit
)
```

引数が４つのModifier.clickable()は、デフォルト値を指定して、もう一方の引数が６つのModifier.clickable()を呼び出しています。

```Kotlin
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()を適用した場合で確認してみます。

```kotlin
@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=”画面を更新する”とすると、「ボタン。画面を更新するにはダブルタップします」と読み上げられます。（トークバックを有効にしている場合、ダブルタップが通常のタップと同じ扱いになります。）

https://youtube.com/shorts/pSJ_q1flu1w?feature=share

role=null, onClickLabel=nullの場合は、「ラベルなし。有効にするにはダブルタップします。」と読み上げられます。これでは、ダブルタップすると何が有効になるのか分かりません。

https://youtube.com/shorts/cPFPmr6fZQQ?feature=share

実際のアプリではBoxの中に何らかのアイコンやテキストが入っていることが多いので、それらが読み上げられることによって、何のボタンか分かることも多いと思います。とはいえ、Modifier.clickableを使う時にはアクセシビリティのことも思い出すように気をつけたいものです。

なお、RoleにはButtonの他に、CheckBoxやSwitchなどがあります。Material3のButtonなどを使っている場合は初めから適切なRoleが設定されているのですが、BoxなどをModifier.clickableでボタンとして使う場合などは、自分で適切にRoleを指定するのが望ましいと言えます。

以下のページの中ほどに、role = Role.Checkboxを指定したサンプルコードが掲載されています。

https://developer.android.com/jetpack/compose/accessibility?hl=ja

また、onClickLabelは「〇〇する」という動詞で定義する良さそうです。読み上げ文の「にはダブルタップします」の部分が定型文なので、「〇〇する」と定義しておくことで、読み上げが自然な日本語になります。onClickLabelに「更新ボタン」のようなボタンの名前を定義するよりも、「画面を更新する」のように、ボタンの挙動や目的を説明したほうが、アクセシビリティが向上しそうです。

## interactionSource

interactionSourceは、コンポーネントで発生したユーザーのInteractionを受け取ることができるストリームです。Interactionは、指が触れた（PressInteraction.Press）、離れた（PressInteraction.Release）などのユーザーの操作です。InteractionSourceの内部にはInteractionのFlowを持っていて、外部に対してはcollectIsPressedAsState()のような関数でStateを公開しています。interactionSourceを使うと、コンポーネントの操作状態に合わせて外観をカスタマイズしたりできます。

以下のページに簡単なサンプルコードが記載されています。

https://developer.android.com/jetpack/compose/touch-input/handling-interactions?hl=ja#interaction-state

これを参考にして、次のようなコードを実行してみました。

```kotlin
@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と表示されていることがわかります。他にも、色を変えたり、ボタンの大きさを変えたり、いろいろ工夫できそうです。

![interactionSource.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/470446/5e2079a1-eac3-42de-750d-f5b56f3caac7.gif)

## indication

indicationは、操作状態に応じた視覚効果を指定する際に使います。デフォルトではCompositionLocalのLocalIndicationが指定されているので、MaterialTheme内で使った場合はRippleEffectが設定されます。RippleEffectを無効にしたい場合は、nullを指定します。また、RippleEffect以外にも、操作に合わせたアニメーション効果を作って適用することができます。

以下の記事がとても参考になります。

https://at-sushi.work/blog/50/

## Modifier.clickable()の実装

ここからは、Modifier.clickable()の実装を見ていきます。

```Kotlin
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()は、４つのModifierの組み合わせで構成されていることが分かります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/470446/a165ae27-71e9-3950-e438-c89a914bf566.png)

まずは、名前からして一番の主役っぽいClickableElementから見ていこうと思いますが、その前に簡単にModifier.Nodeの仕組みを説明します。

## Modifier.Nodeの超概要

Modifier.Nodeは、ModifierNodeElementとのペアで実装されます。UIコンポーネント側でModifierチェーンを構成するのはModifierNodeElementです。ModifierNodeElementは実行時にModifier.Nodeオブジェクトを作成します。実際にUIを描画したり、ジェスチャーを検出したりするのはModifier.Nodeの役割です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/470446/cf7ee1f1-2739-b83d-a838-2c5a5846ded4.png)

以降の図ではModifierNodeElementは省略し、Modifier.Nodeのみ図示します。

Modifier.Nodeは、いろいろな定義済みのインターフェースを実装したり、別の定義済みのDelegatableNodeに委任したりすることで、比較的簡単に実装することができるようになっています。（とはいってもそれなりに複雑なコードになりがちですが。）そのため、実装しているインターフェースと、delegate関数を使って委任している部分を見ると、どんな機能を組み合わせてその機能を実現しているのかがおおよそ分かります。

## ClickableNode

さて、Modifier.clickable()の実装に戻りましょう。

```Kotlin
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を実装しています。このことから、ジェスチャー検出処理とキーイベント検出処理が実装されていることが予想できます。

```kotlin
private class ClickableNode() : AbstractClickableNode()
```

```kotlin
private sealed class AbstractClickableNode()
    : DelegatingNode(), PointerInputModifierNode, KeyInputModifierNode
```

AbstractClickableNodeという抽象化レイヤーを挟んでいるのは、Modifier.clickable()とModifier.combinedClickableで実装を共通化するためのようです。おかげで少しコードが追いづらいですが、じっくり読んでいきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/470446/15ceefa3-740c-7033-81bd-448a893265b9.png)

### PointerInputModifierNodeの実装

PointerInputModifierNodeは、コンポーネント上で発生したタッチやジェスチャーのイベントを検出する役割を担います。AbstractClickableNodeでPointerInputModifierNodeのonPointerEvent()とonCancelPointerInput()をオーバーライドしていますが、実際の処理はClickablePointerInputNodeという別のNodeに委任しています。delegate()は処理を委任するための関数です。

```kotlin
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()
    }
}
```

```kotlin
private class ClickableNode() : AbstractClickableNode() {

    override val clickablePointerInputNode = delegate(
        ClickablePointerInputNode(
            enabled = enabled,
            interactionSource = interactionSource,
            onClick = onClick,
            interactionData = interactionData
        )
    )
}
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/470446/21540011-8249-9964-ed59-137db9c9cbaf.png)


ClickablePointerInputNodeを見ると、クリック（タップ）検出処理の実体がありました。タップを検出したときにonClickコールバックを呼び出しています。また、InteractionSourceにInteractionを発行しています。

```kotlin
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から呼ばれます。

```kotlin
private sealed class AbstractClickablePointerInputNode()
    : DelegatingNode(), 
    ModifierLocalModifierNode, 
    CompositionLocalConsumerModifierNode,
    PointerInputModifierNode {
    
    private val pointerInputNode = delegate(
        SuspendingPointerInputModifierNode { pointerInput() }
    )
}
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/470446/cc889bc3-109f-6b48-8a27-c07a69bee78b.png)

SuspendingPointerInputModifierNodeは、PointerInputModifierNodeを実装するときによく使われる便利クラスで、PointerInputScopeのスロットを提供してくれるので、ここにジェスチャー検出処理を書くことができます。

また、AbstractClickablePointerInputNodeはModifierLocalModifierNodeとCompositionLocalConsumerModifierNodeという２つのインターフェースも実装しています。これらを使って、コンポーネントがScrollableコンポーネントの上に配置されているかどうかを検出し、Scrollableの場合はPressInteractionの発行をすこし遅らせているようでした（コードは省略）。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/470446/2c59b16c-a89c-d5d9-42f7-af31db54460d.png)

### KeyInputModifierNodeの実装

さて、いったんAbstractClickableNodeに戻ります。

AbstractClickableNodeはKeyInputModifierNodeも実装しています。
onKeyEvent()の中で、onClickコールバックを呼び出していました。また、InteractionSourceにInteractionを発行しています。このコードは、例えばBluetoothキーボードでAndroid端末を操作した時などに実行されます。

```kotlin
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を利用する側で、クリックがキャンセルされたことを検出できています。

```kotlin
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です。

```kotlin
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に渡されます。

```kotlin
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()
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/470446/6c1e12af-a169-47ad-11a4-6dc8e68682d0.png)

### ClickableNode まとめ

以上で、ClickableNodeの処理を概ね把握できました。改めて整理すると、以下のような処理が実装されていました。

- タップイベントを検出してonClickコールバックを呼び出し、Interactionを発行する
- キーイベントを検出してonClickコールバックを呼び出し、Interactionを発行する
- ジェスチャーがキャンセルされたときにInteractionSourceにキャンセルのInteractionを発行する
- roleとonClickLabelを読み上げ機能に設定する

## Modifier.clickableのその他の処理

さて、もう一度Modifier.clickableの実装を見ましょう。

```Kotlin
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()メソッドでタップ時の視覚効果を描画しています。

```kotlin
private class IndicationModifier(
    val indicationInstance: IndicationInstance
) : DrawModifier {

    override fun ContentDrawScope.draw() {
        with(indicationInstance) {
            drawIndication()
        }
    }
}
```

Modifier.hoverable()の機能は、HoverableNodeが担当しています。HoverableNodeもPointerInputModifierNodeを実装していて、マウスポインタがコンポーネントの上に来た時にコンポーネントの外観を変更する処理を実装しています。Android端末にBluetoothマウスを接続した場合などにこのコードが実行されます。

```kotlin
private class HoverableNode() : PointerInputModifierNode, Modifier.Node()
```

最後にModifier.focusableInNonTouchMode()ですが、これはインプットモードを判定し、キーボードのTabキーでフォーカスを当てられるようになっています。内部ではModifier.focusable()を呼び出していて、FocusableNodeがさらにいろいろなModifierNodeを実装したり委任したりしていて、こちらも追いかけるとかなり深そうです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/470446/259f6fb7-31d3-9114-a77e-efe08cd263bb.png)

## 終わり

というわけでこの記事では、Modifier.clickable()の定義の確認と、内部の実装を見てきました。簡単に使えるModifier.clickable()ですが、内部では非常にたくさんの処理を行っていることが分かりました。ここまで長文にお付き合いくださり、ありがとうございました。