1. 簡介
Android 10 以上版本支援新模式,支援瀏覽手勢。這樣一來,應用程式就能使用整個螢幕,提供更身歷其境的顯示體驗。使用者從螢幕底部邊緣向上滑動時,即可前往 Android 主畫面。使用者從螢幕左側或右側向內滑動時,就會前往上一個畫面。
透過這兩種手勢,應用程式可以利用螢幕底部的空間。不過,如果應用程式使用手勢或在系統手勢區域設有控制選項,就可能會與全系統手勢發生衝突。
本程式碼研究室旨在說明如何使用插邊來避免手勢衝突。此外,本程式碼研究室也能學習如何使用 手勢排除 API 管理需要位於手勢區域的控制項,例如拖曳控點。
課程 內容
- 如何在檢視畫面上使用插邊事件監聽器
- 如何使用 手勢排除 API
- 手勢啟動時,沉浸模式的運作方式
本程式碼研究室旨在讓應用程式與系統手勢相容。如有任何不相關的概念和程式碼區塊,皆未事先附上,可供你複製及貼上。
建構項目
通用 Android 音樂播放器 (UAMP) 是以 Kotlin 編寫的 Android 音樂播放器應用程式範例。您需設定 UAMP 來進行模擬導覽。
- 使用插邊將控制選項移出手勢區域
- 如要避免發生衝突的控制項,請使用 手勢排除 API 停用返回手勢
- 使用您的版本,透過手勢操作探索沉浸模式的行為變更
軟硬體需求
- 搭載 Android 10 以上版本的裝置或模擬器
- Android Studio
2. 應用程式總覽
通用 Android 音樂播放器 (UAMP) 是以 Kotlin 編寫的 Android 音樂播放器應用程式範例。包括背景播放、音訊焦點處理、Google 助理整合,以及 Wear、TV 和 Auto 等多種平台。
圖 1:UAMP 中的流程
UAMP 會從遠端伺服器載入音樂目錄,讓使用者瀏覽專輯和歌曲。使用者輕觸歌曲,透過連線的喇叭或耳機播放。這個應用程式無法與系統手勢搭配運作。因此,當您在搭載 Android 10 以上版本的裝置上執行 UAMP 時,一開始會遇到一些問題。
3. 做好準備
如要取得範例應用程式,請從 GitHub 複製存放區,然後切換至 starter 分支版本:
$ git clone https://github.com/googlecodelabs/android-gestural-navigation/
另外,您也可以下載存放區為 ZIP 檔案,然後解壓縮並在 Android Studio 中開啟。
操作步驟如下:
- 在 Android Studio 中開啟並建構應用程式。
- 建立新的虛擬裝置,然後選取「API 級別 29」。或者,您也可以連結搭載 API 級別 29 以上版本的實體裝置。
- 執行應用程式。您會在「推薦」和「專輯」選項中看到這些歌曲,並將歌曲分組顯示。
- 按一下「推薦影片」,然後從歌曲清單中選取歌曲。
- 應用程式開始播放歌曲。
啟用手勢操作
如果您以 API 級別 29 執行新的模擬器執行個體,系統可能不會預設開啟手勢操作。如要啟用手勢操作,請依序選取「系統設定」>系統 >系統導覽 >手勢操作
使用手勢操作執行應用程式
如果在啟用「手勢操作」的情況下執行應用程式並開始播放歌曲,您可能會發現播放器控制項非常靠近主畫面和返回手勢區域。
4. 採用無邊框設計
什麼是無邊框設計?
在 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>
系統 UI 瀏覽權限標記
您也必須設定系統 UI 瀏覽權限標記,指示系統將應用程式安排在系統資訊列下方。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
會隱藏在導覽列後方,且「播放/暫停」大多在導覽列遮住。 - 深入瞭解及進行實驗。完成後,請前往「系統設定」並切換回「手勢操作」:
現在這個應用程式採用無邊框設計,但還是有可用性問題、應用程式控制項會發生衝突和重疊,因此必須解決這些問題。
5. 插邊
WindowInsets
會告知應用程式,系統 UI 顯示在內容上方的位置,以及系統手勢在哪些區域的優先順序高於應用程式內手勢。插邊是以 Jetpack 中的 WindowInsets
類別和 WindowInsetsCompat
類別表示。強烈建議您使用 WindowInsetsCompat
,在所有 API 級別中保持一致的行為。
系統插邊和強制系統插邊
下列插邊 API 是最常用的插邊類型:
- 系統視窗插邊:指出系統 UI 在應用程式中顯示的位置。我們會討論如何使用系統插邊,將控制項移出系統資訊列。
- 系統手勢插邊:會傳回所有手勢區域。這些區域中的任何應用程式內滑動控制項都可能會意外觸發系統手勢。
- 必要手勢插邊:這是系統手勢插邊的子集,且無法覆寫。這些手勢指出畫面上,系統手勢行為在螢幕的特定區域,而非應用程式內手勢。
使用插邊移動應用程式控制項
現在您已進一步瞭解插邊 API,可以按照以下步驟修正應用程式控制項:
- 從
view
物件例項中取得playerLayout
的例項。 - 在
playerView
中新增OnApplyWindowInsetsListener
。 - 將檢視畫面移出手勢區域:找出底部系統插邊的值,然後將該檢視畫面的邊框間距調高。如要據此更新檢視畫面的邊框間距,請在 [與應用程式底部邊框間距相關的值] 中加入 [與系統插邊底值相關聯的值]。
請查看下列 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
首次能完美運作。不過,後續呼叫仍會將插邊的底部值新增至已更新檢視畫面的底部邊框間距。
如要解決這個問題,請執行下列步驟:
- 記錄初始檢視畫面的邊框間距值。建立新的 val,並儲存
playerView
的初始檢視畫面邊框間距值,緊接在事件監聽器程式碼之前。
請查看下列 NowPlayingFragment.kt
程式碼範例:
val initialPadding = playerView.paddingBottom
- 使用此初始值更新檢視區塊的底部邊框間距,即可避免使用應用程式目前的底部邊框間距值。
請查看下列 NowPlayingFragment.kt
程式碼範例:
playerView.setOnApplyWindowInsetsListener { view, insets ->
view.updatePadding(bottom = insets.systemWindowInsetBottom + initialPadding)
insets
}
- 再次執行應用程式。瀏覽不同的應用程式並前往主畫面。返回應用程式時,播放器控制項會位於手勢區域上方。
重新設計應用程式控制項
播放器的跳轉列太靠近底部手勢區域,代表使用者想水平滑動,因而不小心觸發主畫面手勢。如果進一步提高邊框間距,雖然可以解決問題,但也可能會使播放器移動到所需高度。
使用插邊可以修正手勢衝突,但有時候,在變更設計略有小幅變更時,就能完全避免手勢衝突。如要重新設計播放器控制項來避免手勢衝突,請按照下列步驟操作:
- 開啟
fragment_nowplaying.xml
。切換至設計檢視模式,然後選取最底部的SeekBar
:
- 切換至程式碼檢視。
- 如要將
SeekBar
移至playerLayout
頂端,請將 SeekBar 的layout_constraintTop_toBottomOf
變更為parent
。 - 如要將
playerView
中的其他項目限制在SeekBar
的底部,請將media_button
、title
和position
的layout_constraintTop_toTopOf
從父項變更為@+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
會自動處理手勢衝突。然而,您可能需要使用其他會觸發手勢衝突的 UI 元件。在這些情況下,您可以使用 Gesture Exclusion API
選擇部分停用返回手勢。
使用手勢排除 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
}
- 由於 Menu Exclude 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. 恭喜
恭喜!您已學會如何避免及解決系統手勢的衝突!
您把應用程式設為採用全螢幕狀態時,是從無邊框到來的,並使用插邊將應用程式控制項移出手勢區域。此外,您也學到瞭如何在應用程式控制項中停用系統返回手勢。
您現已瞭解讓應用程式與系統手勢搭配使用所需的重要步驟!