Handle edge-to-edge enforcements in Android 15

1. Before you begin

SociaLite demonstrates how to use various Android platform APIs to implement features that are commonly seen in social-network apps, leveraging a variety of Jetpack APIs to achieve complex functionalities that work reliably on more devices and require less code.

This codelab walks you through the process of making the SociaLite app compatible with the Android 15 edge-to-edge enforcement and making the app edge-to-edge in a backward-compatible way. After going edge-to-edge, SociaLite will look like the following, depending on your device and navigation mode:

The SociaLite App in three-button navigation.

The SociaLite app in gesture navigation.

SociaLite with three-button navigation

SociaLite with gesture navigation

The SociaLite app on a large screen device.

SociaLite on a large screen device

Prerequisites

  • Basic Kotlin knowledge.
  • Completion of the Setup Android Studio codelab, or familiarity with how to use Android Studio and test apps in an emulator or physical device running Android 15.

What you learn

  • How to handle Android 15 edge-to-edge changes.
  • How to make your app edge-to-edge in a backward compatible way.

What you need

  • The latest version of Android Studio.
  • A test device or emulator running Android 15 Beta 1 or higher.
  • An Android 15 Beta 1 SDK or higher.

2. Get the starter code

  1. Download the starter code from GitHub.

Alternatively, clone the repository and check out the codelab_improve_android_experience_2024 branch.

$ git clone git@github.com:android/socialite.git
$ cd socialite
$ git checkout codelab_improve_android_experience_2024
  1. Open SociaLite in Android Studio and run the app on your Android 15 device or emulator. You see a screen that looks like one of the following:

SociaLite with three-button navigation.

SociaLite with gesture navigation.

Three-button navigation

Gesture navigation

SociaLite on a large screen device.

Large screen

  1. On the Chats page, select one of the conversations, such as the one with the dog.

Dog chat message with three-button navigation

Dog chat message with gesture navigation

Dog chat message with three-button navigation

Dog chat message with gesture navigation

3. Make your app edge-to-edge on Android 15

What is edge-to-edge?

Apps can draw behind the system bars, allowing for a polished user experience and full use of the display space. This is called going edge-to-edge.

GIF of an app going edge to edge

How to handle Android 15 edge-to-edge changes

Before Android 15, your app's UI by default is restricted to being laid out such that it avoids the system bar areas, like the status bar and navigation bar. Apps opted-into going edge-to-edge. Depending on the app, opting-in could range from being trivial to cumbersome.

Starting in Android 15, your app will be edge-to-edge by default. You'll see the following defaults:

  • Three-button navigation bar is translucent.
  • Gesture navigation bar is transparent.
  • Status bar is transparent.
  • Unless content applies insets or padding, content will draw behind system bars, like the navigation bar, status bar, and caption bar.

This ensures edge-to-edge is not overlooked as a means of increasing app quality and reduces the work required for your app to go edge-to-edge. However, this change may negatively impact your app. You'll see two examples of negative impacts within SociaLite after you upgrade the target SDK to Android 15.

Change the target SDK value to Android 15

  1. Within SociaLite's app's build.gradle file, change the target and compile SDK versions to Android 15 or VanillaIceCream.

If you are taking this codelab before the stable release of Android 15, the code will look like this:

android {
    namespace = "com.google.android.samples.socialite"
    compileSdkPreview = "VanillaIceCream"

    defaultConfig {
        applicationId = "com.google.android.samples.socialite"
        minSdk = 21
        targetSdkPreview = "VanillaIceCream"
        ...
    }
...
}

If you are taking this codelab after the stable release of Android 15, the code will look like this:

android {
    namespace = "com.google.android.samples.socialite"
    compileSdk = 35

    defaultConfig {
        applicationId = "com.google.android.samples.socialite"
        minSdk = 21
        targetSdk = 35
        ...
    }
...
}
  1. Rebuild SociaLite and observe the following issues:
  • Three-button navigation background protection does not match the navigation bar. The Chats screen looks edge-to-edge without any intervention on your end for gesture navigation. However, there is three-button navigation background protection that should be removed.

Chats screen with three-button navigation.

Chats screen with gesture navigation.

Chats screen with three-button navigation

Chats screen with gesture navigation

  • Occluded UI. A conversation has the bottom UI elements occluded by the navigation bars. This is most apparent in three-button navigation.

Dog chat message with three-button navigation.

Dog chat message with gesture navigation.

Dog chat message with three-button navigation

Dog chat message with gesture navigation

Fix SociaLite

To remove default three-button navigation background protection, follow these steps:

  1. In the MainActivity.kt file, remove the default background protection by setting the window.isNavigationBarContrastEnforced property to false.
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        installSplashScreen()
        super.onCreate(savedInstanceState)
        setContent {
            // Add this block:
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                window.isNavigationBarContrastEnforced = false
            }
        }
    }
    ...
}

window.isNavigationBarContrastEnforced ensures that the navigation bar has enough contrast when a fully transparent background is requested. By setting this attribute to false, you are effectively setting the three-button navigation background to transparent. window.isNavigationBarContrastEnforced will only impact three-button navigation and has no impact on gesture navigation.

  1. Rerun the app and view one of the conversations on your Android 15 device. The Timeline, Chats, and Settings screens now all appear edge-to-edge. The app's NavigationBar (with the Timeline, Chats, and Settings buttons) are drawn behind the system's transparent three-button navigation bar.

Chats screen with three-button navigation and banding removed.

Dog conversation in gesture navigation.

Chats screen with banding removed

No changes in gesture navigation

However, notice that the conversation's InputBar is still occluded by the system bars. You need to properly handle insets to fix this issue.

Dog conversation in three-button navigation.

Dog conversation in gesture navigation.

Dog conversation in three-button navigation. The input field at the bottom is occluded by the system's navigation bar.

Dog conversation in gesture navigation. The input field at the bottom is occluded by the system's navigation bar.

In SociaLite, the InputBar is obscured. In practice, you may find elements at the top, bottom, left, and right obscured when you rotate to landscape mode or when you're on a large-screen device. You need to consider how to handle insets for all these use cases. For SociaLite, you apply padding to bump up the tappable content of the InputBar.

To apply insets to fix occluded UI, follow these steps:

  1. Navigate to the ui/chat/ChatScreen.kt file and then find the ChatContent composable around line 178, which contains the UI for the conversation screen. ChatContent takes advantage of Scaffold to easily construct UI. By default, Scaffold provides information about the system UI, such as how deep the system bars are, as insets that you can consume with Scaffold's padding values (the innerPadding parameter). Add padding using Scaffold's innerPadding to InputBar.
  2. Find InputBar within ChatContent near line 214. This is a custom composable that creates the UI for users to write messages. The preview looks like this:

The PreviewInputBar.

InputBar takes a contentPadding and applies it as padding to the Row composable that contains the rest of the UI. The padding will be applied to all sides of the Row composable. You can see this around line 432. Here is the InputBar composable for reference (don't add this code):

// Don't add this code because it's only for reference.
@Composable
private fun InputBar(
    contentPadding: PaddingValues,
    ...,
) {
    Surface(...) {
        Row(
            modifier = Modifier
                .padding(contentPadding)
            ...
        ) {
            IconButton(...) { ... } // take picture
            IconButton(...) { ... } // attach picture
            TextField(...) // write message
            FilledIconButton(...){ ... } // send message
            }
        }
    }
}
  1. Go back to the InputBar within ChatContent and change contentPadding so you are consuming the system bar insets. This is around line 220.
InputBar(
    ...
    contentPadding = innerPadding, //Add this line.
    // contentPadding = PaddingValues(0.dp), // Remove this line.
    ...
 )
  1. Rerun the app on your Android 15 device.

Dog conversation in three-button navigation.

Dog conversation in gesture navigation.

Dog conversation in three-button navigation with insets incorrectly applied.

Dog conversation in gesture navigation with insets incorrectly applied.

The bottom padding was applied so that the buttons are no longer obscured by the system bars; however, the top padding was also applied. The top padding encompasses the depth of the TopAppBar and the system bar. Scaffold passes the padding values to its content so that it can avoid the top app bar as well as the system bars.

  1. To fix the top padding, create a copy of the innerPadding PaddingValues, set the top padding to 0.dp, and pass your modified copy into contentPadding.
InputBar(
    ...
    contentPadding = innerPadding.copy(layoutDirection, top = 0.dp), //Add this line.
    // contentPadding = innerPadding, // Remove this line.
    ...
 )
  1. Rerun the app on your Android 15 device.

Dog conversation in three-button navigation.

Dog conversation in gesture navigation.

Dog conversation in three-button navigation with insets correctly applied.

Dog conversation in gesture navigation with insets correctly applied.

Congratulations! You made SociaLite compatible with the Android 15 edge-to-edge platform changes. Next, you learn how to make SociaLite edge-to-edge in a backward-compatible way.

4. Make SociaLite edge-to-edge in a backward-compatible way

SociaLite is now edge-to-edge on Android 15, but is still not edge-to-edge on older Android devices. To make SociaLite edge-to-edge on older Android devices, call enableEdgeToEdge before setting the content in the MainActivity.kt file.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        installSplashScreen()
        enableEdgeToEdge() // Add this line.
        window.isNavigationBarContrastEnforced = false
        super.onCreate(savedInstanceState)
        setContent {... }
    }
}

The import for enableEdgeToEdge is import androidx.activity.enableEdgeToEdge. The dependency is AndroidX Activity 1.8.0 or higher.

For an in-depth overview of how to make your app edge-to-edge in a backward-compatible way and how to handle insets, see the following guides:

This concludes the edge-to-edge portion of the pathway. The next section is optional and discusses other edge-to-edge considerations that might be applicable to your app.

5. Optional: Additional edge-to-edge considerations

Handling insets across architectures

Components

You might have noticed that many components in SociaLite did not shift after we changed the target SDK value. SociaLite is architected with best practices and so handling this platform change is easy. Best practices include the following:

Scrolling content

Your app might contain lists and the last item of the list may be occluded by the system's navigation bars with the Android 15 change.

App with last list item occluded by three-button navigation.

Shows the last item in the list is occluded by three-button navigation.

Scrolling content with Compose

In Compose, use LazyColumn's contentPadding to add a space to the last item unless you are using TextField:

Scaffold { innerPadding ->
    LazyColumn(
        contentPadding = innerPadding
    ) {
        // Content that does not contain TextField
    }
}

App with last list item is not occluded by three-button navigation.

Shows the last item in the list is not occluded by three-button navigation.

For TextField, use a Spacer to draw the last TextField in a LazyColumn. For more information, see Inset consumption.

LazyColumn(
    Modifier.imePadding()
) {
    // Content with TextField
    item {
        Spacer(
            Modifier.windowInsetsBottomHeight(
                WindowInsets.systemBars
            )
        )
    }
}

Scrolling content with Views

For RecyclerView or NestedScrollView, add android:clipToPadding="false".

<androidx.recyclerview.widget.RecyclerView
    android:id="@+id/recycler"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:clipToPadding="false"
    app:layoutManager="LinearLayoutManager" />

Provide left, right and bottom paddings from window insets using setOnApplyWindowInsetsListener:

ViewCompat.setOnApplyWindowInsetsListener(binding.recycler) { v, insets ->
    val i = insets.getInsets(
        WindowInsetsCompat.Type.systemBars() + WindowInsetsCompat.Type.displayCutout()
    )
    v.updatePadding(
        left = i.left,
        right = i.right,
        bottom = i.bottom + bottomPadding,
    )
    WindowInsetsCompat.CONSUMED
}

Using LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS

Prior to targeting SDK 35, SocialLite looked like this in landscape where the left edge has a large white box to account for the camera cutout. In three-button navigation, the buttons are on the right side.

The SociaLite app in landscape.

After targeting SDK 35, SociaLite will look like this where the left edge no longer has a large white box to account for the camera cutout. To achieve this effect, Android automatically sets LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS. The SociaLite app in landscape.

Depending on your app, you may wish to handle insets here.

To do so in SociaLite, follow these steps:

  1. In the ui/ContactRow.kt file, find the Row composable.
  2. Modify the padding to account for the display cutout.
@Composable
fun ChatRow(
   chat: ChatDetail,
   onClick: (() -> Unit)?,
   modifier: Modifier = Modifier,
) {
   // Add layoutDirection, displayCutout, startPadding, and endPadding.
   val layoutDirection = LocalLayoutDirection.current
   val displayCutout = WindowInsets.displayCutout.asPaddingValues()
   val startPadding = displayCutout.calculateStartPadding(layoutDirection)
   val endPadding = displayCutout.calculateEndPadding(layoutDirection)
   Row(
       modifier = modifier
           ...
           // .padding(16.dp) // Remove this line.
           // Add this block:
           .padding(
               PaddingValues(
                   top = 16.dp,
                   bottom = 16.dp,
                   // Ensure content is not occluded by display cutouts
                   // when rotating the device.
                   start = startPadding.coerceAtLeast(16.dp),
                   end = endPadding.coerceAtLeast(16.dp)
               )
           ),
       ...
   ) { ... }

After handling display cutouts, SociaLite looks like this:

The SociaLite app in landscape.

You can test various display cutout configurations on the Developer options screen under Display cutout.

If your app has a non-floating window (for example, an Activity) that is using LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT, LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER or LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES, Android will interpret these cutout modes to be LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS starting in Android 15 Beta 2. Previously, in Android 15 Beta 1, your app would crash.

Caption bars are also system bars

A caption bar is also a system bar as it describes the system UI window decoration of a freeform window, like the top title bar. You can view the caption bar within a desktop emulator in Android Studio. In the following screenshot, the caption bar is at the top of the app.

Emulator showing a caption bar.

In Compose, if you are using Scaffold's PaddingValues, safeContent, safeDrawing, or the built-in WindowInsets.systemBars, your app will display as expected. However, if you are handling insets with statusBar, your app content may not display as expected because status bar does not account for the caption bar.

In Views, if you are manually handling insets using WindowInsetsCompat.systemBars, your app will display as expected. If you are manually handling insets using WindowInsetsCompat.statusBars, your app might not display as expected because status bars are not caption bars.

Apps in immersive mode

Screens in immersive mode are largely unaffected by the Android 15 edge-to-edge enforcements, as immersive apps are already edge-to-edge.

Protecting system bars

You may wish your app to have a transparent bar for gesture navigation, but a translucent or opaque bar for three-button navigation.

In Android 15, a translucent three-button navigation is the default because the platform sets the window.isNavigationBarContrastEnforced property to true. Gesture navigation remains transparent.

An app in three-button navigation.

Three-button navigation is translucent by default.

In general, a translucent three-button navigation should be sufficient. However, in some cases, your app might require an opaque three-button navigation. First, set the window.isNavigationBarContrastEnforced property to false. Then, use WindowInsetsCompat.tappableElement for Views or WindowInsets.tappableElement for Compose. If these are 0, the user is using gesture navigation. Otherwise, the user is using three-button navigation. If the user is in three-button navigation, draw a view or box behind the navigation bar. A compose example might look like this:

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            window.isNavigationBarContrastEnforced = false
            MyTheme {
                Surface(...) {
                    MyContent(...)
                    ProtectNavigationBar()
                }
            }
        }
    }
}


// Use only if required.
@Composable
fun ProtectNavigationBar(modifier: Modifier = Modifier) {
   val density = LocalDensity.current
   val tappableElement = WindowInsets.tappableElement
   val bottomPixels = tappableElement.getBottom(density)
   val usingTappableBars = remember(bottomPixels) {
       bottomPixels != 0
   }
   val barHeight = remember(bottomPixels) {
       tappableElement.asPaddingValues(density).calculateBottomPadding()
   }

   Column(
       modifier = modifier.fillMaxSize(),
       verticalArrangement = Arrangement.Bottom
   ) {
       if (usingTappableBars) {
           Box(
               modifier = Modifier
                   .background(MaterialTheme.colorScheme.background)
                   .fillMaxWidth()
                   .height(barHeight)
           )
       }
   }
}

An app in three-button navigation.

Opaque three-button navigation

6. Review the solution code

The onCreate method of the MainActivity.kt file should look like this:

class MainActivity : ComponentActivity() {
   override fun onCreate(savedInstanceState: Bundle?) {
       installSplashScreen()
       enableEdgeToEdge()
       window.isNavigationBarContrastEnforced = false
       super.onCreate(savedInstanceState)
       setContent {
           Main(
               shortcutParams = extractShortcutParams(intent),
           )
       }
   }
}

The ChatContent composable within the ChatScreen.kt file should handle insets:

private fun ChatContent(...) {
   ...
   Scaffold(...) { innerPadding ->
       Column {
           ...
           InputBar(
               input = input,
               onInputChanged = onInputChanged,
               onSendClick = onSendClick,
               onCameraClick = onCameraClick,
               onPhotoPickerClick = onPhotoPickerClick,
               contentPadding = innerPadding.copy(
                    layoutDirection, top = 0.dp
                ),
               sendEnabled = sendEnabled,
               modifier = Modifier
                   .fillMaxWidth()
                   .windowInsetsPadding(
                       WindowInsets.ime.exclude(WindowInsets.navigationBars)
                    ),
            )
       }
   }
}

The solution code is available in the main branch. If you've already downloaded SociaLite:

git checkout main

Otherwise, you can download the code again to view the main branch either directly or through git:

git clone git@github.com:android/socialite.git