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)
})
}
Related
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)
I am using nested navigation graphs in order to scope and share my viewmodels across a set of fragments.
I also have a BaseFragment class which obtains the reference to the required viewmodel:
fun provideViewModel() : VM {
return if(viewModelScopeGraphId != null) {
ViewModelProvider(findNavController().getViewModelStoreOwner(viewModelScopeGraphId!!)).get(viewModelClass)
} else {
ViewModelProvider(this).get(viewModelClass)
}
}
I can override a property, viewModelScopeGraphId, if I need the viewmodel to be scoped to the navigation graph (or nested navigation graph) with that specific id.
Ideally I would just want to set a boolean flag like useScopedViewModel and obtain the id of the current navigation graph, for example:
fun provideViewModel() : VM {
return if(useScopedViewModel) {
ViewModelProvider(findNavController().getViewModelStoreOwner(getCurrentNavGraphId())).get(viewModelClass)
} else {
ViewModelProvider(this).get(viewModelClass)
}
}
I have tried using navController.graph.id to get the current graph id, but it seems the id I get from there does not match up with my resource id's (eg. R.id.nav_graph). Is there something I am missing?
I have a similar issue (more context of my case at the end), tried a few solutions, none successful.
In the end, I give up and just give the graph id as a parameter for the Fragment.
As it could help you to achieve your own solution, so my solution looks like the following:
Have to create this extension, as the original navGraphViewModels only accepts a #IdRes navGraphId: Int and I want to lazy load the arguments, etc. (Also I've simplified removing the factoryProducer from the arguments, as so far I will not use it for our solution.
inline fun <reified VM : ViewModel> Fragment.navGraphViewModels(
noinline graphIdProducer: () -> Int
): Lazy<VM> {
val backStackEntry by lazy {
findNavController().getBackStackEntry(graphIdProducer())
}
val storeProducer: () -> ViewModelStore = {
backStackEntry.viewModelStore
}
return createViewModelLazy(VM::class, storeProducer, {
backStackEntry.defaultViewModelProviderFactory
})
}
In my fragment where I want to use it, I recover the Arguments using the navArgs, and recover the ViewModel using the extension above:
private val listenerViewModel: ListenerViewModel by navGraphViewModels {
navArgs.graphId
}
private val navArgs: MyFragmentArgs by navArgs()
And to whoever needs to "listen" for that ViewModel, can simply load using the Navigation navGraphViewModels:
private val listenerViewModel: ListenerViewModel by navGraphViewModels(
R.id.my_graph_a
)
And from another graph/fragment I simple do:
private val listenerViewModel: ListenerViewModel by navGraphViewModels(
R.id.my_graph_b
)
To explain a little the context of my case:
I have two distinct Fragments, where the user can click in a Country selection.
Each of these Fragments is inside a different Graph, as they are different flows.
The Country selection is a Fragment where load the supported countries list from an API, displays it, pre-select any previous user selection (also given as a FragmentArgs), and the user can change the selection, which implies coming back to the previous screen, with the newly selected value or just come back not triggering anything here.
I'm aware and expecting the release of https://issuetracker.google.com/issues/79672220, but as it is today (March-2020) it is only available in alpha.
You may try to check it with graph's start destination.
when (navController.graph.startDestinationId) {
R.id.firstFragmentOfFirstGraph -> { /* First graph */ }
R.id.firstFragmentOfSecondGraph -> { /* Second graph */}
}
I am having difficulty with implementing a proper navigation structure in my app. I want it to behave similarly to the navigation in YouTube and Instagram. The biggest problem I am having is with the backstack and fragment recreation.
I'm currently using the single activity with multiple fragments approach. I have an app bar and bottom navigation view with 3 menu items setup in the main activity. The app bar has one menu item that navigates to the user profile fragment when selected, and each of the bottom nav's menu items navigates to different root fragments(home, search, and profile) when selected. I'm also using google's firebase database and firestore to store user data(email, uid, password, etc...) and photos.
I've tried using the supportFragmentManager.beginTransaction().replace way, and android jetpack's navigation architecture, but haven't been able to produce the results I need with either.
I'm able to navigate to the proper destinations using the supportFragmentManager way, but can't seem to implement a proper backwards navigation structure. I've tried to find other code samples of implementing this, but was unable to find anything that works, and a lot of these samples are older versions in java code with deprecated methods, which makes it difficult when trying to convert to kotlin code.
The jetpack navigation component is a bit easier to use, but I cannot get it to behave properly either. To my knowledge, the current navigation does not support multiple backstacks and does not have a proper backwards navigation structure unless you add the NavigationExtensions file provided here: https://github.com/googlesamples/android-architecture-components/tree/master/NavigationAdvancedSample. Using this sample, I am having problems with:
1.Navigating backwards does not return to the originally saved fragment state, it instead recreates a brand new fragment.
2.Navigating to the profile fragment from the app bar works, but crashes when the user is inside the fragment and presses it again.
3.Passing a default set of arguments to the user fragment item menu in the bottom navigation view. I originally had the account profile fragment tied to a bottom nav menu item (still do for testing purposes) with the logged in user's uid set as the default arguments. The fragment(UserFragment) used takes the uid argument and uses it to fetch the proper information from google's firebase. I was previously able to achieve this by using the regular jetpack navigation component(without the advanced sample) and adding the following code in the MainActivity:
val navArgument1 = NavArgument.Builder().setDefaultValue(uid).build()
val orderDestination = navController.graph.findNode(R.id.user_Fragment)
orderDestination?.addArgument("destinationUid",navArgument1)
Then within the user fragment, I use this code to get the proper uid:
uid = arguments?.getString("destinationUid")
With the advanced sample navigation component, I'm not able to pass this default argument into the user fragment. I keep getting an error that says something like "There is no navigation controller associated with this fragment," and the app crashes.
The Main Activity
class ExploreActivity : AppCompatActivity(),BottomNavigationView.OnNavigationItemSelectedListener{
override fun onNavigationItemSelected(p0:MenuItem):Boolean{
when(p0.itemId){
R.id.home->{
val homeViewFragment = HomeViewFragment()
supportFragmentManager.beginTransaction().replace(R.id.nav_host_fragment,homeViewFragment).commit()
return true
}
R.id.world->{
val publicViewFragment = PublicViewFragment()
supportFragmentManager.beginTransaction().replace(R.id.nav_host_fragment,publicViewFragment).commit()
return true
}
R.id.account->{
val userFragment = UserFragment()
val bundle = Bundle()
val uid=FirebaseAuth.getInstance().currentUser?.uid
bundle.putString("destinationUid",uid)
userFragment.arguments=bundle
supportFragmentManager.beginTransaction().replace(R.id.nav_host_fragment,userFragment).commit()
return true
}
}
return false
}
override fun onCreate(savedInstanceState:Bundle?){
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_explore)
bottom_navigation_explore.setOnNavigationItemSelectedListener(this)
bottom_navigation_explore.selectedItemId=R.id.home
}
override fun onActivityResult(requestCode:Int,resultCode:Int,data:Intent?){
super.onActivityResult(requestCode,resultCode,data)
if(requestCode==UserFragment.PICK__PROFILE_FROM_ALBUM&&resultCode==Activity.RESULT_OK){
val imageUri=data?.data
val uid=FirebaseAuth.getInstance().currentUser?.uid
val storageRef=FirebaseStorage.getInstance().reference.child("userProfileImages")
.child(uid!!)
storageRef.putFile(imageUri!!).continueWithTask{task:Task<UploadTask.TaskSnapshot>->
return#continueWithTask storageRef.downloadUrl
}.addOnSuccessListener{uri->
val map=HashMap<String,Any>()
map["image"]=uri.toString()
FirebaseFirestore.getInstance().collection("profileImages").document(uid).set(map)
}
}
}
}
User Fragment
class UserFragment : Fragment(){
var fragmentView : View? = null
var firestore : FirebaseFirestore? = null
var uid : String? = null
var auth : FirebaseAuth? = null
var currentUserUid : String? = null
companion object{
var PICK__PROFILE_FROM_ALBUM = 10
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
fragmentView = LayoutInflater.from(activity).inflate(R.layout.activity_main,container,false)
uid = arguments?.getString("destinationUid")
firestore = FirebaseFirestore.getInstance()
auth = FirebaseAuth.getInstance()
currentUserUid = auth?.currentUser?.uid
return fragmentView
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
if(uid == currentUserUid) {
fragmentView?.btn_follow_signout_main?.text = "Signout"
fragmentView?.btn_follow_signout_main?.setOnClickListener {
activity?.finish()
startActivity(Intent(activity, LoginActivity::class.java))
auth?.signOut()
}
requestPermissions(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE),1)
iv_createpost_main.setOnClickListener {
if (ContextCompat.checkSelfPermission(context!!, Manifest.permission.READ_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED){
startActivity(Intent(activity,CreatePost::class.java))
}
return#setOnClickListener
}
//add explanation of why permission is needed
if (ContextCompat.checkSelfPermission(context!!, Manifest.permission.READ_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) {
iv_profilepicture_main.setOnClickListener {
val intent = Intent(Intent.ACTION_PICK)
intent.type = "image/*"
activity?.startActivityForResult(intent, PICK__PROFILE_FROM_ALBUM)
}
}
}
else{
fragmentView?.btn_follow_signout_main?.text = "Follow +"
fragmentView?.btn_follow_signout_main?.setOnClickListener {
requestFollow()
}
}
getProfileImage()
getUserName()
}
private fun getProfileImage() {
firestore?.collection("profileImages")!!.document(uid!!).get().addOnCompleteListener { task ->
if(task.isSuccessful){
val url = task.result!!["image"]
if(url != null){
Glide.with(activity!!).load(url).into(iv_profilepicture_main)
}
else{
iv_profilepicture_main.setImageResource(R.drawable.ic_account)
}
}
}
}
private fun getUserName(){
firestore?.collection("users")!!.document(uid!!).get().addOnCompleteListener { task ->
if(task.isSuccessful){
val username = task.result!!["username"]
if(username != null){
activity?.setTitle("" + username)
}
}
}
}
}
My project is currently setup using the support fragment manager, but I've been going back and forth between using it and the navigation component to try and make things work.
I have two other fragments tied to the bottom nav, but I've only included the relevant code where I believe my problem lies. The other two fragments have a user profile picture that navigates the user to the selected profile when it is clicked. I do not have any problems with those transactions, because I can easily apply the bundle and arguments with the setOnClickListener method.
TL;DR
To summarize everything: I am looking for a way to implement a proper navigation flow throughout my app. I'm having problems with backwards navigation and fragments being recreated when they shouldn't. I've tried using the fragment manager and the android jetpack navigation component, but haven't had luck with either. If anyone has any information on how to achieve this using android kotlin and the latest methods, and would like to share, I'd appreciate it.
Thanks.
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
)
I'm currently using Android Navigation Architecture in my project. It has a feature that can launch any fragment with a shortcut. Currently I'm using NavController to navigate to desired destination when clicking at a shortcut.
But when I clicked a shortcuts with multiple times, every time a new instance of the fragment will be created.
So, my question is, Is there any way to only accept one instance of a fragment when navigate to it with NavController?
I'm googling many times but found nothing. Thanks in advance.
Add a check before navigating to the destination as it would not add a new instance.
class A: AppCompatActivity {
fun onCreate(...) {
// ...While navigating
if (navController.currentDestination?.id != desiredDestination?.id) {
navController.navigate(desiredDestination)
}
// ...else do nothing
}
}
Callback from NavController: https://developer.android.com/reference/androidx/navigation/NavController#addOnDestinationChangedListener(androidx.navigation.NavController.OnDestinationChangedListener)
You can use by navGraphViewModels delegate
The most important thing is to set id to your views in order to save state during config changes.This has not mentioned in official docs.
by default fragment navigation won't be saved during config changes(rotation and ...).
ViewModel will remain across config changes and you can save your state there then restore it.
Also check these links:
https://code.luasoftware.com/tutorials/android/android-jetpack-navigation-lost-state-after-navigation/
and
Android navigation component: how save fragment state
You can use safeOnClickListener instead of default onClickListener for capturing click on shortcut, so basically with safeOnClickListener you ignore all the click event for a given duration.
class SafeClickListener(
private var defaultInterval: Int = 2000,
private val onSafeCLick: (View) -> Unit
) : View.OnClickListener {
private var lastTimeClicked: Long = 0
override fun onClick(v: View) {
if (SystemClock.elapsedRealtime() - lastTimeClicked < defaultInterval) {
return
}
lastTimeClicked = SystemClock.elapsedRealtime()
onSafeCLick(v)
}
}
fun View.setSafeOnClickListener(delay: Int = 2000, onSafeClick: (View) -> Unit) {
val safeClickListener = SafeClickListener(delay) {
onSafeClick(it)
}
setOnClickListener(safeClickListener)
}