Android Navigation Component with Retained Fragments and enter animation - android

I am trying to use the Navigation Component with a retained Fragment and an enter animation. The fragment appears with the animation as expected. On rotating my device I get the following crash:
java.lang.IllegalStateException: The specified child already has a parent. You must call removeView() on the child's parent first.
I can solve this by removing the enter animation (from the nav graph) or by making the fragment not be retained.
Does anyone have a clue how to solves this as I wanted both retained and the animation.
Here is my onCreate:
private var binding: FragmentDemoBinding? = null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? {
if (binding == null) {
binding = FragmentDemoBinding.inflate(inflater, container, false)
}
retainInstance = true
return binding?.root
}
edit: Actually on reading a bit more I am not sure it makes sense to try and use retained fragments within the navigation component. If I need to persist data on configuration changes I guess a headless fragment or use onSaveInstance or ViewModel component etc.

In onDestroyView method remove all views or just animation view.
override fun onDestroyView() {
if(view != null){
val parent = view.parent
parent.removeAllViews
}
super.onDestroyView()
}

Related

Fragment onSaveInstanceState() called after onDestroyView()

The application started to receive some crashes (it is not reproducible 100%) due to some lifecycle issue for the Fragment.
I'm using view binding and I'm manually invalidating the binding as per Android recommendations to avoid high memory usage in case the reference to the binding is kept after the Fragment is destroyed.
private var _binding: FragmentCustomBinding? = null
private val binding get() = _binding!!
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View = FragmentCustomBinding.inflate(inflater, container, false).also {
_binding = it
}.root
override fun onDestroyView() {
_binding = null
super.onDestroyView()
}
override fun onSaveInstanceState(outState: Bundle) {
outState.apply {
putString(BUNDLE_KEY_SOME_VALUE, binding.etSomeValue.text.toString())
}
super.onSaveInstanceState(outState)
}
I'm getting a NullPointerException in onSaveInstanceState() as the binding is null as this was called after onDestroyView().
Any idea how I could solve this without manually creating a saved state and manually handling it?
The binding = null is causing the issue. To get rid of the _binding = null in the correct manner use this code:
class CustomFragment : Fragment(R.layout.fragment_custom) {
private val binding: FragmentCustomBinding by viewBinding()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// Any code we used to do in onCreateView can go here instead
}
}
According to an article on this workaround:
This technique uses an optional backing field and a non-optional val which is only valid between onCreateView and onDestroyView.
In onCreateView, the optional backing field is set and in onDestroyView, it is cleared. This fixes the memory leak!
It seems the answer for this is in how the fragments are handled, even when they do not have a view, as changes in the Activity state can still trigger onSavedInstanceState() thus I can end up in scenarios where I am in onSavedInstanceState() but without a view.
This seems to be intentional as fragments are still supported whether they have a view or not.
The recommendation was to use the view APIs for saving and restoring state (or my SavedStateRegistery).
A few more details can be found here: https://issuetracker.google.com/issues/245355409

Full Screen Dialog, programmatically setting text

I have a fullscreen dialog (defined in FullScreenDialog()) being shown on click of a button, but would like to be able to also programmatically set elements of the dialog. Something like this in the host fragment's onCreateView():
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
view.btn_computePaletteFragment.setOnClickListener {
val fragmentManager = fragmentManager
val fullScreenDialog = FullScreenDialog()
val transaction = fragmentManager?.beginTransaction()
transaction?.add(android.R.id.content, fullScreenDialog)?.addToBackStack(null)?.commit()
fullScreenDialog.dialog_title.text = "Computed Palette Input"
}
return view
}
and the corresponding class for the custom fullscreen dialog Fragment:
class FullScreenDialog : DialogFragment() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = inflater.inflate(org.plainsound.hejicalc.R.layout.fs_dialog, container, false)
view.button_close.setOnClickListener { dismiss() }
return view
}
}
However, I get a Null pointer exception: Attempt to invoke virtual method 'android.view.View android.view.View.findViewById(int)' on a null object reference. What is the correct way to do this?
The reason you're getting an exception is because the dialog hasn't been inflated yet. I know you mentioned in a comment that it's already been instantiated, but this just means that the object has been constructed, it provides no guarantee that the Android system has called onCreateView for the dialog yet.
However even if you were to get this to work, through a delay or layout listener it would be incredibly bad practice because of how Android manages fragments. If there is a rotation, low memory issue or something similar, the system can recreate the fragments just using the default constructor which will result in you losing the state you set.
In order to to this properly you should pass arguments to the fragment using setArguments and then retrieving them in the dialog with getArguments. These arguments are managed by the system and will be kept regardless of whether the fragment is recreated.
You can also use a shared ViewModel if you have complex data to share, or the new fragment results API which you can read about here.

Shared transition between fragment1 inside activity1 and fragment2 inside activity2

Is there any way to make a shared transition between fragment1 inside activity1 and fragment2 inside activity2?
I have tried achieving this like so:
val intent = Intent(this, RecipeActivity::class.java)
intent.putExtra("recipeId", recipeId)
val elem1 =
Pair<View, String>(itemView.findViewById(R.id.recipe_preview), "preview")
val elem2 =
Pair<View, String>(itemView.findViewById(R.id.recipe_title), "title")
val elem3 =
Pair<View, String>(itemView.findViewById(R.id.recipe_rating_stars), "rating_stars")
val elem4 =
Pair<View, String>(itemView.findViewById(R.id.recipe_rating), "rating")
val elem5 =
Pair<View, String>(itemView.findViewById(R.id.recipe_description), "description")
val elem6 =
Pair<View, String>(itemView.findViewById(R.id.author_avatar), "avatar")
val options =
ActivityOptionsCompat.makeSceneTransitionAnimation(
this, elem1, elem2, elem3, elem4, elem5, elem6
)
startActivity(intent, options.toBundle())
But that didn't work so well. Do I have to redesign my app so those two fragments will be inside a single activity or is there any workaround? Thank you
The idea: Pause the transaction until the targed fragment is fully loaded, created and is about to be drawn. Then continue the transaction.
The code:
Everything you do in your first activity is ok and we won't touch it.
The first thing your activity has to is to stop the transaction. Therefore you need to call supportPostponeEnterTransition() in onCreate() of your second activity. This will tell android to wait with the transaction until you tell it to start it.
Secondly you need to know when the fragment is about to be drawn. In my use case I display some fragments in a ViewPager what makes things a lot easier, as you can add an ViewTreeObserver to it and wait until the ViewPager is loaded because you know that at this point the fragments are already created and basically drawn even if you can't see them. When using frgaments the normal way with transaction you need some trickery.
Important: Everything from now on is not tested but in theory it should work.
In your fragment you do something like this:
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_home, container, false)
}
Instead you to do it like this:
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val view = inflater.inflate(R.layout.fragment_home, container, false)
return view
}
You need the root view of your fragment because we will add the ViewTreeObserver to it.
But before you to that you need to create an interface in your FragmentClass or add the method to your preexisting one:
interface FragmentListener {
fun resumeTransaction()
}
You need to implement it in your activity:
override fun resumeTransaction() {
supportStartPostponedEnterTransition()
}
In your fragment we need to get the Activity as listener. In the onAttach do following:
try {
// Instantiate the FragmentListener so we can send the event to the host
listener = context as FragmentInterface
} catch (e: ClassCastException) {
// The activity doesn't implement the interface, throw exception
throw ClassCastException((context.toString() + " must implement FragmentInterface"))
}
Now we get back to the ViewTreeObserver. In the onCreateView you do this:
viewPager.viewTreeObserver.addOnPreDrawListener(
object : ViewTreeObserver.OnPreDrawListener {
override fun onPreDraw(): Boolean {
listener.resumeTransaction()
}
}
)
If I didn't forget anything this should work. If not please tell me, I will then try to make an example app later this day as I don't have more time now.

The specified child already has a parent. Fragment on back press

I know this question seems to be asked and answered, but it is not true. Because I haven't met a solution yet.
I have Activity with a lot of fragments. And I store all transitions history (can return to each fragment in LIFO order with the back press - because each adds to back stack)
I want to reach the next feature:
When I press back - view of bottom fragment must not be re-creating.
I do next
1) Use android navigation components and transitions like
fun navigate(#IdRes resId: Int, bundle: Bundle? = null, navOptions: NavOptions? = null, sharedElements: List<View>? = null) {
navController.navigate(resId, bundle, navOptions, sharedElements?.let {
if (it.isEmpty()) null else
FragmentNavigatorExtras(
*it.map { view -> Pair(view, view.transitionNameCompat.safe) }
.filter { pair -> pair.second.isNotEmpty() }
.toTypedArray()
)
})
}
where idRes is destination id (not transition id)
2) hold the content view in fragment and detach it from the parent in onCreateView view method. Because getView() returns null even fragment appear from back stack.
private var contentView: View? = null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? =
prepareView(contentView) ?: createView(inflater, container)
private fun prepareView(view: View?): View? {
val parent = view?.parent
val viewGroup = parent as? ViewGroup
viewGroup?.removeView(view)
return view
}
protected open fun createView(inflater: LayoutInflater, container: ViewGroup?): View? {
val layout = layout()
if (layout == -1)
throw IllegalArgumentException("You need to override \"layout()\" fun or override \"createView\" fun")
return inflater.inflate(layout(), container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
if (contentView == null) {
onFirstInitialization(view, savedInstanceState)
} else {
onNextInitialization(view, savedInstanceState)
}
onEachInitialization(view, savedInstanceState)
contentView = view
}
I've got exception ONLY IF I transit to next fragment and press "back" rapidly! In normal mode all good and correct.
Exception:
java.lang.IllegalStateException: The specified child already has a parent. You must call removeView() on the child's parent first.
at android.view.ViewGroup.addViewInner(ViewGroup.java:5050)
at android.view.ViewGroup.addView(ViewGroup.java:4881)
at android.view.ViewGroup.addView(ViewGroup.java:4821)
at android.view.ViewGroup.addView(ViewGroup.java:4794)
at androidx.fragment.app.FragmentManagerImpl.moveToState(FragmentManagerImpl.java:890)
at androidx.fragment.app.FragmentManagerImpl.addAddedFragments(FragmentManagerImpl.java:2092)
at androidx.fragment.app.FragmentManagerImpl.executeOpsTogether(FragmentManagerImpl.java:1866)
at androidx.fragment.app.FragmentManagerImpl.removeRedundantOperationsAndExecute(FragmentManagerImpl.java:1822)
at androidx.fragment.app.FragmentManagerImpl.execPendingActions(FragmentManagerImpl.java:1723)
at androidx.fragment.app.FragmentManagerImpl.dispatchStateChange(FragmentManagerImpl.java:2624)
at androidx.fragment.app.FragmentManagerImpl.dispatchActivityCreated(FragmentManagerImpl.java:2580)
at androidx.fragment.app.Fragment.performActivityCreated(Fragment.java:2571)
at androidx.fragment.app.FragmentManagerImpl.moveToState(FragmentManagerImpl.java:907)
at androidx.fragment.app.FragmentManagerImpl.moveFragmentToExpectedState(FragmentManagerImpl.java:1235)
at androidx.fragment.app.FragmentManagerImpl.moveToState(FragmentManagerImpl.java:1301)
at androidx.fragment.app.FragmentManagerImpl.dispatchStateChange(FragmentManagerImpl.java:2620)
at androidx.fragment.app.FragmentManagerImpl.dispatchActivityCreated(FragmentManagerImpl.java:2580)
at androidx.fragment.app.FragmentController.dispatchActivityCreated(FragmentController.java:246)
at androidx.fragment.app.FragmentActivity.onStart(FragmentActivity.java:541)
at androidx.appcompat.app.AppCompatActivity.onStart(AppCompatActivity.java:201)
at android.app.Instrumentation.callActivityOnStart(Instrumentation.java:1470)
at android.app.Activity.performStart(Activity.java:7176)
at android.app.ActivityThread.handleStartActivity(ActivityThread.java:3086)
at android.app.servertransaction.TransactionExecutor.performLifecycleSequence(TransactionExecutor.java:180)
at android.app.servertransaction.TransactionExecutor.cycleToPath(TransactionExecutor.java:165)
at android.app.servertransaction.TransactionExecutor.executeLifecycleState(TransactionExecutor.java:142)
at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:70)
at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1926)
at android.os.Handler.dispatchMessage(Handler.java:106)
at android.os.Looper.loop(Looper.java:193)
at android.app.ActivityThread.main(ActivityThread.java:6923)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:537)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:858)
I can solve this by place prepareView to onDestroyView lifecycle method and everything work correctly, but it produces memory leak: I find it out - when the view has not a parent after onDestroyView then onDestroy never called and fragments hold in memory even after the back press.
You can say "Hey dude, don't hurry, use your app slowly and the exception will never be thrown, as you say. But app in production and I met crashes with this bug" :( How I can handle this case?
UPDATE:
I researched a little and found out something. Exception thrown only when view not detached from window. And view detach from window only after 300-400 ms after new Fragment opened
Yes! It's true. You can't create again its object because it has a parent. The main problem may you haven't noticed is whenever you called your fragment again then onCreateView() is called.
So, in-sort I suggest you move your code from onCreateView() to onCreate() method of the fragment.
I also struggled with this type of problem. So, inflate your view in onCreate() method and return main view in onCreateView() method.

Memory leak while trying to hold a reference of Fragment view beyond onDestoryView()

I want to go from FragmentA (RootFragment) to FragmentB but I do not want to recreate the view of FragmentA once it comes back from FragmentB.
I am using Jetpack Navigation for navigating between Fragments.
To achieve the above goal, I have a fragment Fragment like this:
class RootFragment : DaggerFragment() {
private var viewToRestore: View? = null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
return if (viewToRestore != null) {
viewToRestore!!
} else {
return inflater.inflate(R.layout.fragment_root, parent, false)
}
}
override fun onDestroyView() {
viewToRestore = this.view
super.onDestroyView()
}
override fun onDestroy() {
super.onDestroy()
}
}
But the FragmentA (RootFragment) is leaking once I reach Fragment B with the attribute viewToRestore.
Any solution that can work without leak but achieve the same goal?
The leak is a false positive. It is totally fine from a Fragment point of view to hold onto the View you create in onCreateView and return it later, under the condition that your Fragment is not retained or otherwise kept longer than the Context used to create the view is alive.
The problem that you have is a problem for Jetpack navigation as you cannot add when going to another fragment you can just replace:
supportFragmentManager.beginTransaction()
.replace(R.id.fragment_container, FragmentB.newInstance())
.addToBackStack(null)
.commit()
supportFragmentManager.beginTransaction()
.add(R.id.fragment_container, FragmentB.newInstance())
.addToBackStack(null)
.commit()
This is the difference replace and add.
I searched a lot and I guess that jetPack navigation does not support add instead of replace, so I recommend not to use navigation when it's important not to recreate the first fragment

Categories

Resources