Circular navigation in navcontroller - android

I am having ListFragment, DetailFragment and OperationFragemnt. My ListFragment has the data list in Bundle object so it is always there when I am navigating from DetailFragment to ListFragment using
findNavController().popBackStack()
In OperationFragment my app is speaking with backend server and based on response I am trying to take user ListFragment using setFragmentResult as per the doc but can not get the setFragmentResultListener working
in OperationFragment
val resultToBeSent = "result"
parentFragmentManager.setFragmentResult("requestKey", bundleOf("bundleKey" to resultToBeSent))
in DetailFragment
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Use the Kotlin extension in the fragment-ktx artifact
parentFragmentManager.setFragmentResultListener("requestKey",this) { requestKey, bundle ->
// We use a String here, but any type that can be put in a Bundle is supported
val result = bundle.getString("bundleKey")
// Do something with the result
findNavController().popBackStack()
}
}

The result comes from the child fragment, everything else are NavArgs (there's two directions).
getParentFragmentManager().setFragmentResult("request_key", bundle)
Not sure if .popBackStack() may interfere. I use <dialog/> and dismiss(). I mean, the result already arrives before navigating. You cannot set this as listenener unless implementing interface FragmentResultListener:
/** Callback used to handle results passed between fragments. */
public fun onFragmentResult(requestKey: String, result: Bundle) {
if (requestKey.equals("request_key")) {
result.getString("bundle_key")
}
}

Related

Use repeatOnLifecycle to collect a StateFlow's value but only receive the update when current fragment resume

When I collect a StateFlow's value in repeatOnLifecycle, I have to navigate to other fragment and back then can collect the value's change. But not once the value has been changed the collect lambda receives the change immediate without resume the current fragment. How can I solve this?
// Dao.kt
#Dao
interface AppDao {
// other codes
#Query("SELECT * FROM Property")
fun getAllProperties(): Flow<List<Property>>
// other codes
}
Repository with Singleton pattern and provide functions in above AppDao.
ViewModel:
class PropertyViewModel : ViewModel() {
private val repository = AppRepository.get()
private val _properties: MutableStateFlow<List<Property>> = MutableStateFlow(emptyList())
val properties: StateFlow<List<Property>> = _properties
// or ⬇️ this way still not work
// val properties: StateFlow<List<Property>>
// get() = _propertis.asStateFlow()
init {
viewModelScope.launch {
repository.getAllProperties().collect {
_properties.value = it
}
}
}
// other codes
}
Fragment:
class SomeFragment : Fragment() {
private var _binding: FragmentPropertyBinding? = null
// This property is only valid between onCreateView and
// onDestroyView.
private val binding get() = _binding!!
private val propertyViewModel: PropertyViewModel by viewModels()
// onCreate lifecycle method here
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// other codes
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.lifecycleScope.repeatOnLifecycle(Lifecycle.State.CREATED) {
propertyViewModel.properties.collect { propertis: List<Property> ->
// 🔴 Here I can only collet the propertis list when I go to other framgent/ or Home and then back to this fragment
// todo something
}
}
}
propertyViewModel.properties
.flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.CREATED)
.onEach {
// 🔴 and this way is identical to the repeatOnLifecycle method
Log.e("Database flowWithLifecycle", it.toString())
}
.launchIn(viewLifecycleOwner.lifecycleScope)
}
// other override lifecycle methods
}
How can I modify the code to collect the values once it's value has been changed with out leaving the current fragment and back?
using different lifecycleScope to test the collect and confirmed the value in ViewModel collected the up-to-date value once it be modifed.
Expect to collect the values in fragment once it's value has been changed with out leaving the current fragment and back?
StateFlow has Strong equality-based conflation, from the documentation:
Values in state flow are conflated using Any.equals comparison in a similar way to distinctUntilChanged operator. It is used to conflate incoming updates to value in MutableStateFlow and to suppress emission of the values to collectors when new value is equal to the previously emitted one. State flow behavior with classes that violate the contract for Any.equals is unspecified.
using it with mutable data types can cause issues. I recommend switching to immutable data types.

When setting navigation graph programmatically after recreating activity, wrong fragment is shown

I am setting a navigation graph programmatically to set the start destination depending on some condition (for example, active session), but when I tested this with the "Don't keep activities" option enabled I faced the following bug.
When activity is just recreated and the app calls method NavController.setGraph, NavController forces restoring the Navigation back stack (from internal field mBackStackToRestore in onGraphCreated method) even if start destination is different than before so the user sees the wrong fragment.
Here is my MainActivity code:
class MainActivity : AppCompatActivity() {
lateinit var navController: NavController
lateinit var navHost: NavHostFragment
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.main_activity)
log("fresh start = ${savedInstanceState == null}")
navHost = supportFragmentManager.findFragmentById(R.id.main_nav_host) as NavHostFragment
navController = navHost.navController
createGraph(App.instance.getValue())
}
private fun createGraph(bool: Boolean) {
Toast.makeText(this, "Is session active: $bool", Toast.LENGTH_SHORT).show()
log("one: ${R.id.fragment_one}, two: ${R.id.fragment_two}")
val graph =
if (bool) {
log("fragment one")
navController.navInflater.inflate(R.navigation.nav_graph).also {
it.startDestination = R.id.fragment_one
}
} else {
log("fragment two")
navController.navInflater.inflate(R.navigation.nav_graph).also {
it.startDestination = R.id.fragment_two
}
}
navController.setGraph(graph, null)
}
}
App code:
class App : Application() {
companion object {
lateinit var instance: App
}
private var someValue = true
override fun onCreate() {
super.onCreate()
instance = this
}
fun getValue(): Boolean {
val result = someValue
someValue = !someValue
return result
}
}
Fragment One and Two are just empty fragments.
How it looks like:
Repository with full code and more explanation available by link
My question: is it a Navigation library bug or I am doing something wrong? Maybe I am using a bad approach and there is a better one to achieve what I want?
As you tried in your repository, It comes from save/restoreInstanceState.
It means you set suit graph in onCreate via createGraph(App.instance.getValue()) and then fragmentManager in onRestoreInstanceState will override your configuration for NavHostFragment.
So you can set another another time the graph in onRestoreInstanceState. But it will not work because of this line and backstack is not empty. (I think this behavior may be a bug...)
Because of you're using a graph (R.navigation.nav_graph) for different situation and just change their startDestination, you can be sure after process death, used graph is your demand graph. So just override startDestination in onRestoreInstanceState.
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
if (codition) {
navController.graph.startDestination = R.id.fragment_one
} else {
navController.graph.startDestination = R.id.fragment_two
}
}
Looks like there is some wrong behaviour in the library and my approach wasn't 100% correct too. At least, there is the better one and it works well.
Because I am using the same graph and only changing the start destination, I can simply set that graph in onCreate of my activity and set some default start destination there. Then, in createGraph method, I can do the following:
// pop backStack while it is not empty
while (navController.currentBackStackEntry != null) {
navController.popBackStack()
}
// then just navigate to desired destination with additional arguments if needed
navController.navigate(destinationId, destinationBundle)

Android FragmentManager and Fragment Result API

How can be that a fragment F which uses the new Fragment Result API to get results from 2 other fragments: A, B gets the result from A but not from B because B has a different parent FragmentManager (and I don't know why) ? How could be something like that ? 2 fragments called in the same way but they end up having same Activity but different FragmentManager ? The function calls are the following:
//THIS DOESN'T WORK. THE LISTENER IS NOT CALLED AFTER THE RESULT IS SET
private fun navigateToItemLocation() {
setFragmentResultListener(REQUEST_LOCATION_KEY) { s: String, bundle: Bundle ->
val locationId = bundle.getParcelable<ParcelUuid>(LOCATION_ID)!!.uuid
viewModel.viewModelScope.launch(Dispatchers.IO) {
val location = LocationRepository().get(locationId)!!
changeItemLocation(location)
}
}
val action = ItemRegistrationPagerHolderDirections.actionNavItemRegistrationPagerToNavStockLocationSelection()
findNavController().navigate(action)
}
//THIS WORKS FINE:
private fun navigateToItemDetails(item: Item2) {
setFragmentResultListener(SELECTED_ITEM_KEY) { s: String, bundle: Bundle ->
val propertySetId = bundle.getParcelable<ParcelUuid>(SELECTED_ITEM_SET_ID)!!.uuid
clearFragmentResultListener(SELECTED_ITEM_KEY)
viewModel.viewModelScope.launch(Dispatchers.IO) {
val repository = PropertySetRepository()
val propertySet = repository.get(propertySetId)!!
val propertySetInfo = ItemFactory.loadPropertySetInfo(propertySet)
withContext(Dispatchers.Main) { setPackageCode(null) }
selectItem(item.item, propertySetInfo, item.description, null)
}
}
val action = ItemRegistrationPagerHolderDirections.actionNavItemRegistrationToNavStockItemDetails(ParcelUuid(item.item.id), true)
findNavController().navigate(action)
}
Both fragments A and B are in a separate Dynamic Feature. The only single problem I have is that when the following function is called:
fun onSelect() {
viewModel.pickedLocation.value = (viewModel.selectedLocation as? LocationExt2?)?.location
val result = bundleOf(Pair(LOCATION_ID, ParcelUuid(viewModel.pickedLocation.value!!.id)))
setFragmentResult(REQUEST_LOCATION_KEY, result)
findNavController().popBackStack()
}
setFragmentResult(REQUEST_LOCATION_KEY, result)
Doesn't produce any result because the FragmentManager is not the same of the calling Fragment. The same method in fragment A which is:
private fun onSetSelected(id: UUID) {
propertySets.removeObservers(viewLifecycleOwner)
adapter.tracker = null
setFragmentResult(SELECTED_ITEM_KEY, bundleOf(Pair(SELECTED_ITEM_SET_ID, ParcelUuid(id))))
findNavController().popBackStack()
}
As a temporarily workaround I replaced the call to Fragment's FragmentManager with Activity.supportFragmentManager.setFragmentResultListener. It works but still I do not understand why fragments A and B behave differently...
Check that fragment where you listen for fragment result and fragment where you set the result are in the same fragment manager.
Common case where this would happen is if you are using Activity.getSupportFragmentManager() or Fragment.getParentFragmentManager() alongside Fragment.getChildFragmentManager().
Check this blog article for the principles and rules with Fragment Result API on medium: https://medium.com/#FrederickKlyk/state-of-the-art-communication-between-fragments-and-their-activity-daa1fe4e014d
Only one listener can be registered for a specific request key.
If more than one listener is registered on the same key, the previous one will be replaced by the newest listener.

Android jetpack navigation component result from dialog

So far I'm successfully able to navigate to dialogs and back using only navigation component. The problem is that, I have to do some stuff in dialog and return result to the fragment where dialog was called from.
One way is to use shared viewmodel. But for that I have to use .of(activity) which leaves my app with a singleton taking up memory, even when I no longer need it.
Another way is to override show(fragmentManager, id) method, get access to fragment manager and from it, access to previous fragment, which could then be set as targetfragment. I've used targetFragment approach before where I would implement a callback interface, so my dialog could notify targetFragment about result. But in navigation component approach it feels hacky and might stop working at one point or another.
Any other ways to do what I want? Maybe there's a way to fix issue on first approach?
Thanks to #NataTse and also the official docs, i came up with the extensions so that hopefully less boilerplate code to write:
fun <T>Fragment.setNavigationResult(key: String, value: T) {
findNavController().previousBackStackEntry?.savedStateHandle?.set(
key,
value
)
}
fun <T>Fragment.getNavigationResult(#IdRes id: Int, key: String, onResult: (result: T) -> Unit) {
val navBackStackEntry = findNavController().getBackStackEntry(id)
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME
&& navBackStackEntry.savedStateHandle.contains(key)
) {
val result = navBackStackEntry.savedStateHandle.get<T>(key)
result?.let(onResult)
navBackStackEntry.savedStateHandle.remove<T>(key)
}
}
navBackStackEntry.lifecycle.addObserver(observer)
viewLifecycleOwner.lifecycle.addObserver(LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_DESTROY) {
navBackStackEntry.lifecycle.removeObserver(observer)
}
})
}
In Navigation 2.3.0-alpha02 and higher, NavBackStackEntry gives access to a SavedStateHandle. A SavedStateHandle is a key-value map that can be used to store and retrieve data. These values persist through process death, including configuration changes, and remain available through the same object. By using the given SavedStateHandle, you can access and pass data between destinations. This is especially useful as a mechanism to get data back from a destination after it is popped off the stack.
To pass data back to Destination A from Destination B, first set up Destination A to listen for a result on its SavedStateHandle. To do so, retrieve the NavBackStackEntry by using the getCurrentBackStackEntry() API and then observe the LiveData provided by SavedStateHandle.
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val navController = findNavController();
// We use a String here, but any type that can be put in a Bundle is supported
navController.currentBackStackEntry?.savedStateHandle?.getLiveData("key")?.observe(
viewLifecycleOwner) { result ->
// Do something with the result.
}
}
In Destination B, you must set the result on the SavedStateHandle of Destination A by using the getPreviousBackStackEntry() API.
navController.previousBackStackEntry?.savedStateHandle?.set("key", result)
When you use Navigation Component with dialogs, this part of code looks not so good (for me it returned nothing)
navController.currentBackStackEntry?.savedStateHandle?.getLiveData("key")?.observe(
viewLifecycleOwner) { result ->
// Do something with the result.}
You need to try way from official docs and it help me a lot
This part is working for me:
val navBackStackEntry = navController.getBackStackEntry(R.id.target_fragment_id)
// Create observer and add it to the NavBackStackEntry's lifecycle
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME
&& navBackStackEntry.savedStateHandle.contains("key")
) {
val result =
navBackStackEntry.savedStateHandle.get<Boolean>("key")
// Do something with the result
}
}
navBackStackEntry.lifecycle.addObserver(observer)
// As addObserver() does not automatically remove the observer, we
// call removeObserver() manually when the view lifecycle is destroyed
viewLifecycleOwner.lifecycle.addObserver(LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_DESTROY) {
navBackStackEntry.lifecycle.removeObserver(observer)
}
})
And in your dialog:
navController.previousBackStackEntry?.savedStateHandle?.set(
"key",
true
)

Updating NavDestination label at runtime

I can not update NavDestination's label at runtime.
it reflects but not from the first time i enter the screen, it doesn't reflected instantaneously
My ViewModel
class PrepareOrderDetailsViewModel(
brief: MarketHistoryResponse,
private val ordersRepository: OrdersRepository
) : BaseViewModel() {
private val _briefLiveData = MutableLiveData(brief)
val orderIdLiveData: LiveData<Int?> =
Transformations.distinctUntilChanged(Transformations.map(_briefLiveData) { it.id })
}
LiveData observation in the fragment
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
registerObservers()
}
private fun registerObservers() {
viewModel.orderIdLiveData.observe(viewLifecycleOwner, Observer {
findNavController().currentDestination?.label = getString(R.string.prepare_order_details_title, it)
})
}
As per the Navigation UI documentation, the NavigationUI methods, such as the setupActionBarWithNavController() method rely on an OnDestinationChangedListener, which gets called every time you navigate() to a new destination. That's why the label is not instantly changed - it is only updated when you navigate to a new destination.
The documentation does explain that for the top app bar:
the label you attach to destinations can be automatically populated from the arguments provided to the destination by using the format of {argName} in your label.
This allows you to update your R.string.prepare_order_details_title to be in the form of
<string name="prepare_order_details_title">Prepare order {orderId}</string>
By using that same argument on your destination, your title will automatically be populated with the correct information.
Of course, if you don't have an argument that you can determine ahead of time, then you'd want to avoid setting an android:label on your destination at all and instead manually update your action bar's title, etc. from that destination.
I reach to a workaround for that issue by accessing the SupportActionBar itself and set the title on label behalf
private fun registerObservers() {
viewModel.orderIdLiveData.observe(viewLifecycleOwner, Observer {
(activity as AppCompatActivity).supportActionBar?.title =
getString(R.string.prepare_order_details_title, it)
})
}

Categories

Resources