自适应应用

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 来解决此问题,它会根据当前窗口大小显示一个或多个窗格。在此 Codelab 中,我们将使用自适应布局在有足够窗口空间时自动并排显示 chat listchat detail 屏幕。

c549fd9fa64589e9.gif

自适应布局专为无缝集成到任何应用而设计。

在本教程中,我们将重点介绍如何将它们与 Navigation 3 库结合使用,Socialite 应用就是基于该库构建的。

为了理解 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()

我们将直接在 Main 入口点可组合项中实现 Navigation 3。

取消注释 MainNavigation 函数调用,以连接导航逻辑。

现在,我们开始构建导航基础架构。

首先,创建返回堆栈。它是 Navigation 3 的基石。

到目前为止,我们已经介绍了几个 Navigation 3 概念。但是,该库如何确定哪个对象代表返回堆栈?又如何将其元素转换为实际界面?

这就是 NavDisplay 的作用。它是整合所有组件并呈现返回堆栈的组件。它需要几个重要参数,下面我们逐一说明:

参数 1 - 返回堆栈

NavDisplay 需要访问返回堆栈才能呈现其内容。我们来传入这个参数。

参数 2 - EntryProvider

EntryProvider 是一个 lambda,用于将返回堆栈键转换为可组合界面内容。它接受一个键并返回一个 NavEntry,其中包含要显示的内容以及有关如何显示该内容的元数据(稍后会详细介绍)。

每当 NavDisplay 需要获取特定键对应的内容时(例如,当向返回堆栈添加新键时),都会调用此 lambda。

目前,如果在 Socialite 中点击 Timeline 图标,就会看到返回“Unknown back stack key: Timeline”的提示。

532134900a30c9c.gif

这是因为,即使 Timeline 键已添加到返回堆栈,EntryProvider 仍不知道如何呈现该键,因此会回退到默认实现。点击 Settings 图标时也会发生同样的情况。让我们通过确保 EntryProvider 正确处理 TimelineSettings 返回堆栈键来解决此问题。

参数 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,它用于定义处理已接受拖放事件的逻辑的回调函数。

首先,为 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,我们需要:

  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 符合接收项的条件时调用。此处非常适合为传入会话准备界面状态。
  2. onEntered():当拖动的项进入此 DragAndDropTarget 的边界时触发。
  3. onMoved():当拖动的项在此 DragAndDropTarget 的边界内移动时调用。
  4. onExited():当拖动的项移出此 DragAndDropTarget 的边界时调用。
  5. onChanged():当拖放会话在此目标边界内发生变化时调用(如按下/释放辅助键)。
  6. onEnded():在拖放会话结束时调用。之前收到过 onStarted 事件的任何 DragAndDropTarget 都会收到,适合重置界面状态。

如需添加视觉边框,我们需要执行以下操作:

  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

上下文菜单是自适应界面的重要组成部分。

在此步骤中,我们将添加一个 Reply 弹出式菜单,该菜单会在用户右键点击消息时显示。

d9d30ae7e0230422.gif

系统开箱即支持许多不同的手势,例如 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,它会在用户每次开始新的手势时启动新的作用域。在该作用域内,我们需要:

  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 并添加了:

  • 自适应布局
  • 拖放
  • 键盘快捷键
  • 上下文菜单

这为构建完全自适应应用奠定了坚实的基础!

了解详情