Jetpack Composeでツールチップを使う方法:Material 3対応ステップバイステップガイド
前回Jetpack Composeに関する記事を執筆した際、「Jetpack Composeには(少なくとも筆者の観点では)基本的なコンポーネントがまだいくつか欠けている。その一例がツールチップである」と述べました。
当時、ツールチップを表示するための組み込みComposableは存在せず、ネット上には複数の代替実装が流通していました。しかし、これらの代替案には「Jetpack Composeの新しいバージョンがリリースされるたびに動作しなくなる可能性がある」という大きな問題がありました。決して理想的とは言えず、コミュニティは将来の公式サポート追加を待ち望むしかなかったのです。
そして朗報です。Compose Material 3のバージョン1.1.0以降、ツールチップが正式にサポートされるようになりました👏
これは素晴らしいニュースですが、そのバージョンのリリースからすでに1年以上が経過しています。さらに、その後のバージョンアップに伴い、ツールチップ関連のAPIも大きく変更されてきました。
変更履歴(changelog)を見れば、公開APIや内部APIがどれほど変化したかが分かります。したがって、この記事をお読みになる頃には状況がさらに変わっている可能性もあることにご留意ください。ツールチップに関連するすべてのAPIは、現在もExperimentalMaterial3Api::classアノテーションでマークされた実験的な機能だからです。
❗️ この記事で使用しているMaterial 3のバージョンは1.2.1(2024年3月6日リリース)です。
サポートされる2種類のツールチップ
現在、以下の2種類のツールチップがサポートされています。
- プレーンツールチップ(Plain Tooltip)
- リッチツールチップ(Rich Media Tooltip)
プレーンツールチップ(Plain Tooltip)
1つ目のタイプは、アイコンボタンの意味がひと目では分かりにくい場合などに補足情報を提供するために使えます。たとえば、アイコンボタンが何を表しているのかをユーザーに伝える用途に最適です。

アプリケーションにツールチップを追加するには、TooltipBoxというComposableを使用します。このComposableは複数の引数を受け取ります。
fun TooltipBox(
positionProvider: PopupPositionProvider,
tooltip: @Composable TooltipScope.() -> Unit,
state: TooltipState,
modifier: Modifier = Modifier,
focusable: Boolean = true,
enableUserInput: Boolean = true,
content: @Composable () -> Unit,
)
Composableの使用経験があれば、見慣れた引数も多いでしょう。ここでは、特別な役割を持つ引数を中心に解説します。
- positionProvider:PopupPositionProvider型で、ツールチップの表示位置を計算するために使用されます。
- tooltip:ツールチップのUIデザインを記述する場所です。
- state:特定のTooltipインスタンスに関連付けられた状態を保持します。ツールチップの表示・非表示といったメソッドが公開されており、インスタンス化の際に、ツールチップを永続的(persistent)にするかどうかを宣言できます(永続的にした場合、ユーザーがツールチップ外をクリックするまで表示され続けます)。
- content:ツールチップが上下に表示される対象となるUIです。
以下は、必要な引数をすべて指定してBasicTooltipBoxをインスタンス化する例です。
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun BasicTooltip() {
val tooltipPosition = TooltipDefaults.rememberPlainTooltipPositionProvider()
val tooltipState = rememberBasicTooltipState(isPersistent = false)
BasicTooltipBox(positionProvider = tooltipPosition,
tooltip = { Text("Hello World") } ,
state = tooltipState) {
IconButton(onClick = { }) {
Icon(imageVector = Icons.Filled.Favorite,
contentDescription = "Your icon's description")
}
}
}

Jetpack ComposeにはTooltipDefaultsという組み込みクラスがあり、これを活用するとTooltipBoxを構成する各引数を簡単に用意できます。たとえば、TooltipDefaults.rememberPlainTooltipPositionProviderを使用すれば、アンカー要素との相対位置に応じてツールチップを適切に配置できます。
リッチツールチップ(Rich Tooltip)
リッチメディアツールチップはプレーンツールチップよりも多くのスペースを占有でき、アイコンボタンの機能についてより詳細なコンテキストを提供するのに役立ちます。ツールチップの表示中にボタンやリンクを追加し、さらなる説明や定義を提示することも可能です。
インスタンス化の方法はプレーンツールチップとほぼ同じで、TooltipBoxの中でRichTooltipというComposableを使用します。
TooltipBox(positionProvider = tooltipPosition,
tooltip = {
RichTooltip(
title = { Text("RichTooltip") },
caretSize = caretSize,
action = {
TextButton(onClick = {
scope.launch {
tooltipState.dismiss()
tooltipState.onDispose()
}
}) {
Text("Dismiss")
}
}
) {
Text("This is where a description would go.")
}
},
state = tooltipState) {
IconButton(onClick = {
/* Icon button's click event */
}) {
Icon(imageVector = tooltipIcon,
contentDescription = "Your icon's description",
tint = iconColor)
}
}
リッチツールチップについて押さえておきたいポイントは次のとおりです。
- キャレット(caret)をサポートしており、アンカー要素を指し示すことができる。
- アクション(ボタン)を追加でき、ユーザーがさらに詳しい情報を得るための選択肢を提供できる。
- ツールチップを閉じる(dismiss)ロジックを追加できる。


エッジケースへの対処
ツールチップの状態を永続的(persistent)に設定すると、ユーザーがツールチップを表示させるUIを操作した後、画面上の他の場所を押すまでツールチップは表示されたままになります。
先ほどのリッチツールチップの例をご覧になった方は、クリックされたらツールチップを閉じるためのボタンを追加していたことに気づいたかもしれません。
ところが、ユーザーがそのボタンを押すと問題が発生します。dismiss(非表示)アクションはツールチップ側で実行されるため、ユーザーが同じUI項目を再度長押ししても、ツールチップは再表示されません。つまり、ツールチップの状態が「dismissed(非表示済み)」として固定されてしまうのです。では、この問題をどう解決すればよいのでしょうか?

ツールチップの状態を「リセット」するには、tooltip stateが公開しているonDisposeメソッドを呼び出す必要があります。こうすることでツールチップの状態がリセットされ、ユーザーが再度長押ししたときにツールチップが正しく表示されるようになります。
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RichTooltip() {
val tooltipPosition = TooltipDefaults.rememberRichTooltipPositionProvider()
val tooltipState = rememberTooltipState(isPersistent = true)
val scope = rememberCoroutineScope()
TooltipBox(positionProvider = tooltipPosition,
tooltip = {
RichTooltip(
title = { Text("RichTooltip") },
caretSize = TooltipDefaults.caretSize,
action = {
TextButton(onClick = {
scope.launch {
tooltipState.dismiss()
tooltipState.onDispose() /// <---- ここ
}
}) {
Text("Dismiss")
}
}
) {
}
},
state = tooltipState) {
IconButton(onClick = { }) {
Icon(imageVector = Icons.Filled.Call, contentDescription = "Your icon's description")
}
}
}

もう一つ、ツールチップの状態がリセットされないケースがあります。それは、ユーザーのアクションに応じて自分でdismissメソッドを呼び出すのではなく、ユーザーがツールチップの外側をクリックして閉じられた場合です。この場合、内部的にdismissメソッドが呼ばれてツールチップの状態は「dismissed」になりますが、onDisposeは呼ばれないため、UI要素を長押ししてもツールチップは再表示されません。

onDisposeを呼び出す独自のロジックは発火しないため、この場合ツールチップの状態をどうリセットすればよいのでしょうか?
現時点では、筆者も明確な解決策を見つけられていません。おそらくツールチップ内部のMutatorMutexが関係していると思われます。今後のリリースで、この目的のためのAPIが提供されるかもしれません。ただ、画面上に他のツールチップが存在し、それを押下すると、以前押されたツールチップの状態がリセットされることには気づきました。

本記事で紹介したコードは、GitHubリポジトリで確認できます。また、実際のアプリケーションにおけるツールチップの動作サンプルも公開されていますので、ぜひ参考にしてください。
参考文献
- Material3 Tooltip Overview(公式ドキュメント)
- Tooltip Defaults(公式ドキュメント)
- Tooltip Source Code(公式ソースコード)
-
CopperheadOSとは?Googleフリーで安全・安心なAndroidカスタムROMのすべて
スマートフォンを購入して電源を入れた瞬間、削除できないアプリや不要な機能がプリインストールされていて驚いた経験はありませんか。こうしたソフトウェアはユーザー体験を損ない、貴重なストレージ容量を無駄に消費します。だからこそ、カスタムROMは多くのユーザーから支持されているのです。カスタムROMを使えば、スマートフォンのセキュリティとプライバシーを細かいレベルで自分好みにコントロールできます。 なお、カスタムROMは「root化」とは異なる点に注意が必要です。root化が既存OSの制限を解除する手法であるのに対し、カスタムROMはデバイスのオペレーティングシステムそのものを丸ごと置き換えます。An
-
Androidスマホでアプリを素早く見つけて起動する5つの方法
あなたのスマホには、いったい何個のアプリが入っていますか?調査によると、一般的なユーザーは毎月2〜3個のアプリを新しくインストールしており、積み重なればかなりの数になります。筆者が最後に数えたところ、なんと97個もありました。 しかし、どのアプリがどこにあるのかを把握しておくのは意外と難しいもの。幸い、Androidにはアプリを見つけて起動するための便利な方法が数多く用意されています。ここでは、その中でも特におすすめの5つの方法をご紹介します。 1. ホーム画面を上手に活用する Androidでアプリを起動する最も基本的な方法は、ホーム画面を最大限に活用することです。Androidは、端末にイ