ジェスチャー ナビゲーションと狭額縁エクスペリエンス

Android バージョン 10 以上では、新しいモードとしてナビゲーション ジェスチャーがサポートされています。これにより、アプリは画面全体を使用して、没入感の高いディスプレイ エクスペリエンスを提供できます。ユーザーが画面の下端から上にスワイプすると、Android のホーム画面に移動します。左端または右端から内側にスワイプすると、前の画面に移動します。

これら 2 つのジェスチャーにより、アプリは画面の下部にある画面領域を利用できるようになります。ただし、アプリがジェスチャーを使用している場合や、アプリのコントロールがシステム ジェスチャー領域に存在する場合は、システム全体のジェスチャーと競合する可能性があります。

この Codelab は、インセットを使用してジェスチャーの競合を回避する方法を学ぶことを目的としています。さらに、ジェスチャー ゾーンに配置する必要があるドラッグ ハンドルなどのコントロールで Gesture Exclusion API を使用する方法を学ぶことも目的としています。

学習内容

  • ビューでのインセット リスナーの使用方法
  • Gesture Exclusion API の使用方法
  • ジェスチャーがアクティブになっている場合の没入モードの動作

この Codelab は、アプリでシステム ジェスチャーとの互換性を確保することを目的としています。関連のない概念とコードブロックについては軽く触れるにとどめ、そのままコピーして貼り付けられるようにしています。

作成するアプリの概要

Universal Android Music Player(UAMP)は、Kotlin で記述された Android 用音楽プレーヤー アプリのサンプルです。この後、ジェスチャー ナビゲーションを使用できるように UAMP をセットアップします。

  • インセットを使用して、コントロールをジェスチャー領域から引き離す
  • Gesture Exclusion API を使用して、競合しているコントロールの「戻る」ジェスチャーをオプトアウトする
  • ビルドを使用して、ジェスチャー ナビゲーションによる没入モードの動作の変化を調べる

必要なもの

  • Android 10 以上を実行するデバイスまたはエミュレータ
  • Android Studio

Universal Android Music Player(UAMP)は、Kotlin で記述された Android 用音楽プレーヤー アプリのサンプルです。バックグラウンド再生、音声フォーカス処理、アシスタント統合などの機能と、Wear、TV、Auto といった複数のプラットフォームをサポートします。

図 1: UAMP のフロー

UAMP は、リモート サーバーから音楽カタログを読み込んで、ユーザーがアルバムと曲をブラウジングできるようにします。ユーザーが曲をタップしたら、接続されているスピーカーまたはヘッドフォンで曲を再生します。このアプリは、システム ジェスチャーで動作するようには設計されていません。したがって、Android 10 以上を搭載したデバイスで UAMP を実行すると、最初にいくつかの問題が発生します。

サンプルアプリを入手するには、GitHub からリポジトリのクローンを作成し、starter ブランチに切り替えます。

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


または、リポジトリを ZIP ファイルとしてダウンロードし、Android Studio で開くこともできます。

ZIP をダウンロード

次の手順を行います。

  1. Android Studio でアプリを開いてビルドします。
  2. 新しい仮想デバイスを作成して、API レベル 29 を選択します。または、API レベル 29 以上を搭載した実際のデバイスを接続することもできます。
  3. アプリを実行します。[Recommended] と [Albums] が表示されます。いずれかを選択して、それぞれに含まれている曲をブラウジングできます。
  4. [Recommended] をクリックして、曲のリストから曲を選択します。
  5. アプリが曲の再生を開始します。

ジェスチャー ナビゲーションを有効にする

API レベル 29 の新しいエミュレータ インスタンスを実行する場合、ジェスチャー ナビゲーションがデフォルトでオンになっていないことがあります。ジェスチャー ナビゲーションを有効にするには、[システム設定] > [システム] > [システム ナビゲーション] > [ジェスチャー ナビゲーション] を選択します。

ジェスチャー ナビゲーションでアプリを実行する

ジェスチャー ナビゲーションを有効にしてアプリを実行し、曲の再生を開始すると、プレーヤー コントロールが「ホーム」および「戻る」ジェスチャー領域に非常に近い場所にあることに気付くはずです。

狭額縁エクスペリエンスについて

Android 10 以上で実行されるアプリは、ナビゲーションでジェスチャーまたはボタンが有効になっているかどうかにかかわらず、完全な狭額縁ディスプレイ エクスペリエンスを提供できます。アプリで狭額縁エクスペリエンスを提供するには、透明なナビゲーション バーとステータスバーの背後に描画する必要があります。

ナビゲーション バーの背後で描画する

アプリがナビゲーション バーの背後にコンテンツをレンダリングするためには、最初にナビゲーション バーの背景を透明にする必要があります。次に、ステータスバーを透明にします。これにより、画面の高さ全体にアプリを表示できます。

ナビゲーション バーとステータスバーの色を変更するには、次の手順を行います。

  1. ナビゲーション バー: res/values-29/styles.xml を開き、navigationBarColorcolor/transparent に設定します。
  2. ステータスバー: 同様に、statusBarColorcolor/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. [システム設定] に移動し、3 ボタン ナビゲーション モードに戻してから、アプリに戻ります。
  2. 3 ボタン ナビゲーション バーのためにコントロールがさらに使いづらくなったことを確認します。SeekBar がナビゲーション バーの背後に隠れ、再生と一時停止がナビゲーション バーでほとんど覆い隠されていることに注目してください。
  3. 少し操作を試します。試し終わったら、[システム設定] に移動して [ジェスチャー ナビゲーション] に戻します。

これで、アプリは端の余白がない狭額縁の状態で描画されるようになりましたが、使いづらさの問題と、アプリ コントロールの競合と重なりという問題が存在します。これらを解決する必要があります。

WindowInsets は、システム UI がコンテンツを覆って表示される場所と、システム ジェスチャーがアプリ内ジェスチャーよりも優先される画面領域をアプリに通知します。インセットは、JetpackWindowInsets クラスと WindowInsetsCompat クラスで表現されます。WindowInsetsCompat を使用して、すべての API レベルで一貫した動作を実現することを強くおすすめします。

システム インセットと必須システム インセット

以下のインセット API は、最もよく使用されるインセット タイプの API です。

  • システム ウィンドウ インセット: システム UI がアプリを覆って表示される場所を示します。システム インセットを使用してコントロールをシステムバーから引き離す方法については、後で説明します。
  • システム ジェスチャー インセット: すべてのジェスチャー領域を返します。これらの領域のアプリ内スワイプ コントロールは、誤ってシステム ジェスチャーをトリガーする可能性があります。
  • 必須ジェスチャー インセット: これはシステム ジェスチャー インセットのサブセットであり、オーバーライドできません。システム ジェスチャーの動作が常にアプリ内ジェスチャーよりも優先される画面領域を示します。

インセットを使用してアプリ コントロールを移動する

インセット API について詳しく学んだところで、以下の手順に沿ってアプリ コントロールを修正します。

  1. view オブジェクト インスタンスから playerLayout のインスタンスを取得します。
  2. OnApplyWindowInsetsListenerplayerView に追加します。
  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 を開き、FrameLayoutFragmentContainerView に変更します。

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 が複数回ディスパッチされる可能性があります。

playerView の現在の InsetListener は、activity_main.xml で宣言されたアプリの下部パディング値にインセットの下部の量を初めて加算したときには、正しく機能します。しかし、その後の呼び出しでは、インセットの下部の値が、すでに更新済みのビューの下部パディングに引き続き加算されます。

これを修正するには、次の手順を行います。

  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 を選択します。

  1. コードビューに切り替えます。
  2. SeekBarplayerLayout の一番上に移動するには、SeekBar の layout_constraintTop_toBottomOfparent に変更します。
  3. playerView 内の他のアイテムを SeekBar の下部に制限するには、media_buttontitlepositionlayout_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. アプリを実行し、プレーヤーとシークバーを操作します。

このわずかなデザインの変更により、アプリが大幅に改善されます。

「ホーム」ジェスチャー領域については、プレーヤー コントロールのジェスチャーが競合する問題が解決しました。「戻る」ジェスチャー領域についても、アプリ コントロールとの競合が発生する可能性があります。以下のスクリーンショットは、現在プレーヤーのシークバーが左右両方の「戻る」ジェスチャー領域に存在することを示しています。

SeekBar は、ジェスチャーの競合を自動的に処理します。ただし、ジェスチャーの競合を引き起こす他の UI コンポーネントを使用しなければならない場合があります。そのような場合は、Gesture Exclusion API を使用して、「戻る」ジェスチャーを部分的にオプトアウトできます。

Gesture Exclusion 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. 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()
    }
}
  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. SeekBarcom.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 に変更します。変数の型を一致させるため、MySeekBar に対する findViewById 呼び出しで SeekBar ジェネリックを変更します。

NowPlayingFragment.kt の以下のコードサンプルを確認してください。

val positionSeekBar: MySeekBar = view.findViewById<MySeekBar>(
     R.id.seekBar
).apply { progress = 0 }
  1. アプリを実行し、SeekBar を操作します。依然としてジェスチャーの競合が発生する場合は、MySeekBar でサムネイル境界をテストして変更します。必要以上に大きいジェスチャー除外ゾーンを作成しないよう注意してください。他の潜在的なジェスチャー除外呼び出しが制限され、ユーザーに対する動作の一貫性が失われるからです。

お疲れさまでした。以上で、システム ジェスチャーとの競合を回避および解決する方法を習得しました。

アプリを拡張して狭額縁エクスペリエンスを実現し、インセットを使ってアプリ コントロールをジェスチャー ゾーンから引き離した場合に、アプリが全画面を使用するようにしました。また、アプリ コントロールで「戻る」システム ジェスチャーを無効にする方法も習得しました。

アプリをシステム ジェスチャーで動作させるために必要となる主な手順について確認しました。

その他の教材

リファレンス ドキュメント