Navigation Component Using Custom Views

Android Navigation Component by default supports few destination types, those are Activity, Fragment & DialogFragment. In this article, we will take a look at how to create a new destination type for View and how to create a view based navigation component.

Why?

So you might be wondering, "Why do we even need to use view-based navigation when Navigation Components already supports different destinations?"

For me the reason was simple: I wanted to use Views instead of Fragments and to see how much effort will it take to add support for new destination type.

Concepts

Before we go any further, first let’s take a look at the components from the Navigation Component API.

An XML resource that contains all navigation-related information in one centralised location. This includes all of the individual content areas within your app, called destinations, as well as the possible paths that a user can take through your app.

An empty container that displays destinations from your navigation graph. The Navigation component contains a default NavHost implementation, NavHostFragment , that displays fragment destinations.

An object that manages app navigation within a NavHost. The NavController orchestrates the swapping of destination content in the NavHost as users move throughout your app.

As you navigate through your app, you tell the NavController that you want to navigate either along a specific path in your navigation graph or directly to a specific destination. The NavController then shows the appropriate destination in the NavHost.

This is the official documentation on the Android Developers website

Now let’s see how we can use these components to create our own destination type and let NavController handle the navigation.

Code Time

So the first thing we will be doing is creating a Navigator<D> subclass call ViewNavigator. Navigator takes in a generic parameter of NavDestination. We annotate the navigator class with @Navigator.Name to set the name of the tag that we will use in navigation graph.

@Navigator.Name("view")
class ViewNavigator : Navigator<ViewNavigator.Destination>() {

	  // More code
    
    @NavDestination.ClassType(View::class)
    class Destination : NavDestination {

        constructor(navigatorProvider: NavigatorProvider) : this(
            navigatorProvider.getNavigator(
                ViewNavigator::class.java
            )
        )

        constructor(navigator: Navigator<out Destination?>) : super(navigator)
    }
}

We create a Destination class, we annotate the class with @NavDestination.ClassType . The annotation is optional, it is used by tooling to provide auto-complete in the navigation graph. Now let’s create custom attributes for ViewNavigator that will get the layout id from the navigation graph.

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="ViewNavigator">
        <attr name="layout" format="reference" />
    </declare-styleable>
</resources>

With the custom attributes in place, we will be able to use layout attribute in the navigation graph for the view destination. For example, let's say we have added a custom View class called HomeScreen to the navigation graph. We will provide the layout attribute for that destination like this.

<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/nav_graph"
    app:startDestination="@id/firstScreen"
    tools:ignore="UnusedNavigation">
    <view
        android:id="@+id/homeScreen"
        android:name="dev.sasikanth.navigator.views.HomeScreen"
        android:label="Home"
        app:layout="@layout/home_screen"
        tools:layout="@layout/home_screen" />
</navigation>

Now that we have access to the layout reference in navigation graph, we will obtain it from attributes in Destination class.

@NavDestination.ClassType(View::class)
class Destination : NavDestination {
    @LayoutRes
    var layoutRes: Int = 0
        private set

    constructor(navigatorProvider: NavigatorProvider) : super(
        navigatorProvider.getNavigator(
            ViewNavigator::class.java
        )
    )
    constructor(navigator: Navigator<out Destination?>) : super(navigator)

    override fun onInflate(context: Context, attrs: AttributeSet) {
        super.onInflate(context, attrs)
        context.resources.obtainAttributes(
            attrs,
            R.styleable.ViewNavigator
        ).apply {
            layoutRes = getResourceId(R.styleable.ViewNavigator_layout, 0)
            recycle()
        }
    }
}

Let's work on the ViewNavigator class next. We need to implement 3 methods in ViewNavigator class, those are:

  • createDestination

  • popBackStack

  • navigate

So before we dig into code, let's take a look at what we want to achieve: we want to be able to navigate to new views, save them to back stack and pop them from the back stack on up/back button clicks.

For creating a back stack we will use ArrayDeque<Int> which takes in a layout reference.

val backStack = ArrayDeque<@LayoutRes Int>()

We will use this to push and pop views to and from back stack.

Now for the actual ViewNavigator implementation.

@Navigator.Name("view")
class ViewNavigator(
    private val container: ViewGroup
) : Navigator<ViewNavigator.Destination>() {

    private val backStack = ArrayDeque<@LayoutRes Int>()

    override fun navigate(
        destination: Destination,
        args: Bundle?,
        navOptions: NavOptions?,
        navigatorExtras: Extras?
    ): NavDestination? {
        val destinationLayoutRes = destination.layoutRes

        val destinationView = instantiateView(destinationLayoutRes)
        replaceView(destinationView)

        backStack.push(destinationLayoutRes)
        return destination
    }

    override fun createDestination(): Destination = Destination(this)

    override fun popBackStack(): Boolean {
        if (backStack.isEmpty()) return false

        // Removing the last item from the back stack
        backStack.pop()

        // Once last item is removed, we are getting the new last item
        // and using it as destination
        val backStackItem = backStack.peekLast()
        if (backStackItem != null) {
            val destinationView = instantiateView(backStackItem)
            replaceView(destinationView)
        }
        return true
    }

    private fun instantiateView(destinationLayoutRes: Int): View {
        return LayoutInflater.from(container.context).inflate(destinationLayoutRes, container, false)
    }

    private fun replaceView(destinationView: View) {
        container.apply {
            removeAllViews()
            addView(destinationView)
        }
    }

    @NavDestination.ClassType(View::class)
    class Destination : NavDestination {
		  // Destination implementation
    }
}

The implementation is pretty simple, navigate is the method which is called whenever you want to navigate to different destinations. popBackStack will handle the back stack operations.

We get the layout res from the Destination and use that to inflate the view and add it to the container. That's the all the navigator does in this example. Here is the complete source code of the ViewNavigator class

@Navigator.Name("view")
class ViewNavigator(
    private val container: ViewGroup
) : Navigator<ViewNavigator.Destination>() {

    private val backStack = ArrayDeque<@LayoutRes Int>()

    override fun navigate(
        destination: Destination,
        args: Bundle?,
        navOptions: NavOptions?,
        navigatorExtras: Extras?
    ): NavDestination? {
        val destinationLayoutRes = destination.layoutRes

        val destinationView = instantiateView(destinationLayoutRes)
        replaceView(destinationView)

        backStack.push(destinationLayoutRes)
        return destination
    }

    override fun createDestination(): Destination =
        Destination(this)

    override fun popBackStack(): Boolean {
        if (backStack.isEmpty()) return false

        // Removing the last item from the back stack
        backStack.pop()

        // Once last item is removed, we are getting the new last item
        // and using it as destination
        val backStackItem = backStack.peekLast()
        if (backStackItem != null) {
            val destinationView = instantiateView(backStackItem)
            replaceView(destinationView)
        }
        return true
    }

    private fun instantiateView(destinationLayoutRes: Int): View {
        return LayoutInflater.from(container.context).inflate(destinationLayoutRes, container, false)
    }

    private fun replaceView(destinationView: View) {
        container.apply {
            removeAllViews()
            addView(destinationView)
        }
    }

    @NavDestination.ClassType(View::class)
    class Destination : NavDestination {

        @LayoutRes
        var layoutRes: Int = 0
            private set

        constructor(navigatorProvider: NavigatorProvider) : super(
            navigatorProvider.getNavigator(
                ViewNavigator::class.java
            )
        )

        constructor(navigator: Navigator<out Destination?>) : super(
            navigator
        )

        override fun onInflate(context: Context, attrs: AttributeSet) {
            super.onInflate(context, attrs)
            context.resources.obtainAttributes(
                attrs,
                R.styleable.ViewNavigator
            ).apply {
                layoutRes = getResourceId(R.styleable.ViewNavigator_layout, 0)
                recycle()
            }
        }
    }
}

So you might be wondering, "How does the navigation know what class to get when it inflates the view?" We will be creating a custom view-group that will act the screen parent. So for creating the home screen, we make a custom class called HomeScreen that extends ConstraintLayout

class HomeScreen @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr)

We then create a new layout file that will use this class as the parent, we can place all the children views inside this just like you would with another view-group.

<?xml version="1.0" encoding="utf-8"?>
<dev.sasikanth.navigator.views.HomeScreen xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
	  android:id="@+id/homeScreenRoot"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

	  <!— children —>

</dev.sasikanth.navigator.views.HomeScreen>

Now the important question is how will you access the views that are defined in the layout in the view class?. You can override the onFinishInflate class and access the views.

Let's take a look at what we made so far

  • We have created a ViewNavigator and Destination

  • We have created a navigation graph that will contain the view destinations

  • We have created custom views that will act as the screens

All we have to do now is make the navigation component use our custom ViewNavigator and display our destinations. If you remember the concepts that were mentioned earlier, NavHost is the one that displays the destinations.

Navigation component provides a default NavHost implementation, NavHostFragment, but that is useful if you want manage fragment navigation. So we have created a new implementation of NavHost that will handle

view navigation. We create a class called NavHostView, this will be a simple custom view that implements NavHost.

We also make the NavHostView lifecycle aware by making it a LifecyelOwner; the main reason for doing this is to provide the lifecycle owner for the NavHostController#setOnBackPressedDispatcher. This is the method that will handle the back clicks for the NavHost.

class NavHostView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = -1
) : FrameLayout(context, attrs, defStyleAttr), NavHost, LifecycleOwner {

    private val navigationController = NavHostController(context)
    private val lifecycleRegistry = LifecycleRegistry(this)

    private val activity: AppCompatActivity
        get() {
            var context = context
            while (context is ContextWrapper) {
                if (context is AppCompatActivity) {
                    return context
                }
                context = context.baseContext
            }

            throw NullPointerException("Failed to get activity in NavHostView")
        }

    init {
        val ta = context.resources.obtainAttributes(
            attrs,
            R.styleable.NavHostView
        )
        val navGraphId = ta.getResourceId(R.styleable.NavHostView_navGraph, 0)
        val viewNavigator = ViewNavigator(this)

        Navigation.setViewNavController(this, navigationController)

        navigationController.navigatorProvider += viewNavigator
        navigationController.setGraph(navGraphId)

        navigationController.setLifecycleOwner(this)
        navigationController.setOnBackPressedDispatcher(activity.onBackPressedDispatcher)

        ta.recycle()
    }

    override fun onFinishInflate() {
        super.onFinishInflate()
        if (isInEditMode) return

        lifecycleRegistry.currentState = Lifecycle.State.STARTED
        lifecycleRegistry.currentState = Lifecycle.State.RESUMED
    }

    override fun onDetachedFromWindow() {
        lifecycleRegistry.currentState = Lifecycle.State.DESTROYED
        super.onDetachedFromWindow()
    }

    override fun getNavController() = navigationController

    override fun getLifecycle() = lifecycleRegistry
}

If you don't want to make the NavHostView as LifecycleOwner but still handle the back clicks, you can still do that by simply overriding the onBackPressed in the activity.

override fun onBackPressed() {
    if (!navController.popBackStack()) {
        super.onBackPressed()
    }
}

By doing that your NavHostView will become simpler. It just a personal preference of mine to let the NavHostController handle the back clicks.

class NavHostView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = -1
) : FrameLayout(context, attrs, defStyleAttr), NavHost {

    private val navigationController = NavController(context)

    init {
        val ta = context.resources.obtainAttributes(
            attrs,
            R.styleable.NavHostView
        )
        val navGraphId = ta.getResourceId(R.styleable.NavHostView_navGraph, 0)
        val viewNavigator = ViewNavigator(this)

        Navigation.setViewNavController(this, navigationController)

        navigationController.navigatorProvider += viewNavigator
        navigationController.setGraph(navGraphId)

        ta.recycle()
    }

    override fun getNavController() = navigationController
}

Now, we can just implement the NavHostView in the activity layout, and you can continue using the navigation component normally.

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <dev.sasikanth.navigator.navigation.NavHostView
        android:id="@+id/navHostView"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:navGraph="@navigation/nav_graph" />

</androidx.constraintlayout.widget.ConstraintLayout>

Conclusion

That's it, now we have a custom navigator that will handle the view-based navigation in your app. You can get NavController from the screens by simply calling findNavController and you should get access to the navigation controller and you can use that to navigate the new screens.

Few things to keep in mind

  • In this example the state restoration for navigation is not handled, so every time a config change happens the navigation will go back to the first screen in nav graph.

  • Currently, the ViewNavigator implementation doesn't handle the properties set from the navigation graph like animations & extras. To keep the article and example simple, I didn't implement it. But you can go through Navigation Component source code and implement the behaviours you want.

  • If you have any feedback or suggestions please let me know 😊

You can find the code sample here

Notes