Gesture Navigation and the edge-to-edge experience

1. Introduction

For Android version 10 or higher, navigation gestures are supported as a new mode. This lets your app use the entire screen and provide a more immersive display experience. When the user swipes up from the bottom edge of the screen, it takes them to the Android home screen. When they swipe inward from the left or right edges, it takes the user to the previous screen.

With these two gestures, your app can take advantage of the screen's real estate at the bottom of the screen. However, if your app uses gestures or has controls in the system gesture areas, it may create conflict with system-wide gestures.

This codelab aims to teach you how to use insets to avoid gesture conflicts. Additionally, this codelabs aims to teach you how to use the Gesture Exclusion API for controls, such as drag handles, that need to reside in the gesture zones.

What you'll learn

  • How to use inset listeners on views
  • How to use the Gesture Exclusion API
  • How immersive mode behaves when gestures are active

This codelab aims to make your app compatible with System Gestures. Irrelevant concepts and code blocks are glossed over and provided for you to copy and paste.

What you'll build

The Universal Android Music Player (UAMP) is an example music player app for Android written in Kotlin. You'll set up UAMP for gestural navigation.

  • Use insets to move controls away from gesture areas
  • Use the Gesture Exclusion API to opt out of the back gesture for controls that conflict
  • Use your builds to explore immersive mode behavior changes with Gesture Navigation

What you'll need

2. App overview

The Universal Android Music Player (UAMP) is a sample music player app for Android written in Kotlin. It supports features that includes background playback, audio focus handling, Assistant integration, and multiple platforms such as Wear, TV, and Auto.

Figure 1: A flow in UAMP

UAMP loads a music catalog from a remote server and allows the user to browse the albums and songs. The user taps on a song and it plays through connected speakers or headphones. The app isn't designed to work with System Gestures. Therefore, when you run UAMP on a device that runs Android 10 or higher, initially you experience some issues.

3. Get set up

To get the sample app, clone the repository from GitHub and switch to the starter branch:

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

Alternatively, you can download the repository as a zip file, unzip it, and open it in Android Studio.

Complete the following steps:

  1. Open and build the app in Android Studio.
  2. Create a new virtual device and select API level 29. Alternatively, you can connect a real device that runs API level 29 or higher.
  3. Run the app. The list you see groups the songs into under the Recommended and Albums selections.
  4. Click Recommended and select a song from the list of songs.
  5. The app begins playback of the song.

Enable Gesture Navigation

If you run a new emulator instance with API level 29, Gesture Navigation might not be turned on by default. To enable Gesture Navigation, select System settings > System > System Navigation > Gesture Navigation.

Run the app with Gesture Navigation

If you run the app with Gesture Navigation enabled and begin playback of a song, you might notice the player controls are very close to home and back gesture areas.

4. Go edge-to-edge

What is edge-to-edge?

Apps that are run on Android 10 or higher, can offer a full edge-to-edge screen experience, regardless of whether gestures or buttons are enabled for navigation. To offer an edge-to-edge experience, your apps must draw behind the transparent navigation and status bars.

Draw behind the navigation bar

For your app to render content under the navigation bar, you must first make the navigation bar background transparent. Then you must make the status bar transparent. This allows your app to display your app for the entire height of the screen.

To change the navigation bar and status bar color, perform the following steps:

  1. Navigation bar: Open res/values-29/styles.xml and set navigationBarColor to color/transparent.
  2. Status bar: Similarly set statusBarColor to color/transparent.

Review the following code sample of 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>

System UI visibility flags

You also must set the system UI visibility flags to tell the system to lay out the app underneath the system bars. The systemUiVisibility APIs on the View class lets a variety of flags to be set. Perform the following steps:

  1. Open the MainActivity.kt class and find the onCreate() method. Get an instance of the fragmentContainer.
  2. Set the following to content.systemUiVisibility:

Review the following code sample of 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

When you set these flags together, you tell the system that you want your app to be displayed fullscreen as if the navigation and status bars are not there. Perform the following steps:

  1. Run the app and to navigate to the player screen, select a song to play.
  2. Verify that the player controls are drawn under the navigation bar, making them difficult to access:

  1. Navigate to the System settings, switch back to the three-button navigation mode, and go back to the app.
  2. Verify that the controls are even more difficult to use with the three button navigation bar: Notice that the SeekBar is hidden behind the navigation bar and that Play/Pause is mostly covered by the navigation bar.
  3. Explore and experiment a bit. After you're done, navigate to the System settings and switch back to Gesture Navigation:

741ef664e9be5e7f.gif

The app is now drawing edge-to-edge, but there are usability issues, app controls which conflict and overlap, and these must be resolved.

5. Insets

WindowInsets tell the app where the system UI appears on top of your content, along with which regions of the screen System Gestures take priority over in-app gestures. Insets are represented by the WindowInsets class and WindowInsetsCompat class in Jetpack. We strongly recommend that you use WindowInsetsCompat to have consistent behavior across all API levels.

System insets and mandatory system insets

The following inset APIs are the most commonly used inset types:

  • System window insets: They tell you where the system UI is displayed over your app. We discuss how you can use system insets to move your controls away from the system bars.
  • System gesture insets: They return all gesture areas. Any in-app swipe controls in these regions can accidentally trigger System Gestures.
  • Mandatory gesture insets: They are a subset of the system gesture insets and can't be overridden. They tell you the areas of the screen where the behavior of the System Gestures will always take priority over in-app gestures.

Use insets to move app controls

Now that you know more about inset APIs, you can fix the app controls, as is described in the following steps:

  1. Get an instance of playerLayout from the view object instance.
  2. Add an OnApplyWindowInsetsListener to the playerView.
  3. Move the view away from the gesture area: Find the system inset value for the bottom and increase the view's padding by that amount. To update the view's padding accordingly, to the [value associated with the app's bottom padding], add [the value associated with the system's inset bottom value].

Review the following code sample of NowPlayingFragment.kt:

playerView = view.findViewById(R.id.playerLayout)
playerView.setOnApplyWindowInsetsListener { view, insets ->
   view.updatePadding(
      bottom = insets.systemWindowInsetBottom + view.paddingBottom
   )
   insets
}
  1. Run the app and select a song. Notice that nothing seems to change in the player controls. If you add a breakpoint and run the app in debug, you see the listener isn't called.
  2. To fix this, switch to FragmentContainerView, which handles this issue automatically. Open activity_main.xml and change FrameLayout to FragmentContainerView.

Review the following code sample of 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. Run the app again and navigate to the player screen. The bottom player controls are shifted away from the bottom gesture area.

The app controls now work with Gesture Navigation, but the controls move more than expected. You must resolve this.

Keep current padding and margins

If you switch to other apps or go to the home screen and go back to the app without closing the app, notice that the player controls move up each time.

This is because the app triggers requestApplyInsets() each time the activity starts. Even without this call, WindowInsets can be dispatched multiple times at any time during the lifecycle of a view.

The current InsetListener on the playerView works perfectly the first time when you add the inset bottom value amount to the app's bottom padding value declared in activity_main.xml. Subsequent calls, however, continue to add the inset bottom value to the already updated view's bottom padding.

To remedy this, perform the following steps:

  1. Record the initial view padding value. Create a new val and store the initial view padding value of playerView, just before the listener code.

Review the following code sample of NowPlayingFragment.kt:

   val initialPadding = playerView.paddingBottom
  1. Use this initial value to update the view's bottom padding, which lets you avoid the use of the app's current bottom padding value.

Review the following code sample of NowPlayingFragment.kt:

   playerView.setOnApplyWindowInsetsListener { view, insets ->
            view.updatePadding(bottom = insets.systemWindowInsetBottom + initialPadding)
            insets
        }
  1. Run the app again. Navigate between apps and go to the home screen. When you return the app, the player controls are in place just over the gesture area.

Redesign app controls

The seekbar of the player is too close to the bottom gesture area, which means the user can accidentally trigger the home gesture when they complete a horizontal swipe. If you increase the padding even more, it can address the issue, but it may also move the player higher than desired.

The use of insets lets you fix gesture conflicts, but sometimes with small design changes, you can avoid gesture conflicts entirely. To re-design the player controls to avoid gesture conflicts, perform the following steps:

  1. Open fragment_nowplaying.xml. Switch to Design view and select the SeekBar at the very bottom:

74918dec3926293f.png

  1. Switch to Code view.
  2. To move the SeekBar to the top of the playerLayout, change layout_constraintTop_toBottomOf of the SeekBar to parent.
  3. To constrain other items in the playerView to the bottom of the SeekBar, change layout_constraintTop_toTopOf from parent to @+id/seekBar on media_button, title, and position.

Review the following code sample of 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. Run the app and interact with the player and the seekbar.

These minimal design changes significantly improve the app.

6. Gesture Exclusion API

The player controls for gesture conflicts in the home gesture area are fixed. The back gesture area can also create conflicts with the app controls. The following screenshot shows that the player seekbar currently resides in both the right and left back gesture areas:

e6d98e94dcf83dde.png

SeekBar automatically handles gesture conflicts. Yet you might need to use other UI components that trigger gesture conflicts. In these cases, you can use the Gesture Exclusion API to partially opt out of the back gesture.

Use Gesture Exclusion API

To create a gesture exclusion zone, call setSystemGestureExclusionRects() on your view with a list of rect objects. These rect objects map to coordinates of the excluded rectangle areas. This call must be done in the onLayout() or onDraw() methods of the view. To do so, perform the following steps:

  1. Create a new package named view.
  2. To call this API, create a new class called MySeekBar and extend AppCompatSeekBar.

Review the following code sample of 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. Create a new method called updateGestureExclusion().

Review the following code sample of MySeekBar.kt:

private fun updateGestureExclusion() {

}
  1. Add a check to skip this call on API level 28 or lower.

Review the following code sample of MySeekBar.kt:

private fun updateGestureExclusion() {
        // Skip this call if we're not running on Android 10+
        if (Build.VERSION.SDK_INT < 29) return
}
  1. Because the Gesture Exclusion API has a limit of 200 dp, only exclude the thumb of the seekbar. Get a copy of the bounds of the seekbar and add each object to a mutable list.

Review the following code sample of 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. Call systemGestureExclusionRects() with the gestureExclusionRects lists you create.

Review the following code sample of 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. Call updateGestureExclusion() method from onDraw() or onLayout(). Override onDraw() and add a call to updateGestureExclusion.

Review the following code sample of MySeekBar.kt:

override fun onDraw(canvas: Canvas) {
    super.onDraw(canvas)
    updateGestureExclusion()
}
  1. You must update the SeekBar references. To begin, open fragment_nowplaying.xml.
  2. Change SeekBar to com.example.android.uamp.view.MySeekBar.

Review the following code sample of 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. To update the SeekBar references in NowPlayingFragment.kt, open NowPlayingFragment.kt and change the type of positionSeekBar to MySeekBar. To match the variable type, change the SeekBar generics for the findViewById call to MySeekBar.

Review the following code sample of NowPlayingFragment.kt:

val positionSeekBar: MySeekBar = view.findViewById<MySeekBar>(
     R.id.seekBar
).apply { progress = 0 }
  1. Run the app and interact with the SeekBar. If you still experience gesture conflicts, you can experiment and modify the thumb bounds in MySeekBar. Be careful not to create a gesture exclusion zone larger than necessary, because this limits other potential gesture exclusion calls, and creates inconsistent behavior for the user.

7. Congratulations

Congratulations! You've learned how to avoid and solve conflicts with System Gestures!

You made your app use full-screen when you extended edge-to-edge and used insets to move app controls away from gesture zones. You also learned how to disable the system back gesture on app controls.

You now know the key steps required to make your apps work with System Gestures!

Additional materials

Reference docs