測試1-手勢導航和全屏體驗
手勢導航和全屏體驗
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 中打開。
完成以下步驟:
- 在 Android Studio 中打開并構建應用。
- 創建新的虛擬設備,然后選擇 API 級別 29。或者,您也可以連接運行 29 級或更高級別 API 的實際設備。
- 運行應用。系統會在出現的列表中,將歌曲分組顯示在 **Recommended(推薦)**和 **Albums(專輯)**選項下。
- 點擊 Recommended(推薦),然后從歌曲列表中選擇一首歌曲。
- 應用開始播放此歌曲。
啟用手勢導航
如果您在運行使用 API 級別 29 的新模擬器實例,默認情況下,系統將不會開啟手勢導航功能。如要啟用手勢導航功能,請選擇 System settings(系統設置)> System(系統)> System Navigation(系統導航)> Gesture Navigation(手勢導航)。
運行啟用手勢導航的應用
如果您在運行啟用手勢導航的應用,并開始播放歌曲,您可能會發現,播放器控件非常接近"主屏幕"和"返回"手勢區域。
4. 進入全屏模式
什么是全屏?
不管是啟用手勢還是按鈕進行導航,在 Android 10 或更高版本中運行的應用都可以為您帶來全屏體驗。如要提供全屏體驗,您必須將應用移至透明的導航欄和狀態欄后方。
移到導航欄后方
您必須先將導航欄背景設置為透明背景,然后您的應用才能在導航欄下面渲染內容。然后,必須將狀態欄設置為透明。這樣,您的應用才能按屏幕的全高進行顯示。
**注意:**對于運行 Android 10 或更高版本的設備,強烈建議實行全屏體驗。對于運行舊版 Android 的設備,全屏為可選項,但仍建議使用。
如要更改導航欄和狀態欄的顏色,請執行以下步驟:
- **導航欄:**打開
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_STABLEView.SYSTEM_UI_FLAG_LAYOUT_FULLSCREENView.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
同時設置這些標記后,即可讓系統以全屏模式顯示您的應用,就像導航欄和狀態欄不存在一樣。請執行以下步驟:
- 運行應用,并導航至播放器屏幕,選擇要播放的歌曲。
- 驗證系統是否已將播放器控件移到導航欄下方,使其難以訪問:
|
|
|
- 導航至"System settings"(系統設置),切換回三鍵導航模式,然后返回應用。
- 驗證這些控件是否因應用三鍵導航欄而更難以使用:請注意,系統已將
SeekBar隱藏到導航欄后方,而且 **Play/Pause(播放/暫停)**基本上已由導航欄所遮蓋。 - 探索并試驗一下。完成操作后,導航至"System settings"(系統設置), 切換回手勢導航:

此應用現在會以全屏模式顯示在您的面前,但其中存在應用控件沖突和重疊的易用性問題,而我們必須解決這些問題。
5. 邊襯區
通過使用 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"/>
- 再次運行應用,并導航至播放器屏幕。系統已將底部播放器控件從底部手勢區域移開。
應用控件現在可與手勢導航功能一起發揮作用,但這些控件的移動距離超出預期。您必須解決此問題。
保留當前內邊距和外邊距
如果在不關閉此應用的情況下切換至其他應用或轉到主屏幕,然后返回此應用,您會發現播放器控件每次都會上移。
這是因為該活動每次開始時,應用都會觸發 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"(設計)視圖,然后選擇最底部的SeekBar:

- 切換至"Code"(代碼)視圖。
- 如要將
SeekBar移至playerLayout頂部,請將拖動條的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>
- 運行應用,并與播放器和拖動條交互。
這些極小的設計改變可顯著改進應用。
6. 手勢排除 API
與主屏幕手勢區域手勢沖突的播放器控件問題已解決。"返回"手勢區域也會與應用控件發生沖突。以下屏幕截圖顯示的是播放器拖動條當前駐留在左側和右側的"返回"手勢區域:

SeekBar 可自動處理手勢沖突問題。但您可能需要使用會觸發手勢沖突的其他界面組件。在這些情況下,您可以使用 Gesture Exclusion API 分部分地停用"返回"手勢。
**注意:**每側手勢排除 API 的限制為 200 dp,而且僅在必要時才可使用。如果在視圖中或在應用的某些部分禁用"返回"手勢,則會導致系統及其他應用出現不一致問題。
使用手勢排除 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() {
}
- 添加一項檢查,以便在使用 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
}
- 由于手勢排除 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的小塊邊框。注意,不要創建超過必需大小的手勢排除區域,這樣會限制其他潛在手勢排除調用,并會導致用戶行為出現不一致的問題。
7. 恭喜
恭喜!您已學會如何避免與系統手勢沖突以及解決此問題!
在擴展全屏并使用邊襯區從手勢區域移開應用控件后,您可確保應用使用全屏模式。此外,您已學會如何在使用應用控件時禁用系統"返回"手勢。
現在您已了解讓應用使用系統手勢所需的關鍵步驟!





浙公網安備 33010602011771號