WindowInsets — Listeners to layouts

WindowInsets — Listeners to layouts

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 think I’ve come up with some methods which make handling insets easier (hopefully).

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>

As you can see, this is only the beginning of what we need to do though. Since the Activity is now being 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 or margin 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-ui. To do that, we can set an OnApplyWindowInsetsListener which adds the necessary bottom padding to the view, ensuring that it’s content isn’t obscured.

bottomNav.setOnApplyWindowInsetsListener { view, insets ->
    view.updatePadding(bottom = insets.systemWindowInsetBottom)
    insets
}

OK great, we’ve now correctly handled the bottom system window inset. But later we decide to add some padding in the layout, maybe for aesthetic reasons:

<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.

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 subsequent dispatch we’re going to be adding more and more bottom padding. Not what we want. 🤦

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
}

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 the doOnApplyWindowInsets() extension method. This is a wrapper around setOnApplyWindowInsetsListener() which generalises the pattern above:

fun View.doOnApplyWindowInsets(f: (View, WindowInsets, InitialPadding) -> Unit) {
    // Create a snapshot of the view's padding state
    val initialPadding = recordInitialPaddingForView(this)
    // Set an actual OnApplyWindowInsetsListener which proxies to the given
    // lambda, also passing in the original padding state
    setOnApplyWindowInsetsListener { v, insets ->
        f(v, insets, initialPadding)
        // Always return the insets, so that children can also use them
        insets
    }
    // request some insets
    requestApplyInsetsWhenAttached()
}

data class InitialPadding(val left: Int, val top: Int, 
    val right: Int, val bottom: Int)

private fun recordInitialPaddingForView(view: View) = InitialPadding(
    view.paddingLeft, view.paddingTop, view.paddingRight, view.paddingBottom)

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:

fun View.requestApplyInsetsWhenAttached() {
    if (isAttachedToWindow) {
        // We're already attached, just request as normal
        requestApplyInsets()
    } else {
        // We're not attached to the hierarchy, add a listener to
        // request when we are
        addOnAttachStateChangeListener(object : OnAttachStateChangeListener {
            override fun onViewAttachedToWindow(v: View) {
                v.removeOnAttachStateChangeListener(this)
                v.requestApplyInsets()
            }

            override fun onViewDetachedFromWindow(v: View) = Unit
        })
    }
}

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. First, the logic lives away from our layouts, meaning that it is very easy to forget about. Secondly, 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). So lets create an attribute to do this for us:

@BindingAdapter("paddingBottomSystemWindowInsets")
fun applySystemWindowBottomInset(view: View, applyBottomInset: Boolean) {
    view.doOnApplyWindowInsets { view, insets, padding ->
        val bottom = if (applyBottomInset) insets.systemWindowInsetBottom else 0
        view.updatePadding(bottom = padding.bottom + insets.systemWindowInsetBottom)
    }
}

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:

@BindingAdapter(
    "paddingLeftSystemWindowInsets",
    "paddingTopSystemWindowInsets",
    "paddingRightSystemWindowInsets",
    "paddingBottomSystemWindowInsets",
    requireAll = false
)
fun applySystemWindows(
    view: View,
    applyLeft: Boolean,
    applyTop: Boolean,
    applyRight: Boolean,
    applyBottom: Boolean
) {
    view.doOnApplyWindowInsets { view, insets, padding ->
        val left = if (applyLeft) insets.systemWindowInsetLeft else 0
        val top = if (applyTop) insets.systemWindowInsetTop else 0
        val right = if (applyRight) insets.systemWindowInsetRight else 0
        val bottom = if (applyBottom) insets.systemWindowInsetBottom else 0

        view.setPadding(
            padding.left + left,
            padding.top + top,
            padding.right + right,
            padding.bottom + bottom
        )
    }
}

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 as instructed. 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.