Add (not replace) fragment with navigation architecture component

AndroidAndroid Architecture-ComponentsAndroid Architecture-Navigation

Android Problem Overview


I have an activity with a product list fragment and many other fragments and I am trying to use architecture component navigation controller.

The problem is: it replaces the (start destination) product list fragment and I don't want the list to be reloaded when user click back button.

How to make the fragment transaction as add not replace?

Android Solutions


Solution 1 - Android

Android navigation component just replace but you want to add fragment instead of replace like dialog you can use this but need to min. "Version 2.1.0" for navigation component.

Solution

and you can see "Dialog destinations"

Solution 2 - Android

I faced the same problem, while waiting on add and other options for fragment transactions I implemented this work around to preserve the state when hitting back.

I just added a check if the binding is present then I just restore the previous state, the same with the networking call, I added a check if the data is present in view model then don't do the network refetching. After testing it works as expected.

EDIT: For the recycler view I believe it will automatically return to the same sate the list was before you navigated from the fragment but storing the position in the onSavedInstanceSate is also possible

  private lateinit var binding: FragmentSearchResultsBinding

  override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        viewModel =
            ViewModelProviders.of(this, mViewModelFactory).get(SearchResultsViewModel::class.java)
        return if (::binding.isInitialized) {
            binding.root
        } else {
            binding = DataBindingUtil.inflate(inflater, R.layout.fragment_search_results, container, false)

            with(binding) {
               //some stuff
                root
            }
        }
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        //reload only if search results are empty
        if (viewModel.searchResults.isEmpty()) {
           args.searchKey.let {
                binding.toolbarHome.title = it
                viewModel.onSearchResultRequest(it)
            }
        }
    }

Solution 3 - Android

You have to override NavHostFragment's createFragmentNavigator method and return YourFragmentNavigator.

YourFragmentNavigator must override FragmentNavigator's navigate method.

Copy and paste FragmentNavigator's navigate method to your YourFragmentNavigator.

In navigate method, change the line ft.replace(mContainerId, frag); with

if (fragmentManager.fragments.size <= 0) {
    ft.replace(containerId, frag)
} else {
    ft.hide(fragmentManager.fragments[fragmentManager.fragments.size - 1])
    ft.add(containerId, frag)
}

The solution will look like this:

class YourNavHostFragment : NavHostFragment() {
override fun createFragmentNavigator(): Navigator<...> {
    return YourFragmentNavigator(...)
}}

....

class YourFragmentNavigator(...) : FragmentNavigator(...) {

override fun navigate(...){
	....
	if (fragmentManager.fragments.size <= 0) {
        ft.replace(containerId, frag)
    } else {
        ft.hide(fragmentManager.fragments[fragmentManager.fragments.size - 1])
        ft.add(containerId, frag)
     }
     ....
}}

in your xml use YourNavHostFragment.

Solution 4 - Android

I was facing the same issue but in my case I updated my code to use livedata and viewmodel. when you press back the viewmodel is not created again and thus your data is retained.

make sure you do the api call in init method of viewmodel, so that it happens only once when viewmodel is created

Solution 5 - Android

just copy the FragmentNavigator's code (300 lines) and replace replace() with add(). this is the best solution for me at the moment.

@Navigator.Name("fragment")
public class CustomFragmentNavigator extends 
Navigator<...> {
    ...

    public NavDestination navigate(...) {
        ...
        ft.add(mContainerId, frag);
        ...
    }

    ...
}

Solution 6 - Android

@Rainmaker is right in my opinion, I did the same thing. We can also save the recycler view position/state in onSaveInstanceState in order to return to the same recycler view position when navigating back to the list fragment.

Solution 7 - Android

You can use these classes as your custom NavHostFragment and Navigator

NavHostFragment

class YourNavHostFragment : NavHostFragment() {

    override fun onCreateNavHostController(navHostController: NavHostController) {
        /**
         * Done this on purpose.
         */
        if (false) {
            super.onCreateNavHostController(navHostController)
        }
        val containerId = if (id != 0 && id != View.NO_ID) id else R.id.nav_host_fragment_container
        navController.navigatorProvider += YourFragmentNavigator(requireContext(), parentFragmentManager, containerId)
        navController.navigatorProvider += DialogFragmentNavigator(requireContext(), childFragmentManager)
    }
}

Navigator

@Navigator.Name("fragment")
class YourFragmentNavigator(private val context: Context, private val fragmentManager: FragmentManager, private val containerId: Int) : Navigator<YourFragmentNavigator.Destination>() {

    private val savedIds = mutableSetOf<String>()

    /**
     * {@inheritDoc}
     *
     * This method must call
     * [FragmentTransaction.setPrimaryNavigationFragment]
     * if the pop succeeded so that the newly visible Fragment can be retrieved with
     * [FragmentManager.getPrimaryNavigationFragment].
     *
     * Note that the default implementation pops the Fragment
     * asynchronously, so the newly visible Fragment from the back stack
     * is not instantly available after this call completes.
     */
    override fun popBackStack(popUpTo: NavBackStackEntry, savedState: Boolean) {
        if (fragmentManager.isStateSaved) {
            Log.i(TAG, "Ignoring popBackStack() call: FragmentManager has already saved its state")
            return
        }
        if (savedState) {
            val beforePopList = state.backStack.value
            val initialEntry = beforePopList.first()
            // Get the set of entries that are going to be popped
            val poppedList = beforePopList.subList(
                beforePopList.indexOf(popUpTo),
                beforePopList.size
            )
            // Now go through the list in reversed order (i.e., started from the most added)
            // and save the back stack state of each.
            for (entry in poppedList.reversed()) {
                if (entry == initialEntry) {
                    Log.i(TAG, "FragmentManager cannot save the state of the initial destination $entry")
                } else {
                    fragmentManager.saveBackStack(entry.id)
                    savedIds += entry.id
                }
            }
        } else {
            fragmentManager.popBackStack(popUpTo.id, FragmentManager.POP_BACK_STACK_INCLUSIVE)
        }
        state.pop(popUpTo, savedState)
    }

    override fun createDestination(): Destination {
        return Destination(this)
    }

    /**
     * Instantiates the Fragment via the FragmentManager's
     * [androidx.fragment.app.FragmentFactory].
     *
     * Note that this method is **not** responsible for calling
     * [Fragment.setArguments] on the returned Fragment instance.
     *
     * @param context Context providing the correct [ClassLoader]
     * @param fragmentManager FragmentManager the Fragment will be added to
     * @param className The Fragment to instantiate
     * @param args The Fragment's arguments, if any
     * @return A new fragment instance.
     */
    @Suppress("DeprecatedCallableAddReplaceWith")
    @Deprecated(
        """Set a custom {@link androidx.fragment.app.FragmentFactory} via
      {@link FragmentManager#setFragmentFactory(FragmentFactory)} to control
      instantiation of Fragments."""
    )
    fun instantiateFragment(context: Context, fragmentManager: FragmentManager, className: String, args: Bundle?): Fragment {
        return fragmentManager.fragmentFactory.instantiate(context.classLoader, className)
    }

    /**
     * {@inheritDoc}
     *
     * This method should always call
     * [FragmentTransaction.setPrimaryNavigationFragment]
     * so that the Fragment associated with the new destination can be retrieved with
     * [FragmentManager.getPrimaryNavigationFragment].
     *
     * Note that the default implementation commits the new Fragment
     * asynchronously, so the new Fragment is not instantly available
     * after this call completes.
     */
    override fun navigate(entries: List<NavBackStackEntry>, navOptions: NavOptions?, navigatorExtras: Navigator.Extras?) {
        if (fragmentManager.isStateSaved) {
            Log.i(TAG, "Ignoring navigate() call: FragmentManager has already saved its state")
            return
        }
        for (entry in entries) {
            navigate(entry, navOptions, navigatorExtras)
        }
    }

    private fun navigate(entry: NavBackStackEntry, navOptions: NavOptions?, navigatorExtras: Navigator.Extras?) {
        val backStack = state.backStack.value
        val initialNavigation = backStack.isEmpty()
        val restoreState = (navOptions != null && !initialNavigation && navOptions.shouldRestoreState() && savedIds.remove(entry.id))
        if (restoreState) {
            // Restore back stack does all the work to restore the entry
            fragmentManager.restoreBackStack(entry.id)
            state.push(entry)
            return
        }
        val destination = entry.destination as Destination
        val args = entry.arguments
        var className = destination.className
        if (className[0] == '.') {
            className = context.packageName + className
        }
        val frag = fragmentManager.fragmentFactory.instantiate(context.classLoader, className)
        frag.arguments = args
        val ft = fragmentManager.beginTransaction()
        var enterAnim = navOptions?.enterAnim ?: -1
        var exitAnim = navOptions?.exitAnim ?: -1
        var popEnterAnim = navOptions?.popEnterAnim ?: -1
        var popExitAnim = navOptions?.popExitAnim ?: -1
        if (enterAnim != -1 || exitAnim != -1 || popEnterAnim != -1 || popExitAnim != -1) {
            enterAnim = if (enterAnim != -1) enterAnim else 0
            exitAnim = if (exitAnim != -1) exitAnim else 0
            popEnterAnim = if (popEnterAnim != -1) popEnterAnim else 0
            popExitAnim = if (popExitAnim != -1) popExitAnim else 0
            ft.setCustomAnimations(enterAnim, exitAnim, popEnterAnim, popExitAnim)
        }
        if (fragmentManager.fragments.size <= 0) {
            ft.replace(containerId, frag)
        } else {
            ft.hide(fragmentManager.fragments[fragmentManager.fragments.size - 1])
            ft.add(containerId, frag)
        }
        @IdRes val destId = destination.id
        // TODO Build first class singleTop behavior for fragments
        val isSingleTopReplacement = (navOptions != null && !initialNavigation && navOptions.shouldLaunchSingleTop() && backStack.last().destination.id == destId)
        val isAdded = when {
            initialNavigation -> {
                true
            }
            isSingleTopReplacement -> {
                // Single Top means we only want one instance on the back stack
                if (backStack.size > 1) {
                    // If the Fragment to be replaced is on the FragmentManager's
                    // back stack, a simple replace() isn't enough so we
                    // remove it from the back stack and put our replacement
                    // on the back stack in its place
                    fragmentManager.popBackStack(entry.id, FragmentManager.POP_BACK_STACK_INCLUSIVE)
                    ft.addToBackStack(entry.id)
                }
                false
            }
            else -> {
                ft.addToBackStack(entry.id)
                true
            }
        }
        if (navigatorExtras is Extras) {
            for ((key, value) in navigatorExtras.sharedElements) {
                ft.addSharedElement(key, value)
            }
        }
        ft.setReorderingAllowed(true)
        ft.commit()
        // The commit succeeded, update our view of the world
        if (isAdded) {
            state.push(entry)
        }
    }

    override fun onSaveState(): Bundle? {
        if (savedIds.isEmpty()) {
            return null
        }
        return bundleOf(KEY_SAVED_IDS to ArrayList(savedIds))
    }

    override fun onRestoreState(savedState: Bundle) {
        val savedIds = savedState.getStringArrayList(KEY_SAVED_IDS)
        if (savedIds != null) {
            this.savedIds.clear()
            this.savedIds += savedIds
        }
    }

    /**
     * NavDestination specific to [FragmentNavigator]
     *
     * Construct a new fragment destination. This destination is not valid until you set the
     * Fragment via [setClassName].
     *
     * @param fragmentNavigator The [FragmentNavigator] which this destination will be associated
     * with. Generally retrieved via a [NavController]'s [NavigatorProvider.getNavigator] method.
     */
    @NavDestination.ClassType(Fragment::class)
    open class Destination
    constructor(fragmentNavigator: Navigator<out Destination>) : NavDestination(fragmentNavigator) {

        /**
         * Construct a new fragment destination. This destination is not valid until you set the
         * Fragment via [setClassName].
         *
         * @param navigatorProvider The [NavController] which this destination
         * will be associated with.
         */
        //public constructor(navigatorProvider: NavigatorProvider) : this(navigatorProvider.getNavigator(FragmentNavigator::class.java))

        @CallSuper
        public override fun onInflate(context: Context, attrs: AttributeSet) {
            super.onInflate(context, attrs)
            context.resources.obtainAttributes(attrs, R.styleable.FragmentNavigator).use { array ->
                val className = array.getString(R.styleable.FragmentNavigator_android_name)
                if (className != null) setClassName(className)
            }
        }

        /**
         * Set the Fragment class name associated with this destination
         * @param className The class name of the Fragment to show when you navigate to this
         * destination
         * @return this [Destination]
         */
        fun setClassName(className: String): Destination {
            _className = className
            return this
        }

        private var _className: String? = null

        /**
         * The Fragment's class name associated with this destination
         *
         * @throws IllegalStateException when no Fragment class was set.
         */
        val className: String
            get() {
                checkNotNull(_className) { "Fragment class was not set" }
                return _className as String
            }

        override fun toString(): String {
            val sb = StringBuilder()
            sb.append(super.toString())
            sb.append(" class=")
            if (_className == null) {
                sb.append("null")
            } else {
                sb.append(_className)
            }
            return sb.toString()
        }

        override fun equals(other: Any?): Boolean {
            if (other == null || other !is Destination) return false
            return super.equals(other) && _className == other._className
        }

        override fun hashCode(): Int {
            var result = super.hashCode()
            result = 31 * result + _className.hashCode()
            return result
        }
    }

    /**
     * Extras that can be passed to FragmentNavigator to enable Fragment specific behavior
     */
    class Extras internal constructor(sharedElements: Map<View, String>) :
        Navigator.Extras {
        private val _sharedElements = LinkedHashMap<View, String>()

        /**
         * The map of shared elements associated with these Extras. The returned map
         * is an [unmodifiable][Map] copy of the underlying map and should be treated as immutable.
         */
        val sharedElements: Map<View, String>
            get() = _sharedElements.toMap()

        /**
         * Builder for constructing new [Extras] instances. The resulting instances are
         * immutable.
         */
        class Builder {
            private val _sharedElements = LinkedHashMap<View, String>()

            /**
             * Adds multiple shared elements for mapping Views in the current Fragment to
             * transitionNames in the Fragment being navigated to.
             *
             * @param sharedElements Shared element pairs to add
             * @return this [Builder]
             */
            fun addSharedElements(sharedElements: Map<View, String>): Builder {
                for ((view, name) in sharedElements) {
                    addSharedElement(view, name)
                }
                return this
            }

            /**
             * Maps the given View in the current Fragment to the given transition name in the
             * Fragment being navigated to.
             *
             * @param sharedElement A View in the current Fragment to match with a View in the
             * Fragment being navigated to.
             * @param name The transitionName of the View in the Fragment being navigated to that
             * should be matched to the shared element.
             * @return this [Builder]
             * @see FragmentTransaction.addSharedElement
             */
            fun addSharedElement(sharedElement: View, name: String): Builder {
                _sharedElements[sharedElement] = name
                return this
            }

            /**
             * Constructs the final [Extras] instance.
             *
             * @return An immutable [Extras] instance.
             */
            fun build(): Extras {
                return Extras(_sharedElements)
            }
        }

        init {
            _sharedElements.putAll(sharedElements)
        }
    }

    private companion object {
        private const val TAG = "YourFragmentNavigator"
        private const val KEY_SAVED_IDS = "androidx-nav-fragment:navigator:savedIds"
    }
}

Usage

In your activity/fragment your FragmentContainerView should look like this.

<androidx.fragment.app.FragmentContainerView
            android:id="@+id/navHost"
            android:name="in.your.android.core.platform.navigation.YourNavHostFragment"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:defaultNavHost="true"
            app:navGraph="@navigation/nav_graph" />

Solution 8 - Android

After searching a bit, it's not possible, but the problem itself can be solved with viewmodel and livedata or rxjava. So fragment state is kept after transactions and my product list will not reload each time

Attributions

All content for this solution is sourced from the original question on Stackoverflow.

The content on this page is licensed under the Attribution-ShareAlike 4.0 International (CC BY-SA 4.0) license.

Content TypeOriginal AuthorOriginal Content on Stackoverflow
Questionmohamed ShaterView Question on Stackoverflow
Solution 1 - AndroidutkukutluView Answer on Stackoverflow
Solution 2 - AndroidRainmakerView Answer on Stackoverflow
Solution 3 - AndroidsrdrslmnView Answer on Stackoverflow
Solution 4 - AndroidCULT_MENTALITYView Answer on Stackoverflow
Solution 5 - AndroidЯрослав НестеровView Answer on Stackoverflow
Solution 6 - Androiduser3193413View Answer on Stackoverflow
Solution 7 - AndroidOhhhThatVarunView Answer on Stackoverflow
Solution 8 - AndroidMohamed ShaterView Answer on Stackoverflow