Kotlin delegate disrupting Navigation - android

I'm trying Jetpack Navigation component and have set up a very basic navigation graph with just 2 Fragments with one home fragment (Foo) containing a button which calls a navigation action to open the other fragment (Bar).
With only the basic Android usage and functions it works as intended, I can navigate back to Foo by pressing the back button and navigate forward to Bar again.
I implemented this convenience delegate class for binding views by id in my preferred way (Im originally an iOS dev).
class FindViewById<in R, T: View>(private val id: Int) {
private var view: T? = null
operator fun getValue(thisRef: R, property: KProperty<*>): T {
var view = this.view
if (view == null) {
view = when (thisRef) {
is Activity -> thisRef.findViewById(id)!!
is Fragment -> thisRef.requireView().findViewById(id)!!
is View -> thisRef.findViewById(id)!!
else -> throw NullPointerException()
}
this.view = view // Comment out to never cache reference
}
return view
}
}
This allows me to write code like this
class FragmentFoo: Fragment() {
private val textView: TextView by FindViewById(R.id.text_view)
private val button: Button by FindViewById(R.id.button)
...
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
button.setOnClickListener {
findNavController().navigate(R.id.action_foo_to_bar)
}
}
}
Now all of a sudden when I navigate to Bar and then press the back button I arrive at Foo again but I cannot navigate forward to Bar. If I remove the line this.view = view in FindViewById it works again.
My guess is there is some memory related issue, though I tried wrapping the view inside a WeakReference but it didn't solve the problem.
I think it is a good idea performance-wise to cache the found view in the delegate.
Any idea why this is occurring and how I can resolve the problem while caching the found view?
Edit
My intention is not to find another way of referencing views but rather why this delegate implementation disrupts the Navigation component so I don't experience it again if I were to make another custom delegate in the future.
Solution
is Fragment -> {
thisRef.viewLifecycleOwnerLiveData.value!!.lifecycle.addObserver(object: LifecycleEventObserver {
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
if (event == Lifecycle.Event.ON_STOP) this#FindViewById.view = null
}
})
return thisRef.requireView().findViewById(id)!!
}

In android Fragment view has its own well-defined lifecycle and this lifecycle is managed independently from that of the fragment's Lifecycle.
When you are using navigation component it uses a fragment replace transaction under the hood and adds the previous fragment to the back stack. At this point this fragment goes into CREATED state and as you can see on this diagram its view is actually destroyed. At this point your delegate still keeps the reference to this old view hierarchy, causing a memory leak.
Later, when you are navigating back, the fragment goes back through STARTED into RESUMED state, but the view hierarchy is recreated - onCreateView and onViewCreated methods are called again during this process. So while the fragment displays a completely new view hierarchy, your delegate still references the old one.
So if you'd like to manually cache any view references, you need to override onDestroyView and clear these references to avoid memory leaks and this kind of incorrect behavior. Also for this particular problem I'd recommend using ViewBinding.
If you'd like to have your own implementation, but do not like to clear references in onDestroyView (e.g. because it breaks nice and self-contained abstraction), viewLifecycleOwnerLiveData might be useful to observe current view state and clear all references when view is destroyed.
Please check out the fragments documentation, it has been recently updated and covers most aspects of fragments.

Related

Problem with android toolbar using navigation drawer

List the item
I'm developing an app using a navigation drawer and navigation components and I'm facing two issues:
I settled specifically each toolbar title where it is supposed to be, but every time I change the fragments, in the toolbar, for an instant, I can see the previous name from the fragment, which is the fragment name itself. So, it quickly changes from MySpecificFragment to MyFragmentName and I would like it to not happen. I've settled the title even onCreateView or onViewCreated. It didn't matter, still happening.
How could I decide the direction in which the back button of the fragment goes? I would like to create a standard position where the back button arrow goes, always the same. But it just travels back to the previous fragment (which is not a real problem, but I would like to improve its behavior)
Sorry for the lack of code, I don't know what I am supposed to display since I'm going against the standard android behavior.
P.S.: Using android studio and kotlin
Regarding the first issue, one way to avoid the brief display of the previous fragment name in the toolbar is to set the toolbar title in the parent activity and then update it from the fragment's onResume() method. This ensures that the toolbar title is set correctly when the fragment is resumed after being pushed onto the back stack. Here's an example code snippet:
In your activity:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
setSupportActionBar(toolbar)
}
fun setToolbarTitle(title: String) {
supportActionBar?.title = title
}
}
In your fragment:
class MySpecificFragment : Fragment() {
override fun onResume() {
super.onResume()
(activity as? MainActivity)?.setToolbarTitle("MySpecificFragment")
}
}
Regarding the second issue, you can customize the back button behavior by using a custom NavController.OnDestinationChangedListener. In the listener, you can set the back button icon and its behavior based on the current and previous destinations. Here's an example code snippet:
class MyNavigationController(activity: AppCompatActivity, navController: NavController) {
init {
navController.addOnDestinationChangedListener(
activity, object : NavController.OnDestinationChangedListener {
override fun onDestinationChanged(
controller: NavController,
destination: NavDestination,
arguments: Bundle?
) {
if (destination.id == R.id.my_fragment) {
activity.supportActionBar?.setDisplayHomeAsUpEnabled(false)
} else {
activity.supportActionBar?.setDisplayHomeAsUpEnabled(true)
activity.supportActionBar?.setHomeAsUpIndicator(R.drawable.ic_arrow_back)
activity.supportActionBar?.setHomeActionContentDescription(R.string.back)
activity.supportActionBar?.setHomeAsUpIndicator(R.drawable.ic_arrow_back)
activity.supportActionBar?.setDisplayShowHomeEnabled(true)
}
}
})
}
}
Here, you can adjust the back button icon and behavior based on the current and previous destinations by setting setDisplayHomeAsUpEnabled, setHomeAsUpIndicator, and setHomeActionContentDescription.

RecyclerView with StaggeredGridLayoutManager in ViewPager, arranges items automatically when going back to fragment

I am using Navigation component in my App, using google Advanced Sample(here).
my problem is when going back to a fragment, the scrolling position does not lost but it rearranges items and moves highest visible items so that top of those item align to top of recyclerview. please see this:
before going to next fragment:
and after back to fragment:
this problem is matter because some times clicked item goes down and not seen until scroll down.
how to prevent this behavior?
please consider:
this problem exist if using navigation component to change fragment. if start fragment using supportFragmentManager.beginTransaction() or start another activity and then go to this fragment it is OK. but if I navigate to another fragment using navigation component this problem is exist.(maybe because of recreating fragment)
also this problem exist if using fragment in ViewPager. i.e recyclerView is in a fragment that handle with ViewPagerAdapter and viewPager is in HomeFragment that opened with Navigation component. if recyclerView is in HomeFragment there is no problem.
no problem with LinearLayoutManager. only with StaggeredGridLayoutManager.
there is not difference if using ViewPager2 and also FragmentStatePagerAdapter
I try to prevent recreate of fragment(by this solution) but not solved.
UPDATE:
you can clone project with this problem from here
When using Navigation Component + ViewPager + StaggeredGridLayoutManager, wrong recyclerView.computeVerticalScrollOffset() has been returned during Fragment recreate.
In general, all layout managers bundled in the support library already know how to save and restore scroll position, but in this case, we had to take responsibility for this.
class TestFragment : Fragment(R.layout.fragment_test) {
private val testListAdapter: TestListAdapter by lazy {
TestListAdapter()
}
private var layoutManagerState: Parcelable? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
postListView.apply {
layoutManager = StaggeredGridLayoutManager(
2, StaggeredGridLayoutManager.VERTICAL
).apply {
gapStrategy = StaggeredGridLayoutManager.GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS
}
setHasFixedSize(true)
adapter = testListAdapter
}
testListAdapter.stateRestorationPolicy = RecyclerView.Adapter.StateRestorationPolicy.PREVENT
}
override fun onPause() {
saveLayoutManagerState()
super.onPause()
}
override fun onViewStateRestored(savedInstanceState: Bundle?) {
super.onViewStateRestored(savedInstanceState)
restoreLayoutManagerState()
}
private fun restoreLayoutManagerState () {
layoutManagerState?.let { postListView.layoutManager?.onRestoreInstanceState(it) }
}
private fun saveLayoutManagerState () {
layoutManagerState = postListView.layoutManager?.onSaveInstanceState()
}
}
Source code: https://github.com/dautovicharis/MyStaggeredListSample/tree/q_65539771
The Navigation Component behavior is normal when you navigate from one fragment to another. I mean, onDestroyView() method from the previous fragment is executed, so it means that your view is destroyed, but not the fragment. Remembering that fragment has two lifecycles one for the fragment and another one for the view, There was a video about it.
Also, there were issues registered in issue tracker in order to avoid this behavior in some cases and the GitHub issues:
https://issuetracker.google.com/issues/127932815
https://github.com/android/architecture-components-samples/issues/530
The problem is that when you have fragment that is heavy to recreate, is easier to do not destroy it and just add one fragment. So, when you go back it is not recreated. But, for this behavior is not part of navigation component.
Solutions
The easiest solution is to not use navigation component and work with the tradicional way, as you can see this works perfectly in you use case.
You can use the traditional way just for this use case, and use the navigation component for other cases.
You can inflate this view in an activity. So you are adding un activity
But if the previous tree options is not possible. You can try the following:
If you are using viewModel, you can use SaveState. Basically, it can save the data from your fragment, it is like a map data structure, so you can save positions from your list or recycler view. When go back to this fragment, get the position from this saveState object and use scrollToPosition method in order to add the real position.
Recycler view have methods for restore positions. You can see the uses cases for that, because first you need the data and then add the real position, for more details you can visit this link. This configuration for recycler view is useful also when you lose memory and you need to recreate the recycler view with asynchronous data.
Finally, if you want to understand more about how fragment works with navigation component, you can see this link

FragmentController vs FragmentScenario

Roblectric's FragmentController allowed us to drive the lifecycle of the Fragment to a desired state. It seems that FragmentScenario always takes the Fragment under test to its resumed state. One would assume that calling moveToState(CREATED) would take the Fragment back to through its onCreate(), but in reality, as per the docs, moveToState() simulates an external app interacting with the Fragment under test. This means that moveToState(CREATED) moves the Fragment under test through onPause() and onStop() as it would happen when a new Activity is launched.
To drive the fragment to a different lifecycle state, call moveToState(). This methods supports the following states as arguments: CREATED, STARTED, RESUMED, and DESTROYED. This action simulates a situation where the activity containing your fragment changes its state because it's interrupted by another app or a system action.
Is it possible to somehow direct FragmentScenario to drive the Fragment to a desired state instead of always going through onResume()? How do we test that something happened inside onCreate() and not inside onResume() using FragmentScenario? Am I missing something here?
Not sure why you need to test something during fragment's onCreate, but I had the case to supply my fragment with fake viewmodel(mocking happy case, etc.) - you can do it as follows:
#Test fun yetAnotherTest(){
val fakeViewModel = YourViewModel(fakeDependency1, fakeDependency2,...)
val scenario: FragmentScenario<YourFragment> =
launchFragmentInContainer {
YourFragment().apply {
viewModel = fakeViewModel
}
}
}
and some minor modifications
lateinit var viewmodel: YourViewModel
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
//if this is yetAnotherTest() run, line is skipped
if (!::viewModel.isInitialized) {
//normal setup
viewModel = provideYourViewModel()
}
alternatively, you can provide FragmentFactory for your fragment so you can test fragments with FragmentScenario more easily.

The Fragment refreshes on back- Android Navigation

I am making an application using the Android Navigation component. But I ran into a very fundamental problem which can cause problems in the whole development of my application.
The Scenario
I have this Fragment where in onViewCreated I am observing a field from my viewmodel.
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel = ViewModelProviders.of(this).get(EventDetailsViewModel::class.java)
viewModel.init(context!!,eventId)
viewModel.onEventDetailsUpdated().observe(this, Observer {
setEventDetails(it)
})
}
And in the setEventDetails method, I set recyclerviews with the data.
The PROBLEM
This fragment is a long fragment with a scroll. Suppose I scroll long way down to a section and click on a button which takes me to another fragment.
But when I come back to this fragment, it again takes me to the top and does everything that it did on first load.
That can be troubling. It is kind of recreating the whole fragment instead of keeping its old state.
What I tried
I searched a lot of questions. And went through This Github Query, This SO question, Another Git... But I could not solve my problem.
Please help, Thanks in advance.
Yes, Fragment's view will get destroyed whenever you navigate forward to another fragment.
RecyclerView's scroll position should be automatically restored, even when new instance of RecyclerView is created and new Adapter instance is set, as long as you setup everything with the same dataset as before. Also, you need to do it before the first layout pass.
This means that you need your old data and you need to have it ready immediately (no async loads!).
ViewModelProvider should return the same ViewModel instance. That ViewModel holds the data you should be able to synchronously get and display on the UI. Make sure to refactor your viewModel.init method - you don't want to make API call if data is already there in case when going back. A simple boolean isInitialized can work here, or you can even check if LiveData is empty or not.
Also, you have a subtle bug when calling observe on LiveData. onViewCreated can be called many times for the same fragment (each time you navigate forward and back!) - so observe will be called each time. Your Fragment will be subscribed many times to the same LiveData. This means you will get events multiple times (once for each subscription). This can cause issues with RecyclerView state restoration too. Your subscription is tied to Lifecycle owner you passed. You passed Fragment's Lifecycle owner which is tied to Fragment's lifecycle. What you want to do is pass Fragment view's lifecycle owner, so whenever the view is destroyed the subscription gets cleared, and you only have 1 subscription ever and only while the Fragment's view is alive. For this, you can use getViewLifecycleOwner instead of this.
You need to rely on ViewModel to restore the fragment state because ViewModel doesn't get destroyed on fragment change.
In your viewModel, create a variable listState
class HomeViewModel : ViewModel() {
var listState: Parcelable? = null
}
Then in your fragment use below code
class HomeFragment : Fragment() {
private val viewModel by navGraphViewModels<HomeViewModel>(R.id.mobile_navigation)
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
if (viewModel.listState != null) {
list.layoutManager?.onRestoreInstanceState(viewModel.listState)
viewModel.listState = null
}else{
//load data normally
}
override fun onDestroyView() {
super.onDestroyView()
viewModel.listState = list.layoutManager?.onSaveInstanceState()
}
}
You don't have to initialize the view model each time. Just check for null before initializing. Don't know kotlin, still it will be something like:
if(viewModel == null){
viewModel = ViewModelProviders.of(this).get(EventDetailsViewModel::class.java)
viewModel.init(context!!,eventId)
}
try putting this code where you first call your fragment.
ft = fm.beginTransaction();
ft.replace(R.id.main_fragment, yourSearchFragment, "searchFragment");
ft.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN);
ft.commit();
and this when going back to the fragment
ft = fm.beginTransaction();
ft.hide(getFragmentManager().findFragmentByTag("searchFragment"));
ft.add(R.id.main_fragment, yourDetailfragment);
ft.addToBackStack(null);
ft.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN);
ft.commit();

Navigation Component Lifecycle

I have been trying out Navigation Component for a while now but somehow not getting the reason (or explanation) behind current Lifecycle of Navigation Component. Here are some points that needs clarification.
1. Fragment to Fragment flow
In navigation Component every fragment (or lets say page) is recreated every time it is visited (or revisited). So, when you are at A and go to B, A is destroyed and later when you go back to A (like pressing back button) A is created from stretch.
In a traditional Activity patterns when you go back to A it just goes to onResume as it wasn't destroyed when moving to B. Any reason that this pattern is changed in Navigation Component?
The problem of recreating is when you have a lot of data and it takes time to get redrawn and it feels like app is freezing. An example would be simple HomePage (say Facebook NewsFeed). Preserving data can be handled with ViewModel but drawing of all of the feeds again require time and it will freeze.
There is another problem that recreation generates. Assume this scenario: A has an Collapsing AppBar with a NestedScrollView. User scrolls down and AppBar will collapse and then user moves to a different page B. When he comes back to A it will be redrawn and AppBar is expanded. I am not sure if it is a bug or I should do something to fix it? So any solution here is also welcomed.
2. Activity recreation
If activity is recreated for certain reasons like orientation change, every page in Navigation Component gets recreated until current destination. Although onCreate method of Fragment not onCreateView is getting called, I don't see a point of creating Fragments in Activity recreation. Any explanation would be welcomed.
Navigation component only supports fragment replacement as of now. So you won't be able to add() a fragment as you do it with Manual fragment transaction.
However, if your worry is about re-inflating the layout and re-fetching the data for the fragment, it could be easily resolved with below two methods.
Once the view is created, store it in a variable and use it whenever onCreateView() is called.
private var view: View? = null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
if (view == null) {
view = inflater.inflate(R.layout.fragment_list, container, false)
//...
}
return view
}
Source: https://twitter.com/ianhlake/status/1103522856535638016
Use ViewModel with the Fragment and hold the data required as a member variable. By this way, the data is not cleared when you replace the associated fragment. The ViewModel gets cleared only on onDestroy() of the fragment, which will only happen when you destroy the parent activity. https://developer.android.com/images/topic/libraries/architecture/viewmodel-lifecycle.png
The way that we use fragments to bridge data and views has changed slightly, and in a good way, when migrating to the Navigation library. It forces us to distinguish between Fragment and View lifecycles.
Pre-navigation: observe LiveData in onCreate() using Fragment's lifecycleScope.
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
...
import kotlinx.android.synthetic.main.old_fragment.*
class OldFragment : Fragment(R.layout.old_fragment) {
private val vm by activityViewModels<MainViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
vm.getLiveData().observe(this) { data ->
oldTextView.text = data.name
}
}
}
Navigation: observe LiveData in onViewCreated() using viewLifecycleOwner scope.
...
class NewFragment : Fragment() {
private val vm by activityViewModels<MainViewModel>()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
vm.getLiveData().observe(viewLifecycleOwner) { data ->
oldTextView.text = data.name
}
}
}
Key Notes:
Not all Lifecycle Owners are the same. The fragment lifecycle will not execute the observer when the view is recreated (while using Navigation library and navigating up/back).
The viewLifecycleOwner cannot be accessed before the view is created.
Hopefully, this can help prevent replacement of LiveData code as developers migrate to Navigation.

Categories

Resources