アダプティブ アプリ

1. 始める前に

前提条件

  • Android アプリを作成した経験
  • Jetpack Composeを使用した経験

必要なもの

学習内容

  • アダプティブ レイアウトと Navigation 3 の基本
  • ドラッグ&ドロップの実装
  • キーボード ショートカットのサポート
  • コンテキスト メニューの有効化

2. セットアップする

バックアップの手順:

  1. Android Studio を起動します。
  2. [File] > [New] > Project from Version control をクリックします。
  3. URL: を貼り付けます。
https://github.com/android/socialite.git
  1. Clone をクリックします。

プロジェクトが完全に読み込まれるまで待ちます。

  1. ターミナルを開いて実行します。
$ git checkout codelab-adaptive-apps-start
  1. Gradle 同期を実行します。

Android Studio で、[File] > [Sync Project with Gradle Files] を選択します。

  1. (省略可)大型デスクトップ エミュレータをダウンロードします。

Android Studio で、[Tools] > [Device Manager] > + > [Create Virtual Device] > [New hardware profile] を選択します。

[デバイスの種類] の選択: デスクトップ

画面サイズ: 14 インチ

解像度: 1,920 x 1,080 ピクセル

[Finish] をクリックします。

  1. タブレットまたはデスクトップ エミュレータでアプリを実行します。

3. サンプルアプリについて理解する

このチュートリアルでは、Jetpack Compose で作成された Socialite というサンプル チャット アプリを操作します。e9e4541f0f76d669.png

このアプリでは、さまざまな動物とチャットできます。動物はそれぞれ独自の方法であなたのメッセージに返信します。

現在のところ、このアプリはモバイル ファーストのアプリであり、タブレットやデスクトップなどの大型デバイス向けには最適化されていません

アプリを大画面向けに適応させ、あらゆるフォーム ファクタでエクスペリエンスを向上させる機能をいくつか追加する予定です。

それでは始めましょう。

4. アダプティブ レイアウトとNavigation 3 の基本

$ git checkout codelab-adaptive-apps-step-1

現在、アプリは画面の空きスペースに関わらず、常に一度に 1 つのペインのみを表示しません。

現在のウィンドウ サイズに応じて 1 つまたは複数のペインを表示する adaptive layouts を使用することで、この問題を解決する予定です。この Codelab では、ウィンドウ スペースが十分にある場合、アダプティブ レイアウトを使用して chat list 画面と chat detail 画面を自動的に並べて表示します。

c549fd9fa64589e9.gif

アダプティブ レイアウトは、あらゆるアプリケーションにシームレスに統合されるように設計されています。

このチュートリアルでは、Socialite アプリが構築されている 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 は、Main エントリ ポイント コンポーザブルに直接実装します。

MainNavigation 関数呼び出しをコメント化解除して、Navigation ロジックを接続します。

では、Navigation インフラストラクチャの構築をはじめましょう。

まず、バックスタックを作成します。これは Navigation 3 の基盤となります。

ここまでに、Navigation 3 のコンセプトについていくつか説明してきました。では、ライブラリはどのようにしてバックスタックを表すオブジェクトを決定し、その要素を実際の UI に変換するのでしょうか。

NavDisplay を使用します。これは、すべてをまとめてバックスタックをレンダリングするコンポーネントで、いくつかの重要なパラメータが必要です。1 つずつ詳しくご説明しましょう。

パラメータ 1 - バックスタック

NavDisplay がコンテンツをレンダリングするには、バックスタックにアクセスする必要がありますので、アクセスを許可してください。

パラメータ 2 - EntryProvider

EntryProvider は、バックスタック キーをコンポーザブル UI コンテンツに変換するラムダです。キーを受け取って、表示するコンテンツとその表示方法に関するメタデータを含むNavEntry を返します(詳細は後述)。

NavDisplay は、特定のキーのコンテンツを取得する必要があるたびに、このラムダを呼び出します(新しいキーがバックスタックに追加されたときなど)。

現在、Socialite で [タイムライン] アイコンをクリックすると、「不明なバックスタック キー: タイムライン」と表示されます。

532134900a30c9c.gif

これは、タイムラインキーがバックスタックに追加されても、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 に画像をドラッグできるようにします。

78fe1bb6689c9b93.gif

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 つ含まれている場合は、次の操作を行う必要があります。

  1. URI にアクセスするためのドラッグ&ドロップ権限をリクエストします。
  2. URI を処理します(ここでは、すでに実装されている onMediaItemAttached() 関数を呼び出します)。
  3. 権限を解除します。
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 に写真をドラッグできます。

ドロップ可能な領域であることを視覚的に強調するため、枠線を追加して見栄えを良くしましょう。

これを行うには、ドラッグ&ドロップ セッションのさまざまなステージに対応する追加のフックを使用します。

  1. onStarted() : ドラッグ&ドロップ セッションが開始され、この DragAndDropTarget がアイテムを受け取る対象になったときに呼び出されます。これは、受信するセッションの UI 状態を準備するのに適しています。
  2. onEntered() : ドラッグされたアイテムがこの DragAndDropTarget の境界内に入ったときにトリガーされます。
  3. onMoved() : ドラッグされたアイテムがこの DragAndDropTarget の境界内に移動したときに呼び出されます。
  4. onExited() : ドラッグされたアイテムがこの DragAndDropTarget の境界外に移動したときに呼び出されます。
  5. onChanged() : このターゲットの境界内でドラッグ&ドロップ セッションで何かが変更されたときに呼び出されます(修飾キーが押されたときや離されたときなど)。
  6. onEnded() : ドラッグ&ドロップ セッションが終了したときに呼び出されます。以前に onStarted イベントを受信した DragAndDropTarget はすべてこれを受信します。UI 状態をリセットする場合に便利です。

視覚的な枠線を追加するには、次の操作を行います。

  1. ドラッグ&ドロップを開始したときに true に設定され、終了したときに false にリセットされる記憶されたブール値の変数を作成します。
  2. この変数が 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 キーイベントを使用し、カーソルを新しい行に移動するため、TextFieldonKeyEvent を使用することはできません。

.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 の重要な要素です。

このステップでは、ユーザーがメッセージを右クリックしたときに表示される [返信] ポップアップ メニューを追加します。

d9d30ae7e0230422.gif

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 を追加します。これにより、ユーザーが新しい操作を開始するたびに新しいスコープが開始されます。そのスコープ内で、次の操作を行う必要があります。

  1. 次のポインタ イベントを取得する - awaitPointerEvent() はポインタ イベントを表すオブジェクトを提供します。
  2. 右クリックのみを除外する - セカンダリ ボタンのみが押されたことを確認します。
  3. クリック位置をキャプチャする - ピクセル単位で位置を取得し、DpOffset に変換してメニューの配置が DPI に依存しないようにします。
  4. メニューを表示する - isMenuVisible = true に設定し、オフセットを保存して、ポインタがあった場所に DropdownMenu が正確にポップアップされるようにします。
  5. イベントを使用する - 押下とその解放の両方で 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 に正常に移行し、以下を追加しました。

  • アダプティブ レイアウト
  • ドラッグ&ドロップ
  • キーボード ショートカット
  • コンテキスト メニュー

これで、完全にアダプティブ アプリを構築する強固な基盤ができました。

詳細