手势导航和全屏体验

1. 简介

对于 Android 10 或更高版本,支持导航手势这种新模式。在此模式中,您的应用可使用整个 屏幕,提供更身临其境的显示体验。当用户从屏幕下边缘向上滑动时,可转到 Android 主屏幕。当用户从左边缘或右边缘向内滑动时,可转到上一屏幕。

使用这两种手势,您的应用即可充分利用屏幕底部的实际空间。但是,如果您的应用在系统手势区域使用手势或具有控件,则可能与系统级手势发生冲突。

此 Codelab 旨在说明如何使用边衬区避免手势冲突。此外,此 Codelab 还将说明如何对需要驻留在手势区的拖动手柄等控件使用 手势排除 API

您将学习的内容

  • 如何在视图上使用边衬区监听器
  • 如何使用手势排除 API
  • 在激活手势时,沉浸模式有何表现

此 Codelab 旨在确保您的应用可与系统手势相兼容。对于无关紧要的概念和代码块,本文不作详细介绍,仅提供相关内容以供您进行复制和粘贴。

您将构建的应用

Universal Android Music Player (UAMP) 是一款展示用的 Android 音乐播放器应用,采用 Kotlin 编写而成。您将针对手势导航功能设置 UAMP。

  • 使用边衬区从手势区域移开控件
  • 使用手势排除 API 停用"返回"手势,以保留与之冲突的控件
  • 使用您的版本,探索沉浸模式的行为随应用手势导航发生的变化

您需要用到的工具

  • 运行 Android 10 或更高版本的设备或模拟器
  • Android Studio

2. 应用概览

Universal Android Music Player (UAMP) 是一款展示用的 Android 音乐播放器应用,采用 Kotlin 编写而成。此应用支持多种功能(包括后台播放、音频焦点处理、Google 助理集成),并可在多种平台上使用(如 Wear、TV 和 Auto)。

图 1:UAMP 中的流程

UAMP 会从远程服务器中加载音乐目录,用户可使用此应用浏览专辑和歌曲。用户点按歌曲后,此应用会通过连接的扬声器或头戴式耳机进行播放。此应用在设计时,不支持使用系统手势。因此,在运行 Android 10 或更高版本的设备上运行 UAMP 时,您会在开始时遇到一些问题。

3. 开始设置

如要获取此应用示例,可克隆 GitHub 中的代码库,然后切换到初学者分支:

$  git clone https://github.com/googlecodelabs/android-gestural-navigation/

或者,您也可以 zip 文件形式下载代码库,将其解压缩,并在 Android Studio 中打开。

完成以下步骤:

  1. 在 Android Studio 中打开并构建应用。
  2. 创建新的虚拟设备,然后选择 API 级别 29。或者,您也可以连接运行 29 级或更高级别 API 的实际设备。
  3. 运行应用。系统会在出现的列表中,将歌曲分组显示在 **Recommended(推荐)**和 **Albums(专辑)**选项下。
  4. 点击 Recommended(推荐),然后从歌曲列表中选择一首歌曲。
  5. 应用开始播放此歌曲。

启用手势导航

如果您在运行使用 API 级别 29 的新模拟器实例,默认情况下,系统将不会开启手势导航功能。如要启用手势导航功能,请选择 System settings(系统设置)> System(系统)> System Navigation(系统导航)> Gesture Navigation(手势导航)

运行启用手势导航的应用

如果您在运行启用手势导航的应用,并开始播放歌曲,您可能会发现,播放器控件非常接近"主屏幕"和"返回"手势区域。

4. 进入全屏模式

什么是全屏?

不管是启用手势还是按钮进行导航,在 Android 10 或更高版本中运行的应用都可以为您带来全屏体验。如要提供全屏体验,您必须将应用移至透明的导航栏和状态栏后方。

移到导航栏后方

您必须先将导航栏背景设置为透明背景,然后您的应用才能在导航栏下面渲染内容。然后,必须将状态栏设置为透明。这样,您的应用才能按屏幕的全高进行显示。

如要更改导航栏和状态栏的颜色,请执行以下步骤:

  1. **导航栏:**打开 res/values-29/styles.xml,并将 navigationBarColor 设置为 color/transparent
  2. **状态栏:**同样,将 statusBarColor 设置为 color/transparent

查看 res/values-29/styles.xml 的以下代码示例:

<!-- change navigation bar color -->
<item name="android:navigationBarColor">
    @android:color/transparent
</item>

<!-- change status bar color -->
<item name="android:statusBarColor">
    @android:color/transparent
</item>

系统界面可见度标记

您还必须设置系统界面可见度标记,才能让系统将应用置于系统栏下方。您可使用 View 类的 systemUiVisibility API 设置各种标记。请执行以下步骤:

  1. 打开 MainActivity.kt 类,并查找 onCreate() 方法。获取 fragmentContainer 的实例。
  2. 将以下内容设置为 content.systemUiVisibility

查看 MainActivity.kt 的以下代码示例:

  val content: FrameLayout = findViewById(R.id.fragmentContainer)
  content.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
            View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
            View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION

同时设置这些标记后,即可让系统以全屏模式显示您的应用,就像导航栏和状态栏不存在一样。请执行以下步骤:

  1. 运行应用,并导航至播放器屏幕,选择要播放的歌曲。
  2. 验证系统是否已将播放器控件移到导航栏下方,使其难以访问:

  1. 导航至"System settings"(系统设置),切换回三键导航模式,然后返回应用。
  2. 验证这些控件是否因应用三键导航栏而更难以使用:请注意,系统已将 SeekBar 隐藏到导航栏后方,而且 **Play/Pause(播放/暂停)**基本上已由导航栏所遮盖。
  3. 探索并试验一下。完成操作后,导航至"System settings"(系统设置), 切换回手势导航:

c7085e9130e5ecb3.gif

此应用现在会以全屏模式显示在您的面前,但其中存在应用控件冲突和重叠的易用性问题,而我们必须解决这些问题。

5. 边衬区

通过使用 WindowInsets,应用可得知系统界面出现在内容顶层的什么位置,以及在屏幕的哪些区域内,系统手势会优先于应用内手势。边衬区将由 Jetpack 中的 WindowInsets 类和 WindowInsetsCompat 类表示。我们强烈建议使用 WindowInsetsCompat,以便在所有 API 级别中都保持行为一致。

系统边衬区和强制系统边衬区

以下边衬区 API 是最常用的边衬区类型:

  • **系统窗口边衬区:**您可通过这些边衬区,了解系统界面会显示在应用上方的什么位置。我们将讨论如何使用系统边衬区从系统栏移开控件。
  • **系统手势边衬区:**这些边衬区可返回所有手势区域。这些区域的所有应用内滑动控件均可意外触发系统手势。
  • **强制手势边衬区:**这些边衬区是系统手势边衬区的子集,不得覆盖。您可借此了解到在哪些屏幕区域内,系统手势的行为会始终优先于应用内手势。

使用边衬区移动应用控件

您现在已经了解边衬区 API 的详细信息,可以按以下步骤所述修复应用控件:

  1. view 对象实例中获取 playerLayout 实例。
  2. OnApplyWindowInsetsListener 添加到 playerView
  3. 从手势区域移开视图:找到底部的系统边衬区值,然后按该数量增加视图的边距。如要将视图的边距相应地更新为 [与应用底部边距关联的值],请添加 [与系统边衬区底部值关联的值]。

查看 NowPlayingFragment.kt 的以下代码示例:

playerView = view.findViewById(R.id.playerLayout)
playerView.setOnApplyWindowInsetsListener { view, insets ->
   view.updatePadding(
      bottom = insets.systemWindowInsetBottom + view.paddingBottom
   )
   insets
}
  1. 运行应用,并选择歌曲。请注意,播放器控件似乎没有变化。如果在调试中添加断点并运行应用,您会看到监听器尚未调用。
  2. 要修复此问题,请切换至 FragmentContainerView,以便其自动处理此问题。打开 activity_main.xml,并将 FrameLayout 更改为 FragmentContainerView

查看 activity_main.xml 的以下代码示例:

<androidx.fragment.app.FragmentContainerView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/fragmentContainer"
    tools:context="com.example.android.uamp.MainActivity"
    android:layout_width="match_parent"
    android:layout_height="match_parent"/>
  1. 再次运行应用,并导航至播放器屏幕。系统已将底部播放器控件从底部手势区域移开。

应用控件现在可与手势导航功能一起发挥作用,但这些控件的移动距离超出预期。您必须解决此问题。

保留当前内边距和外边距

如果在不关闭此应用的情况下切换至其他应用或转到主屏幕,然后返回此应用,您会发现播放器控件每次都会上移。

这是因为该活动每次开始时,应用都会触发 requestApplyInsets()。即使您没有执行此 调用,系统也会在视图的生命周期内随时多次分派 WindowInsets

首次将边衬区底部值数量添加到 activity_main.xml 中声明的应用底部边距值时,playerView 上的当前 InsetListener 会正常运行。但是,后续调用会将边衬区底部值继续添加到已更新视图的底部边距中。

要解决此问题,请执行以下步骤:

  1. 记录视图初始边距值。创建新的值,并存储 playerView 视图初始边距值,然后再存储监听器代码。

查看 NowPlayingFragment.kt 的以下代码示例:

   val initialPadding = playerView.paddingBottom
  1. 使用此初始值更新视图的底部边距,这样可避免使用应用的当前底部边距值。

查看 NowPlayingFragment.kt 的以下代码示例:

   playerView.setOnApplyWindowInsetsListener { view, insets ->
            view.updatePadding(bottom = insets.systemWindowInsetBottom + initialPadding)
            insets
        }
  1. 再次运行应用。在应用之间导航,然后转到主屏幕。当返回应用时,播放器控件刚好在手势区域上方的位置。

重新设计应用控件

播放器拖动条太靠近底部手势区域,意味着用户在完成水平滑动手势时会意外触发主屏幕手势。如果增大边距,则可解决此问题,但也可能会将播放器移动得过高,超出预期高度。

尽管可通过使用边衬区解决手势冲突问题,但有时在设计时稍作改变,就可以完全避免手势冲突问题。如要重新设计播放器控件以避免手势冲突,请执行以下步骤:

  1. 打开 fragment_nowplaying.xml。切换至"Design"(设计)视图,然后选择最底部的 SeekBar

e7f5e258660d92af.png

  1. 切换至"Code"(代码)视图。
  2. 如要将 SeekBar 移至 playerLayout 顶部,请将拖动条的 layout_constraintTop_toBottomOf 更改为 parent
  3. 如要将 playerView 中的其他项目限定至 SeekBar 的底部,请在 media_buttontitleposition 中将 layout_constraintTop_toTopOf 从 parent 更改为 @+id/seekBar

查看 fragment_nowplaying.xml 的以下代码示例:

<androidx.constraintlayout.widget.ConstraintLayout
   android:layout_width="match_parent"
   android:layout_height="wrap_content"
   android:padding="8dp"
   android:layout_gravity="bottom"
   android:background="@drawable/media_overlay_background"
   android:id="@+id/playerLayout">

   <ImageButton
       android:id="@+id/media_button"
       android:layout_width="@dimen/exo_media_button_width"
       android:layout_height="@dimen/exo_media_button_height"
       android:background="?attr/selectableItemBackground"
       android:scaleType="centerInside"
       app:layout_constraintLeft_toLeftOf="parent"
       app:layout_constraintTop_toTopOf="@+id/seekBar"
       app:srcCompat="@drawable/ic_play_arrow_black_24dp"
       tools:ignore="ContentDescription" />

   <TextView
       android:id="@+id/title"
       android:layout_width="0dp"
       android:layout_height="wrap_content"
       android:layout_marginTop="8dp"
       android:layout_marginStart="@dimen/text_margin"
       android:layout_marginEnd="@dimen/text_margin"
       android:ellipsize="end"
       android:maxLines="1"
       android:textAppearance="@style/TextAppearance.Uamp.Title"
       app:layout_constraintTop_toTopOf="@+id/seekBar"
       app:layout_constraintLeft_toRightOf="@id/media_button"
       app:layout_constraintRight_toLeftOf="@id/position"
       tools:text="Song Title" />

   <TextView
       android:id="@+id/subtitle"
       android:layout_width="0dp"
       android:layout_height="wrap_content"
       android:layout_marginStart="@dimen/text_margin"
       android:layout_marginEnd="@dimen/text_margin"
       android:ellipsize="end"
       android:maxLines="1"
       android:textAppearance="@style/TextAppearance.Uamp.Subtitle"
       app:layout_constraintTop_toBottomOf="@+id/title"
       app:layout_constraintLeft_toRightOf="@id/media_button"
       app:layout_constraintRight_toLeftOf="@id/position"
       tools:text="Artist" />

   <TextView
       android:id="@+id/position"
       android:layout_width="0dp"
       android:layout_height="wrap_content"
       android:layout_marginTop="8dp"
       android:layout_marginStart="@dimen/text_margin"
       android:layout_marginEnd="@dimen/text_margin"
       android:ellipsize="end"
       android:maxLines="1"
       android:textAppearance="@style/TextAppearance.Uamp.Title"
       app:layout_constraintTop_toTopOf="@+id/seekBar"
       app:layout_constraintRight_toRightOf="parent"
       tools:text="0:00" />

   <TextView
       android:id="@+id/duration"
       android:layout_width="0dp"
       android:layout_height="wrap_content"
       android:layout_marginStart="@dimen/text_margin"
       android:layout_marginEnd="@dimen/text_margin"
       android:ellipsize="end"
       android:maxLines="1"
       android:textAppearance="@style/TextAppearance.Uamp.Subtitle"
       app:layout_constraintTop_toBottomOf="@id/position"
       app:layout_constraintRight_toRightOf="parent"
       tools:text="0:00" />

   <SeekBar
       android:id="@+id/seekBar"
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       app:layout_constraintBottom_toBottomOf="parent"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toBottomOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>
  1. 运行应用,并与播放器和拖动条交互。

这些极小的设计改变可显著改进应用。

6. 手势排除 API

与主屏幕手势区域手势冲突的播放器控件问题已解决。"返回"手势区域也会与应用控件发生冲突。以下屏幕截图显示的是播放器拖动条当前驻留在左侧和右侧的"返回"手势区域:

e6d98e94dcf83dde.png

SeekBar 可自动处理手势冲突问题。但您可能需要使用会触发手势冲突的其他界面组件。在这些情况下,您可以使用 Gesture Exclusion API 分部分地停用"返回"手势。

使用手势排除 API

要创建手势排除区域,请使用 rect 对象列表对视图调用 setSystemGestureExclusionRects()。这些 rect 对象会映射至已排除的矩形区域的坐标。您必须采用视图的 onLayout()onDraw() 方法完成此调用。为此,请执行以下步骤:

  1. 创建名为 view 的新软件包。
  2. 要调用此 API,请创建一个名为 MySeekBar 的新类,并扩展 AppCompatSeekBar

查看 MySeekBar.kt 的以下代码示例:

class MySeekBar @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyle: Int = android.R.attr.seekBarStyle
) : androidx.appcompat.widget.AppCompatSeekBar(context, attrs, defStyle) {

}
  1. 创建一个名为 updateGestureExclusion() 的新方法。

查看 MySeekBar.kt 的以下代码示例:

private fun updateGestureExclusion() {

}
  1. 添加一项检查,以便在使用 28 级或更低级别 API 时跳过此调用。

查看 MySeekBar.kt 的以下代码示例:

private fun updateGestureExclusion() {
        // Skip this call if we're not running on Android 10+
        if (Build.VERSION.SDK_INT < 29) return
}
  1. 由于手势排除 API 限制为 200 dp,所以我们只能排除小块的拖动条。复制拖动条的边框,并将每个对象添加到可变列表中。

查看 MySeekBar.kt 的以下代码示例:

private val gestureExclusionRects = mutableListOf<Rect>()

private fun updateGestureExclusion() {
    // Skip this call if we're not running on Android 10+
    if (Build.VERSION.SDK_INT < 29) return

    thumb?.also { t ->
        gestureExclusionRects += t.copyBounds()
    }
}
  1. 使用创建的 gestureExclusionRects 列表调用 systemGestureExclusionRects()

查看 MySeekBar.kt 的以下代码示例:

private val gestureExclusionRects = mutableListOf<Rect>()

private fun updateGestureExclusion() {
    // Skip this call if we're not running on Android 10+
    if (Build.VERSION.SDK_INT < 29) return

    thumb?.also { t ->
        gestureExclusionRects += t.copyBounds()
    }
    // Finally pass our updated list of rectangles to the system
    systemGestureExclusionRects = gestureExclusionRects
}
  1. onDraw()onLayout() 中调用 updateGestureExclusion() 方法。覆盖 onDraw(),并向 updateGestureExclusion 中添加调用。

查看 MySeekBar.kt 的以下代码示例:

override fun onDraw(canvas: Canvas) {
    super.onDraw(canvas)
    updateGestureExclusion()
}
  1. 必须更新 SeekBar 引用。如要开始更新,请打开 fragment_nowplaying.xml
  2. SeekBar 更改为 com.example.android.uamp.view.MySeekBar

查看 fragment_nowplaying.xml 的以下代码示例:

<com.example.android.uamp.view.MySeekBar
    android:id="@+id/seekBar"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toBottomOf="parent" />
  1. 如要在 NowPlayingFragment.kt 中更新 SeekBar 引用,请打开 NowPlayingFragment.kt,并将 positionSeekBar 的类型更改为 MySeekBar。如要使变量类型一致,请将 findViewById 调用的 SeekBar 泛型更改为 MySeekBar

查看 NowPlayingFragment.kt 的以下代码示例:

val positionSeekBar: MySeekBar = view.findViewById<MySeekBar>(
     R.id.seekBar
).apply { progress = 0 }
  1. 运行应用,并与 SeekBar 交互。如果手势冲突问题仍然存在,则可尝试修改 MySeekBar 的小块边框。注意,不要创建超过必需大小的手势排除区域,这样会限制其他潜在手势排除调用,并会导致用户行为出现不一致的问题。

7. 恭喜

恭喜!您已学会如何避免与系统手势冲突以及解决此问题!

在扩展全屏并使用边衬区从手势区域移开应用控件后,您可确保应用使用全屏模式。此外,您已学会如何在使用应用控件时禁用系统"返回"手势。

现在您已了解让应用使用系统手势所需的关键步骤!

其他材料

参考文档