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

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)

Related

How to implement proper navigation similar to YouTube's and Instagram's using Android Kotlin

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.

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)
})
}

Move between fragments without recreation- Kotlin

I'm trying to move between two fragments without recreation of them so the data in the previous fragment won't disappear.
I tried to look over the internet for answers and tried for hours but without success. I looked at those links:
link 1
link 2
link 3- android developer site
After show() and hide() I also tried the AddToBackStack() but yet no success
link 4
class MainActivity : AppCompatActivity(){
private val onNavigationItemSelectedListener = BottomNavigationView.OnNavigationItemSelectedListener { item ->
#Override
when (item.itemId) {
R.id.navigation_home -> {
//replaceFragment(SignInFragment())
supportFragmentManager.beginTransaction().hide(AllEventsFragment()).commit()
supportFragmentManager.beginTransaction().show(SignInFragment()).commit()
return#OnNavigationItemSelectedListener true
}
R.id.navigation_events -> {
//replaceFragment(AllEventsFragment())
supportFragmentManager.beginTransaction().hide(SignInFragment()).commit()
supportFragmentManager.beginTransaction().show(AllEventsFragment()).commit()
if (currentUser.isNotEmpty()) {
updateRecyclerView()
sign_in_error?.visibility = View.INVISIBLE
}
return#OnNavigationItemSelectedListener true
}
}
return#OnNavigationItemSelectedListener false
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
supportFragmentManager.beginTransaction().add(R.id.activity_main, AllEventsFragment(), "2").commit()
supportFragmentManager.beginTransaction().add(R.id.activity_main, SignInFragment(), "1").commit()
val navView: BottomNavigationView = findViewById(R.id.nav_view)
navView.setOnNavigationItemSelectedListener(onNavigationItemSelectedListener)
personInEvent = false
}
The result is overlapping fragments without an option to really navigate between them. I really tried everything I know there are some answers over the internet but none of them helped me fix my issue. I would really appreciate some help with this frustrating issue.
Before navigation:
After navigation:
supportFragmentManager.beginTransaction().hide(AllEventsFragment()).commit()
your recreating your fragments every time!
calling AllEventsFragment() is equivelant to new AllEventsFragment()
you need to instantiate them first
for example,
your code needs to be like this,
val fragment1: Fragment = SignInFragment()
val fragment2: Fragment = AllEventsFragment()
var active = fragment1
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
supportFragmentManager.beginTransaction().add(R.id.activity_main,fragment2 , "2").commit()
supportFragmentManager.beginTransaction().add(R.id.activity_main, fragment1, "1").commit()
val navView: BottomNavigationView = findViewById(R.id.nav_view)
navView.setOnNavigationItemSelectedListener(onNavigationItemSelectedListener)
personInEvent = false
}
inside your listener
R.id.navigation_home -> {
supportFragmentManager.beginTransaction().beginTransaction().hide(active).show(fragment1).commit();
active = fragment1;
return#OnNavigationItemSelectedListener true
}
R.id.navigation_events -> {
//replaceFragment(AllEventsFragment())
supportFragmentManager.beginTransaction().beginTransaction().hide(active).show(fragment2).commit();
active = fragment2
)
//handle rest of the cases
Take a look at architicture components, yuo can also achieve that in the old way Android - save/restore fragment state
When a fragment isnt viewable its paused or might even be destroyed use bundle to perserve data.
What you are trying to acheive could be done using two containers but you really shouldn't

Only allow one instance when navigate with NavController

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)
}

Navigate to fragment on FAB click (Navigation Architecture Components)

I have no idea how to, using the new navigation architecture component, navigate from my main screen (with a FloatingActionButton attatched to a BottomAppBar) to another screen without the app bar.
When I click the fab I want my next screen (fragment?) to slide in from the right. The problem is where do I put my BottomAppBar? If I put it in my MainActivity then I have the issue of the FloatingActionButton not having a NavController set. I also cannot put my BottomAppBar in my Fragment. I am at a loss.
Ran into this issue today and I found out that there is a simple and elegant solution for it.
val navController = findNavController(R.id.navHostFragment)
fabAdd.setOnClickListener {
navController.navigate(R.id.yourFragment)
}
This takes care of the navigation. Then you must control the visibility of your BottomAppBar inside your Activity.
You could have your BottomAppBar in MainActivity and access your FloatingActionButton in your fragment as follows
activity?.fab?.setOnClickListener {
/*...*/
findNavController().navigate(R.id.action_firstFragment_to_secondFragment, mDataBundle)
}
You could hide the BottomAppBar from another activity as follows
(activity as AppCompatActivity).supportActionBar?.hide()
Make sure you .show() the BottomAppBar while returning to previous fragment
Put it in MainActivity and setOnClickListener in onStart() of the activity and it will work fine.
override fun onStart() {
super.onStart()
floatingActionButton.setOnClickListener {
it.findNavController().navigate(R.id.yourFragment)
}
}
Note:This solution is like and hack and better is to follow Activity LifeCycle and setUp OnClickListener when the activity is ready to interact.
Similar question [SOLVED]
if you wanted to navigate to certain fragment (not the star one) in the beginning for some reason, and also you have to graphs for one activity, here is what I suggest:
this method will start activity
companion object {
const val REQUEST_OR_CONFIRM = "request_or_confirm"
const val IS_JUST_VIEW = "IS_JUST_VIEW"
const val MODEL = "model"
fun open(activity: Activity, isRequestOrConfirm: Boolean, isJustView: Boolean = false, model: DataModel? = null) {
val intent = Intent(activity, HostActivity::class.java)
intent.putExtra(REQUEST_OR_CONFIRM, isRequestOrConfirm)
intent.putExtra(IS_JUST_VIEW, isJustView)
intent.putExtra(MODEL, model)
activity.startActivity(intent)
}
}
and then in, onCreate method of Host Activity, first decide which graph to use and then pass the intent extras bundle so the start fragment can decide what to do:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_purchase_nav)
if (intent.getBooleanExtra(REQUEST_OR_CONFIRM, true)) {
findNavController(R.id.nav_host_fragment).setGraph(R.navigation.nav_first_scenario, intent.extras)
} else {
findNavController(R.id.nav_host_fragment).setGraph(R.navigation.nav_second_scenario, intent.extras)
}
}
and here's how you can decide what to do in start fragment:
if (arguments != null && arguments!!.getBoolean(HostActivity.IS_JUST_VIEW)){
navigateToYourDestinationFrag(arguments!!.getParcelable<DataModel>(HostActivity.MODEL))
}
and then navigate like you would do normally:
private fun navigateToYourDestinationFrag(model: DataModel) {
val action = StartFragmentDirections.actionStartFragmentToOtherFragment(model)
findNavController().navigate(action)
}
here's how your graph might look in case you wanted to jump to the third fragment in the beginning
PS: make sure you will handle back button on the third fragment, here's a solution
UPDATE:
as EpicPandaForce mentioned, you can also start activities using Navigation Components:
to do that, first add the Activity to your existing graph, either by the + icon (which didn't work for me) or by manually adding in the xml:
<activity
android:id="#+id/secondActivity"
tools:layout="#layout/activity_second"
android:name="com.amin.SecondActivity" >
</activity>
you can also add arguments and use them just like you would in a fragment, with navArgs()
<activity
android:id="#+id/secondActivity"
tools:layout="#layout/activity_second"
android:name="com.amin.SecondActivity" >
<argument
android:name="testArgument"
app:argType="string"
android:defaultValue="helloWorld" />
</activity>
in koltin,here's how you would use the argument, First declare args with the type of generated class named after you activity, in this case SecondActivityArgs in top of your activity class:
val args: SecondActivityArgsby by navArgs()
and then you can use it like this:
print(args.testArgument)
This doesn't destroy BottomAppBar. Add this to MainActivity only and don't do anything else
val navHostFragment = supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
fabAdd.setOnClickListener {
findNavController(navHostFragment).navigate(R.id.fab)
}

Categories

Resources