1. 事前準備
必要條件
- 具備建構 Android 應用程式的經驗。
- 具備使用 Jetpack Compose 的經驗。
軟硬體需求
- 最新的 Android Studio 穩定版。
課程內容
- 自動調整式版面配置和 Navigation 3 基本資訊
- 實作拖曳功能
- 支援鍵盤快速鍵
- 啟用內容選單
2. 做好準備
首先,請按照下列步驟操作:
- 啟動 Android Studio
- 依序點按「File」>「New」>「
Project from Version control
」 - 貼上網址:
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」
選取裝置類型:「Desktop」
螢幕尺寸:14 吋
解析度:1920 x 1080 像素
按一下「Finish」
- 在平板電腦或桌面模擬器上執行應用程式
3. 瞭解範例應用程式
在本教學課程中,您將處理一個使用 Jetpack Compose 建構而成,名為「Socialite」的應用程式。
在這個應用程式中,您可以與各種動物聊天,而牠們會以各自的方式回覆您的訊息。
目前,這是一款行動優先應用程式,並未針對平板電腦或桌上型電腦等大型裝置進行最佳化。
我們將調整這個應用程式,讓它適應大螢幕裝置,並新增一些功能,改善在所有板型規格上的使用體驗。
立即開始!
4. 自動調整式版面配置 + Navigation 3 基本資訊
$ git checkout codelab-adaptive-apps-step-1
目前,無論螢幕空間有多大,這個應用程式始終一次顯示一個窗格。
我們將透過 adaptive layouts
來修正這個問題,根據目前的視窗大小顯示一個或多個窗格。在本程式碼實驗室中,我們會使用自動調整式版面配置,在有足夠的視窗空間時,自動並排顯示 chat list
和 chat detail
畫面。
自動調整式版面配置的設計能無縫整合至任何應用程式中。
在本教學課程中,我們將著重於說明如何將自動調整式版面配置與 Navigation 3 程式庫搭配使用,這也是「Socialite」應用程式採用的架構。
Navigation 3 基本資訊
為了理解 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()
Navigation 3 實作
我們將直接在 Main
進入點可組合函式中實作 Navigation 3。
取消註解 MainNavigation
函式呼叫,以接入導覽邏輯。
接下來,我們開始建構導覽基礎架構。
首先,建立返回堆疊。這是 Navigation 3 的基礎。
NavDisplay
到目前為止,我們已經介紹了幾個 Navigation 3 概念。但程式庫如何判斷哪個物件代表返回堆疊?又該如何將其中的元素轉換為實際的 UI 呢?
這時就要介紹 NavDisplay
。這是整合所有元件並負責轉譯返回堆疊的元件。它需要用到幾個重要參數,我們接下來逐一介紹。
參數 1 - 返回堆疊
NavDisplay
需要存取返回堆疊,才能轉譯其中的內容。我們將其傳入。
參數 2 - EntryProvider
EntryProvider
是個 lambda 函式,負責將返回堆疊中的鍵轉換為可組合函式 UI 內容。它會接收一個鍵,並傳回 NavEntry
,其中包含要顯示的內容,以及如何顯示該內容的中繼資料 (稍後會進一步說明)。
NavDisplay
需要為特定鍵取得內容時,就會呼叫這個 lambda 函式,例如在返回堆疊中新增鍵時。
目前,如果我們在「Socialite」中按一下「Timeline」圖示,會看到「Unknown back stack key: Timeline」的訊息。
這是因為,即使「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」。
我們的目標是在 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,我們需要執行以下操作:
- 要求拖曳權限以存取該 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
}
到這個階段,拖曳功能已完全實作,您可以順利將相片從「Files」應用程式拖曳至「Socialite」。
我們接下來增添視覺邊框,讓這個區域看起來更醒目,並強調這個區域可接受拖曳的項目。
為此,我們可以使用與拖曳工作階段中的不同階段相對應的額外掛鉤:
onStarted()
:在拖曳工作階段開始,且此DragAndDropTarget
可接收項目時呼叫這個方法。適合用來為即將到來的工作階段準備 UI 狀態。onEntered()
:當拖曳的項目進入這個DragAndDropTarget
的邊界時觸發這個方法。onMoved()
:當拖曳的項目在DragAndDropTarget
的邊界內移動時呼叫這個方法。onExited()
:當拖曳的項目移出此DragAndDropTarget
的邊界時呼叫這個方法。onChanged()
:在拖曳工作階段中,當此目標範圍內發生變化 (例如按下或放開輔助鍵) 時,觸發這個方法。onEnded()
:在拖曳工作階段結束時呼叫這個方法。先前收到onStarted
事件的任何DragAndDropTarget
都會收到這個事件。適合用於重設 UI 狀態。
如要新增視覺邊框,我們需要執行以下操作:
- 建立一個記憶用的布林值變數,在拖曳開始時設為
true
,並在拖曳結束時重設為false
。 - 將修飾符套用至
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」彈出式選單,當使用者在訊息上按一下滑鼠右鍵時,就會顯示這項選單。
系統預設支援許多手勢,例如 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
,它會在使用者每次開始新手勢時,啟動新的範圍。在該範圍內,我們需要執行以下操作:
- 取得下一個指標事件:
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,並新增了:
- 自動調整式版面配置
- 拖曳
- 鍵盤快速鍵
- 內容選單
這就為建構完全可自動調整的應用程式奠定了穩固基礎!
瞭解詳情