手勢操作和無邊框體驗

1. 簡介

Android 10 以上版本支援新模式,支援瀏覽手勢。這樣一來,應用程式就能使用整個螢幕,提供更身歷其境的顯示體驗。使用者從螢幕底部邊緣向上滑動時,即可前往 Android 主畫面。使用者從螢幕左側或右側向內滑動時,就會前往上一個畫面。

透過這兩種手勢,應用程式可以利用螢幕底部的空間。不過,如果應用程式使用手勢或在系統手勢區域設有控制選項,就可能會與全系統手勢發生衝突。

本程式碼研究室旨在說明如何使用插邊來避免手勢衝突。此外,本程式碼研究室也能學習如何使用 手勢排除 API 管理需要位於手勢區域的控制項,例如拖曳控點。

課程 內容

  • 如何在檢視畫面上使用插邊事件監聽器
  • 如何使用 手勢排除 API
  • 手勢啟動時,沉浸模式的運作方式

本程式碼研究室旨在讓應用程式與系統手勢相容。如有任何不相關的概念和程式碼區塊,皆未事先附上,可供你複製及貼上。

建構項目

通用 Android 音樂播放器 (UAMP) 是以 Kotlin 編寫的 Android 音樂播放器應用程式範例。您需設定 UAMP 來進行模擬導覽。

  • 使用插邊將控制選項移出手勢區域
  • 如要避免發生衝突的控制項,請使用 手勢排除 API 停用返回手勢
  • 使用您的版本,透過手勢操作探索沉浸模式的行為變更

軟硬體需求

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 中開啟。

操作步驟如下:

  1. 在 Android Studio 中開啟並建構應用程式。
  2. 建立新的虛擬裝置,然後選取「API 級別 29」。或者,您也可以連結搭載 API 級別 29 以上版本的實體裝置。
  3. 執行應用程式。您會在「推薦」和「專輯」選項中看到這些歌曲,並將歌曲分組顯示。
  4. 按一下「推薦影片」,然後從歌曲清單中選取歌曲。
  5. 應用程式開始播放歌曲。

啟用手勢操作

如果您以 API 級別 29 執行新的模擬器執行個體,系統可能不會預設開啟手勢操作。如要啟用手勢操作,請依序選取「系統設定」>系統 >系統導覽 >手勢操作

使用手勢操作執行應用程式

如果在啟用「手勢操作」的情況下執行應用程式並開始播放歌曲,您可能會發現播放器控制項非常靠近主畫面和返回手勢區域。

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>

系統 UI 瀏覽權限標記

您也必須設定系統 UI 瀏覽權限標記,指示系統將應用程式安排在系統資訊列下方。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. 前往「系統設定」,切換回三按鈕操作模式,然後返回應用程式。
  2. 確認搭配三個按鈕導覽列使用控制項更難使用:請注意,SeekBar 會隱藏在導覽列後方,且「播放/暫停」大多在導覽列遮住。
  3. 深入瞭解及進行實驗。完成後,請前往「系統設定」並切換回「手勢操作」:

741ef664e9be5e7f.gif

現在這個應用程式採用無邊框設計,但還是有可用性問題、應用程式控制項會發生衝突和重疊,因此必須解決這些問題。

5. 插邊

WindowInsets 會告知應用程式,系統 UI 顯示在內容上方的位置,以及系統手勢在哪些區域的優先順序高於應用程式內手勢。插邊是以 Jetpack 中的 WindowInsets 類別和 WindowInsetsCompat 類別表示。強烈建議您使用 WindowInsetsCompat,在所有 API 級別中保持一致的行為。

系統插邊和強制系統插邊

下列插邊 API 是最常用的插邊類型:

  • 系統視窗插邊:指出系統 UI 在應用程式中顯示的位置。我們會討論如何使用系統插邊,將控制項移出系統資訊列。
  • 系統手勢插邊:會傳回所有手勢區域。這些區域中的任何應用程式內滑動控制項都可能會意外觸發系統手勢。
  • 必要手勢插邊:這是系統手勢插邊的子集,且無法覆寫。這些手勢指出畫面上,系統手勢行為在螢幕的特定區域,而非應用程式內手勢。

使用插邊移動應用程式控制項

現在您已進一步瞭解插邊 API,可以按照以下步驟修正應用程式控制項:

  1. view 物件例項中取得 playerLayout 的例項。
  2. playerView 中新增 OnApplyWindowInsetsListener
  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. 記錄初始檢視畫面的邊框間距值。建立新的 val,並儲存 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。切換至設計檢視模式,然後選取最底部的 SeekBar

74918dec3926293f.png

  1. 切換至程式碼檢視。
  2. 如要將 SeekBar 移至 playerLayout 頂端,請將 SeekBar 的 layout_constraintTop_toBottomOf 變更為 parent
  3. 如要將 playerView 中的其他項目限制在 SeekBar 的底部,請將 media_buttontitlepositionlayout_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>
  1. 執行應用程式,與播放器和跳轉滑桿互動。

這些微幅變更的設計大幅改善了應用程式品質。

6. 手勢排除 API

已修正主畫面手勢區域的手勢衝突控制項。返回手勢區域也可能會與應用程式控制項發生衝突。以下螢幕截圖顯示玩家跳轉滑桿目前位於右側和返回手勢區域:

e6d98e94dcf83dde.png

SeekBar 會自動處理手勢衝突。然而,您可能需要使用其他會觸發手勢衝突的 UI 元件。在這些情況下,您可以使用 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. 新增檢查程序,在 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
}
  1. 由於 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()
    }
}
  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. 恭喜

恭喜!您已學會如何避免及解決系統手勢的衝突!

您把應用程式設為採用全螢幕狀態時,是從無邊框到來的,並使用插邊將應用程式控制項移出手勢區域。此外,您也學到瞭如何在應用程式控制項中停用系統返回手勢。

您現已瞭解讓應用程式與系統手勢搭配使用所需的重要步驟!

其他資料

參考文件