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 View
s instead of Fragment
s 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.
Navigation graph
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.
NavHost
An empty container that displays destinations from your navigation graph. The Navigation component contains a default NavHost implementation, NavHostFragment , that displays fragment destinations.
NavController
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
andDestination
-
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
-
AndroidX Navigation - Source code
-
Add support for new destination types - Android Developers
-
Provide custom back navigation - Android Developers
-
Get started with the Navigation component - Android Developers