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.
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.
Related
The bounty expires in 12 hours. Answers to this question are eligible for a +200 reputation bounty.
martin1337 is looking for a canonical answer:
Need to fix this asap - I cant simulate it by myself by any means. I just got this log from one of many users who are affected by this bug.
I have interesting issue with my app for some time now. Unfortunately I personally cant simulate this behavior on any of my phones but there are some users who are affected by this.
The issue is that I have Fragment which can be started only by pressing button by user. And this fragment is removed from stack after user exits it.
Fragment is used to open WebView with payment gateway to handle transactions (paying by credit card for items).
What is happening to some users is that if they leave phone and app in background (screen is locked and they are not using phone at all), sometimes when phone wakes up, transaction from like 2 days ago is executed without their knowledge. It will just randomly start that Fragment which was used 2 days ago without any user interaction.
It seems like Fragment just popped without calling constructor with data out of nowhere. The only way how to start this fragment again is to push button "Buy Item" on specific screen. This screen is not even there when user unlocked that phone.
I cant simulate this behavior but its happening to some users and its annoying.
Anyone knows what can be a cause of this and how to prevent that?
Code:
startFragmentForResult(
PurchaseFragment(provider, id, sum.first, sum.second, discountId,
validFrom = validFromDateIso, duration = duration),
object : FragmentResult<Boolean>() {
override fun onFragmentResult(result: Boolean) {
App.log("PurchaseDis: BasketFragment - onFragmentResult - enable back")
mainActivity?.onBackEnabled(true)
removeThis{
App.log("FragmentTest - BasketFragment - onFragmentResult - after remove")
mainActivity?.refreshData()
}
}
})
}
fun startFragmentForResult(f: BaseFragment, receiver: FragmentResult<*>, hidePrevFragment: Boolean = true){
val isStarting = mainActivity?.isFragmentStarting?:false
if (!isStarting) {
mainActivity?.isFragmentStarting = true
fragmentStarting = true
app.sysLog("startFragmentForResult ($debugTitle) -> (${f.debugTitle})")
mainActivity?.startFragmentForResult(f, receiver, hidePrevFragment = hidePrevFragment)
}else if(BuildConfig.DEBUG) App.log("Prevent multiple fragment start")
}
private var fragmentResultReceivers = HashMap<BaseFragment, BaseFragment.FragmentResult<*>>()
fun startFragmentForResult(f: BaseFragment, receiver: BaseFragment.FragmentResult<*>, hidePrevFragment: Boolean = true) {
fragmentResultReceivers[f] = receiver
startFragment(f, hidePrevFragment = hidePrevFragment)
}
fun startFragment(
f: BaseFragment,
allowGoBack: Boolean = true,
hidePrevFragment: Boolean = true) {
App.log("MainActivity: startFragment: ${f.title}")
app.sysLog("startFragment (${f.debugTitle})")
App.log("FragmentArray: ${getFragments().size}")
makeFragmnentTransaction { ft ->
ft.setCustomAnimations(f.animIn, R.animator.fade_out)
val tag = f.javaClass.simpleName
if (allowGoBack && (f.isModal || f.canGoBack)) {
if (hidePrevFragment){
// hide previous fragment
getTopFragment()?.let {
ft.hide(it)
it.onHide()
}
}
ft.add(R.id.content, f, tag)
} else {
fragmentResultReceivers.values.forEach { it.onFragmentResultCanceled() }
fragmentResultReceivers.clear()
ft.replace(R.id.content, f, tag)
}
ft.runOnCommit {
updateUI(f)
}
}
}
private fun makeFragmnentTransaction(body: (FragmentTransaction) -> Unit) {
with(supportFragmentManager.beginTransaction()) {
body(this)
commitAllowingStateLoss()
}
}
fun removeFragment(f: BaseFragment, initDefaultTabIfEmpty: Boolean = true, onCommit: (() -> Unit)? = null) {
app.sysLog("removeFragment (${f.debugTitle})")
makeFragmnentTransaction { ft ->
ft.setCustomAnimations(R.animator.fade_in, f.animOut)
ft.remove(f)
val frgs = getFragments()
val fI = frgs.indexOf(f)
if (fI == frgs.lastIndex && fI > 0) {
// show previous fragment again - only if removed fragment is last one
val af = frgs[fI - 1]
app.sysLog("onShowAgain (${af.debugTitle})")
af.onShowAgain()
ft.show(af)
}
ft.setReorderingAllowed(false)
ft.runOnCommit {
afterFragmentRemoval(f, initDefaultTabIfEmpty)
onCommit?.invoke()
}
}
}
private fun getFragments(): List<BaseFragment> {
return ArrayList<BaseFragment>().apply {
for (e in supportFragmentManager.fragments) {
if (e is BaseFragment)
add(e)
}
}
}
fun getTopFragment(): BaseFragment? = getFragments().lastOrNull()
Maybe worth mentioning. I have data which phones are causing this issue:
"deviceBrand": "samsung",
"deviceModel": "SM-G991B",
"systemSdkInt": 33,
"deviceBrand": "samsung",
"deviceModel": "SM-G990B",
"systemSdkInt": 33,
"deviceBrand": "samsung",
"deviceModel": "SM-G970F",
"systemSdkInt": 31,
All of those devices are Samsung and all of them are Android 11+
UPDATE:
I have new logs from user which is affected by it:
2023/01/13 15:11:59: Purchase successful
2023/01/13 15:11:59: returnFragmentResult (Purchase)
2023/01/13 15:11:59: removeFragment (Purchase)
2023/01/13 15:11:59: onShowAgain (Basket)
2023/01/13 15:11:59: removeFragment (Basket)
2023/01/13 15:11:59: onShowAgain (Tickets)
2023/01/13 15:11:59: FragmentManagerListener: onFragmentDestroyed: Purchase
2023/01/13 15:11:59: FragmentManagerListener: onFragmentDetached: Purchase
2023/01/13 15:12:00: FragmentManagerListener: onFragmentDestroyed: Basket
2023/01/13 15:12:00: FragmentManagerListener: onFragmentDetached: Basket
2023/01/14 18:38:59: App create
2023/01/14 18:39:00: PurchaseFragment.onCreate, args=Bundle[{duration=0, tickets={"tickets":[{"ticket_id":5443,"qty":4}]}, org_id=1208, provider=52, valid_from=null, price=4.23, currency=€}]
2023/01/14 18:39:00: Buy tickets: {"tickets":[{"ticket_id":5443,"qty":4}]}
2023/01/14 18:39:00: proceedWithPreorderId: 9857788, transact: null
2023/01/14 18:39:00: Wait for preorder...
2023/01/14 18:39:08: Purchase successful
I added log to supportFragmentManager listener to log state of each fragment in app. And it seems like after successful purchase, fragments are successfully detached and destroyed in manager but 1 day later as user wakes up phone and app, MainActivity is recreated (onCreate is called "App create") and 1 second later constructor of fragment is randomly out of nowhere called by the system. Even Fragment Manager listener is not detecting that fragment.
Not sure what can be wrong here.
Worth to mention that I've added to MainActivity function to clear backstack every time MainActivity is recreated to remove any fragments hanging there (if there are some):
override fun onCreate(si: Bundle?) {
clearFragmentBackstack()
isRecreated = si != null
if (si == null){
val splashScreen = installSplashScreen()
splashScreen.setKeepOnScreenCondition{ !areDataLoaded }
} else {
App.log("MainActivity: SavedStateTest: onCreate: recreated")
val splashScreen = installSplashScreen()
splashScreen.setKeepOnScreenCondition{ false }
}
super.onCreate(si)
...
}
private fun clearFragmentBackstack(){
if (supportFragmentManager.backStackEntryCount > 0) {
val first =supportFragmentManager.getBackStackEntryAt(0)
supportFragmentManager.popBackStack(first.id, FragmentManager.POP_BACK_STACK_INCLUSIVE)
}
}
when the activity is recreated again, it's normal that the last fragment was in the activity to be recreated but in your situation, it's a payment fragment that shouldn't be started by itself.
so to prevent such scenario.
You can create another status fragment and after payment is done, remove payment fragment from the stack and navigate to the status fragment.
also as we do in our apps. when the user initiates a payment, we are saving user's click time in shared preferences (datastore recently) and before making the payment we are checking for the user's click time, and if it was more than 5 minutes ago, we are not proceeding with the payment.
I have been facing this issue for quite sometime and would like to know a better approach to solve this problem. If you are aware of anything about how to solve it then please let me know.
I am building a project which takes data from an API and then following MVVM architecture I take the Retrofit instance to Repository, and then to ViewModel and further observe it from my fragment.
Now what I am working on currently is Login feature. I will send a number to the API and in response I will receive if the number is registered or not. If it is registered then I would move to the next screen.
Now the problem is that using one of the function in ViewModel I send that number to the API to get the response. And using a variable I observe that response.
Now I create a function which checks if the response was true or false and based on that I am using the logic to move to the next screen, but the issue is the returned value from the function. As LiveData works asynchronously in background it takes some time to return the value and in meantime the function returns the initial value which is false.
Function to verify response
private fun checkNumber(): Boolean {
var valid = false
authRiderViewModel.response.observe(viewLifecycleOwner, Observer {
Timber.d("Response: $it")
if (it.success == true) {
valid = true
}
})
Timber.d("Boolean: $valid")
return valid
}
Moving to next screen code:
binding.btnContinue.setOnClickListener {
val number = binding.etMobileNumber.text.toString().toLong()
Timber.d("Number: $number")
authRiderViewModel.authDriver(number)
if (checkNumber()) {
val action = LoginFragmentDirections.actionLoginFragmentToOtpFragment()
findNavController().navigate(action)
} else {
Toast.makeText(requireContext(), "Number not registered", Toast.LENGTH_SHORT).show()
}
}
So in case I received the true response from the server even then I would not move to the next screen because the initial value I received is false. I have spent few hours trying to fix it and any help would be appreciated. If you need any code let me know in comments. Thanks.
You have four distinct states:
The server returned a positive response
The server returned a negative response
The server failed (e.g., returned a 500, timed out)
You are waiting on the server
You are attempting to model that with two states: true and false. This will not work.
Instead, model it with four states. One approach is called "loading-content-error" and uses a sealed class to represent those states:
sealed class LoginState {
object Loading : LoginState()
data class Content(val isSuccess: Boolean) : LoginState()
object Error : LoginState()
}
Your LiveData (or your StateFlow, once you migrate to coroutines) would be a LiveData<LoginState>. Your observer can then use a when to handle Loading, Content, and Error as needed, such as:
For Loading, display a progress indicator
For Content, do whatever you are doing now with your boolean
For Error, display an error message
Actually, live data observation is an asynchronous operation. You have to code accordingly.
Just calling checkNumber() won't return since is asynchronous instead I give you some ideas to implement in a better way.
Just call the checkNumber when button click inside the check number do this instead of return valid
authRiderViewModel.response.observe(viewLifecycleOwner, Observer {
Timber.d("Response: $it")
if (it.success == true) {
val action = LoginFragmentDirections.actionLoginFragmentToOtpFragment()
findNavController().navigate(action)
} else {
Toast.makeText(requireContext(), "Number not registered", Toast.LENGTH_SHORT).show()
}
})
I want to create a shared view model for communication between MainActivity to fragments.
I decided to use share flow for managing events.
private val _sharedChannel: MutableSharedFlow<SharedEvent> = MutableSharedFlow(
replay = 0,extraBufferCapacity=0,onBufferOverflow = BufferOverflow.SUSPEND)
val sharedChannel = _sharedChannel.asSharedFlow()
I don't need to cache the last event, not even when orientation changes.. so I set "replay = 0"
When I collect the events only in my main activity - everything works fine:
lifecycleScope.launchWhenStarted {
gamePlaySharedViewModel.sharedChannel.collect { event->
SnappLog.log("GamePlayContainer-> sharedChannel EVENT: $event ")
when(event){
GamePlaySharedViewModel.SharedEvent.OnBackPress -> {
onBackPressed()
}
is GamePlaySharedViewModel.SharedEvent.BlockScreen -> {
blockScreen(event.isBlocked)
}
else -> {
}
}
}
}
}
When adding a second subscriber to another fragment - both of the subscribers stop receiving events after the first one (the first event send successfully.. )
what can I do to subscribe for multi MutableSharedFlow?
I've tried to increase the number of "replay" and to change the "onBufferOverflow" - nothing seems to work..
Consider a tabbar with "home" and "profile" buttons, when i click on either i switch between two pages, on the "home" page the user can navigate multiple times up in the navigationstack still having the focus on the "home" tab indicating that this is where the user came from.
Now, on iOS whenever the user clicks on "home" from high up in the navigationstack the user is popped to root and all is well, this is not the case on android however, on android the user has to pop one page at a time by clicking on the backbutton to get to the root.
Is this intended behavior, am i doing something wrong, does someone have a clue as to what i can do to get the desired behavior?
This is the intended behavior between iOS and Android .
If you need to make the Android has the same effect with iOS, you need to custom TabbedPageRenderer to achieve that. And the bottom tab bar effect can custom a FreshTabbedNavigationContainer . Last, we will use MessagingCenter to send message to Forms to pop to Root Page.
For example, CustomFreshTabbedNavigationContainer class:
public class CustomFreshTabbedNavigationContainer : FreshTabbedNavigationContainer
{
public CustomFreshTabbedNavigationContainer()
{
On<Android>().SetToolbarPlacement(ToolbarPlacement.Bottom);
MessagingCenter.Subscribe<object>(this, "Hi", (sender) =>
{
// Do something whenever the "Hi" message is received
PopToRoot(true);
});
}
}
Used in App.xaml.cs:
public App()
{
InitializeComponent();
var container = new CustomFreshTabbedNavigationContainer();
container.AddTab<FirstPageModel>("Home", default);
container.AddTab<ProfilePageModel>("Profile", default);
MainPage = container;
}
Now we will create a CustomTabbedPageRenderer in Android:
public class CustomTabbedPageRenderer : TabbedPageRenderer, BottomNavigationView.IOnNavigationItemSelectedListener
{
public CustomTabbedPageRenderer(Context context) : base(context)
{
}
int previousItemId = 0;
bool BottomNavigationView.IOnNavigationItemSelectedListener.OnNavigationItemSelected(IMenuItem item)
{
base.OnNavigationItemSelected(item);
if (item.IsChecked)
{
if (previousItemId != item.ItemId)
{
previousItemId = item.ItemId;
}
else
{
Console.WriteLine("ok");
MessagingCenter.Send<object>(this, "Hi");
}
}
return true;
}
}
The effect:
Note: If need to have the same effect with the top Tabbar in Android, there is different code in CustomTabbedPageRenderer. You can have a look at this discussion.
Question:
How can I prevent my livedata immediately receiving stale data when navigating backwards? I am using the Event class outlined here which I thought would prevent this.
Problem:
I open the app with a login fragment, and navigate to a registration fragment when a live data email/password is set (and backend call says "this is a new account go register"). If the user hits the back button during the registration Android is popping back to login.
When the login fragment is recreated after a back press, it immediately fires the live data again with the stale backend response and I would like to prevent that.
LoginFragment.kt
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
subscribeToLoginEvent()
}
private fun subscribeToLoginEvent() {
//When a back press occurs, we subscribe again and this instantly
//fires with the same data it used to leave the screen
//(a Resource<User> with status=SUCCESS, data = null)
viewModel.user.observe(viewLifecycleOwner, Observer { response ->
Timber.i("login event observed....status:" + response?.status + ", data: " + response?.data)
binding.userResource = response
response?.let {
val status = it.status
val message = it.message
if (status == Status.SUCCESS && it.data == null) {
//This is a brand new user so we need to register now
navController()
.navigate(LoginFragmentDirections.showUserRegistration()))
}
else if(status == Status.SUCCESS && it.data != null){
goHome()
}
}
})
}
LoginViewModel.kt
private val _loginCredentials: MutableLiveData<Event<Pair<String, String>>> = MutableLiveData()
val user: LiveData<Resource<User>> = Transformations.switchMap(_loginCredentials) {
val data = it.getContentIfNotHandled()
if(data != null && data.first.isNotBlank() && data.second.isNotBlank())
interactor.callUserLoginRepo(data.first, data.second)
else
AbsentLiveData.create()
}
Okay there's two issues here which I hope helps somebody else.
The first is that the Event class does not appear to work with transformations. I think it is because the Event is literally pointing to the wrong live data (_login_credentials vs user)
The second problem is a little bit more fundamental but also blindingly obvious now. We are told all over the place that live data observations emit the latest data when a subscription is made to ensure you get the most up to date data. This means the way I am using live data here is simply incorrect, I can't subscribe to a login event, navigate somewhere, navigate back and re-subscribe because the ViewModel is rightfully giving me the latest data it has (because the login fragment was only detached, never destroyed).
Solution
The solution is to simply move the logic which performs the fetch one fragment deeper. So instead of listening for user credentials + login click -> fetching a user -> and then navigating somewhere, I need to listen for user credentials + login click -> navigate somewhere -> and then start subscribing for my user live data. That way I can head back to the login screen as much as I want and not subscribe to some stale live data that was never destroyed. And if I go back to login and then forwards the subscription and fragment were destroyed so I will appropriately be getting new data in that case.