自動調整式應用程式

1. 事前準備

必要條件

  • 具備建構 Android 應用程式的經驗。
  • 具備使用 Jetpack Compose 的經驗。

軟硬體需求

課程內容

  • 自動調整式版面配置和 Navigation 3 基本資訊
  • 實作拖曳功能
  • 支援鍵盤快速鍵
  • 啟用內容選單

2. 做好準備

首先,請按照下列步驟操作:

  1. 啟動 Android Studio
  2. 依序點按「File」>「New」>「Project from Version control
  3. 貼上網址:
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」

選取裝置類型:「Desktop」

螢幕尺寸:14

解析度:1920 x 1080 像素

按一下「Finish」

  1. 在平板電腦或桌面模擬器上執行應用程式

3. 瞭解範例應用程式

在本教學課程中,您將處理一個使用 Jetpack Compose 建構而成,名為「Socialite」的應用程式。e9e4541f0f76d669.png

在這個應用程式中,您可以與各種動物聊天,而牠們會以各自的方式回覆您的訊息。

目前,這是一款行動優先應用程式,並未針對平板電腦或桌上型電腦等大型裝置進行最佳化。

我們將調整這個應用程式,讓它適應大螢幕裝置,並新增一些功能,改善在所有板型規格上的使用體驗。

立即開始!

4. 自動調整式版面配置 + Navigation 3 基本資訊

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

目前,無論螢幕空間有多大,這個應用程式始終一次顯示一個窗格。

我們將透過 adaptive layouts 來修正這個問題,根據目前的視窗大小顯示一個或多個窗格。在本程式碼實驗室中,我們會使用自動調整式版面配置,在有足夠的視窗空間時,自動並排顯示 chat listchat detail 畫面。

c549fd9fa64589e9.gif

自動調整式版面配置的設計能無縫整合至任何應用程式中。

在本教學課程中,我們將著重於說明如何將自動調整式版面配置與 Navigation 3 程式庫搭配使用,這也是「Socialite」應用程式採用的架構。

為了理解 Navigation 3,我們先瞭解幾個術語:

  • NavEntry:應用程式中顯示的部分內容,可供使用者前往,由專屬鍵識別。NavEntry 不一定會填滿應用程式可用的整個視窗。可以同時顯示多個 NavEntry (稍後會進一步說明)。
  • NavEntry 的專屬 ID。這些鍵儲存在返回堆疊中。
  • 返回堆疊:一組鍵,代表先前顯示或目前正在顯示的 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()

我們將直接在 Main 進入點可組合函式中實作 Navigation 3。

取消註解 MainNavigation 函式呼叫,以接入導覽邏輯。

接下來,我們開始建構導覽基礎架構。

首先,建立返回堆疊。這是 Navigation 3 的基礎。

到目前為止,我們已經介紹了幾個 Navigation 3 概念。但程式庫如何判斷哪個物件代表返回堆疊?又該如何將其中的元素轉換為實際的 UI 呢?

這時就要介紹 NavDisplay。這是整合所有元件並負責轉譯返回堆疊的元件。它需要用到幾個重要參數,我們接下來逐一介紹。

參數 1 - 返回堆疊

NavDisplay 需要存取返回堆疊,才能轉譯其中的內容。我們將其傳入。

參數 2 - EntryProvider

EntryProvider 是個 lambda 函式,負責將返回堆疊中的鍵轉換為可組合函式 UI 內容。它會接收一個鍵,並傳回 NavEntry,其中包含要顯示的內容,以及如何顯示該內容的中繼資料 (稍後會進一步說明)。

NavDisplay 需要為特定鍵取得內容時,就會呼叫這個 lambda 函式,例如在返回堆疊中新增鍵時。

目前,如果我們在「Socialite」中按一下「Timeline」圖示,會看到「Unknown back stack key: Timeline」的訊息。

532134900a30c9c.gif

這是因為,即使「Timeline」鍵已新增至返回堆疊,EntryProvider 卻不知道如何轉譯,因此會回退到預設的實作方式。按一下「設定」圖示時也會發生同樣的情況。我們來修正這個問題,確保 EntryProvider 正確處理「Timeline」和「Settings」返回堆疊鍵。

參數 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

在這個步驟中,我們將新增拖曳功能,讓使用者能將圖片從「Files」應用程式拖曳至「Socialite」

78fe1bb6689c9b93.gif

我們的目標是在 message list 區域中啟用拖曳功能,這個區域是由 ChatScreen.kt 檔案中的 MessageList 可組合函式定義的。

在 Jetpack Compose 中,拖曳功能透過 dragAndDropTarget 修飾符實作。我們會將其套用至需要接受放置項目的可組合函式。

Modifier.dragAndDropTarget(
   shouldStartDragAndDrop = { event ->
       // condition to accept dragged item
   },
   target = // DragAndDropTarget
)

這個修飾符有兩個參數。

  • 第一個 shouldStartDragAndDrop 參數允許可組合函式篩選拖曳事件。在本例中,我們只想接受圖片,並忽略所有其他類型的資料。
  • 第二個 target 參數是回呼,用於定義處理已接受之拖曳事件的邏輯。

首先,將 dragAndDropTarget 新增至 MessageList 可組合函式。

.dragAndDropTarget(
   shouldStartDragAndDrop = { event ->
       event.mimeTypes().any { it.startsWith("image/") }
   },
   target = remember {
       object : DragAndDropTarget {
           override fun onDrop(event: DragAndDropEvent): Boolean {
               TODO("Not yet implemented")
           }
       }
   }
),

target 回呼物件需要實作 onDrop() 方法,該方法接收 DragAndDropEvent 做為其引數。

當使用者將項目拖曳到可組合函式時,系統會叫用此方法。如果系統已處理項目,就會傳回 true;如果拒絕處理,則傳回 false

每個 DragAndDropEvent 都包含 ClipData 物件,用於封裝拖曳的資料。

ClipData 中的資料是 Item 物件的陣列。一次可以拖曳多個項目,每個 Item 都代表其中一個項目。

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 包含 URI,我們需要執行以下操作:

  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
}

到這個階段,拖曳功能已完全實作,您可以順利將相片從「Files」應用程式拖曳至「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. 將修飾符套用至 MessageList 可組合函式,在此變數為 true 時算繪邊框
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 中,鍵盤事件透過修飾符來處理。

主要有兩種修飾符:

  • onPreviewKeyEvent:在焦點元素處理鍵盤事件「之前」攔截事件,實作時可以決定是否繼續傳遞或消耗該事件。
  • onKeyEvent:在焦點元素處理完鍵盤事件「之後」攔截事件,只有在其他處理常式沒有消耗事件時才會觸發。

在本例中,在 TextField 上使用 onKeyEvent 會失敗,因為預設處理常式會消耗 Enter 按鍵事件,並將游標移至新行。

.onPreviewKeyEvent { keyEvent ->
   //TODO: implement key event handling
},

使用者按下按鍵時和放開按鍵時,系統會各呼叫一次這個修飾符內的 lambda。

可以檢查 KeyEvent 物件的 type 屬性,來判斷使用的修飾符。事件物件也會提供多個修飾符的標記,包括:

  • isAltPressed
  • isCtrlPressed
  • isMetaPressed
  • isShiftPressed

如果從 lambda 傳回 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 的重要元素。

在這個步驟中,我們會新增一個「Reply」彈出式選單,當使用者在訊息上按一下滑鼠右鍵時,就會顯示這項選單。

d9d30ae7e0230422.gif

系統預設支援許多手勢,例如 clickable 修飾符可輕鬆偵測點按動作。

針對自訂手勢 (例如按一下滑鼠右鍵),我們可以使用 pointerInput 修飾符,這樣就能存取原始指標事件,並完全控制手勢偵測。

首先,我們新增能夠回應按一下滑鼠右鍵的 UI。在本例中,我們想顯示 DropdownMenu,裡面有一個項目:「Reply」按鈕。我們需要 2 個 remember 變數:

  • rightClickOffset 儲存點按位置,以便將「Reply」按鈕顯示在游標附近
  • isMenuVisible:控制顯示還是隱藏「Reply」按鈕

系統會在處理按一下滑鼠右鍵手勢時更新這些值。

我們也需要將訊息可組合函式納入 Box,讓 DropdownMenu 顯示在其上方。

@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,並新增了:

  • 自動調整式版面配置
  • 拖曳
  • 鍵盤快速鍵
  • 內容選單

這就為建構完全可自動調整的應用程式奠定了穩固基礎!

瞭解詳情