16

I am using the Navigation Architecture Component for Android.

For one of my fragments I wish to intercept "back" and "up" navigation, so that I can show a confirmation dialog before discarding any unsaved changes by the user. (Same behavior as the default Calendar app when you press back/up after editing event details)

My current approach (untested) is as follows:

For "up" navigation, I override onOptionsItemSelected on the fragment:

override fun onOptionsItemSelected(item: MenuItem?): Boolean {
    if(item?.itemId == android.R.id.home) {
        if(unsavedChangesExist()) {
            // TODO: show confirmation dialog
            return true
        }
    }
    return super.onOptionsItemSelected(item)
}

For "back" navigation, I created a custom interface and callback system between the fragment and its activity:

interface BackHandler {
    fun onBackPressed(): Boolean
}

class MainActivity : AppCompatActivity() {
    ...

    val backHandlers: MutableSet<BackHandler> = mutableSetOf()

    override fun onBackPressed() {
        for(handler in backHandlers) {
            if(handler.onBackPressed()) {
                return
            }
        }
        super.onBackPressed()
    }

    ...
}

class MyFragment: Fragment(), BackHandler {
    ...

    override fun onAttach(context: Context) {
        super.onAttach(context)
        if (context is MainActivity) {
            context.backHandlers.add(this)
        }
    }

    override fun onDetach() {
        (activity as? MainActivity)?.backHandlers?.remove(this)
        super.onDetach()
    }

    override fun onBackPressed(): Boolean {
        if(unsavedChangedExist()) {
            // TODO: show confirmation dialog
            return true
        }
    }

    ...
}

This is all pretty gross and boilerplatey for such a simple thing. Is there a better way?

James
  • 3,597
  • 2
  • 39
  • 38

7 Answers7

31

As of androidx.appcompat:appcompat:1.1.0-beta01, in order to intercept the back button with navigation component you need to add a callback to the OnBackPressedDispatcher. This callback has to extend OnBackPressedCallback and override handleOnBackPressed. OnBackPressedDispatcher follows a chain of responsibility pattern to handle the callbacks. In other words, if you set your callback as enabled, only your callback will be executed. Otherwise, OnBackPressedDispatcher will ignore it and proceed to the next callback, and so on until it finds an enabled one (this might be useful when you have more than one callback, for instance). More info on this here.

So, in order to show your dialog, you would have to do something similar to this in your Fragment:

override fun onCreate(savedInstanceState: Bundle?) {
  super.onCreate(savedInstanceState)

    val callback = requireActivity().onBackPressedDispatcher.addCallback(this) {
        // Show your dialog and handle navigation
    }

    // you can enable/disable the callback here by setting
    // callback.isEnabled = true/false. Or just enable it in the lambda.
}

That addCallback method takes in a LifecycleOwner, and it will make sure that the callback is added when LifecycleOwner reaches the STARTED stage. Not only that, but this also makes it so that the callback is removed when its associated LifecycleOwner is destroyed.

As for the up button, it seems like (at least for now) there aren't many possibilities. The only option I could find up until now that uses the navigation component is to add a listener for the navigation itself, which would handle both buttons at the same time:

navController.addOnDestinationChangedListener { navController, destination ->
  if (destination.id == R.id.destination) {
    // do your thing
  }
}

Regardless, this has the caveat of allowing the activity or fragment where you add the listener to know about destinations it might not be supposed to.

Ricardo Costeira
  • 3,171
  • 2
  • 23
  • 23
  • 1
    am i missing something? the function `handleOnBackPressed()` does not return a value so how can i tell the system whether i handled the callback or not? – or_dvir May 20 '19 at 07:45
  • I'm not sure I understand your problem. You tell the system you want to handle the back button press by adding your own callbacks to `onBackPressedDispatcher`. If you set the callbacks you add as enabled, the system will call them. – Ricardo Costeira May 20 '19 at 09:22
  • 1
    issue 1 - you say that we can either return true or false and depending on that let default callbacks handle it or we need to handle navigation manually. where would we return this boolean value? issue 2 - we want to confirm the back button press right? if the user confirms, we want to exit but if he doesnt, we want to stay (as in NOT invoke default back press). therefore we need to let the system know "hey you can stop processing here, i already took care of it" or "please handle the back press like you normally would". where and how can we do this? – or_dvir May 20 '19 at 12:27
  • OK, I think I know what's going on. Since the latest dependency, the framework changed a little bit :P I will update the answer accordingly. – Ricardo Costeira May 20 '19 at 13:02
  • please let us know when you do – or_dvir May 21 '19 at 06:36
  • 2
    If you press the HOME button and then return to where you use `requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, callback)`, the `handleOnBackPressed()` will not be invoked – S1ngoooor May 23 '19 at 13:48
  • @SeanZom Yes, you're right. `viewLifecycleOwner` must get destroyed somehow (and consequentially, the callback), although the Home button doesn't trigger a call to the fragment's `onDestroy` - it only triggers `onStop`. Weird. I don't think this is supposed to happen... Regardless, if you move the callback setup to `onResume`, it'll work. I'll have to investigate this later. – Ricardo Costeira May 23 '19 at 14:23
  • 1
    @RicardoCosteira I did some debugging and found out that an extra unknown `OnBackPressedCallback` with `enabled` was added to the top of the stack instead of triggering `onDestroy`, so the callback that I added was intercepted, and this happened in `onStart()` – S1ngoooor May 23 '19 at 15:13
  • Ok, didn't expect that. There's no mention of this in the documentation... Guess that's what you get for working with alpha libs. Still, it explains why adding the callback in `onResume` works. I'll update the answer when I can. – Ricardo Costeira May 23 '19 at 15:40
  • 1
    @RicardoCosteira Yes, I think that is a bug. Adding a new one in `onResume` is a workaround at the moment even though the old callback was still in the queue. – S1ngoooor May 23 '19 at 23:11
  • @S1ngoooor I've updated the answer. I'll try to find out if this is really a bug or just expected behavior. – Ricardo Costeira May 24 '19 at 09:45
  • @S1ngoooor version 1.1.0-beta01 fixed the bug. – Ricardo Costeira Jun 10 '19 at 00:46
  • @RicardoCosteira Good to hear and thanks for sharing! – S1ngoooor Jun 10 '19 at 14:48
  • It should be added in the onAttach lifecycle – Salam El-Banna Feb 16 '23 at 21:41
9

With the navigation architecture components, you can do something like this:

  1. Tell your activity to dispatch all up clicks on the home button(back arrow) to anyone listening for it. This goes in your activity.
override fun onOptionsItemSelected(item: MenuItem): Boolean {
     if (item.itemId == android.R.id.home) {
         onBackPressedDispatcher.onBackPressed()
         return true
     }
     return super.onOptionsItemSelected(item)
}
  1. Then in your fragments, consume the events like so
override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        requireActivity().onBackPressedDispatcher.addCallback(this) {
           if (*condition for showing dialog here*) {
               // Show dialog
           } else {
               // pop fragment by calling function below. Analogous to when the user presses the system UP button when the associated navigation host has focus.
               findNavController().navigateUp()
           }
        }
    }
11m0
  • 216
  • 7
  • 16
4

for up navigation simply override onOptionsItemSelected()

override fun onOptionsItemSelected(item: MenuItem): Boolean =
    when (item.itemId) {
        android.R.id.home -> {
            showDialog() // show your dialog here
            true
        }
        else -> super.onOptionsItemSelected(item)
}
2

You can used following function in onAttach in your fragment to override the onBackPressed() with help of the navigation components.

requireActivity().onBackPressedDispatcher.addCallback(
    this,
    object : OnBackPressedCallback(true) {
        override fun handleOnBackPressed() {
            if (YOUR_CONDITION) {
                // Do something here
            } else {
                if (!findNavController().navigateUp()) {
                    if (isEnabled) {
                        isEnabled = false
                        requireActivity().onBackPressedDispatcher.onBackPressed()
                    }
                }
            }
        }
    }
)
Dilanka Laksiri
  • 408
  • 3
  • 12
0

If you're using it with AppBarConfiguration, with the latest release there is now an AppBarConfiguration.OnNavigateUpListener. Refer to the below link for more information

https://developer.android.com/reference/androidx/navigation/ui/AppBarConfiguration.OnNavigateUpListener

Onur D.
  • 515
  • 3
  • 11
0

if u override onBackPressed() in your activity must ensure that it should call super.onBackOnBackPressed() otherwise these dispatcher wont trigger

Abhijith mogaveera
  • 918
  • 10
  • 16
0

With back click, there are possibilities that we want to stay on the same fragment and do some stuff or while going back to the previous fragment do some stuff. For such case, I've created below function and I'm using it with navigation component.

//you can keep this function inside a BaseFragment or a separate utility class
    fun onBackPressed(block: () -> Unit, stay: Boolean = false) {
        activity?.onBackPressedDispatcher?.addCallback {
            if (stay) {
                //ignore back click and do the required operations
                block()
            } else {
                block()
                findNavController().navigateUp()
            }
        }
    }

usage:

onBackPressed {
            showToast()
        }
Vishal Naikawadi
  • 419
  • 6
  • 11