AndroidX Fragment Result API - A deep dive

08 June 2020
#Android#AndroidX#Fragment

Sharing data between Fragments in an Android Application has always been a boring task to do. It is because there is no standard way to do it elegantly. One may use interface and the host Activity as the bridge, others may use ViewModel1. But still, it leaves an un-answered question about the best practice for this use case.

Lately, AndroidX Fragment 1.3.0 (still alpha at the time I publish this post) introduces a new practice to share data between Fragments2 3. These new APIs can end the war, so let's explore them.

The samples

The simplest use case would be to create the so-called master/detail UI. In the sample below, we have a Master Fragment and a Detail Fragment. Once the user clicks the button in the Master Fragment, it generates a UUID and send that UUID to the Detail Fragment, just like how you would build an App that opens an email detail when the user clicks the email title.

Source code

MasterFragment.kt
class MasterFragment : Fragment(R.layout.master_fragment) {

  override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
    masterButton.setOnClickListener {
      val newUuid = UUID.randomUUID().toString()
      masterText.text = newUuid
      setFragmentResult(DetailFragment.REQUEST_KEY, Bundle().apply {
        putString(DetailFragment.DATA_KEY, newUuid)
      })
    }
  }
}
DetailFragment.kt
class DetailFragment : Fragment(R.layout.detail_fragment) {

  override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
    setFragmentResultListener(REQUEST_KEY) { _, bundle ->
      val content = bundle.getString(DATA_KEY, "Not Found")
      detailText.text = content
    }
  }

  companion object {
    const val REQUEST_KEY = "requestKey"
    const val DATA_KEY = "dataKey"
  }
}

Understand the code

The result looks good. The code looks simple enough. The practice looks promising. Now let's take a look on what it does.

First, the mechanism looks similar to a pub/sub coupling where the DetailFragment subscribes to the result by registering a FragmentResultListener with a specific key:

Register a FragmentResultListener from the Detail Fragment
setFragmentResultListener(REQUEST_KEY) { requestKey, bundle ->
  // TODO consume the result for the `REQUEST_KEY`
}

The MasterFragment then using that same requestKey to send the data to the DetailFragment:

Send data to the Detail Fragment
setFragmentResult(DetailFragment.REQUEST_KEY, dataBundle)

Note that the method accepts a Bundle so you can pass anything that can be stored in a Bundle. Also, it's worth notice from the document1 that

You can have only a single listener and result for a given key.

Now let's take a further look about how it was implemented. We gonna check out the document, and then the source code step by step.

First, it seems that all the jobs are handled by the FragmentManager3:

Starting with Fragment 1.3.0-alpha04, each FragmentManager implements FragmentResultOwner. This means that a FragmentManager can act as a central store for fragment results. This change allows separate fragments to communicate with each other by setting fragment results and listening for those results without requiring fragments to have direct references to each other.

It is now clear that the main actor is FragmentManager and it implements the FragmentResultOwner interface. We can take a look at the source code of the FragmentResultOwner to understand the API roughly.

Going to the detail, we can have a quick memo about the implementation:

  • From the receiver side (the Fragment that registers the FragmentResultListener): The FragmentResultListener will be wrapped by a LifecycleAwareResultListener to ensure that it will be notified only when the LifecycleOwner in the same registration call is at least started:
FragmentResultOwner#setFragmentResultListener
void setFragmentResultListener(
    @NonNull String requestKey,
    @NonNull LifecycleOwner lifecycleOwner, 
    @NonNull FragmentResultListener listener
);
  • The FragmentResultListener will be meaningless and harmless if it is registered after the LifecycleOwner is destroyed.
  • From the sender side (the Fragment that sends the result): if there is existing listener, and the LifecycleOwner bound to it is at least started (e.g. the Fragment is started), the receiver will get the result immediately/synchronously. Otherwise, the receiver will get the result when it starts.

If you wonder why the signature of the method call at the top of this post doesn't look the same like the one defined by the FragmentResultOwner, it is because what we used before is the extension method brought to us by the androidx:fragment:fragment-ktx package:

Fragment.kt
inline fun Fragment.setFragmentResultListener(
    requestKey: String,
    crossinline listener: ((resultKey: String, bundle: Bundle) -> Unit)
) {
    parentFragmentManager.setFragmentResultListener(
      requestKey, 
      lifecycleOwner = this /* the Fragment */, 
      listener
    )
}

Without using the fragment-ktx, you will need to use the parentFragmentManager:

DetailFragment.kt
parentFragmentManager.setFragmentResultListener(RESULT_KEY, this /* LifecycleOwner */,
      FragmentResultListener { requestKey, result -> TODO("Consume the result") })

The data flow can be summaried in the chart below:

Next, let's think about a slightly more advance use case:

In this example, the MasterFragment is now a screen that requires authorization. When the user clicks the Master button, it opens a dialog that allows user to sign in. Once authorized, the dialog sends the authorization information back to the MasterFragment. It seems to be a legit use case.

What we would do without the new fragment result sharing mechanism? I can think of using an Authorization Activity with the method startActivityForResult, or using the targetFragment mechanism: you open the AuthorizationFragment from the MasterFragment, set the target Fragment of the AuthorizationFragment to be the MasterFragment. And once the authorization finishes, from the AuthorizationFragment it uses the targetFragment to send the authorization result back. Since the AuthorizationFragment doesn't know about the implementation detail of the targetFragment, you may need to define an interface here to help.

How can we do this using the new API? Let's say we have 2 Fragments: the MasterFragment that requires authorization, and the AuthDialogFragment that does the authorization.

Consider the code below:

MasterFragment.kt
class MasterFragment : Fragment(R.layout.master_fragment) {

  companion object {
    val requestKey = UUID.randomUUID().toString()
  }

  override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
    childFragmentManager.setFragmentResultListener(requestKey, this) { _, bundle ->
      val userName = bundle.getString(AuthDialogFragment.RESULT_AUTH_USER, "Unknown")
      masterText.text = "Auth user: $userName"
      Toast.makeText(requireContext(), "Auth successfully!", Toast.LENGTH_LONG).show()
      childFragmentManager.clearFragmentResultListener(requestKey)
    }

    masterButton.setOnClickListener {
      childFragmentManager.openAuthDialog(requestKey)
    }
  }
}
AuthDialogFragment.kt
class AuthDialogFragment : AppCompatDialogFragment() {

  override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
    // First, get the resultKey from the caller.
    val resultKey: String? = arguments?.getString(EXTRA_RESULT_KEY, null)
    if (resultKey.isNullOrEmpty()) {
      dismiss()
      return
    }

    // Next, once user clicks "Auth", access the auth service and get the result.
    authButton.setOnClickListener {
      val userName = userName.text.toString()
      val password = password.text.toString()
      if (userName == password) { // ugly auth service, don't try at home
        authButton.text = "Authorizing..."
        view.postDelayed(1500) { // the auth service is running hard and securely
          setFragmentResult(resultKey, Bundle().apply {
            putString(RESULT_AUTH_USER, userName)
          })
          dismiss()
        }
      }
    }
  }

  companion object {

    const val EXTRA_RESULT_KEY = "extra_result_key"
    const val RESULT_AUTH_USER = "result_auth_user"
  }
}

And the extension function to open the AuthDialogFragment

fun FragmentManager.openAuthDialog(resultKey: String) {
  val existing = findFragmentByTag("AuthDialogFragment")
  if (existing != null) commitNow { remove(existing) }
  val newDialog = AuthDialogFragment().apply {
    val bundle = Bundle()
    bundle.putString(AuthDialogFragment.EXTRA_RESULT_KEY, resultKey)
    arguments = bundle
  }
  newDialog.show(this, "AuthDialogFragment")
}

Looks pretty straight forward right? One good thing about this new API is that, it survives the configuration change. The implementation suggests us that, if the result sender sends the result back during a configuration changes (and assume that the result receiver is now destroyed and will be recreated soon), the result will be stored to a map and this map will also be saved/restore during the configuration change. Once the result receiver is recreated, it just need to re-register the FragmentResultListener for the same requestKey to get the result back.

In our example above, the auth service is quite ugly and doesn't take a lot of time to finish. But in practice, if the authorization takes time, and at the time it finishes and start sending the data back, user rotates the devices, the authorization result is still safely sent back to the MasterFragment.

Also, the example above, I just introduce another API that is made possible: sending data from child Fragment to the parent Fragment. The API is explained quite detail here and the flow can be seen from the chart below:

Of course, this is not the only way to achieve the authorization flow. We have been dealing with this use case for decades before this API is available, right?

Wrap up

To be honest, this deep dive article is not that deep as I thought/planned. But it also suggests that the API is quite simple, to learn and to use. I think this API is promissing. It is simple, it can help us to solve many work-around in our code base, in much simpler way. While many developers are skepticle to use Fragment, I think that the AndroidX team is doing a lot to make it easier and more reliable for us to use it daily. So let's give it a try.

Happy sending data around!