WindowInsets — Listeners to layouts

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

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

For the rest of this post we will be going through a simple example using a BottomNavigationView, which is laid out at the bottom of the screen. It is very simply implemented as so:

<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

One of the usual ways to handle WindowInsets is to add padding to views, so that their contents are not displayed behind the system views. To do that, we can set an OnApplyWindowInsetsListener to add the necessary bottom padding to the view, ensuring that its content isn’t obscured.

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

Enter my newdoOnApplyWindowInsets() extension method. This is a wrapper around setOnApplyWindowInsetsListener() which generalises the pattern above:

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()

You may have noticed the requestApplyInsetsWhenAttached() above. This isn’t strictly necessary, but does work around a limitation in how WindowInsets are dispatched. If a view calls requestApplyInsets() while it is not attached to the view hierarchy, the call is dropped on the floor and ignored.

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

At this point we’ve greatly simplified how to handle window insets. We are actually using this functionality in some upcoming apps, including one for an upcoming conference 😉. It still has some downsides though:

  • 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

You might have read this post, and thought “Why hasn’t he mentioned the fitSystemWindows attribute?”. The reason for that is because the functionality that the attribute brings is not usually what we want.

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

Phew, this was a long post! Aside from us making WindowInsets easier to handle, hopefully it has demonstrated how features like extension functions, lambdas, and binding adapters can make any API easier to use.