1. 准备工作
前提条件
- 具备构建 Android 应用的经验。
- 具备使用 Jetpack Compose 的经验。
所需条件
学习内容
- 自适应布局和 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
来解决此问题,它会根据当前窗口大小显示一个或多个窗格。在此 Codelab 中,我们将使用自适应布局在有足够窗口空间时自动并排显示 chat list
和 chat detail
屏幕。
自适应布局专为无缝集成到任何应用而设计。
在本教程中,我们将重点介绍如何将它们与 Navigation 3 库结合使用,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
入口点可组合项中实现 Navigation 3。
取消注释 MainNavigation
函数调用,以连接导航逻辑。
现在,我们开始构建导航基础架构。
首先,创建返回堆栈。它是 Navigation 3 的基石。
NavDisplay
到目前为止,我们已经介绍了几个 Navigation 3 概念。但是,该库如何确定哪个对象代表返回堆栈?又如何将其元素转换为实际界面?
这就是 NavDisplay
的作用。它是整合所有组件并呈现返回堆栈的组件。它需要几个重要参数,下面我们逐一说明:
参数 1 - 返回堆栈
NavDisplay
需要访问返回堆栈才能呈现其内容。我们来传入这个参数。
参数 2 - EntryProvider
EntryProvider
是一个 lambda,用于将返回堆栈键转换为可组合界面内容。它接受一个键并返回一个 NavEntry
,其中包含要显示的内容以及有关如何显示该内容的元数据(稍后会详细介绍)。
每当 NavDisplay
需要获取特定键对应的内容时(例如,当向返回堆栈添加新键时),都会调用此 lambda。
目前,如果在 Socialite 中点击 Timeline 图标,就会看到返回“Unknown back stack key: Timeline”的提示。
这是因为,即使 Timeline 键已添加到返回堆栈,EntryProvider
仍不知道如何呈现该键,因此会回退到默认实现。点击 Settings 图标时也会发生同样的情况。让我们通过确保 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
,它用于定义处理已接受拖放事件的逻辑的回调函数。
首先,为 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
回调对象需要实现 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
符合接收项的条件时调用。此处非常适合为传入会话准备界面状态。onEntered()
:当拖动的项进入此DragAndDropTarget
的边界时触发。onMoved()
:当拖动的项在此DragAndDropTarget
的边界内移动时调用。onExited()
:当拖动的项移出此DragAndDropTarget
的边界时调用。onChanged()
:当拖放会话在此目标边界内发生变化时调用(如按下/释放辅助键)。onEnded()
:在拖放会话结束时调用。之前收到过onStarted
事件的任何DragAndDropTarget
都会收到,适合重置界面状态。
如需添加视觉边框,我们需要执行以下操作:
- 创建一个记住的布尔变量,在拖放操作开始时将其设置为
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
上下文菜单是自适应界面的重要组成部分。
在此步骤中,我们将添加一个 Reply 弹出式菜单,该菜单会在用户右键点击消息时显示。
系统开箱即支持许多不同的手势,例如 clickable
修饰符可轻松检测点击。
对于自定义手势(例如右键点击),我们可以使用 pointerInput
修饰符,该修饰符可让我们访问原始指针事件并完全控制手势检测。
首先,我们添加一个界面来响应右键点击。在本例中,我们希望显示一个包含单个项(即 Reply 按钮)的 DropdownMenu
。我们需要定义两个使用 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 并添加了:
- 自适应布局
- 拖放
- 键盘快捷键
- 上下文菜单
这为构建完全自适应应用奠定了坚实的基础!
了解详情