nhaarman / acorn

Mastering Android navigation :chipmunk:
https://nhaarman.github.io/acorn
Apache License 2.0
181 stars 7 forks source link

Press back twice to close the activity #161

Closed manueldidonna closed 4 years ago

manueldidonna commented 4 years ago

I'm having some trouble to understand how the back press is handled by navigators

class Navigator : CompositeStackNavigator {
    // I don't know why this function will always return true but the activity will be closed anyway
    override fun onBackPressed(): Boolean {
          super.onBackPressed()
          return true
    }
}

I also tried to register an OnBackPressedCallback on backPressDispatcher but it doesn't work.

How could I achieve a back twice to close behavior?

nhaarman commented 4 years ago

An Activity's onBackPressed() normally finishes the Activity.
The AcornActivity overrides Activity.onBackPressed() to prevent the Activity from closing and allowing the Navigator to handle back press events, using the OnBackPressListener.

If the Navigator returns true in its onBackPressed() function, it is assumed the Navigator successfully handled the event and no further action is necessary. If it returns false, it is assumed the Navigator could not handle the back press, and the Activity's own onBackPressed() function is called, finishing the Activity: https://github.com/nhaarman/acorn/blob/dc0e051402cf24eefb369d3efb480cfa5e95ef56/ext/acorn-android/src/main/java/com/nhaarman/acorn/android/AcornActivity.kt#L178-L183

Thus, in short, returning true from the root Navigator's onBackPressed() will not finish the Activity, returning false will finish it.

nhaarman commented 4 years ago

Giving it a thought, https://github.com/nhaarman/acorn/pull/144 introduced routing the back press to the currently active ViewController. If your ViewController implements the OnBackPressedListener interface, you have the control to intercept the event there.

I would probably handle such a requirement here, since you might want to show a notification to the user on the first back press. This will result in a similar setup as if you were to add a button to exit the app:

interface MyContainer {

   fun setOnBackPressListener(f: () -> Unit)
}

class MyViewController(...) : ViewController, MyContainer, OnBackPressedListener {

  private var onBackPressedListener: (() -> Unit)? = null

  override fun setOnBackPressListener(f: () -> Unit) {
    onBackPressedListener = f
  }

  private var backPressCount = 0
  override fun onBackPressed() : Boolean {
    backPressCount++
    if(backPressCount < 2) { 
      // Return true here to 'claim' the back press event and prevent the Activity from being finished
      return true 
    }

    onBackPressedListener?.invoke()
    // Always return true here, we're manually handling the back event now.
    return true
  }
}

class MyScene(
  private val listener: Events
): Scene<MyContainer> {

  override fun attach(c: MyContainer) {
    c.setOnBackPressListener { listener.onBackPressed() }
  }

  interface Events {

    fun onBackPressed()
  }
}

Then call finish() in the Navigator when you implement the MyScene.Events interface. Something like this should work, I've typed this out without the compiler 🙃

manueldidonna commented 4 years ago

Thus, in short, returning true from the root Navigator's onBackPressed() will not finish the Activity, returning false will finish it.

You can see that my simplified version of fun onBackPressed(): Boolean from a CompositeStackNavigator always return true but the activity will be normally finished

nhaarman commented 4 years ago

That is because you're invoking super.onBackPressed(), which will result in an empty stack in the CompositeStackNavigator, finishing the Activity because of that (Navigator.Events.finished() will be invoked).

manueldidonna commented 4 years ago

I got it, is there a way to avoid it happening without rewriting the entire navigator implementation?

nhaarman commented 4 years ago

I've edited my comment above with a sample, does that help?

nhaarman commented 4 years ago

An easier sample without the manual routing through the Scene (which might work well enough as well):

interface MyContainer : Container

class MyViewController(...) : ViewController, MyContainer, OnBackPressedListener {

  private var backPressCount = 0
  override fun onBackPressed() : Boolean {
    backPressCount++
    if(backPressCount < 2) { 
      // Return true here to 'claim' the back press event and prevent the Activity from being finished
      return true 
    }

    // Return false here, allowing the Navigator to handle the back press.
    return false
  }
}
manueldidonna commented 4 years ago

An easier sample without the manual routing through the Scene (which might work well enough as well)

The scene could be present many times in the stack, and the back twice behavior should happen only when that scene is the last one.

Anyway I really apprecciate your work, using Acorn is a pleasure (far better than fragments). I love how you have separate the responsabilities of ui, data managment and navigation into several components but I think that Navigators are the least flexible components. This kind of issue proves it. I have to rewrite their implementation for every minimal behavior change

manueldidonna commented 4 years ago

Here is my solution. I hope this could be helpful

class Navigator : Navigator.Events {
    // true by default
    var isRootSceneActive = true 

    var onLastBackPressListener: (() -> Boolean)? = null

    // this listener is added to every child navigator
    override fun scene(scene: Scene<out Container>, data: TransitionData?) {
        isRootSceneActive = **some black magic based on the current scene**
    }

    override fun onBackPressed(): Boolean {
         if (!isRootSceneActive || onLastBackPressListener?.invoke() == false)
             return super.onBackPressed()
         else
             return true
     }
}

class Activity {
    override fun onCreate(savedInstanceState: Bundle?) {
        navigator().onLastBackPressListener = { **back press twice logic** }
    }

    override fun onDestroy() {
        // don't leak activity
        navigator().onLastBackPressListener = null
    }

}