0

I'm trying to implement back button handling on Android using CoRedux for my Redux store. I did find one way to do it, but I am hoping there is a more elegant solution because mine seems like a hack.

Problem

At the heart of the problem is the fact returning to an Android Fragment is not the same as rendering that Fragment for the first time.

The first time a user visits the Fragment, I render it with the FragmentManager as a transaction, adding a back stack entry for the "main" screen

fragmentManager?.beginTransaction()
   ?.add(R.id.myFragmentContainer, MyFragment1())
   ?.addToBackStack("main")?.commit()

When the user returns to that fragment from another fragment, the way to get back to it is to pop the back stack:

fragmentManager?.popBackStack()

This seems to conflict with Redux principles wherein the state should be enough to render the UI but in this case the path TO the state also matters.

Hack Solution

I'm hoping someone can improve on this solution, but I managed to solve this problem by introducing some state that resides outside of Redux, a boolean called skipRendering. You could call this "ephemeral" state perhaps. Initialized to false, skipRendering gets set to true when the user taps the back button:

fun popBackStack() {
    fragmentManager?.popBackStack()
    mapViewModel.dispatchAction(MapViewModel.ReduxAction.BackButton)
    skipRendering = true
}

Dispatching the back button to the redux store rewinds the redux state to the prior state as follows:

return when (action) {
// ...
    ReduxAction.BackButton -> {
        state.pastState
            ?: throw IllegalStateException("More back taps processed than past state frames")
    }
}

For what it's worth, pastState gets populated by the reducer whenever the user requests to visit a fragment from which the user can subsequently tap back.

return when (action) {
// ...
    ReduxAction.ShowMyFragment1 -> {
        state.copy(pastState = state, screenDisplayed = C)
    }
}

Finally, the render skips processing if skipRendering since the necessary work of calling fragmentManager?.popBackStack() was handled before dispatching the BackButton action.

I suspect there is a better solution which uses Redux constructs for example a side effect. But I'm stuck figuring out a way to solve this more elegantly.

Michael Osofsky
  • 11,429
  • 16
  • 68
  • 113
  • not too sure about redux but you can override the back button on Android Kotlin by calling override fun onBackPressed(){ //code what you want to do here } within whichever activity/fragment/dialog is currently active – Rowan Berry Dec 18 '19 at 00:37
  • Thanks @RowanBerry, that's actually what I'm already doing. The Activity overrides `onBackPressed()` and calls the Fragment's `popBackStack()` function above. Sorry I didn't clarify that. – Michael Osofsky Dec 18 '19 at 01:00
  • My recommendation would be to not use Redux if it makes simple things like a back click tricky. – EpicPandaForce Dec 18 '19 at 19:07

1 Answers1

0

To solve this problem, I decided to accept that the conflict cannot be resolved directly. The conflict is between Redux and Android's native back button handling because Redux needs to be master of the state but Android holds the back stack information. Recognizing that these two don't mix well, I decided to ditch Android's back stack handling and implement it entirely on my Redux store.

data class LLReduxState(
    // ... 
    val screenBackStack: List<ScreenDisplayed> = listOf(ScreenDisplayed.MainScreen)
)

sealed class ScreenDisplayed {
    object MainScreen : ScreenDisplayed()
    object AScreen : ScreenDisplayed()
    object BScreen : ScreenDisplayed()
    object CScreen : ScreenDisplayed()
}

Here's what the reducer looks like:

private fun reducer(state: LLReduxState, action: ReduxAction): LLReduxState {
    return when (action) {
        // ...

        ReduxAction.BackButton -> {
            state.copy(screenBackStack = mutableListOf<ScreenDisplayed>().also {
                it.addAll(state.screenBackStack)
                it.removeAt(0)
            })
        }

        ReduxAction.AButton -> {
            state.copy(screenBackStack = mutableListOf<ScreenDisplayed>().also {
                it.add(ScreenDisplayed.AScreen)
                it.addAll(state.screenBackStack)
            })
        }

        ReduxAction.BButton -> {
            state.copy(screenBackStack = mutableListOf<ScreenDisplayed>().also {
                it.add(ScreenDisplayed.BScreen)
                it.addAll(state.screenBackStack)
            })
        }

        ReduxAction.CButton -> {
            state.copy(screenBackStack = mutableListOf<ScreenDisplayed>().also {
                it.add(ScreenDisplayed.CScreen)
                it.addAll(state.screenBackStack)
            })
        }
    }
}

In my fragment, the Activity can call this API I exposed when the Activity's onBackPressed() gets called by the operating system:

fun popBackStack() {
    mapViewModel.dispatchAction(MapViewModel.ReduxAction.BackButton)
}

Lastly, the Fragment renders as follows:

private fun render(state: LLReduxState) {
    // ...


    if (ScreenDisplayed.AScreen == state.screenBackStack[0]) {
        fragmentManager?.beginTransaction()
            ?.replace(R.id.llNavigationFragmentContainer, AFragment())
            ?.commit()
    }

    if (ScreenDisplayed.BScreen == state.screenBackStack[0]) {
        fragmentManager?.beginTransaction()
            ?.replace(R.id.llNavigationFragmentContainer, BFragment())
            ?.commit()
    }

    if (ScreenDisplayed.CScreen == state.screenBackStack[0]) {
        fragmentManager?.beginTransaction()
            ?.replace(R.id.llNavigationFragmentContainer, CFragment())
            ?.commit()
    }
}

This solution works perfectly for back button handling because it applies Redux in the way it was meant to be applied. As evidence, I was able to write automation tests which mock the back stack as follows by setting the initial state to one with the deepest back stack:

        LLReduxState(
            screenBackStack = listOf(
                ScreenDisplayed.CScreen,
                ScreenDisplayed.BScreen,
                ScreenDisplayed.AScreen,
                ScreenDisplayed.MainScreen
            )
        )

I've left some details out which are specific to CoRedux.

Michael Osofsky
  • 11,429
  • 16
  • 68
  • 113
  • 1
    I'm now eagerly awaiting what'll happen to your app when you test for https://stackoverflow.com/questions/49046773/singleton-object-becomes-null-after-app-is-resumed/49107399#49107399 when you are on either one of FragmentB or FragmentC – EpicPandaForce Dec 18 '19 at 19:08
  • Hopefully the use of ViewModels will protect us against configuration changes like these. Although I'm not sure if returning from the background is considered a configuration change. – Michael Osofsky Dec 18 '19 at 23:17
  • This is not a configuration change, this is a completely different mechanism. – EpicPandaForce Dec 19 '19 at 06:55