Skip to main content
Fragment Transitions

Fragment Transitions

·802 words·4 mins

This is the first post in a small post series where I explore how to get transitions working nicely with fragments. This post is all about getting them running.

A couple of months ago I showed a grid to grid transition from an app I’m building called Tivi.

Before I managed to get to that point though, I was stuck on getting any transition to work. In the app I’m using fragment changes for non-boundary changes, so things like expanding data sets and bottom navigation events. When the type of data significantly changes (for instance, drilling down into the details of a single item) I start an activity.

The change you’re seeing above is a fragment replacement. Fragment A is the overview screen, and then when the user clicks on the ‘More’ button, it is replaced with Fragment B which contains the full grid of items.

My existing fragment transaction code looked like this:

supportFragmentManager.beginTransaction()  
    .replace(R.id.home_content, fragment)
    .addToBackStack(null)
    .commit()

Nothing exciting yet.

So I looked up the transition APIs and added some shared elements. In this case the shared elements were the square images containing posters:

supportFragmentManager.beginTransaction()  
    .replace(R.id.home_content, fragment)
    .addToBackStack(null)
    .apply {
      for (view in sharedElements) {
        addSharedElement(view)
      }
    }
    .commit()

And then I set a shared element transition on the entering fragment (Fragment B). In this case I used a ChangedBounds to get started since the views were just moving from point A ➡️ B and changing size.

class GridFragment : Fragment() {
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    sharedElementEnterTransition = ChangeBounds()
  }
}

At this point I optimistically expected it to just work, but I actually got this:

So it did not work at all on the enter, but worked fine on the return transition. It’s a start.

Postpone
#

At this point I remembered something which my colleague, Nick Butcher, had mentioned to me in the past while he was writing transitions for Plaid. Specifically around having to postpone transitions until your views are ready (laid out, data loaded, etc).

So off I went and added postponing and starting to my fragments:

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
  // View is created so postpone the transition
  postponeEnterTransition()

  viewModel.liveList.observe(this) {
    controller.setList(it)

    // Data is loaded so we're ready to start our transition
    startPostponedEnterTransition()
  }
}

We have to do this for both the entering and exiting fragments so that both the enter (click) and the exit (back button) work as expected.

But still no enter transition. 🤦

Reordering
#

I cheated a bit here and reached out to Mr Activity-Fragment-Transitions, George Mount, from the Android team. He pointed out what I needed to do to get it working: reordering.

It turns out that you have to enable reordering fragment transactions for postponed fragment transitions to work. Luckily this is pretty simple to do (but easily forgotten):

supportFragmentManager.beginTransaction()
    .setReorderingAllowed(true)
    .replace(R.id.home_content, fragment)
    .addToBackStack(null)
    .apply {
      for (view in sharedElements) {
        addSharedElement(view)
      }
    }
    .commit()

After that the enter transition occasionally worked, but most of the time I just got a crossfade. At least the transitions were running some of the time now though.

Waiting for your parent
#

Spurred on with the fact that my transition was at least running sometimes, I started debugging. I deduced that the times which the transition did run, the views had been laid out (isLaidOut == true) and drawn already.

So the final piece of the puzzle here was… waiting.

If you look back at our postpone calls, we’re actually starting the postponed transition as soon as the data has loaded. We need to give the views a chance to be updated, laid out, and more importantly, drawn before we start the transition.

So our new postpone calls become:

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
  // View is created so postpone the transition
  postponeEnterTransition()

  viewModel.liveList.observe(this) {
    controller.setList(it)

    // Data is loaded so lets wait for our parent to be drawn
    (view?.parent as? ViewGroup)?.doOnPreDraw {
      // Parent has been drawn. Start transitioning!
      startPostponedEnterTransition()
    }
  }
}

You may wonder why we set the OnPreDrawListener on the parent rather than the view itself. Well that is because your view may not actually be drawn, therefore the listener would never fire and the transaction would sit there postponed forever. To work around that we set the listener on the parent instead, which will (probably) be drawn.

And voilà, we have a working transition:

Solution, broken down into steps (20% speed)
Solution, broken down into steps (20% speed)

You might be wondering why this does not look like the tweet I included above. I’ll cover that in a later post. ✨⚡. The next post will look at getting window insets working with fragment transitions.