WindowInsets — listeners to layouts

Moving where we handle insets to where our views live, layout files

Chris Banes
Apr 12, 2019 · 6 min read

If you’ve watched my Becoming a Master Window Fitter talk, you’ll know that handling window insets can be complex. Recently I’ve been improving system bar handling in a few apps, enabling them to draw behind the status and navigation bars. I’ve come up with some methods which make handling insets easier.

Drawing behind the navigation bar

<BottomNavigationView
android:layout_height="wrap_content"
android:layout_width="match_parent" />

By default, your Activity’s content will be laid out within the system provided UI (navigation bar, etc), thus our view sits flush to the navigation bar. Our designer has decided that they would like the app to start drawing behind the navigation bar though. To do that we’ll call setSystemUiVisibility() with the appropriate flags:

rootView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION

And finally we will update our theme so that we have a translucent navigation bar, with dark icons:

<style name="AppTheme" parent="Theme.MaterialComponents.Light">
<!-- Set the navigation bar to 50% translucent white -->
<item name="android:navigationBarColor">#80FFFFFF</item>
<!-- Since the nav bar is white, we will use dark icons -->
<item name="android:windowLightNavigationBar">true</item>
</style>
The view is being displayed behind the navigation bar

As you can see, this is only the beginning of what we need to do though. Since the Activity is now laid out behind the navigation bar, our BottomNavigationView is too. This means the user can’t actually click on any of the navigation items. To fix that, we need to handle any WindowInsets which the system dispatches, and use those values to apply appropriate padding to views.

Handling insets through padding

bottomNav.setOnApplyWindowInsetsListener { view, insets ->
view.updatePadding(bottom = insets.systemWindowInsetBottom)
insets
}
The view now has bottom padding which matches the navigation bar size

OK great, we’ve now correctly handled the bottom system window inset. But later the designer decided to add some padding in the layout too:

<BottomNavigationView
android:layout_height="wrap_content"
android:layout_width="match_parent"
android:paddingVertical="24dp" />

Note: I’m not recommending using 24dp of vertical padding on a BottomNavigationView, I am using a large value here just to make the effect obvious.

The view has the correct top padding, but the intended bottom padding isn’t there

Hmmm, that’s not right. Can you see the problem? Our call to updatePadding() from the OnApplyWindowInsetsListener will now wipe out the intended bottom padding from the layout.

Aha! Lets just add the current padding and the inset together:

bottomNav.setOnApplyWindowInsetsListener { view, insets ->
view.updatePadding(
bottom = view.paddingBottom + insets.systemWindowInsetsBottom
)
insets
}

We now have a new problem. WindowInsets can be dispatched at any time, and multiple times during the lifecycle of a view. This means that our new logic will work great the first time, but for every dispatch we’re going to be adding more and more bottom padding. Not what we want. 🤦

The accumulated padding after 3 WindowInset dispatches

The solution I’ve come up with is to keep a record of the view’s padding values after inflation, and then refer to those values instead. Example:

// Keep a record of the intended bottom padding of the view
val bottomNavBottomPadding = bottomNav.paddingBottom

bottomNav.setOnApplyWindowInsetsListener { view, insets ->
// We've got some insets, set the bottom padding to be the
// original value + the inset value
view.updatePadding(
bottom = bottomNavBottomPadding + insets.systemWindowInsetBottom
)
insets
}
Finally, what we intended

This works great, and means that we maintain the intention of the padding from the layout, and we still inset the views as required. Keeping object level properties for each padding value is very messy though, we can do better… 🤔

doOnApplyWindowInsets

When we need a view to handle insets, we can now do the following:

bottomNav.doOnApplyWindowInsets { view, insets, padding ->
// padding contains the original padding values after inflation
view.updatePadding(
bottom = padding.bottom + insets.systemWindowInsetBottom
)
}

Much nicer! 😏

requestApplyInsetsWhenAttached()

This is a common scenario when you create views in Fragment.onCreateView(). The fix would be to make sure to simply call the method in onStart() instead, or use a listener to request insets once attached. The following extension function handles both cases:

Wrapping it up in a bind

  • The logic lives away from our layouts, meaning that it is very easy to forget about.
  • We will likely need to use this in a number of places, leading to lots of near-identical copies spreading throughout the app.

I knew we could do better.

So far this entire post has concentrated solely on code, and handling insets through setting listeners. We’re talking about views here though, so in an ideal world we would declare our intention to handle insets in our layout files.

Enter data binding adapters! If you’ve never used them before, they let us map code to layout attributes (when you use Data Binding). You can read more about them here:

So lets create an attribute to do this for us:

In our layout we can then simply use our new paddingBottomSystemWindowInsets attribute, which will automatically update with any insets.

<BottomNavigationView
android:layout_height="wrap_content"
android:layout_width="match_parent"
android:paddingVertical="24dp"
app:paddingBottomSystemWindowInsets="@{ true }" />

Hopefully you can see how ergonomic and easy to use this is compared to using an OnApplyWindowListener alone. 🌠

But wait, that binding adapter is hardcoded to only set the bottom dimension. What if we need to handle the top inset too? Or the left? Or right? Luckily binding adapters let us generalise the pattern across all dimensions really nicely:

Here we’ve declared an adapter with multiple attributes, each mapping to the relevant method parameter. One thing to note, is the usage of requireAll = false, meaning that the adapter can handle any combination of the attributes being set. This means that we can do the following for example, setting both the left and bottom:

<BottomNavigationView
android:layout_height="wrap_content"
android:layout_width="match_parent"
android:paddingVertical="24dp"
app:paddingBottomSystemWindowInsets="@{ true }"
app:paddingLeftSystemWindowInsets="@{ true }"
/>

Ease of use level: 💯

android:fitSystemWindows

If you’re using AppBarLayout, CoordinatorLayout, DrawerLayout and friends, then yes use it. Those views have been built to recognize the attribute, and apply window insets in an opinionated way relevant to those views.

The default View implementation of android:fitSystemWindows means to pad every dimension using the insets though, and wouldn’t work for the example above. For more information, see this blog post which is still very relevant:

Ergonomics FTW

Android Developers

The official Android Developers publication on Medium