实现边到边的体验 | 让您的软键盘动起来 (一)

Android 11 中的新功能之一是可以让应用在对于屏幕上的软键盘打开和关闭的过程创建无缝过渡的动画效果,这一功能源自 Android 11 中对 WindowInsets API 的大量改进。

在 Android 11 上有两个针对该功能的例子——这个功能已经被集成到 Google Search 应用和 Messages 应用中了:

两个 Android 11 中软键盘动画效果的示例: Google Search 应用 (左),Messages (右)

让我们来看看如何在您的应用中添加这种用户体验。总共分为三步:

  • 首先,我们需要做到 "边到边" (edge-to-edge)
  • 第二步,应用需要针对边衬区动画做出反应;
  • 最后第三步就是应用在恰当的场景中控制并使用边衬区动画。

上面的每一步都环环相扣,所以我们会在不同的文章中分别介绍。在这个系列的第一部中,我们会介绍如何实现边到边,以及 Android 11 中相关 API 的改动。

实现边到边 (edge-to-edge)

去年我们介绍了一个关于实现 "边到边" 的概念,这个方法可以让应用深度利用 Android 10 的手势导航: 开启全面屏体验 | 手势导航 (一)

简单回顾一下,实现 "边到边" 会让您的应用渲染在系统状态栏的后面,如上图所示。

引用去年我自己的话:

实现从边到边的全面屏体验后,系统栏会覆盖在应用内容前方。应用也得以通过更大幅面的内容为用户带来更具有冲击力的体验。

实现边到边跟软键盘有什么关系?

其实,实现边到边不单单只是在状态栏和导航栏之后渲染。应用本身需要开始负责处理那些跟应用重叠的系统 UI 的部分。

正如我们前面提到的,两个最直观的例子是状态栏和导航栏。除此之外还有软键盘,有时候也叫 IME (输入法编辑器),这是另外一个我们需要了解的系统 UI 。

应用如何实现边到边?

如果我们回想 去年的介绍,实现边到边可以分为三步:

  • 改变系统栏的颜色
  • 设置全屏布局
  • 处理视觉冲突

我们会跳过第一步,因为从去年至今这个部分没有改动。教程中的第二步和第三步有一些针对 Android 11 的改动,让我们来看一下。

2: 设置全屏布局

在以往的第二步中,应用需要使用 systemUiVisibility) API 以及一些参数来设置全屏布局:

view.systemUiVisibility = 
    // 通知系统,视窗希望在极端的情况下该如何布局内容。查看文档来获取更具体的信息。
    View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
    // 通知系统,视窗希望在导航栏被隐藏的情况下如何布局内容。
    View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION

如果您的项目设置编译的目标 SDK 版本已经升级为 30 并且使用这个 API ,您会发现这些 API 都已经被标示为弃用了。

它们已经被 Window 的一个叫作 setDecorFitsSystemWindows() 的函数替代了:

// 通知视窗,我们(应用)会处理任何系统视窗(而不是 decor)
window.setDecorFitsSystemWindows(false)
// 或者您可以使用 AndroidX v1.5.0-alpha02 中的 WindowCompat
WindowCompat.setDecorFitsSystemWindows(window, false)

取代那些参数的是一个布尔值 false,它的意思是应用会处理任何系统窗口的适配 (换句话说就是全屏)。

在 WindowCompat 中,我们还有一个 Jetpack 版本的该函数,androidx.core 库的 v1.5.0-alpha02 版本里也包含了这个函数。

以上就是第二步的改动。

3: 处理视觉冲突

现在让我们来看一下第三步: 避免与系统 UI 产生重叠,也可以说是使用视窗边衬区来决定如何移动应用的内容来避免与系统 UI 的冲突。在 Android 系统中,边衬区可以通过 WindowInsets 类和 AndroidX 中的 WindowInsetsCompat 来访问。

如果我们查看 API 30 以前版本的 WindowInsets,最常用的边衬区类型是系统视窗边衬区。这些边衬区包括了状态栏、导航栏以及打开时的软键盘。

为了使用 WindowInsets,您通常需要在一个视图上添加 OnApplyWindowInsetsListener,并且在这个函数中处理传进来的边衬区:

ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
    v.updatePadding(bottom = insets.systemWindowInsets.bottom)
    // 返回边衬区,这样它们才能够继续在视图树中继续传递下去
    insets
}

在这个例子中,我们获取到 系统视窗边衬区,然后更新视图的内边距,这是一个常见的应用场景。

还有一些其他类型的边衬区,比如 Android 10 最近新增的手势边衬区:

ViewCompat.setOnApplyWindowInsetsListener(v) { view, windowInsets ->
    val sysWindow = windowInsets.systemWindowInsets
    val stable = windowInsets.stableInsets
    val systemGestures = windowInsets.systemGestureInsets
    val tappableElement = windowInsets.tappableElementInsets
}

和 systemUiVisibility API) 类似,许多 WindowInsets API 已经被弃用了,取而代之的一些新函数来查询不同类型的边衬区:

我们刚刚多次提到 "类型",它们在 WindowInsets.Type 类中被定义为函数,每个函数都会返回一个整数标示。我们稍后还会展示如何使用 OR 位运算来查询结合到一起的类型。

所有这些 API 都已经被添加到 AndroidX Core 中的 WindowInsetsCompat,并且向前兼容到 API 14 (请查看 发行注记 来获取更多信息)。

再来看如果我们用新的 API 来更新之前的示例,它们就变成:

ViewCompat.setOnApplyWindowInsetsListener(...) { view, insets ->
-    val sysWindow = insets.systemWindowInsets
+    val sysWindow = insets.getInsets(Type.systemBars() or Type.ime())
-    val stable = insets.stableInsets
+    val stable = insets.getInsetsIgnoringVisibility(Type.systemBars())
-    val systemGestures = insets.systemGestureInsets
+    val systemGestures = insets.getInsets(Type.systemGestures())
-    val tappableElement = insets.tappableElementInsets
+    val tappableElement = insets.getInsets(Type.tappableElement())
}

软键盘类型 ⌨️

这会儿那些敏锐的 可能已经开始盯着这个类型列表,尤其是其中的 软键盘类型)。

在姗姗来迟了十年后,我们终于可以回答这个关于如何查看软键盘可见性的 StackOverflow 问题。

为了获取当前软键盘的可见性,我们可以取得根视窗的边衬区,然后执行 isVisible() 函数并传入 IME) 类型。

同样地,如果我们想查出高度,我们也可以通过相同的方法实现:

val insets = ViewCompat.getRootWindowInsets(view)
val imeVisible = insets.isVisible(Type.ime())
val imeHeight = insets.getInsets(Type.ime()).bottom

如果我们需要监听软键盘的改变,我们可以照常使用 OnApplyWindowInsetsListener,并且使用同样的函数:

ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
    val imeVisible = insets.isVisible(Type.ime())
    val imeHeight = insets.getInsets(Type.ime()).bottom
}

隐藏或显示软键盘

既然我们正在回答 StackOverflow 上的问题,来看一下这个 11 年前关于如何关闭软键盘的问题。

这一次我们要介绍 Android 11 的一个新 API,它叫 WindowInsetsController

应用可以从任何视图获得一个控制器,然后我们就可以通过传入 IME 类型,并执行 show()) 或者 hide()) 函数来实现显示或隐藏软键盘:

val controller = view.windowInsetsController
// 显示软键盘( IME )
controller.show(Type.ime())
// 隐藏软键盘
controller.hide(Type.ime())

然而,这个控制器不单单能控制隐藏和显示软键盘...

WindowInsetsController

之前我们提到过,有一些 View.SYSTEM_UI_* 标志已经在 Android 11 中被弃用,并且被新的 API 代替。还有一些 View.SYSTEM_UI 标志本来是被用来改变系统 UI 的外观和可见性的,包括:

  • View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
  • View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
  • View.SYSTEM_UI_FLAG_LAYOUT_STABLE
  • View.SYSTEM_UI_FLAG_LOW_PROFILE
  • View.SYSTEM_UI_FLAG_FULLSCREEN
  • View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
  • View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
  • View.SYSTEM_UI_FLAG_IMMERSIVE
  • View.SYSTEM_UI_FLAG_VISIBLE
  • View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
  • View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR

和之前的标志类似,这些也都在 API 30 中被弃用,并被 WindowInsetsController 中的 API 代替。

接下来我们会通过几个常见的应用场景来介绍如何更新这些标志,而不是一一介绍所有这些标志的改变:

沉浸模式

如图所示,这个绘图应用隐藏了系统 UI 来让绘图区域最大化:

Markers 应用,展示隐藏系统 UI

为了实现这个效果,我们像以前一样使用 WindowInsetsController 来执行 hide()) 和 show()) 函数,但是这一次我们要传入系统栏类型:

val controller = view.windowInsetsController

// 当我们想隐藏系统栏
controller.hide(Type.systemBars())

// 当我们想显示系统栏
controller.show(Type.systemBars())

应用使用 沉浸模式 来让用户在系统栏隐藏的时候可以通过滑动来召回系统栏。为了实现这个效果,我们使用 WindowInsetsController 并且改变 setSystemBarsBehavior()) 为 BEHAVIOR_SHOW_BARS_BY_SWIPE:

val controller = view.windowInsetsController

// 现在开始沉浸式..
controller.setSystemBarsBehavior(
    WindowInsetsController.BEHAVIOR_SHOW_BARS_BY_SWIPE
)

// 当我们想要隐藏系统栏
controller.hide(Type.systemBars())

类似地,如果您之前使用吸附式的 沉浸模式,这个现在也可以用 BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE 来实现:

val controller = view.windowInsetsController

// 现在开始吸附式沉浸式体验 ...
controller.setSystemBarsBehavior(
    BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
)

// 当我们想要隐藏系统栏
controller.hide(Type.systemBars())

状态栏内容的颜色

接下来的这个应用场景是围绕着状态栏内容的颜色。您会看到如下两个应用:

两个应用,左边的使用的是深色状态栏背景,右边的使用的是浅色背景

左边的应用使用的是一个深色的状态栏背景,而它的内容用的是浅色,比如时间和图标。可如果我们想实现一个浅色的状态栏背景并且搭配深色的内容,像右边显示的一样,我们也可以使用 WindowInsetsController

要实现这个效果,我们可以使用 setSystemBarsAppearance()) 函数,传入 APPEARANCE_LIGHT_STATUS_BARS 值:

val controller = view.windowInsetsController

// 启用浅色状态栏内容
controller.setSystemBarsAppearance(
    APPEARANCE_LIGHT_STATUS_BARS, // value
    APPEARANCE_LIGHT_STATUS_BARS // mask
)

但如果您想设置一个深色的状态栏,可以传入 0,而不是清除那个值。

注意: 您也可以在主题中通过设置  android:windowLightStatusBar 实现上述效果。在您知道这个值不会变动的情况下,这个方式可能更好。

APPEARANCE_LIGHT_NAVIGATION_BARS 标志可以给导航栏提供类似的功能。

AndroidX 中的 WindowInsetsController?

可惜的是这个 API 的 Jetpack 版本还没有上线,而我们正在加紧准备,敬请关注。

实现边到边: ✔️

我们的第一步完成了。在本系列下一篇文章中,我们会研究第二步: 应用对于边衬区的响应式动画。敬请关注。

你可能感兴趣的