Application Crash/Navigation issue with observer when clicking "back" from fragment - android

I have a fragment A which sends a search query to the network, and if the result is positive uses Android navigation component to navigate to fragment B, and its done using observers.
After navigation to fragment B, i click on "<-" arrow on the top of the screen, but instead of navigating back to fragment A it reloads fragment B again. And if using the native "back" button on the device, the app crashes with "illegalArgumentException navigation destination unknown" error.
I check the internet for clues on this issue, but all i learned is that this happens because i am using .observe in onViewCreated() and when i go back, it gets called again, and because livedata has something in it already, it just navigates me back to B.
I have tried observing in onActivityCreated(), and using getViewLifeCycleOwner, but no success... the only thing that helped is checking if livedata has observers and returning if true, before using .observe, but it seems incorrect.
This is the viewModel:
private val getAssetResult = MutableLiveData<GeneralResponse<Asset>>()
private val updateAssetResult = MutableLiveData<GeneralResponse<Int>>()
private val deleteAssetResult = MutableLiveData<GeneralResponse<Int>>()
init {
state.value = ViewState(false)
Log.d(TAG, "State in init: $state")
}
fun getAssetResult(): LiveData<GeneralResponse<Asset>>{
return getAssetResult
}
fun findAsset(req: GetAssetRequest) {
scope.launch {
setProgressIndicator(true)
val result = repository.getAsset(req)
getAssetResult.postValue(result)
setProgressIndicator(false)
}
}
This is the fragment:
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel = ViewModelProvider(this).get(EditAssetViewModel::class.java)
setupViewModel()
initFields()
}
private fun setupViewModel() {
if (viewModel.getAssetResult().hasObservers()) // <- This is the part that prevents the app from crashing.
return
viewModel.getAssetResult().observe(this, Observer {
if (it == null) return#Observer
handleSearchResult(it)
})
if (viewModel.getState().hasObservers())
return
viewModel.getState().observe(this, Observer { handleState(it) })
}
private fun handleSearchResult(response: GeneralResponse<Asset>) {
if (response.singleValue == null) {
Toast.makeText(context!!, response.errorMessage, Toast.LENGTH_SHORT).show()
return
}
targetFragment?.let { it ->
val bundle = bundleOf("asset" to response.singleValue)
when(it) {
"UpdateLocation" ->
Navigation.findNavController(view!!).navigate(R.id.updateLocation, bundle)
"EditAsset" -> {
Navigation.findNavController(view!!).navigate(R.id.editAsset, bundle)
}
}
}
}
if i remove this part from the setupViewModel function:
if (viewModel.getAssetResult().hasObservers())
return
the app will either crash when clicked "back" using the device button or go back to fragment A, just to be navigated back to fragment B because of the .observe function.

Override the method onBackPressed() to handle the "<-" arrow

Seems like the LiveData that you use to signal to fragment A that it should navigate to fragment B is actually an event. An event happens only once and once it is consumed (navigation event is done), it is gone. Therefore, after navigating you need to send a message to the viewmodel that the navigation took place and that the corresponding data holder should be (e.g.) null again. In Fragment A you check that the new value is unequal to null, and only if this is the case, you issue the navigation event. This would prevent fragment A to immediatelly switch to B again in the back scenario.
If you want to learn more about ways to use live data for events, please refer to this article.

Related

Why state flow calls callectLatest multiple times?

So, I would like to use StateFlow instead of LiveData, but I can not figure out what's the problem with my logic.
I have a flow, which has a default null value. When I open a dialog which contains a some datas, after that I select one data, I emit the new value to the flow.
In the first time, after the dialog closed, collectLatest called, and I get the null value (init), after the emit, I get the new value, it is good. But If I open the dialog again, and select value, and close the dialog, the collectLatest fun called 3-times, and I again open the dialog... and collectLatest called 4 times and so on.
So this is very bad behavior, and I'm sure , I did something wrong, but I don't see the bug.
In the liveData the expected behavior is after the dialog close, that the observer fun is called just once. I would like to achive this.
I also checked, that I emit the new value only once, so there is no reason why collectLatest fire multiple times.
ViewModel:
private val _previousManufacture = MutableStateFlow<PreviousManufactureView?>(null)
val previousManufacture = _previousManufacture.asStateFlow()
private suspend fun setPreviousManufactureByMachineId(machineId: String) {
val result = stateReportRepository.getPreviousManufactureByMachineId(machineId)
if (result is Result.Success) {
_previousManufacture.emit(result.data)
} else {
_previousManufacture.emit(null)
}
}
Fragment:
lifecycleScope.launchWhenCreated {
viewModel.previousManufacture.collectLatest {
var d = it
}
}
[Update]
Fragment:
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.vm = viewModel
initFlows()
}
private fun initFlows() {
lifecycleScope.launchWhenCreated {
viewModel.openStateOfWorkflowBrowser.collectLatest {
openStateOfWorkflowSelectionDialog()
}
}
...
}
Sorry, I missed this before in my comment, but I think the problem is that you are calling launchWhenCreated in the lifecycleScope of the Fragment, not in its viewLifecycle.lifecycleScope. So if the Fragment is reused (like after a dialog fragment has a appeared), the old collector is not cancelled and a new one is added, because the lifecycle of the Fragment has not ended, only the lifecycle of its previous view. You should almost always use viewLifecycle.lifecycleScope when you are using coroutines in a Fragment.

StateFlow fetching the same data again on Back Navigation

I am working with RecyclerView and using Retrofit to fetch the data from Server. I am using Kotlin with MVVM Design Pattern. I have used LiveData it was working fine. But with Stateflow causing issues when we navigate to another Fragment and Comes back to the Same Fragment again. It just fetches the same data again. Below is the code for ViewModel and the observer:
//View Model
private val _allTimeSheetsResponse =
MutableStateFlow<ResponsesResult<AllTimeSheetsResponse>>(ResponsesResult.Empty)
val allTimeSheetsResponse : StateFlow<ResponsesResult<AllTimeSheetsResponse>> get() = _allTimeSheetsResponse
fun getAllTimeSheets(auth: String) =
viewModelScope.launch {
timeSheetsRepository.getAllTimeSheets(auth).collect {
_allTimeSheetsResponse.value = it
}
}
//Observer
lifecycleScope.launchWhenStarted{
timeSheetsViewModel.allTimeSheetsResponse.collect { timeSheetsResponse ->
when (timeSheetsResponse) {
is ResponsesResult.Loading -> binding.progressBarLayout.show()
is ResponsesResult.Failure -> {
binding.progressBarLayout.gone()
binding.nothingFoundLayout.show()
handleApiError(timeSheetsResponse)
}
is ResponsesResult.Success -> {
binding.progressBarLayout.gone()
if (timeSheetsResponse.value.payload.isNotEmpty()) {
showAllTimeSheetsRecyclerAdapter.submitList(timeSheetsResponse.value.payload)
} else {
binding.nothingFoundLayout.show()
}
}
else -> Unit
}
}
}
Because you call getAllTimeSheets many times (eg. onCreateView or onViewCreated). Trying call it when accessing allTimeSheetsResponse` for the first time.
Your ViewModel's getAllTimeSheets() function starts a new coroutine to collect from the repo's cold Flow each time you call it, so each time the Fragment comes back, presumably. You should remove this function and simply convert the repo's cold Flow directly to a StateFlow:
val allTimeSheetsResponse: StateFlow<ResponsesResult<AllTimeSheetsResponse>> =
timeSheetsRepository.getAllTimeSheets(auth)
.stateIn(viewModelScope, SharingStarted.Eagerly, ResponsesResult.Empty)
You can pass the auth parameter into the ViewModel's factory through to its constructor.
When you are using the navigationComponent and call navController.navigate() to open a fragment, in the background destination fragment replaces with the old destination's fragment. so old fragment will keep in the fragmentManager backStack. but its view will destroy. and when navigate back, old fragment comes from backStack (not created again) and just its view creates.
So it's better to call getAllTimeSheets() in Fragment's onCreate. (to call one time). When fetching done, all data will set in _allTimeSheetsResponse
And then you should observe allTimeSheetsResponse in onViewCreated with viewLifecycleOwner scope.
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.allTimeSheetsResponse.onEach { response ->
// do sth with response
}.launchIn(viewLifecycleOwner.lifecycleScope)
}
fun getAllTimeSheets(auth: String) :StateFlow<ResponsesResult<AllTimeSheetsResponse>> {
var mutableStateFlow = MutableStateFlow<ResponsesResult<AllTimeSheetsResponse>>(ResponsesResult.Empty)
viewModelScope.launch {
timeSheetsRepository.getAllTimeSheets(auth).collect {
mutableStateFlow.value = it
}
}
return mutableStateFlow
}

ViewModel refetching data again with distinctUntilChanged()

I have a Fragment that I want to do a fetch once on its data, I have used distinctUntilChanged() to fetch just once because my location is not changing during this fragment.
Fragment
private val viewModel by viewModels<LandingViewModel> {
VMLandingFactory(
LandingRepoImpl(
LandingDataSource()
)
)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val sharedPref = requireContext().getSharedPreferences("LOCATION", Context.MODE_PRIVATE)
val nombre = sharedPref.getString("name", null)
location = name!!
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupRecyclerView()
fetchShops(location)
}
private fun fetchShops(localidad: String) {
viewModel.setLocation(location.toLowerCase(Locale.ROOT).trim())
viewModel.fetchShopList
.observe(viewLifecycleOwner, Observer {
when (it) {
is Resource.Loading -> {
showProgress()
}
is Resource.Success -> {
hideProgress()
myAdapter.setItems(it.data)
}
is Resource.Failure -> {
hideProgress()
Toast.makeText(
requireContext(),
"There was an error loading the shops.",
Toast.LENGTH_SHORT
).show()
}
}
})
}
Viewmodel
private val locationQuery = MutableLiveData<String>()
fun setLocation(location: String) {
locationQuery.value = location
}
val fetchShopList = locationQuery.distinctUntilChanged().switchMap { location ->
liveData(viewModelScope.coroutineContext + Dispatchers.IO) {
emit(Resource.Loading())
try{
emit(repo.getShopList(location))
}catch (e:Exception){
emit(Resource.Failure(e))
}
}
}
Now, if I go to the next fragment and press back, this fires again, I know that maybe this is because the fragment is recreating and then passing a new instance of viewmodel and thats why the location is not retained, but if I put activityViewModels as the instance of the viewmodel, it also happends the same, the data is loaded again on backpress, this is not acceptable since going back will get the data each time and this is not server efficient for me, I need to just fetch this data when the user is in this fragment and if they press back to not fetch it again.
Any clues ?
I'm using navigation components, so I cant use .add or do fragment transactions, I want to just fetch once on this fragment when creating it first time and not refetching on backpress of the next fragment
TL;DR
You need to use a LiveData that emits its event only once, even if the ui re-subscribe to it. for more info and explanation and ways to fix, continue reading.
When you go from Fragment 1 -> Fragment 2, Fragment 1 is not actually destroyed right away, it just un-subscribe from your ViewModel LiveData.
Now when you go back from F2 to F1, the fragment will re-subscribe back to ViewModel LiveData, and since the LiveData is - by nature - state holder, then it will re-emit its latest value right away, causing the ui to rebind.
What you need is some sort of LiveData that won't emit an event that has been emitted before.
This is common use case with LiveData, there's a pretty nice article talking about this need for a similar LiveData for different types of use cases, you can read it here.
Although the article proposed a couple of solutions but those can be a bit of an overkill sometimes, so a simpler solution would be using the following ActionLiveView
// First extend the MutableLiveData class
class ActionLiveData<T> : MutableLiveData<T>() {
#MainThread
override fun observe(owner: LifecycleOwner, observer: Observer<T?>) {
// Being strict about the observer numbers is up to you
// I thought it made sense to only allow one to handle the event
if (hasObservers()) {
throw Throwable("Only one observer at a time may subscribe to a ActionLiveData")
}
super.observe(owner, Observer { data ->
// We ignore any null values and early return
if (data == null) return
observer.onChanged(data)
// We set the value to null straight after emitting the change to the observer
value = null
// This means that the state of the data will always be null / non existent
// It will only be available to the observer in its callback and since we do not emit null values
// the observer never receives a null value and any observers resuming do not receive the last event.
// Therefore it only emits to the observer the single action so you are free to show messages over and over again
// Or launch an activity/dialog or anything that should only happen once per action / click :).
})
}
// Just a nicely named method that wraps setting the value
#MainThread
fun sendAction(data: T) {
value = data
}
}
You can find more explainiation for ActionLiveData in this link if you want.
I would advise using the ActionLiveData class, I've been using it for small to medium project size and it's working alright so far, but again, you know your use cases better than me. :)

preventing from re-navigating after pop, in onObserve?

I'm using MVVM + Retrofit + navigation in my new project and calling retrofit from the repository. Now when I receive a response in fragment-A with liveData, I navigate to next fragment-B, everything looks good so far. The problem starts from where in fragment-B I want to pop to the previous fragment and edit something.
In previous fragment(fragment-A) as soon as init, onObserve is called and navigates to fragment-B again!
some solutions came to my mind that worked, like:
setValue(null) after navigate to fragment-B
Remove observe in onCreateView and observe it when call retrofit from repository(This solution creates other problems)
and Etc.
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// fetch data from server
viewModel.articles.observe(viewLifecycleOwner) {
//if it was success
findNavController().navigate(R.id.fragment_a_to_fragment_b)
}
}
In scenarios where payload of a live data should be handled only once, you should use an event class which wraps the payload:
open class Event<out T>(private val content: T) {
var hasBeenHandled = false
private set // Allow external read but not write
/**
* Returns the content and prevents its use again.
*/
fun getContentIfNotHandled(): T? {
return if (hasBeenHandled) {
null
} else {
hasBeenHandled = true
content
}
}
/**
* Returns the content, even if it's already been handled.
*/
fun peekContent(): T = content
}
In your view model you should set the event like this:
fun loadArticles() {
val data = // load data from repository
articles.value = Event(data) // Trigger the event by setting a new Event as a new value
}
Then observe like this:
viewModel.articles.observe(viewLifecycleOwner, Observer {
it.getContentIfNotHandled()?.let { // Only proceed if the event has never been handled
findNavController().navigate(R.id.fragment_a_to_fragment_b)
}
})
For more info see LiveData with SnackBar, Navigation and other events (the SingleLiveEvent case)

OnBackPressedCallback is being called, but app is not going back

I recently updated my dependencies to include the OnBackPressedCallback change from an interface into an abstract class.
I have set things up according to the new documentation here but I feel like things are not working as they should.
My fragment's OnCreate looks a lot like the documentation:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requireActivity().onBackPressedDispatcher.addCallback(this) {
backPressed()
}
}
When I press the back button, the code in backPressed() is run, but nothing more happens.
I have tried calling handleBackPressed() and requireActivity().onBackPressedDispatcher.onBackPressed() and requireActivity().onBackPressed() from inside the callback, but those all cause a StackOverflowError because it seems to run that callback recursively.
There has got to be something really obvious I am missing...
There has got to be something really obvious I am missing...
You forget to disable your custom callback in you fragment before asking Activity to handle back pressed.
My solutiuon suitable for me:
#Override
public void onCreate(#Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
final OnBackPressedCallback callback = new OnBackPressedCallback(true) {
#Override
public void handleOnBackPressed() {
if (/*situation to handle back pressing*/){
//here handle your backPress in your fragment
} else {
setEnabled(false); //this is important line
requireActivity().onBackPressed();
}
}
};
requireActivity().getOnBackPressedDispatcher().addCallback(this, callback);
}
When you register an OnBackPressedCallback, you are taking on the responsibility for handling the back button. That means that no other on back pressed behavior is going to occur when you get a callback.
If you're using Navigation, you can use your NavController to pop the back stack:
requireActivity().onBackPressedDispatcher.addCallback(this) {
backPressed()
// Now actually go back
findNavController().popBackStack()
}
This works for me in androidx.appcompat:appcompat:1.1.0
requireActivity().onBackPressedDispatcher.addCallback(
this,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
Log.d(TAG, "Fragment back pressed invoked")
// Do custom work here
// if you want onBackPressed() to be called as normal afterwards
if (isEnabled) {
isEnabled = false
requireActivity().onBackPressed()
}
}
}
)
You can also remove callback instead of setting enabled if it's no longer needed. I use it with nested graph like this because when you touch back in a nested nav graph with it's NavHostFragment, it removes it from main fragment back stack instead of opening last fragment in nested nav graph.
// Get NavHostFragment
val navHostFragment =
childFragmentManager.findFragmentById(R.id.nested_nav_host_fragment)
// ChildFragmentManager of the current NavHostFragment
val navHostChildFragmentManager = navHostFragment?.childFragmentManager
val callback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
val backStackEntryCount = navHostChildFragmentManager!!.backStackEntryCount
if (backStackEntryCount == 1) {
// We are at the root of nested navigation, remove this callback
remove()
requireActivity().onBackPressed()
} else {
navController?.navigateUp()
}
}
}
requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, callback)

Categories

Resources