ユーザー操作の処理

ユーザー インターフェース コンポーネントは、ユーザー操作に応答する方法でデバイス ユーザーにフィードバックを返します。コンポーネントはそれぞれの方法で操作に応答するため、操作の効果がユーザーにわかりやすくなっています。たとえば、ユーザーがデバイスのタッチスクリーン上でボタンをタップすると、(ハイライト色が加わるなど)なんらかの方法でボタンが変化します。このように変化することで、ユーザーはボタンをタップしたことを把握できます。ユーザーがその操作を望まない場合は、指を離さずにボタンの外に指をドラッグすれば、ボタンはアクティブになりません。

Compose のジェスチャーのドキュメントでは、ポインタの移動やクリックなど、Compose コンポーネントによる下位レベルのポインタ イベントの処理について説明しています。Compose では、そうした下位レベルのイベントが上位レベルの操作に抽象化されるようになっています。たとえば、一連のポインタ イベントがボタンのタップとリリースとして処理される場合があります。このような上位レベルの抽象化を理解することで、ユーザーに対する UI の応答をカスタマイズできるようになります。たとえば、ユーザーが操作したときにコンポーネントの外観がどのように変化するかをカスタマイズしたり、それらのユーザー アクションのログを保持したりすることができます。このドキュメントには、標準の UI 要素の変更や、独自の UI の設計に必要な情報が記載されています。

操作

通常、Compose コンポーネントがユーザー操作をどのように解釈しているかを知る必要はありません。たとえば、ButtonModifier.clickable によって、ユーザーがボタンをクリックしたかどうかを判別します。アプリに一般的なボタンを追加する場合、ボタンの onClick コードを定義すると、Modifier.clickable が必要に応じてそのコードを実行します。つまり、ユーザーが画面をタップしたか、キーボードでボタンを選択したかを知る必要はありません。ユーザーがクリックしたことを Modifier.clickable が判別し、onClick コードを実行して応答します。

ただし、ユーザーの操作に対する UI コンポーネントの応答をカスタマイズするには、内部で何が起きているのかを把握しなければなりません。このセクションでは、その方法について説明します。

ユーザーが UI コンポーネントを操作すると、システムはいくつかの Interaction イベントを生成することで、ユーザーの操作を表します。たとえば、ユーザーがボタンをタップすると PressInteraction.Press が生成されます。ユーザーがボタン内で指を離すと PressInteraction.Release が生成され、クリックが完了したことをボタンが認識します。一方、ユーザーが指をボタンの外にドラッグしてから指を離すと、PressInteraction.Cancel が生成され、ボタンのタップが完了せずにキャンセルされたことを示します。

こうした操作は固定的なものではありません。つまり、このような下位レベルの操作イベントは、ユーザー アクションの意味や順序を解釈するものではありません。また、特定のユーザー アクションが他のアクションに優先するかどうかも解釈しません。

これらの操作は通常、開始と終了がペアになっています。2 番目の操作には、最初の操作に対する参照が含まれています。たとえば、ユーザーがボタンをタップして指を離した場合、タップによって PressInteraction.Press 操作が生成され、指を離すことで PressInteraction.Release が生成されます。Release には、最初の PressInteraction.Press を識別する press プロパティがあります。

特定のコンポーネントに対する操作は InteractionSource によって確認できます。InteractionSourceKotlin Flow 上に構築されており、他のフローの場合と同じように操作を収集できます。

操作の状態

操作を自分で追跡することで、コンポーネントの組み込み機能を拡張することもできます。たとえば、ボタンがタップされたときに色を変えたいとします。操作を追跡する最も簡単な方法は、適切な操作状態を監視することです。InteractionSource には、操作のさまざまなステータスを状態として示す、各種のメソッドが用意されています。たとえば、特定のボタンがタップされたかどうかを確認する場合は InteractionSource.collectIsPressedAsState() メソッドを呼び出します。

val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()

Button(
    onClick = { /* do something */ },
    interactionSource = interactionSource) {
    Text(if (isPressed) "Pressed!" else "Not pressed")
}

Compose には、collectIsPressedAsState() 以外にも、collectIsFocusedAsState()collectIsDraggedAsState()collectIsHoveredAsState() が用意されています。これらのメソッドは、実際には下位レベルの InteractionSource API 上にビルドされるコンビニエンス メソッドです。必要に応じて、それら下位レベルの関数を直接使用することもあります。

たとえば、あるボタンがタップされ、さらにドラッグされているかを判別する必要があるとします。collectIsPressedAsState()collectIsDraggedAsState() の両方を使用すると、Compose で多数の処理が重複することになります。その場合、すべての操作が正しい順序で実行される保証はありません。そのような状況では、InteractionSource を直接使用することをおすすめします。次のセクションでは、操作を自分で追跡して必要な情報だけを取得する方法を説明します。

InteractionSource の使用方法

コンポーネントの操作に関する下位レベルの情報が必要な場合は、そのコンポーネントの InteractionSource に対して標準のフロー API を使用できます。たとえば、InteractionSource に対するタップとドラッグ操作のリストを保持するとします。このコードでは、新たなタップがリストに追加されるまでの処理が行われます。

val interactionSource = remember { MutableInteractionSource() }
val interactions = remember { mutableStateListOf<Interaction>() }

LaunchedEffect(interactionSource) {
    interactionSource.interactions.collect { interaction ->
        when (interaction) {
            is PressInteraction.Press -> {
                interactions.add(interaction)
            }
            is DragInteraction.Start -> {
                interactions.add(interaction)
            }
        }
    }
}

その場合は、新しい操作を追加するだけでなく、終了した操作(たとえばユーザーがコンポーネントから指を離すなど)を削除する必要があります。ただし、終了操作は関連する開始操作に対する参照を常に伴うため、終了した操作の削除は簡単です。終了した操作を削除するコードを次に示します。

val interactions = remember { mutableStateListOf<Interaction>() }

LaunchedEffect(interactionSource) {
    interactionSource.interactions.collect { interaction ->
        when (interaction) {
            is PressInteraction.Press -> {
                interactions.add(interaction)
            }
            is PressInteraction.Release -> {
                interactions.remove(interaction.press)
            }
            is PressInteraction.Cancel -> {
                interactions.remove(interaction.press)
            }
            is DragInteraction.Start -> {
                interactions.add(interaction)
            }
            is DragInteraction.Stop -> {
                interactions.remove(interaction.start)
            }
            is DragInteraction.Cancel -> {
                interactions.remove(interaction.start)
            }
        }
    }
}

コンポーネントがタップされているかドラッグされているかは、interactions が空かどうかを見るだけで確認できます。

val isPressedOrDragged = interactions.isNotEmpty()

直近の操作が何であったかは、リストの最後の項目で確認できます。たとえば、Compose のリップル実装では、直近の操作に対して使用する適切な状態オーバーレイを次の方法で判別します。

val lastInteraction = when (interactions.lastOrNull()) {
    is DragInteraction.Start -> "Dragged"
    is PressInteraction.Press -> "Pressed"
    else -> "No state"
}

例を確認する

コンポーネントを作成し、入力に対するカスタム応答を指定する方法については、以下の変更されたボタンの例をご覧ください。この例に示すボタンは、タップに対して、外観が変わることで応答しています。

クリックされるとアイコンを動的に追加するボタンのアニメーション

ここでは、Button に基づくカスタム コンポーザブルを作成し、icon パラメータを追加してアイコン(この場合はショッピング カート)を描画します。collectIsPressedAsState() を呼び出して、ユーザーがボタンにカーソルを合わせているかどうかを追跡します。そしてカーソルが合わさった場合にアイコンが表示されるようにします。コードは次のようになります。

@Composable
fun PressIconButton(
    onClick: () -> Unit,
    icon: @Composable () -> Unit,
    text: @Composable () -> Unit,
    modifier: Modifier = Modifier,
    interactionSource: MutableInteractionSource =
        remember { MutableInteractionSource() },
) {
    val isPressed by interactionSource.collectIsPressedAsState()
    Button(onClick = onClick, modifier = modifier,
        interactionSource = interactionSource) {
        AnimatedVisibility(visible = isPressed) {
            if (isPressed) {
                Row {
                    icon()
                    Spacer(Modifier.size(ButtonDefaults.IconSpacing))
                }
            }
        }
        text()
    }
}

新しいコンポーザブルは次のように使用します。

PressIconButton(
    onClick = {},
    icon = { Icon(Icons.Filled.ShoppingCart, contentDescription = null) },
    text = { Text("Add to cart") }
)

この新しい PressIconButton は既存の Material Button 上にビルドされているため、ユーザーの操作に通常の方法で反応します。ユーザーがボタンをタップすると、通常の Material Button と同様に不透明度が少し変わります。また新しいコードにより、アイコンを追加することで、HoverIconButton がカーソルに動的に応答します。