Load Android Fragments Asynchronously

February 9, 2020 · 4 min read

If you are like me, you may occasionally receive design requests to build a more complex screen in an Android app. So, you set about building the layout, making it look just like the design mocks, and everything works great, except for one small problem. Animating to that screen either stutters or hangs until the screen is loaded. You may have gone back to the layout and refactored everything to be in one ConstraintLayout as Google suggests doing, but it still takes a bit of time to inflate the view because it's so large. Another trick you may have tried if it's a long scrolling view is using a library like Epoxy to only load what is currently visible in the scrolled section. This can work in some cases, but it may cause stuttering or undesired visual artifacts while scrolling due to loading each view on the screen as it appears. It may also just be too much time and effort to refactor a complex view in this way.

Thankfully, Google gave us an option to inflate a view asynchronously. It's called, unsurprisingly, AsyncLayoutInflater and is available both in the v4 support library and is part of AndroidX. Until I discovered this, I thought that all UI (including inflating views) had to be run on the main thread. It turns out that entire layouts can actually be inflated on a background thread with very little change to existing code. There are a few caveats to be aware of before going down this route, but this method works for many different situations.

Caveats

  1. AsyncLayoutInflater does not support inflating layouts that contain fragment. However, a layout can be inflated within a fragment.
  2. The layout's parent must have a thread-safe implementation of generateLayoutParams(AttributeSet)
  3. All the views being constructed as part of inflation must not create any Handlers or otherwise call myLooper()
  4. When a layout is inflated with AsyncLayoutInflator it does not automatically get added to the parent (i.e. attachToRoot is false)

Some of these caveats may sound like a big deal, but in practice they are not typically an issue.

Converting an existing fragment

The first thing to note if you are converting an existing fragment to inflate asynchronously, is that you can't just swap out LayoutInflator for AsyncLayoutInflator. The reason being that onCreateView, where the layout inflation occurs, expects a view to be returned. The solution to both asynchronously inflating a layout for a fragment and returning a view from onCreateView is to inflate a small view that the larger view can be attached to. To start with, I created loading_view.xml with a ConstraintLayout and a ProgressBar so there isn't just an empty screen while loading the rest of the content:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <ProgressBar
        android:id="@+id/progressBar"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

All that is left then is to inflate this view, asynchronously inflate the large layout and attach it to the loading_view. We should also hide the ProgressBar once the large layout has been loaded.

override fun onCreateView(
    inflater: LayoutInflater,
    container: ViewGroup?,
    savedInstanceState: Bundle?
): View? {
    super.onCreateView(inflater, container, savedInstanceState)

    // get view binding
    val v = inflater.inflate(R.layout.loading_view, container, false)
    val asyncLayoutInflater = context?.let { AsyncLayoutInflater(it) }
    asyncLayoutInflater?.inflate(R.layout.large_layout, null) { view, resid, parent ->
        (v as? ViewGroup)?.addView(view) // add large view to already inflated view
        progressBar.visibility = View.GONE // hide progress bar
    }

    return v
}

And, there you have it. A way to load a fragment's layout asynchronously.