在 Android 10 或更高版本中,导航手势以一种新模式的形式受到支持。这让您的应用能够使用整个屏幕,并提供更具沉浸感的显示体验。当用户从屏幕底部边缘向上滑动时,会转到 Android 主屏幕。当用户从左侧或右侧边缘向内滑动时,则会转到上一个屏幕。
借助这两个手势,您的应用可以充分利用屏幕底部的空间。但是,如果您的应用使用手势,或在系统手势区域放置了控件,则可能会与系统手势产生冲突。
本 Codelab 旨在向您介绍如何使用边衬区来避免手势冲突。此外,还会介绍如何针对需要放在手势区域的控件(如拖动手柄)使用 Gesture Exclusion API。
学习内容
- 如何在视图上使用边衬区监听器
- 如何使用 Gesture Exclusion API
- 启用手势时沉浸模式的行为方式
本 Codelab 旨在确保您的应用与系统手势兼容。对于不相关的概念,我们仅会略作介绍;对于不相关的代码,我们会予以提供,以便您复制和粘贴。
您将构建的内容
Universal Android Music Player (UAMP) 是一个使用 Kotlin 编写的示例 Android 版音乐播放器应用。您将为 UAMP 设置手势导航。
- 使用边衬区以将控件移出手势区域
- 使用 Gesture Exclusion API 为存在冲突的控件停用返回手势
- 使用 build 探索在启用手势导航时沉浸模式行为的变化
所需条件
- 搭载 Android 10 或更高版本的设备或模拟器
- Android Studio
Universal Android Music Player (UAMP) 是一个使用 Kotlin 编写的示例 Android 版音乐播放器应用。此应用支持后台播放、音频焦点处理等功能,集成了Google 助理,并且可在 Wear、电视和 Auto 等多个平台上使用。
图 1:UAMP 中的流程
UAMP 从远程服务器加载音乐目录,可让用户浏览专辑和歌曲。用户点按一首歌曲,该歌曲便会通过连接的音响设备或头戴式耳机播放。此应用与系统手势不兼容。因此,当您在搭载 Android 10 或更高版本的设备上运行 UAMP 时,一开始会遇到一些问题。
如需获取示例应用,请从 GitHub 克隆代码库,然后切换到 starter 分支:
$ git clone https://github.com/googlecodelabs/android-gestural-navigation/
或者,您也可以下载 ZIP 文件形式的代码库,将其解压缩并在 Android Studio 中打开。
请完成以下步骤:
- 在 Android Studio 中打开并构建应用。
- 创建新的虚拟设备,并选择 API level 29。或者,您也可以连接搭载 API 级别 29 或更高级别的实体设备。
- 运行应用。您会看到歌曲划分成了 Recommended 和 Albums 这两组。
- 点击 Recommended 并从歌曲列表中选择一首歌曲。
- 应用开始播放该歌曲。
启用手势导航
如果您运行使用 API 级别 29 的新模拟器实例,手势导航可能默认处于未启用状态。如需启用手势导航,请依次选择 System settings > System > System Navigation > Gesture Navigation。
在已启用手势导航的情况下运行应用
如果您在已启用手势导航的情况下运行应用并开始播放歌曲,则可能会注意到,播放器控件非常靠近主屏幕手势区域和返回手势区域。
什么是无边框?
在搭载 Android 10 或更高版本的设备上运行的应用可以提供无边框屏幕体验,无论设备启用了手势导航还是按钮导航,您都可以提供该体验。为了提供无边框体验,您的应用必须在透明的导航栏和状态栏后绘制。
在导航栏后绘制
若要让应用在导航栏下方呈现内容,您必须先将导航栏背景设为透明,然后将状态栏设为透明。这样,您的应用在显示时就可以占据屏幕的整个高度。
如要更改导航栏和状态栏的颜色,请执行以下操作:
- 导航栏:打开
res/values-29/styles.xml
,并将navigationBarColor
设置为color/transparent
。 - 状态栏:同理将
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 设置各种标志。请执行以下步骤:
- 打开
MainActivity.kt
类并找到onCreate()
方法。获取fragmentContainer
的实例。 - 将以下各项设置为
content.systemUiVisibility
:
View.SYSTEM_UI_FLAG_LAYOUT_STABLE
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
查看以下 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
当您同时设置这些标志时,会告知系统您希望应用全屏显示,就像没有导航栏和状态栏一样。请执行以下步骤:
- 运行应用并转到播放器屏幕,然后选择一首歌曲来播放。
- 验证播放器控件是否在导航栏下方绘制,使其难以访问。
- 转到“系统设置”,切换回“三按钮”导航模式,然后返回应用。
- 验证在使用“三按钮”导航栏时,控件是否更加难以使用:您会注意到,
SeekBar
隐藏在导航栏后面,且 Play/Pause 控件大部分被导航栏遮盖。 - 稍加探索和试验。完成后,转到“System settings”并切换回“Gesture Navigation”:
应用现在会无边框绘制,但存在应用控件冲突和重叠的易用性问题,这些问题必须得到解决。
WindowInsets
会告知应用系统界面会显示在内容上方的什么位置,以及在哪些屏幕区域系统手势的优先级高于应用内手势。边衬区用 Jetpack 中的 WindowInsets
类和 WindowInsetsCompat
类表示。强烈建议您使用 WindowInsetsCompat
,以便在所有 API 级别中具有一致的行为。
系统边衬区和强制性系统边衬区
以下边衬区 API 是最常用的边衬区类型:
- 系统窗口边衬区:这类边衬区会告知您系统界面显示在应用上方的什么位置。我们将探讨如何使用系统边衬区将控件移出系统栏。
- 系统手势边衬区:这类边衬区会返回所有手势区域。这些区域内的任何应用内滑动控件都可能会意外触发系统手势。
- 强制性手势边衬区:这类边衬区是系统手势边衬区的一部分,无法覆盖。它们会告知您,在哪些屏幕区域系统手势行为的优先级始终高于应用内手势。
使用边衬区移动应用控件
现在您已详细了解边衬区 API,可以按照以下步骤修正应用控件:
- 从
view
对象实例获取playerLayout
的实例。 - 将
OnApplyWindowInsetsListener
添加到playerView
。 - 将视图移出手势区域:找到系统的边衬区底部值,并将视图的内边距增加该值。如要将视图的内边距相应地更新为 [与应用的底部内边距相关联的值],请将其增加 [与系统的边衬区底部值相关联的值]。
查看以下 NowPlayingFragment.kt
代码示例:
playerView = view.findViewById(R.id.playerLayout)
playerView.setOnApplyWindowInsetsListener { view, insets ->
view.updatePadding(
bottom = insets.systemWindowInsetBottom + view.paddingBottom
)
insets
}
- 运行应用并选择一首歌曲。您会注意到播放器控件似乎未发生任何变化。如果您添加断点并在调试模式下运行应用,则会发现系统未调用监听器。
- 如要解决此问题,请切换到
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"/>
- 再次运行应用并转到播放器屏幕。底部播放器控件从底部手势区域移开。
应用控件现在可与手势导航配合使用,但控件的移动幅度超出了预期。您必须解决此问题。
保留当前内边距和外边距
如果您在未关闭应用的情况下切换到其他应用或转到主屏幕,然后又返回应用,将注意到播放器控件每次都会向上移动。
这是因为每次 activity 启动时,应用都会触发 requestApplyInsets()
。即使没有此调用,WindowInsets
也可以在视图的生命周期内随时多次分派。
当您首次为 activity_main.xml
中声明的应用底部内边距值增加边衬区底部值时,playerView
上的当前 InsetListener
会正常工作。不过,后续调用会继续为已更新视图的底部内边距增加边衬区底部值。
如需解决此问题,请执行以下步骤:
- 记录初始视图的内边距值。创建新值,并将
playerView
的初始视图内边距值存储在监听器代码的前面。
查看以下 NowPlayingFragment.kt
代码示例:
val initialPadding = playerView.paddingBottom
- 使用此初始值更新视图的底部内边距,这可以让您避免使用应用的当前底部内边距值。
查看以下 NowPlayingFragment.kt
代码示例:
playerView.setOnApplyWindowInsetsListener { view, insets ->
view.updatePadding(bottom = insets.systemWindowInsetBottom + initialPadding)
insets
}
- 再次运行应用。在应用之间导航,然后转到主屏幕。返回应用后,播放器控件刚好在手势区域上方。
重新设计应用控件
播放器的进度条过于靠近底部手势区域,这意味着用户在完成水平滑动时可能会意外触发主屏幕手势。进一步增加内边距可以解决这一问题,但也可能会导致播放器移动的高度超过所需高度。
使用边衬区可以解决手势冲突问题,但有时只需在设计上进行小幅改动,就可以完全避免手势冲突。如需重新设计播放器控件,以避免手势冲突,请执行以下步骤:
- 打开
fragment_nowplaying.xml
。切换到“Design view”并选择最底部的SeekBar
。
- 切换到“Code view”。
- 如要将
SeekBar
移至playerLayout
的顶部,请将 SeekBar 的layout_constraintTop_toBottomOf
更改为parent
。 - 如要将
playerView
中的其他项目约束在SeekBar
的底部,请针对media_button
、title
和position
将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>
- 运行应用并与播放器和进度条互动。
这类设计上的细微改动可以极大地改进您的应用。
播放器控件在主屏幕手势区域的手势冲突问题已得到解决。返回手势区域也可能会产生与应用控件的冲突。下面的屏幕截图展示了播放器进度条目前同时位于右侧和左侧返回手势区域:
SeekBar
会自动处理手势冲突。但您可能需要使用其他会触发手势冲突的界面组件。在这类情况下,您可以使用 Gesture Exclusion API
部分停用返回手势。
使用 Gesture Exclusion API
如要创建手势排除区域,请使用一组 rect
对象在视图上调用 setSystemGestureExclusionRects()
。这些 rect
对象对应排除的矩形区域的坐标。必须在视图的 onLayout()
或 onDraw()
方法中完成此调用。为此,请执行以下步骤:
- 创建一个名为
view
的新软件包。 - 如要调用此 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) {
}
- 创建一个名为
updateGestureExclusion()
的新方法。
查看以下 MySeekBar.kt
代码示例:
private fun updateGestureExclusion() {
}
- 添加检查,以针对 API 级别 28 或更低级别跳过此调用。
查看以下 MySeekBar.kt
代码示例:
private fun updateGestureExclusion() {
// Skip this call if we're not running on Android 10+
if (Build.VERSION.SDK_INT < 29) return
}
- 由于 Gesture Exclusion 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()
}
}
- 使用您创建的
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
}
- 从
onDraw()
或onLayout()
调用updateGestureExclusion()
。覆盖onDraw()
并添加对updateGestureExclusion
的调用。
查看以下 MySeekBar.kt
代码示例:
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
updateGestureExclusion()
}
- 您必须更新
SeekBar
引用。如要开始更新,请打开fragment_nowplaying.xml
。 - 将
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" />
- 如要更新
NowPlayingFragment.kt
中的SeekBar
引用,请打开NowPlayingFragment.kt
并将positionSeekBar
的类型更改为MySeekBar
。如要匹配变量类型,请将findViewById
调用的SeekBar
泛型更改为MySeekBar
。
查看以下 NowPlayingFragment.kt
代码示例:
val positionSeekBar: MySeekBar = view.findViewById<MySeekBar>(
R.id.seekBar
).apply { progress = 0 }
- 运行应用并与
SeekBar
进行互动。如果您仍遇到手势冲突问题,可以试验并修改MySeekBar
中的拇指边界。注意不要让创建的手势排除区域超过必需大小,因为这会限制其他潜在的手势排除调用,并且会让用户遇到行为不一致的情况。
恭喜!您已了解如何避免和解决系统手势冲突问题!
当您延伸至屏幕边缘并使用边衬区将应用控件移出手势区域后,便为应用实现了全屏体验。您还了解了如何为应用控件停用系统返回手势。
现在您已了解让应用与系统手势兼容所需的主要步骤!