借助 Jetpack WindowManager 支持可折叠设备和双屏设备

本 Codelab 非常实用,具体将介绍双屏设备和可折叠设备的应用开发方面的基础知识。完成学习后,您将能够改进应用以支持 Microsoft Surface Duo 和 Samsung Galaxy Z Fold 2 等设备。

前提条件

要完成本 Codelab,您需要:

  • 拥有构建 Android 应用的经验
  • 拥有使用 activityfragmentViewBindingxml-layout 的经验
  • 拥有在项目中添加依赖项的经验
  • 拥有安装和使用设备模拟器的经验。在本 Codelab 中,您将使用可折叠设备模拟器和/或双屏设备模拟器。

实践内容

  • 构建一个简单的应用并对其进行改进,以支持可折叠设备和双屏设备。
  • 借助 Jetpack WindowManager 支持新型设备。

所需条件

  • Android Studio 4.2 或更高版本
  • 可折叠设备或模拟器,如果您使用的是 Android Studio 4.2,则可以使用下图中的几种可折叠设备模拟器:

7a0db14df3576a82.png

  • 如需使用双屏设备模拟器,请点击此处下载适用于您的平台(Windows、MacOS 或 GNU/Linux)的 Microsoft Surface Duo 模拟器。

与以前的移动设备相比,用户在可折叠设备上可体验到更大的屏幕及更灵活多变的界面。可折叠设备的另一个优点是,折叠后的尺寸通常小于普通平板电脑的尺寸,便于携带且实用。

截至撰写本文时,已有两种可折叠设备:

  • 单屏可折叠设备,配备一个可折叠的屏幕。在 Multi-Window 模式下,用户可以在同一屏幕上同时运行多个应用。
  • 双屏可折叠设备,两个屏幕由合页相连。此类设备也可以折叠,但具有两个不同的逻辑显示区域。

affbd6daf04cfe7b.png

与平板电脑及其他单屏移动设备一样,可折叠设备可以:

  • 在其中一个显示区域中运行一个应用。
  • 同时运行两个应用,各位于一个显示区域中(在 Multi-Window 模式下)。

与单屏设备不同的是,可折叠设备还具有不同的折叠状态,能够以不同的方式显示内容。

f2287b68f32b59e3.png

当应用跨整个显示区域(使用可折叠双屏设备上的所有显示区域)显示时,可折叠设备可提供不同的跨屏折叠状态。

可折叠设备还具有不同的折叠状态,例如,在桌面模式下,您可以在放平的屏幕和朝向您倾斜的屏幕之间进行逻辑拆分;在帐篷模式下,您观看屏幕的方式,就和用小支架支起设备时一样。

Jetpack WindowManager 库旨在帮助开发者对应用做出调整,以充分利用可折叠设备为用户提供的全新体验。应用开发者可借助 Jetpack WindowManager 实现对新型设备的支持,并为新旧版本平台上的不同 WindowManager 功能提供通用的 API 接口。

主要功能

Jetpack WindowManager 的 1.0.0-alpha03 版本包含 FoldingFeature 类,该类描述柔性显示屏的折叠状态或两个物理显示面板之间的合页状态。您可通过其 API 访问与设备相关的重要信息:

您可通过主要的 WindowManager 类访问如下重要信息:

  • getCurrentWindowMetrics():根据系统的当前状态返回 WindowMetrics,其值取决于系统的当前窗口状态。
  • getMaximumWindowMetrics():根据系统的当前状态返回最大的 WindowMetrics,其值取决于系统的最大窗口状态。例如,对于多窗口模式下的 activity,返回的指标值取决于用户展开窗口覆盖整个屏幕时的边界。

请为要改进的应用克隆 GitHub 代码库或下载示例代码:

git clone https://github.com/googlecodelabs/android-foldable-codelab

声明依赖项

要使用 Jetpack WindowManager,您必须添加相关依赖项。

  1. 首先,添加 Google Maven 代码库到您的项目中
  2. 在您的应用或模块的 build.gradle 文件中添加工件的依赖项:
dependencies {
    implementation "androidx.window:window:1.0.0-alpha03"
}

使用 WindowManager

可以轻松使用 Jetpack WindowManager 通过注册应用来监听配置变化。

首先,初始化 WindowManager 实例,以便能够访问其 API。要初始化 WindowManager 实例,请在您的 activity 中实现以下代码:

private lateinit var wm: WindowManager

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        wm = WindowManager(this)
}

主构造函数仅允许使用一个参数:视觉背景信息(例如某个 Activity 或某个 activity 的 ContextWrapper)。此构造函数会在后台使用默认的 WindowBackend,这是一个后台服务器类,用于提供实例信息。

设置好 WindowManager 实例后,您可以注册一个回调,以了解折叠状态何时发生变化、设备具有哪些功能以及相应功能的边界(如果有)。此外,如前所述,您可以根据系统的当前状态查看当前指标和最大指标。

  1. 打开 Android Studio。
  2. 依次点击 File > New > New Project > Empty Activity 以新建一个项目。
  3. 点击 Next,接受默认属性和默认值,然后点击 Finish

创建一个简单的布局,以便查看 WindowManager 要报告的信息。为此,您必须创建布局文件夹及特定的布局文件:

  1. 依次点击 File > New > Android resource directory
  2. 在新窗口中,选择 Resource Type layout,然后点击 OK
  3. 转到项目结构,然后在 src/main/res/layout 下新建一个名为 activity_main.xml 的新布局资源文件(File > New > Layout resource file
  4. 打开该文件,然后添加以下内容作为布局:

res/layout/activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:app="http://schemas.android.com/apk/res-auto"
   xmlns:tools="http://schemas.android.com/tools"
   android:id="@+id/constraint_layout"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   tools:context=".MainActivity">

   <TextView
       android:id="@+id/window_metrics"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:padding="20dp"
       android:text="@string/window_metrics"
       android:textSize="20sp"
       app:layout_constraintBottom_toTopOf="@+id/layout_change"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toTopOf="parent"
       app:layout_constraintVertical_chainStyle="packed" />

   <TextView
       android:id="@+id/layout_change"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:padding="20dp"
       android:text="@string/layout_change_text"
       android:textSize="20sp"
       app:layout_constrainedWidth="true"
       app:layout_constraintBottom_toTopOf="@+id/configuration_changed"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toBottomOf="@+id/window_metrics" />

   <TextView
       android:id="@+id/configuration_changed"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:padding="20dp"
       android:text="@string/configuration_changed"
       android:textSize="20sp"
       app:layout_constraintBottom_toBottomOf="parent"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toBottomOf="@+id/layout_change" />

</androidx.constraintlayout.widget.ConstraintLayout>

您现已使用 ConstraintLayout 和 3 个 TextViews 构建了一个简单的布局。为了与父视图(以及屏幕)的中心对齐,这几个视图之间相互约束。

  1. 打开 MainActivity.kt 文件并添加以下代码:

window_manager/MainActivity.kt

class MainActivity : AppCompatActivity() {
  1. 创建一个可用于处理回调结果的内部类:
inner class LayoutStateChangeCallback : Consumer<WindowLayoutInfo> {
   override fun accept(newLayoutInfo: WindowLayoutInfo) {
       printLayoutStateChange(newLayoutInfo)
   }
}

内部类使用一些简单函数来输出您通过界面组件 (TextView) 从 WindowManager 获取的信息:

private fun printLayoutStateChange(newLayoutInfo: WindowLayoutInfo) {
   binding.layoutChange.text = newLayoutInfo.toString()
   if (newLayoutInfo.displayFeatures.size > 0) {
       binding.configurationChanged.text = "Spanned across displays"
   } else {
       binding.configurationChanged.text = "One logic/physical display - unspanned"
   }
}
  1. 声明 lateinit WindowManager 变量:
private lateinit var wm: WindowManager
  1. 创建一个变量,用于通过您所创建的内部类处理 WindowManager 回调:
private val layoutStateChangeCallback = LayoutStateChangeCallback()
  1. 添加绑定以便访问不同的视图:
private lateinit var binding: ActivityMainBinding
  1. 创建一个从 Executor 扩展的函数,以便将其作为第一个参数提供给回调,调用回调时会使用该参数。因此,您需要创建一个在界面线程上运行的函数。您也可以创建一个不在界面线程上运行的函数,具体由您自己决定。
private fun runOnUiThreadExecutor(): Executor {
   val handler = Handler(Looper.getMainLooper())
   return Executor() {
       handler.post(it)
   }
}
  1. MainActivityonCreate 中,初始化 WindowManager lateinit
override fun onCreate(savedInstanceState: Bundle?) {
   super.onCreate(savedInstanceState)
   binding = ActivityMainBinding.inflate(layoutInflater)
   setContentView(binding.root)

   wm = WindowManager(this)
}

现在,WindowManager 实例只有 Activity 这一个参数,且将使用默认的 WindowManager 后端实现。

  1. 找到您在第 5 步添加的函数,然后添加以下代码行,紧跟函数头:
binding.windowMetrics.text =
   "CurrentWindowMetrics: ${wm.currentWindowMetrics.bounds.flattenToString()}\n" +
       "MaximumWindowMetrics: ${wm.maximumWindowMetrics.bounds.flattenToString()}"

使用函数 currentWindowMetrics.bounds.flattenToString()maximumWindowMetrics.bounds.flattenToString() 包含的值设置 window_metrics TextView 的值。

这些值会提供有关窗口所占区域指标的实用信息。如下图所示,在双屏设备模拟器中,CurrentWindowMetrics设备镜像的尺寸一致。您还可以查看应用在单屏模式下运行时的指标:

b032c729d6dce292.png

您可从下图中看到应用跨屏显示时指标的变化情况,还可看出应用所占的窗口区域变大:

b72ca8a63b65e4c1.png

该应用在单屏和双屏设备上运行时始终占满显示区域,因此当前窗口指标和最大窗口指标的值相同。

在横向可折叠设备模拟器中,这些值在应用跨整个物理屏幕运行时和使用多窗口模式时有所不相同:

5cb5270ee0e42320.png

如左图所示,这两个指标的值相同,这是因为运行的应用占满了整个显示区域,该区域既是当前的显示区域,也是最大显示区域。

在右图中,应用在多窗口模式下运行,您可看到当前指标如何显示应用在多窗口模式下的特定区域(顶部)运行时所占区域的尺寸,还可看到最大指标如何显示设备的最大显示区域。

您可通过 WindowManager 的指标轻松了解应用当前使用或可以使用的窗口区域。

现在,您将注册布局更改,以便能够获知设备的功能(合页设备还是折叠设备)及该功能的边界。

我们将使用的函数具有以下签名:

public void registerLayoutChangeCallback (
                Executor executor,
                Consumer<WindowLayoutInfo> callback)

此函数使用的类型为 WindowLayoutInfo。此类包含调用回调时需要查看的数据。此类的内部包含 List< DisplayFeature>,该列表会返回从与应用交互的设备中找到的 DisplayFeature 列表。如果没有任何显示功能与应用交互,则此列表为空。

此类将实现 DisplayFeature,获得 List<DisplayFeature> 结果后,您可以将(相应项)投射到 FoldingFeature,其中包含设备的折叠状态、功能类型、边界等信息。

接下来,我们看看如何使用此回调并直观呈现其提供的信息。对于您在上一步(构建示例应用)添加的代码:

  1. 替换 onAttachedToWindow 方法:
override fun onAttachedToWindow() {
   super.onAttachedToWindow()
  1. 通过之前作为第一个参数实现的执行器使用 WindowManager 实例注册到布局更改回调:
   wm.registerLayoutChangeCallback(
       runOnUiThreadExecutor(),
       layoutStateChangeCallback
   )
}

下面我们来看看此回调提供的信息是什么样的。如果您在双屏设备模拟器中运行此代码,将得到以下信息:

49a85b4d10245a9d.png

如您所见,WindowLayoutInfo 为空,其中包含一个空的 List<DisplayFeature>,但如果模拟器的中间位置有合页,为什么不从 WindowManager 获取信息呢?

WindowManager 会在应用跨屏显示(以物理或虚拟方式)时提供 LayoutInfo 数据(设备功能类型、设备功能边界和设备折叠状态)。因此,在上图中,应用在单屏模式下运行时,WindowLayoutInfo 为空。

您可借此得知应用目前在哪种模式下运行(单屏模式还是跨屏模式),从而根据这些特定配置对界面/用户体验进行更改,为用户提供更好的体验。

在没有两个物理显示屏的设备(通常没有物理合页)上,应用可以在多窗口模式下并排运行。在此类设备上,应用在多窗口模式下运行时,其行为与上例中在单屏模式下的行为一样,而当应用在运行时占满逻辑显示屏时,其行为就像跨屏一样,如下图所示:

ecdada42f6df1fb8.png

如您所见,应用在多窗口模式下运行时,不会与可折叠功能交互,因此 WindowManager 返回一个空的 List<LayoutInfo>。

简而言之,只有在应用与设备功能(折叠或合页)交互时,您才会获得 LayoutInfo 数据,否则不会获得任何信息。564eb78fc85f6d3e.png

应用跨屏显示时会出现什么情况?在双屏设备模拟器中,LayoutInfoFoldingFeature 对象将提供以下数据:设备功能 (HINGE)、功能边界 (Rect (0, 0- 1434, 1800))、设备的折叠状态 (FLAT)

13edea3ff94baae4.png

如前所述,设备类型的值可以使用 FOLDHINGE,(已在源代码中公开):

@IntDef({
       TYPE_FOLD,
       TYPE_HINGE,
})
  • type = TYPE_HINGE:此双屏设备模拟器镜像的是具有物理合页的真实 Surface Duo 设备,WindowManager 报告的结果也是如此。
  • Rect (0, 0 - 1434, 1800) 表示窗口坐标空间中应用窗口内功能的边界矩形。如果您已阅读 Surface Duo 设备的尺寸规范,便会发现合页的位置符合报告的边界(左、上、右、下)。
  • 表示设备的折叠状态的值有三个:
  • STATE_HALF_OPENED:可折叠设备的合页处于打开状态和关闭状态的中间位置,且柔性屏幕的各部分之间或物理屏幕之间的夹角不是平角。
  • STATE_FLAT:可折叠设备处于完全打开状态,且呈现给用户的屏幕区域是平面。
  • STATE_FLIPPED:可折叠设备处于翻转状态,柔性屏幕的各部分或者物理屏幕朝向相反的方向。
@IntDef({
       STATE_HALF_OPENED,
       STATE_FLAT,
       STATE_FLIPPED,
})

模拟器默认处于打开 180 度的状态,因此 WindowManager 返回的折叠状态为 STATE_FLAT

如果您使用虚拟传感器将模拟器的折叠状态更改为半开状态,WindowManager 会通知新位置:STATE_HALF_OPENED

7cfb0b26d251bd1.png

不再需要此回调时可将其取消注册。只需从 WindowManager API 调用此函数即可:

public void unregisterDeviceStateChangeCallback (Consumer<DeviceState> callback)

可使用 onDestroyonDetachedFromWindow 方法轻松取消注册回调:

override fun onDetachedFromWindow() {
   super.onDetachedFromWindow()
   wm.unregisterLayoutChangeCallback(layoutStateChangeCallback)
}

通过 WindowManager 调整界面/用户体验

如您在显示窗口布局信息的图中所见,显示的信息被显示功能裁剪,从下图中也可看到:

4ee805070989f322.png

这并非可向用户提供的最佳体验,您可以利用 WindowManager 提供的信息来调整界面/用户体验。

如前所述,当您的应用跨越不同的显示区域时,也是您的应用与设备功能交互时,WindowManager 会提供窗口布局信息作为显示功能和显示的边界。因此,当应用跨屏显示时,您便需要根据这些信息来调整界面/用户体验。

接下来,您要做的就是调整目前应用在运行时跨屏显示的界面/用户体验,确保任何重要信息都不会被显示功能裁剪/隐藏。您将创建一个镜像设备显示功能的视图,并将其作为约束 TextView 的参考,确保任何信息都不会再缺失。

为便于学习,您需要设置此新视图的颜色,从而能够轻松看出该视图的位置与真实的设备显示功能的位置完全一致,且尺寸相同。

  1. activity_main.xml 中添加将用作设备功能参考的新视图。

res/layout/activity_main.xml

<View
   android:id="@+id/device_feature"
   android:layout_width="0dp"
   android:layout_height="0dp"
   android:background="@android:color/holo_red_dark"
   android:visibility="gone" />
  1. MainActivity.kt 中,找到用于显示 WindowManager 回调信息的函数,并添加一个在应用具有显示功能的 if-else 情况下进行的新函数调用。

window_manager/MainActivity.kt

private fun printLayoutStateChange(newLayoutInfo: WindowLayoutInfo) {
   binding.windowMetrics.text =
       "CurrentWindowMetrics: ${wm.currentWindowMetrics.bounds.flattenToString()}\n" +
           "MaximumWindowMetrics: ${wm.maximumWindowMetrics.bounds.flattenToString()}"

   binding.layoutChange.text = newLayoutInfo.toString()
   if (newLayoutInfo.displayFeatures.size > 0) {
       binding.configurationChanged.text = "Spanned across displays"
       alignViewToDeviceFeatureBoundaries(newLayoutInfo)
   } else {
       binding.configurationChanged.text = "One logic/physical display - unspanned"
   }
}

您已添加函数 alignViewToDeviceFeatureBoundaries,该函数会接收 WindowLayoutInfo 作为参数。

  1. 在新函数内,创建 ConstraintSet 以对您的视图应用新约束:
private fun alignViewToDeviceFeatureBoundaries(newLayoutInfo: WindowLayoutInfo) {
   val constraintLayout = binding.constraintLayout
   val set = ConstraintSet()
   set.clone(constraintLayout)
  1. 现在使用 WindowLayoutInfo: 获取显示功能的边界
val rect = newLayoutInfo.displayFeatures[0].bounds
  1. 现在根据边框变量中提供的 WindowLayoutInfo 为您的参考视图设置合适的高度:
set.constrainHeight(
   R.id.device_feature,
   rect.bottom - rect.top
)
  1. 现在用右侧坐标值减左侧坐标值以得出设备功能的宽度,并将视图的宽度调整为显示功能的宽度:
set.constrainWidth(R.id.device_feature, rect.right - rect.left)
  1. 为视图参考设置对齐约束,使其与父视图的起始侧和顶部对齐:
set.connect(
   R.id.device_feature, ConstraintSet.START,
   ConstraintSet.PARENT_ID, ConstraintSet.START, 0
)
set.connect(
   R.id.device_feature, ConstraintSet.TOP,
   ConstraintSet.PARENT_ID, ConstraintSet.TOP, 0
)

您也可以直接在 XML 文件中将此添加为视图的属性,而非在这里的代码中添加。

接下来,您需要涵盖所有可能的设备功能显示方向:具有纵向显示功能的设备(例如双屏设备模拟器)和具有横向显示功能的设备(例如横向可折叠设备模拟器)。

  1. 对于第一种情况,top == 0 表示设备功能是纵向的(例如双屏设备模拟器):
if (rect.top == 0) {
  1. 现在,您需要将外边距应用于参考视图,使其与真实显示功能的位置完全相同。
  2. 然后,为 TextView 应用约束,以调整放置位置使其避开显示功能,因此,该约束需要将显示功能考虑在内:
set.setMargin(R.id.device_feature, ConstraintSet.START, rect.left)
set.connect(
   R.id.layout_change, ConstraintSet.END,
   R.id.device_feature, ConstraintSet.START, 0
)

横向显示功能

用户设备上的显示功能可能为横向(例如横向可折叠设备模拟器)。

您可能需要显示工具栏或状态栏(具体取决于界面),因此最好获取它们的高度,以便调整显示功能表示法,从而与界面完美贴合。

在示例应用中,我们使用了状态栏和工具栏:

val statusBarHeight = calculateStatusBarHeight()
val toolBarHeight = calculateToolbarHeight()

以下代码展示了用于进行这些计算(位于当前函数之外)的函数的简单实现:

private fun calculateToolbarHeight(): Int {
   val typedValue = TypedValue()
   return if (theme.resolveAttribute(android.R.attr.actionBarSize, typedValue, true)) {
       TypedValue.complexToDimensionPixelSize(typedValue.data, resources.displayMetrics)
   } else {
       0
   }
}

private fun calculateStatusBarHeight(): Int {
   val rect = Rect()
   window.decorView.getWindowVisibleDisplayFrame(rect)
   return rect.top
}

对于 else 语句中的主函数(用于处理横向设备功能),由于显示功能的边界不会考虑到任何界面元素,且基于 (0,0) 坐标,因此您可以将状态栏高度和工具栏高度作为外边距。要使参考视图在合适的位置显示,您必须将以下元素考虑在内:

} else {
   //Device feature is placed horizontally
   val statusBarHeight = calculateStatusBarHeight()
   val toolBarHeight = calculateToolbarHeight()
   set.setMargin(
       R.id.device_feature, ConstraintSet.TOP,
       rect.top - statusBarHeight - toolBarHeight
   )
   set.connect(
       R.id.layout_change, ConstraintSet.TOP,
       R.id.device_feature, ConstraintSet.BOTTOM, 0
   )
}

接下来是将参考视图的可见性更改为“可见”,以便在示例中看到该视图(以红色显示),更重要的是应用约束。如果该视图消失,则无法应用约束:

set.setVisibility(R.id.device_feature, View.VISIBLE)

最后一步是将您构建的 ConstraintSet 应用于 ConstraintLayout, 以应用所有更改和界面调整:

    set.applyTo(constraintLayout)
}

现在,曾与设备显示功能冲突的 TextView 会将该功能的位置考虑在内,以确保其内容不会再被裁剪或隐藏:

80993d3695a9a60.png

在双屏设备模拟器(左图)中,您会看到 TextView 如何跨屏显示内容,曾被合页裁剪的内容已正常显示,任何信息都没有缺失。

在可折叠设备模拟器(右图)中,您会看到一条表示折叠显示功能所在位置的浅红色线,现在 TextView 显示在该功能下方,因此当设备处于折叠状态时(例如,笔记本电脑处于 90 度折叠状态),该功能不会影响任何信息的显示。

如果您想知道显示功能在双屏设备模拟器上的位置,由于这是一种合页型设备,因此表示该功能的视图会被合页隐藏。但是,如果我们将应用从跨屏显示更改为不跨屏显示,您就会在功能所在位置看到该视图,且视图高度和宽度正确无误。

4dbe464ac71b498e.png

截至目前,您已了解可折叠设备与单屏设备的区别。

可折叠设备的其中一项功能是支持并排运行两个应用,让用户达到事半功倍的效果。例如,用户可以在屏幕一侧显示电子邮件应用,在另一侧显示日历应用,或者在一个屏幕上视频通话,在另一个屏幕上做笔记。无限可能,等您发现!

使用 Android 框架中的现有 API 即可发挥双屏的价值。下面我们来了解一下您可以做出哪些改进。

在相邻的窗口上启动 activity

经过改进之后,您可以使应用在相邻的窗口上启动新 activity,无需大量的工作便可利用多个窗口区域。

假设您有一个按钮,点击该按钮,应用便会启动一个新 activity:

  1. 首先,创建一个用于处理点击事件的函数:

intent/MainActivity.kt

private fun openActivityInAdjacentWindow() {
}
  1. 在该函数内,创建用于启动新 activity 的 Intent(在本例中为 SecondActivity,这只是一个包含 TextView 消息的简单 activity):
val intent = Intent(this, SecondActivity::class.java)
  1. 接下来,设置将在相邻屏幕为空时启动新 activity 的标记:
intent.addFlags(
   Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT or
       Intent.FLAG_ACTIVITY_NEW_TASK
)

这些标记的作用如下:

  • FLAG_ACTIVITY_NEW_TASK:如果已设置,此 activity 将成为此历史记录堆栈上新任务的起点。
  • FLAG_ACTIVITY_LAUNCH_ADJACENT:此标记用于在多窗口模式下分屏(也适用于具有独立物理屏幕的双屏设备)。新 activity 将显示在启动该 activity 的应用旁边。

当平台发现新任务时,会尝试将新任务分配到相邻窗口。新任务将在当前任务之上启动,因此新 activity 将在当前 activity 之上启动。

  1. 最后,使用我们创建的 intent 启动新 activity 即可:
     startActivity(intent)

生成的测试应用的行为如下方的动画所示,点击某个按钮后,应用便会在相邻的空窗口上启动新 activity。

您可以看到应用在双屏设备及可折叠设备的多窗口模式下运行的情形:

9696f7fa2ee1e35f.gif a2dc98dae26e3045.gif

拖放

为您的应用添加拖放功能可提供深受用户喜爱的实用功能。借助此功能,您的应用可以向其他应用提供内容(通过实现拖动行为),也可以接受来自其他应用的内容(通过实现放下行为),亦或同时提供这两种功能,您的应用便可提供和接受来自其他应用及其自身(例如同一应用内的不同位置)的内容。

从 API 11 开始,Android 框架中的拖放功能便已可用,但直到 API 级别 24 中引入 Multi-Window 支持后,拖放功能才更有意义,这是因为用户可以在同一屏幕上并排运行的应用之间拖放元素。

随着可折叠设备的推出,可提供更多区域用于多窗口用途,甚至可以提供两个不同的逻辑屏幕,因此拖放功能更加有意义。实用场景:一个待办事项应用接受(放下)文本,放下文本时形成一个新任务,或者一个日历应用在某个日期/时段接受(放下)内容,进而形成一个事件,等等。

应用必须实现拖动行为才能使用数据,并且/或者必须实现放下行为才能生成数据,从而充分利用此功能。

在示例中,您将在一个应用中实现拖动行为,在另一个应用中实现放下行为,当然,您肯定可以在同一应用中实现拖放。

实现拖动行为

您的“拖动应用”只有一个 TextView,并且会在用户长按文本时触发拖动操作。

  1. 首先,依次转到 File > New > New Project > Empty Activity 以创建新的应用。
  2. 然后,找到已创建的 activity_main.xml。在此文件中将现有布局替换为以下布局:

res/layout/activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:tools="http://schemas.android.com/tools"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   android:orientation="vertical"
   tools:context=".MainActivity">

   <TextView
       android:id="@+id/drag_text_view"
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       android:layout_margin="20dp"
       android:text="@string/drag_text"
       android:textSize="30sp" />
</LinearLayout>
  1. 打开 MainActivity.kt 文件并添加标记,然后调用其 setOnLongClickListener 函数:

drag/MainActivity.kt

class MainActivity : AppCompatActivity(), View.OnLongClickListener {
   private lateinit var binding: ActivityMainBinding

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       binding = ActivityMainBinding.inflate(layoutInflater)
       setContentView(binding.root)

       binding.dragTextView.tag = "text_view"
       binding.dragTextView.setOnLongClickListener(this)
   }
  1. 替换 onLongClick 函数,以便 TextView 的 onLongClickListener 事件使用此替换后的功能。
override fun onLongClick(view: View): Boolean {
  1. 检查接收器参数是否为要将拖动功能添加到的 View 的类型。在本例中为 TextView
return if (view is TextView) {
  1. 根据 TextView 包含的文本创建一个 ClipData.item
val text = ClipData.Item(view.text)
  1. 定义要使用的 MimeType
val mimeType = arrayOf(ClipDescription.MIMETYPE_TEXT_PLAIN)
  1. 创建上述各项后,创建将用于共享数据的软件包(ClipData 实例):
val dataToShare = ClipData(view.tag.toString(), mimeType, text)

向用户提供反馈非常重要,因此最好直观呈现正在拖动的内容。

  1. 为拖动的内容创建阴影,以便用户在进行拖动交互时可以看到手指下方的内容:
val dragShadowBuilder = View.DragShadowBuilder(view)
  1. 现在,由于您需要允许在不同的应用之间进行拖放,因此必须先定义一组用于启用该功能的标记:
val flags =
   View.DRAG_FLAG_GLOBAL or View.DRAG_FLAG_GLOBAL_URI_READ

根据相关文档说明,标记的含义如下:

  • DRAG_FLAG_GLOBAL:此标记表示可跨窗口边界进行拖动。
  • DRAG_FLAG_GLOBAL_URI_READ:与 DRAG_FLAG_GLOBAL 一起使用时,拖动内容接收方将可以请求对 ClipData 对象中包含的内容 URI 的读取权限。
  1. 最后,使用您已创建的组件在视图中调用 startDragAndDrop 函数,拖动交互便会启动:
view.startDragAndDrop(dataToShare, dragShadowBuilder, view, flags)
  1. 完成并关闭 onLongClick functionMainActivity
         true
       } else {
           false
       }
   }
}

实现放下行为

在示例中,您将创建一个将放下功能附加到 EditText 上的简单应用。此视图将接受文本数据(可以来自拖动应用的 TextView)。

我们的 EditText(或放下区域)会根据所处的拖动阶段更改其背景,以便您向用户提供拖放的交互状态,方便用户判断何时放下内容。

  1. 首先,依次转到 File > New > New Project > Empty Activity 以创建新的应用。
  2. 然后,找到已创建的 activity_main.xml。将现有布局替换为以下布局:

res/layout/activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">

<EditText
   android:id="@+id/drop_edit_text"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   android:background="@android:color/holo_blue_dark"
   android:gravity="top"
   android:hint="@string/drop_text"
   android:textColor="@android:color/white"
   android:textSize="30sp" />

</RelativeLayout>
  1. 打开 MainActivity.kt 文件并向 EditText setOnDragListener 函数添加监听器:

drop/MainActivity.kt

class MainActivity : AppCompatActivity(), View.OnDragListener {
   private lateinit var binding: ActivityMainBinding

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       binding = ActivityMainBinding.inflate(layoutInflater)
       setContentView(binding.root)
       binding.dropEditText.setOnDragListener(this)
   }
  1. 替换 onDrag 函数,以便 EditText(如上所述)的 onDragListener 函数使用此替换后的回调。

每当出现新的 DragEvent 时例如,用户的手指进入或离开放下区域时,用户在放下区域松开手指以执行放下操作时,或者用户在放下区域以外松开手指以取消拖放交互时,系统都会调用此函数。

override fun onDrag(v: View, event: DragEvent): Boolean {
  1. 要响应将触发的不同 DragEvents,请添加一个 when 语句来处理不同的事件:
return when (event.action) {
  1. 处理在拖动交互开始时触发的 ACTION_DRAG_STARTED。触发此事件后,放下区域的颜色会发生变化,以便用户知道 EditText 已接受放下的内容:
DragEvent.ACTION_DRAG_STARTED -> {
       setDragStartedBackground()
       true
}
  1. 处理用户的手指进入放下区域时触发的拖动事件 ACTION_DRAG_ENTERED。放下区域的背景颜色将再次发生变化,用于向用户表明放下区域已准备就绪(当然,您可以忽略此事件,不更改背景事件,仅供参考)。
DragEvent.ACTION_DRAG_ENTERED -> {
   setDragEnteredBackground()
   true
}
  1. 处理 ACTION_DROP 事件。当松开手指以将拖动的内容放入放下区域时,会触发此事件以执行放下操作。
DragEvent.ACTION_DROP -> {
   handleDrop(event)
   true
}

我们稍后将了解如何处理放下操作。

  1. 接下来,处理 ACTION_DRAG_ENDED 事件。此事件会在 ACTION_DROP 之后触发,拖放操作便已完成。

此时,最好恢复您之前所做的更改(例如,将放下区域的背景更改为原始值)。

DragEvent.ACTION_DRAG_ENDED -> {
   clearBackgroundColor()
   true
}
  1. 接下来,处理 ACTION_DRAG_EXITED 事件。此事件会在用户离开放下区域(用户的手指位于放下区域,但随后离开该区域)时触发。

如果您在这一步中更改背景以突出显示用户的手指进入放下区域这一操作,则最好将背景恢复为以前的值。

DragEvent.ACTION_DRAG_EXITED -> {
   setDragStartedBackground()
   true
}
  1. 最后,处理 when 语句的 else 情况,然后关闭 onDrag 函数:
      else -> false
   }
}

现在,我们来看看如何处理放下操作。前面在触发 ACTION_DROP 事件时提到了放下操作,在这一部分,我们需要处理放下功能,现在您将了解如何实现。

  1. DragEvent 作为参数进行传递,因为它是用于存储拖动数据的对象:
private fun handleDrop(event: DragEvent) {
  1. 在该函数内,请求拖放权限。如果您要在不同的应用之间进行拖放,则必须请求拖放权限。
val dropPermissions = requestDragAndDropPermissions(event)
  1. 通过 DragEvent 参数,您可以访问之前在“拖动步骤”中创建的 clipData 项。
val item = event.clipData.getItemAt(0)
  1. 获得拖动项后,访问包含该项且已共享的文本。以下是拖动示例中 TextView 包含的文本:
val dragData = item.text.toString()
  1. 现在,您已获得共享的真实数据(文本),只需在放下区域(我们的 EditText)中对其进行设置即可,就像您通常在代码的 EditText 中设置文本一样:
binding.dropEditText.setText(dragData)
  1. 最后一步是释放所请求的拖放权限。如果您在拖放操作完成后没有执行此操作,那么当 activity 被销毁时,系统会自动释放相关权限。关闭函数和类:
      dropPermissions?.release()
   }
}

您在简单的放下应用中实现此放下行为后,我们可以并排运行两个应用,了解拖放功能的工作原理。

您可以从下方的动画中了解拖放功能的工作原理,不同的拖动事件是如何触发的,以及在处理这些事件(根据具体的 DragEvent 更改放下区域背景并放下相关内容)时执行哪些操作:

d66c5c24c6ea81b3.gif

如我们在此内容块中所看到的,可使用 Jetpack WindowManager 支持可折叠设备等新型设备。

我们可根据 Jetpack WindowManager 提供的实用信息针对这些设备对应用进行调整,以便在应用在这些设备上运行时提供更好的体验。

最后,完成本 Codelab 后,您将了解:

  • 什么是可折叠设备。
  • 不同的可折叠设备之间的区别。
  • 可折叠设备、单屏设备和平板电脑之间的区别。
  • Jetpack WindowManager。此 API 提供哪些功能?
  • 如何使用 Jetpack WindowManager 以及如何针对新型设备对应用进行调整。
  • 只需稍作调整以在相邻的空窗口上启动 activity,并在应用之间实现拖放功能,即可改进应用。

了解详情