1. 始める前に
前提条件
- Android アプリを作成した経験
- Jetpack Composeを使用した経験
必要なもの
学習内容
- アダプティブ レイアウトと Navigation 3 の基本
- ドラッグ&ドロップの実装
- キーボード ショートカットのサポート
- コンテキスト メニューの有効化
2. セットアップする
バックアップの手順:
- Android Studio を起動します。
- [File] > [New] >
Project from Version control
をクリックします。 - URL: を貼り付けます。
https://github.com/android/socialite.git
Clone
をクリックします。
プロジェクトが完全に読み込まれるまで待ちます。
- ターミナルを開いて実行します。
$ git checkout codelab-adaptive-apps-start
- Gradle 同期を実行します。
Android Studio で、[File] > [Sync Project with Gradle Files] を選択します。
- (省略可)大型デスクトップ エミュレータをダウンロードします。
Android Studio で、[Tools] > [Device Manager] > + > [Create Virtual Device] > [New hardware profile] を選択します。
[デバイスの種類] の選択: デスクトップ
画面サイズ: 14 インチ
解像度: 1,920 x 1,080 ピクセル
[Finish] をクリックします。
- タブレットまたはデスクトップ エミュレータでアプリを実行します。
3. サンプルアプリについて理解する
このチュートリアルでは、Jetpack Compose で作成された Socialite というサンプル チャット アプリを操作します。
このアプリでは、さまざまな動物とチャットできます。動物はそれぞれ独自の方法であなたのメッセージに返信します。
現在のところ、このアプリはモバイル ファーストのアプリであり、タブレットやデスクトップなどの大型デバイス向けには最適化されていません。
アプリを大画面向けに適応させ、あらゆるフォーム ファクタでエクスペリエンスを向上させる機能をいくつか追加する予定です。
それでは始めましょう。
4. アダプティブ レイアウトとNavigation 3 の基本
$ git checkout codelab-adaptive-apps-step-1
現在、アプリは画面の空きスペースに関わらず、常に一度に 1 つのペインのみを表示しません。
現在のウィンドウ サイズに応じて 1 つまたは複数のペインを表示する adaptive layouts
を使用することで、この問題を解決する予定です。この Codelab では、ウィンドウ スペースが十分にある場合、アダプティブ レイアウトを使用して chat list
画面と chat detail
画面を自動的に並べて表示します。
アダプティブ レイアウトは、あらゆるアプリケーションにシームレスに統合されるように設計されています。
このチュートリアルでは、Socialite アプリが構築されている Navigation 3 ライブラリでこれらの機能を使用する方法について説明します。
Navigation 3 の基本
Navigation 3 について理解するために、まずは用語について説明します。
- NavEntry - アプリ内に表示される、ユーザーが移動できるコンテンツ。キーによって一意に識別されます。NavEntry は、アプリで利用可能なウィンドウ全体を埋める必要はありません。複数の NavEntry を同時に表示できます(詳細は後述)。
- キー - NavEntry の固有識別子。キーはバックスタックに保存されます。
- バックスタック - 以前に表示された、または現在表示されている NavEntry 要素を表すキーのスタック。移動するには、スタックにキーをプッシュするか、スタックからキーをポップします。
Socialite では、ユーザーがアプリを起動したときに最初に表示する画面はチャットリストです。そのため、バックスタックを作成し、その画面を表すキーで初期化します。
Main.kt
// Create a new back stack
val backStack = rememberNavBackStack(ChatsList)
...
// Navigate to a particular chat
backStack.add(ChatThread(chatId = chatId))
...
// Navigate back
backStack.removeLastOrNull()
Navigation 3 の実装
Navigation 3 は、Main
エントリ ポイント コンポーザブルに直接実装します。
MainNavigation
関数呼び出しをコメント化解除して、Navigation ロジックを接続します。
では、Navigation インフラストラクチャの構築をはじめましょう。
まず、バックスタックを作成します。これは Navigation 3 の基盤となります。
NavDisplay
ここまでに、Navigation 3 のコンセプトについていくつか説明してきました。では、ライブラリはどのようにしてバックスタックを表すオブジェクトを決定し、その要素を実際の UI に変換するのでしょうか。
NavDisplay
を使用します。これは、すべてをまとめてバックスタックをレンダリングするコンポーネントで、いくつかの重要なパラメータが必要です。1 つずつ詳しくご説明しましょう。
パラメータ 1 - バックスタック
NavDisplay
がコンテンツをレンダリングするには、バックスタックにアクセスする必要がありますので、アクセスを許可してください。
パラメータ 2 - EntryProvider
EntryProvider
は、バックスタック キーをコンポーザブル UI コンテンツに変換するラムダです。キーを受け取って、表示するコンテンツとその表示方法に関するメタデータを含むNavEntry
を返します(詳細は後述)。
NavDisplay
は、特定のキーのコンテンツを取得する必要があるたびに、このラムダを呼び出します(新しいキーがバックスタックに追加されたときなど)。
現在、Socialite で [タイムライン] アイコンをクリックすると、「不明なバックスタック キー: タイムライン」と表示されます。
これは、タイムラインキーがバックスタックに追加されても、EntryProvider
がレンダリング方法を認識しないため、デフォルトの実装にフォールバックするためです。[設定] アイコンをクリックした場合も同じことが起こります。EntryProvider
が [タイムライン] と [設定] のバックスタック キーを正しく処理するようにして、この問題を修正しましょう。
パラメータ 3 - SceneStrategy
NavDisplay
の次に重要なパラメータは SceneStrategy
です。これは、複数の NavEntry
要素を同時に表示する場合に使用します。各戦略では、複数の NavEntry
要素を並べて表示するか、重ねて表示するかを定義します。
たとえば、DialogSceneStrategy
を使用して一部の NavEntry
に特別なメタデータを設定すると、画面全体を占有するのではなく、現在のコンテンツの上にダイアログとして表示されます。
ここでは、別の SceneStrategy である ListDetailSceneStrategy
を使用します。これは、正規のリストと詳細のレイアウト向けに設計されています。
まず、これを NavDisplay
コンストラクタに追加しましょう。
sceneStrategy = rememberListDetailSceneStrategy(),
ここで、ChatList
NavEntry
をリストペインとしてマークし、ChatThread
NavEntry を詳細ペインとしてマークする必要があります。これにより、これらの NavEntry 要素が両方ともバックスタックにある場合、それらを並べて表示する必要があるかどうかを戦略で判断できます。
次に、ChatsList
NavEntry
をリストペインとしてマークします。
entryProvider = { backStackKey ->
when (backStackKey) {
is ChatsList -> NavEntry(
key = backStackKey,
metadata = ListDetailSceneStrategy.listPane(),
) {
...
}
...
}
}
同様に、ChatThread
NavEntry
を詳細ペインとしてマークします。
entryProvider = { backStackKey ->
when (backStackKey) {
is ChatThread -> NavEntry(
key = backStackKey,
metadata = ListDetailSceneStrategy.detailPane(),
) {
...
}
...
}
}
これで、アダプティブ レイアウトをアプリに正常に統合できました。
5. ドラッグ&ドロップ
$ git checkout codelab-adaptive-apps-step-2
このステップでは、ドラッグ&ドロップ サポートを追加して、ユーザーが ファイル アプリから Socialite に画像をドラッグできるようにします。
ChatScreen.kt
ファイルにある MessageList
コンポーザブルで定義されている message list
領域でドラッグ&ドロップを有効にすることを目標とします。
Jetpack Compose では、ドラッグ&ドロップ サポートは dragAndDropTarget
修飾子によって実装されます。ドロップされたアイテムを受け入れる必要のあるコンポーザブルに適用します。
Modifier.dragAndDropTarget(
shouldStartDragAndDrop = { event ->
// condition to accept dragged item
},
target = // DragAndDropTarget
)
この修飾子には次の 2 つのパラメータがあります。
- 最初の
shouldStartDragAndDrop
を使用すると、コンポーザブルでドラッグ&ドロップ イベントを除外できます。ここでは、画像のみを受け入れ、他のすべての種類のデータは無視します。 - 2 つ目の
target
は、受け入れたドラッグ&ドロップ イベントを処理するロジックを定義するコールバックです。
まず、MessageList
コンポーザブルに dragAndDropTarget
を追加しましょう。
.dragAndDropTarget(
shouldStartDragAndDrop = { event ->
event.mimeTypes().any { it.startsWith("image/") }
},
target = remember {
object : DragAndDropTarget {
override fun onDrop(event: DragAndDropEvent): Boolean {
TODO("Not yet implemented")
}
}
}
),
target
コールバック オブジェクトは、DragAndDropEvent
を引数として受け取る onDrop()
メソッドを実装する必要があります。
このメソッドは、ユーザーがコンポーザブルにアイテムをドロップしたときに呼び出されます。アイテムが処理された場合は true
を返し、拒否された場合は false
を返します。
各 DragAndDropEvent
には、ドラッグされているデータをカプセル化する ClipData
オブジェクトが含まれています。
ClipData
内のデータは、Item
オブジェクトの配列です。複数のアイテムを一度にドラッグできるため、各 Item
はアイテムの 1 つを表します。
target = remember {
object : DragAndDropTarget {
override fun onDrop(event: DragAndDropEvent): Boolean {
val clipData = event.toAndroidDragEvent().clipData
if (clipData != null && clipData.itemCount > 0) {
repeat(clipData.itemCount) { i ->
val item = clipData.getItemAt(i)
// TODO: Implement Item handling
}
return true
}
return false
}
}
}
Item
には、URI、テキスト、または Intent
の形式のデータを含めることができます。
ここでは、画像のみを受け入れるため、特定のURI を探します。
Item
に 1 つ含まれている場合は、次の操作を行う必要があります。
- URI にアクセスするためのドラッグ&ドロップ権限をリクエストします。
- URI を処理します(ここでは、すでに実装されている
onMediaItemAttached()
関数を呼び出します)。 - 権限を解除します。
override fun onDrop(event: DragAndDropEvent): Boolean {
val clipData = event.toAndroidDragEvent().clipData
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N
&& clipData != null && clipData.itemCount > 0) {
repeat(clipData.itemCount) { i ->
val item = clipData.getItemAt(i)
val passedUri = item.uri?.toString()
if (!passedUri.isNullOrEmpty()) {
val dropPermission = activity
.requestDragAndDropPermissions(
event.toAndroidDragEvent()
)
try {
val mimeType = context.contentResolver
.getType(passedUri.toUri()) ?: ""
onMediaItemAttached(MediaItem(passedUri, mimeType))
} finally {
dropPermission.release()
}
}
}
return true
}
return false
}
この時点で、ドラッグ&ドロップが完全に実装され、ファイルアプリから Socialite に写真をドラッグできます。
ドロップ可能な領域であることを視覚的に強調するため、枠線を追加して見栄えを良くしましょう。
これを行うには、ドラッグ&ドロップ セッションのさまざまなステージに対応する追加のフックを使用します。
onStarted()
: ドラッグ&ドロップ セッションが開始され、このDragAndDropTarget
がアイテムを受け取る対象になったときに呼び出されます。これは、受信するセッションの UI 状態を準備するのに適しています。onEntered()
: ドラッグされたアイテムがこのDragAndDropTarget
の境界内に入ったときにトリガーされます。onMoved()
: ドラッグされたアイテムがこのDragAndDropTarget
の境界内に移動したときに呼び出されます。onExited()
: ドラッグされたアイテムがこのDragAndDropTarget
の境界外に移動したときに呼び出されます。onChanged()
: このターゲットの境界内でドラッグ&ドロップ セッションで何かが変更されたときに呼び出されます(修飾キーが押されたときや離されたときなど)。onEnded()
: ドラッグ&ドロップ セッションが終了したときに呼び出されます。以前にonStarted
イベントを受信したDragAndDropTarget
はすべてこれを受信します。UI 状態をリセットする場合に便利です。
視覚的な枠線を追加するには、次の操作を行います。
- ドラッグ&ドロップを開始したときに
true
に設定され、終了したときにfalse
にリセットされる記憶されたブール値の変数を作成します。 - この変数が
true
の場合に枠線をレンダリングする修飾子をMessageList
コンポーザブルに適用します。
override fun onEntered(event: DragAndDropEvent) {
super.onEntered(event)
isDraggedOver = true
}
override fun onEnded(event: DragAndDropEvent) {
super.onExited(event)
isDraggedOver = false
}
6. キーボード ショートカット
$ git checkout codelab-adaptive-apps-step-3
パソコンでチャットアプリを使用する場合、ユーザーは Enter キーでメッセージを送信するなど、使い慣れたキーボード ショートカットを期待します。
このステップでは、その動作をアプリに追加します。
Compose のキーボード イベントは、修飾子で処理されます。
主に次の 2 つがあります。
onPreviewKeyEvent
- フォーカスされた要素によって処理される前に、キーボード イベントをインターセプトします。実装の一環として、イベントをさらに宣伝するか、使用するかを決定します。onKeyEvent
- フォーカスされた要素によって処理された後に、キーボード イベントをインターセプトします。他のハンドラがイベントを使用しなかった場合にのみトリガーされます。
ここでは、デフォルトのハンドラが Enter キーイベントを使用し、カーソルを新しい行に移動するため、TextField
で onKeyEvent
を使用することはできません。
.onPreviewKeyEvent { keyEvent ->
//TODO: implement key event handling
},
修飾子内のラムダは、キー入力ごとに 2 回(ユーザーがキーを押したときと離したとき)呼び出されます。
どちらであるかは、KeyEvent
オブジェクトの type
プロパティをチェックすることで判断できます。イベント オブジェクトには、次のような修飾子フラグも公開されます。
isAltPressed
isCtrlPressed
isMetaPressed
isShiftPressed
ラムダから true
を返すと、コードがキーイベントを処理したことが Compose に通知され、改行の挿入などのデフォルトの動作が防止されます。
ここで、onPreviewKeyEvent
修飾子を実装します。イベントが押された Enter キーに対応しており、Shift、Alt、Ctrl または Meta のいずれの修飾子も適用されていないかどうかをチェックします。次に、onSendClick()
関数を呼び出します。
.onPreviewKeyEvent { keyEvent ->
if (keyEvent.key == Key.Enter && keyEvent.type == KeyEventType.KeyDown
&& keyEvent.isShiftPressed == false
&& keyEvent.isAltPressed == false
&& keyEvent.isCtrlPressed == false
&& keyEvent.isMetaPressed == false) {
onSendClick()
true
} else {
false
}
},
7. コンテキスト メニュー
$ git checkout codelab-adaptive-apps-step-4
コンテキスト メニューは、アダプティブ UI の重要な要素です。
このステップでは、ユーザーがメッセージを右クリックしたときに表示される [返信] ポップアップ メニューを追加します。
clickable
修飾子を使用すると、クリックを簡単に検出できるなどさまざまな操作が標準でサポートされます。
右クリックなどのカスタム操作の場合は、pointerInput
修飾子を使用できます。これにより、未加工のポインタ イベントにアクセスし、操作検出を完全に制御できます。
まず、右クリックに応答する UI を追加しましょう。ここでは、DropdownMenu
を 1 つのアイテム( [返信] ボタン)で表示します。remember
が付加された次の 2 つの変数が必要です。
rightClickOffset
はクリック位置を保存するため、返信ボタンをカーソルの近くに移動できます。isMenuVisible
は [返信] ボタンの表示 / 非表示を制御します。
これらの値は右クリック操作の処理の一環として更新されます。
また、DropdownMenu
がその上に重ねて表示されるように、メッセージ コンポーザブルを Box
でラップする必要があります。
@Composable
internal fun MessageBubble(
...
) {
var rightClickOffset by remember { mutableStateOf<DpOffset>(DpOffset.Zero) }
var isMenuVisible by remember { mutableStateOf(false) }
val density = LocalDensity.current
Box(
modifier = Modifier
.pointerInput(Unit) {
// TODO: Implement right click handling
}
.then(modifier),
) {
AnimatedVisibility(isMenuVisible) {
DropdownMenu(
expanded = true,
onDismissRequest = { isMenuVisible = false },
offset = rightClickOffset,
) {
DropdownMenuItem(
text = { Text("Reply") },
onClick = {
// Custom Reply functionality
},
)
}
}
MessageBubbleSurface(
...
) {
...
}
}
}
ここで、pointerInput
修飾子を実装しましょう。まず、awaitEachGesture
を追加します。これにより、ユーザーが新しい操作を開始するたびに新しいスコープが開始されます。そのスコープ内で、次の操作を行う必要があります。
- 次のポインタ イベントを取得する -
awaitPointerEvent()
はポインタ イベントを表すオブジェクトを提供します。 - 右クリックのみを除外する - セカンダリ ボタンのみが押されたことを確認します。
- クリック位置をキャプチャする - ピクセル単位で位置を取得し、
DpOffset
に変換してメニューの配置が DPI に依存しないようにします。 - メニューを表示する -
isMenuVisible
=true
に設定し、オフセットを保存して、ポインタがあった場所にDropdownMenu
が正確にポップアップされるようにします。 - イベントを使用する - 押下とその解放の両方で
consume()
を呼び出し、他のハンドラが反応しないようにします。
.pointerInput(Unit) {
awaitEachGesture { // Start listening for pointer gestures
val event = awaitPointerEvent()
if (
event.type == PointerEventType.Press
&& !event.buttons.isPrimaryPressed
&& event.buttons.isSecondaryPressed
&& !event.buttons.isTertiaryPressed
// all pointer inputs just went down
&& event.changes.fastAll { it.changedToDown() }
) {
// Get the pressed pointer info
val press = event.changes.find { it.pressed }
if (press != null) {
// Convert raw press coordinates (px) to dp for positioning the menu
rightClickOffset = with(density) {
isMenuVisible = true // Show the context menu
DpOffset(
press.position.x.toDp(),
press.position.y.toDp()
)
}
}
// Consume the press event so it doesn't propagate further
event.changes.forEach {
it.consume()
}
// Wait for the release and consume it as well
waitForUpOrCancellation()?.consume()
}
}
}
8. 完了
お疲れさまでした。アプリを Navigation 3 に正常に移行し、以下を追加しました。
- アダプティブ レイアウト
- ドラッグ&ドロップ
- キーボード ショートカット
- コンテキスト メニュー
これで、完全にアダプティブ アプリを構築する強固な基盤ができました。
詳細