Photo by Andrew Neel on Unsplash

I have really enjoyed how Instagram and Youtube handle navigation, thier navigation is such that each page preserves the back stack so when jumping between the pages, your back stack is not lost. You can jump from explore to your profile, and back to explore, and the initial state of explore still remains.

In this post I will show you how to achieve that using androids Navigation Component and View Pager, and here are the things that you learn by the end of this post.

  • Setting up navigation using ViewPager and BottomNavigation and keeping both of them in sync
  • Syncing individual root destinations up-nav with the overall back-stack
  • Setting up DeepLinking triggered either; from a URL or from a Notification bar

Here is a preview of what we are trying to achieve

Youtube style navigation
TLDR: you can find the source code to the post on github here.

ebi-igweze/ViewPagerNavigation

For this post, we are going to be working with solely imaginary content. We are going to build a simple app that has an imaginary:

  • Home Screen: that displays explorable books
  • Library Screen: that displays all books the user is reading, from which the user can navigate to a specific book
  • Settings Screen: that displays the user’s Profile and Security settings.

Before we start, this post assumes that you are already familiar with the Navigation API, if you are not, you can check out this codelab by google, it is really awesome and takes about 15–20 minutes or less.

To Begin

To begin, we will first create all the files involved in this post and we will fill them as we go along in this post.

So first create a new android studio project, and name it whatever you like or you can use ViewPagerNav.

Include the Android Design and Navigation Libraries, to your app dependencies.

dependencies {
    //... other dependencies
    // android desing support library
implementation "com.android.support:design:28.0.0"

// navigation extensions
implementation "android.arch.navigation:navigation-fragment-ktx:$navigation_version"
implementation "android.arch.navigation:navigation-ui-ktx:$navigation_version"



}

Now, set up your project to have the following packages and files. So, create each package with its containing file, as seen in the image below:

Project file structure

For each of the fragments, create them as usual; with “file -> new fragment” and uncheck the “fragment factory method” and “interface callback” options, like in the image below.

new fragment

Now that you have all the files created, we will create the graphs we will be working with, in this post.

Create three navigation xml files, which will be named: nav_graph_home, nav_graph_library, and nav_graph_settings. Set the text content of the graphs to look like so:

nav_graph_home.xml

<?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_home"
app:startDestination="@id/home_dest">

<fragment
android:id="@+id/home_dest"
android:name="com.example.viewpagernavigation
.modules.home.HomeFragment"
android:label="Home"
tools:layout="@layout/fragment_home">
<action
android:id="@+id/action_search"
app:destination="@id/search_dest"
app:exitAnim="@anim/nav_default_exit_anim"
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
app:popExitAnim="@anim/nav_default_pop_exit_anim" />
</fragment>
<fragment
android:id="@+id/search_dest"
android:name="com.example.viewpagernavigation
.modules.home.SearchFragment"
android:label="Search" />
</navigation>
nav_graph_home.xml

nav_graph_library.xml

<?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" android:id="@+id/nav_graph_library"
app:startDestination="@id/library_dest">

<fragment
android:id="@+id/library_dest"
android:name="com.example.viewpagernavigation
.modules.library.LibraryFragment"
android:label="Library">
<action
android:id="@+id/action_read"
app:destination="@id/book_dest" />
</fragment>
<fragment
android:id="@+id/book_dest"
android:name="com.example.viewpagernavigation
.modules.library.BookFragment"
android:label="Book" />
</navigation>
nav_graph_library.xml

nav_graph_settings.xml

<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" android:id="@+id/nav_settings"
app:startDestination="@id/settings_dest">

<fragment
android:id="@+id/settings_dest"
android:name="com.example.viewpagernavigation
.modules.settings.SettingsFragment"
android:label="Settings">
<action
android:id="@+id/action_profile_settings"
app:destination="@id/profile_settings_dest" />
<action
android:id="@+id/action_privacy_settings"
app:destination="@id/privacy_settings_dest" />
</fragment>
    <fragment
android:id="@+id/privacy_settings_dest"
android:name="com.example.viewpagernavigation
.modules.settings.PrivacySettingsFragment"
android:label="Privacy Settings" />
    <fragment
android:id="@+id/profile_settings_dest"
android:name="com.example.viewpagernavigation
.modules.settings.ProfileSettingsFragment"
android:label="Profile Settings" />

</navigation>
nav_graph_settings.xml

To set up the navigation using the actions defined in the graphs above, we will be making the following changes.

First, let us set up navigation between the library and book destinations by changing these respective files:

fragment_library.xml

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.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=".modules.library.LibraryFragment">
    <!-- add the following section -->
 <android.support.constraint.ConstraintLayout
android:id="@+id/awesome_book"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:layout_marginBottom="10dp"
android:background="#EEE"
app:layout_constraintTop_toBottomOf="@+id/titleText"
android:padding="10dp">


<TextView
android:id="@+id/roomName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="22sp"
android:text="Winds of Winter"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
/>

<TextView
android:id="@+id/audienceCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textStyle="bold"
android:text="2018"
app:layout_constraintTop_toBottomOf="@id/roomName"
app:layout_constraintStart_toStartOf="parent"
/>

</android.support.constraint.ConstraintLayout>


</android.support.constraint.ConstraintLayout>

LibraryFragment

class LibraryFragment : Fragment() {


// other stuff

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
        // add click listener to book item
awesome_book.setOnClickListener {
 val bundle = Bundle().apply {
putString(BookFragment.KEY_TITLE, "Winds of Winter")
putString(BookFragment.KEY_DATE, "2018")
}

findNavController().navigate(R.id.action_read, bundle)
}
}

}

fragment_book.xml

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.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=".modules.library.BookFragment">

<TextView
android:id="@+id/book_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="20dp"
android:paddingEnd="20dp"
android:text="Book Fragment"
android:textSize="30dp"
android:textAlignment="center"
android:layout_marginTop="30dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toBottomOf="parent"

/>

<TextView
android:id="@+id/book_publish_date"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Book Fragment"
android:layout_marginTop="10dp"
android:textSize="18dp"
app:layout_constraintTop_toBottomOf="@id/book_title"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"

/>


</android.support.constraint.ConstraintLayout>

BookFragment

class BookFragment : Fragment() {

private lateinit var bookTitle: String
private lateinit var bookPublishDate: String

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

arguments?.apply {
bookTitle = getString(KEY_TITLE, "")
bookPublishDate = getString(KEY_DATE, "")
}
}


//... other stuff

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
        // set the text values 
book_title.text = bookTitle
book_publish_date.text = bookPublishDate

}



companion object {
const val KEY_TITLE = "title"
const val KEY_DATE = "date"
}

}

Secondly, we will set up navigation for the settings page, so make the following changes to settings.

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.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=".modules.settings.SettingsFragment">



<TextView
android:id="@+id/titleText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Settings Fragment"
android:layout_marginTop="50dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
/>

<Button
android:id="@+id/btn_privacy_settings"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Privacy Settings"
app:layout_constraintTop_toBottomOf="@+id/titleText"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="20dp"
/>

<Button
android:id="@+id/btn_profile_settings"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Profile Settings"
app:layout_constraintTop_toBottomOf="@+id/btn_privacy_settings"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="20dp"
/>


</android.support.constraint.ConstraintLayout>

And add the click listeners for the buttons to trigger the navigation actions:

class SettingsFragment : Fragment() {
    //... other stuff

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

btn_privacy_settings.setOnClickListener {
findNavController()
.navigate(R.id.action_privacy_settings)
}

btn_profile_settings.setOnClickListener {
findNavController()
.navigate(R.id.action_profile_settings)
}
}
}

With that, we can now navigate from an imaginary library of books to a selected book, and from settings’ page to a specific settings’ destination.

We have now completed our initial setup of the files and navigation components required for this post. So, we move onto the actual demonstration.

Navigation Using ViewPager and BottomNavigation

The ViewPager will serve as the container for the fragments, serving as the page containing the layouts for each root destination, and the BottomNav will contain links pointing to these pages (root destinations).

Creating Non-Swiping ViewPager

The first thing we want to do is use the ViewPager as the container to retain the back-stack of each containing fragment so when we jump between destinations the viewPager just swaps out the fragments (current-page) retaining its previous state saved in memory.

There is an issue with this approach, the view pager by default handles touch and swipe events, to navigate its pages. What we want is to make it seem like we are jumping from page to page instead of its default scrolling behavior.

We would have to create a custom viewPager to disable the swipe and scroll feature and make it not respond to touch events and just jump between its pages when triggered by the BottomNav.

so here is what that class would look like:

class NonSwipingViewPager : ViewPager {

constructor(context: Context) : super(context) {
setMyScroller()
}

constructor(context: Context, attrs: AttributeSet) :
super(context, attrs) {
setMyScroller()
}

override fun onInterceptTouchEvent(event: MotionEvent): Boolean
{
// Never allow swiping to switch between pages
return false
}

override fun onTouchEvent(event: MotionEvent): Boolean {
// Never allow swiping to switch between pages
return false
}


private fun setMyScroller() {
try {
val viewpager = ViewPager::class.java
val scroller = viewpager.getDeclaredField("mScroller")
scroller.isAccessible = true
scroller.set(this, MyScroller(context))
} catch (e: Exception) {
e.printStackTrace()
}

}

inner class MyScroller(context: Context) : Scroller(context, DecelerateInterpolator()) {

override fun startScroll(startX: Int, startY: Int, dx: Int, dy: Int, duration: Int) {
super.startScroll(startX, startY, dx, dy, 0 /* secs */)
}
}
}

There are two things to take note of in this code snippet,

  • The first is we return false for touchEvents, to prevent navigation through swipe gesture on the viewPager.
  • The second thing is the Scroller, we override the default scroller of view pager and set the scroll duration to zero, to simulate jumping between pages and prevent the default scrolling transition the viewPager has. So when we programmatically change the current page, it doesn’t scroll to that page.

Once that is done this is the effect we would achieve with the custom viewPager:

Non-Swiping ViewPager In Action

Now we place our custom viewPager in the layout file of MainActivity like so:

<android.support.constraint.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">


<!-- right here -->
<com.example.viewpagernavigation.shared.views.NonSwipingViewPager
android:id="@+id/main_pager"
android:layout_width="match_parent"
android:layout_height="match_parent" />



<android.support.design.widget.BottomNavigationView
android:id="@+id/bottom_nav"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:labelVisibilityMode="unlabeled"
app:menu="@menu/menu_main"/>

</android.support.constraint.ConstraintLayout>

and here is what that menu looks like

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">

<item
android:id="@+id/home"
android:icon="@drawable/ic_home"
android:title="@string/title_home" />
<item
android:id="@+id/library"
android:icon="@drawable/ic_library"
android:title="@string/title_library" />
<item
android:id="@+id/settings"
android:icon="@drawable/ic_settings"
android:title="@string/title_settings" />

</menu>

Creating the Base Fragment

The next thing we need to do is set up a base fragment that will serve as the container for each page (root destination). Here are the considerations:

  • This base fragment will serve as a section of the app with its own back-stack, hence with its own nav-graph and navController
  • As each root destination is self-contained, each destination would need its own toolbar responsible for controlling its own up navigation.

Here is what the fragment class looks like:

class BaseFragment: Fragment() {

private val defaultInt = -1
private var layoutRes: Int = -1
private var toolbarId: Int = -1
private var navHostId: Int = -1
    // root destinations 
private val rootDestinations =
setOf(R.id.home_dest, R.id.library_dest, R.id.settings_dest)
    // nav config with root destinations
private val appBarConfig = AppBarConfiguration(rootDestination)


override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
        // extract arguments from bundle
arguments?.let {
layoutRes = it.getInt(KEY_LAYOUT)
toolbarId = it.getInt(KEY_TOOLBAR)
navHostId = it.getInt(KEY_NAV_HOST)

} ?: return
}

override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
// Inflate the layout for this fragment
return if (layoutRes == defaultInt) null
else inflater.inflate(layoutRes, container, false)
}

override fun onStart() {
super.onStart()
// return early if no arguments were parsed
if (toolbarId == defaultInt || navHostId == defaultInt) return
        // find navController using navHostFragment
val navController = requireActivity().findNavController(navHostId)
        // setup navigation with root destinations and toolbar
NavigationUI.setupWithNavController(toolbar, navController, appBarConfig)
}


companion object {

private const val KEY_LAYOUT = "layout_key"
private const val KEY_NAV_HOST = "nav_host_key"

fun newInstance(layoutRes: Int, toolbarId: Int, navHostId: Int) = BaseFragment().apply {
arguments = Bundle().apply {
putInt(KEY_LAYOUT, layoutRes)
putInt(KEY_NAV_HOST, navHostId)
}
}
}
}

Here are a couple of things to note:

  • The arguments: we are passing three arguments to the factory function newInstance, each of which serves a purpose. The layoutId is used to identify which view should be inflated for this page (root destination), the toolbarId is used to identify and configure the toolbar for the containing view, and the navHostId is used to identify the navHostFragment in the containing view.
  • The appBarConfig: we create an appBarConfig with contains the set of start destinations, and will be used to configure the toolbar navigation.

Creating each RootView’s Layout

Since we are going to have self-contained navigation in each rootView, each of them would require its own toolbar and navGraph. To make the toolbars uniquely accessible, each toolbar will have a unique toolbarId.

Here is what each root view’s layout would look like:

content_home_base.xml

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.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=".shared.views.BaseFragment">

<android.support.v7.widget.Toolbar
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/toolbar_home"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/colorPrimary"
android:minHeight="?attr/actionBarSize"
android:theme="@style/ThemeOverlay.AppCompat.Dark"
app:layout_constraintTop_toTopOf="parent" />

<fragment
android:id="@+id/nav_host_home"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:defaultNavHost="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar_home"
app:navGraph="@navigation/nav_graph_home" />


</android.support.constraint.ConstraintLayout>

content_settings_base.xml

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.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=".shared.views.BaseFragment"
>

<android.support.v7.widget.Toolbar
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/toolbar_settings"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
android:minHeight="?attr/actionBarSize"
android:background="@color/colorPrimary"
android:theme="@style/ThemeOverlay.AppCompat.Dark"/>

<fragment
android:id="@+id/nav_host_settings"
android:layout_width="match_parent"
android:layout_height="0dp"
android:name="androidx.navigation.fragment.NavHostFragment"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar_settings"
app:navGraph="@navigation/nav_graph_settings"
app:defaultNavHost="true"
/>


</android.support.constraint.ConstraintLayout>

content_library_base.xml

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.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=".shared.views.BaseFragment"
>

<android.support.v7.widget.Toolbar
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/toolbar_library"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
android:minHeight="?attr/actionBarSize"
android:background="@color/colorPrimary"
android:theme="@style/ThemeOverlay.AppCompat.Dark"/>

<fragment
android:id="@+id/nav_host_library"
android:layout_width="match_parent"
android:layout_height="0dp"
android:name="androidx.navigation.fragment.NavHostFragment"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar_library"
app:navGraph="@navigation/nav_graph_library"
app:defaultNavHost="true"
/>


</android.support.constraint.ConstraintLayout>

Notice that each layout contains its own navHostFragment and toolbar each with unique ids.

Hooking up the views and fragments to viewPager

To hook up each root view, we will have to do three things in our MainActivity.

  • Capture a list of all our pages (root destinations)
  • Create a FragmentPagerAdapter
  • Hook the viewPager to the PagerAdapter

here is what main activity looks like

class MainActivity : AppCompatActivity() {


// list of base destination containers
private val fragments = listOf(
BaseFragment.newInstance(R.layout.content_home_base, R.id.toolbar_home, R.id.nav_host_home),
BaseFragment.newInstance(R.layout.content_library_base, R.id.toolbar_library, R.id.nav_host_library),
BaseFragment.newInstance(R.layout.content_settings_base, R.id.toolbar_settings, R.id.nav_host_settings))


override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

// setup main view pager
main_pager.adapter = ViewPagerAdapter()

}


inner class ViewPagerAdapter : FragmentPagerAdapter(supportFragmentManager) {

override fun getItem(position: Int): Fragment = fragments[position]

override fun getCount(): Int = fragments.size

}
}

If you run the app now, the one thing you will notice is that clicking the menu items in the bottom nav does nothing. This is because we have not synced the bottom nav and the view pager.

Syncing BottomNav and ViewPager

To sync the bottomNav and viewPager, we will have to implement two listeners, one for each view.

Here is what the MainActivity will now look like:

class MainActivity : AppCompatActivity(),
ViewPager.OnPageChangeListener,
BottomNavigationView.OnNavigationItemSelectedListener
{


// overall back stack of containers
private val backStack = Stack<Int>()
    // ... other fields


// map of navigation_id to container index
private val indexToPage = mapOf(0 to R.id.home, 1 to R.id.library, 2 to R.id.settings)

override fun onCreate(savedInstanceState: Bundle?) {

// ... other stuff

main_pager.addOnPageChangeListener(this)
bottom_nav.setOnNavigationItemSelectedListener(this)

// initialize backStack with home page index
if (backStack.empty()) backStack.push(0)
}
 // control the backStack when back button is pressed
override fun onBackPressed() {
if (backStack.size > 1) {
// remove current position from stack
backStack.pop()
// set the next item in stack as current
main_pager.currentItem = backStack.peek()

} else super.onBackPressed()
}
    /// BottomNavigationView ItemSelected Implementation
override fun onNavigationItemSelected(item: MenuItem): Boolean {
val position = indexToPage.values.indexOf(item.itemId)
if (main_pager.currentItem != position) setItem(position)
return true
}

/// OnPageSelected Listener Implementation
override fun onPageScrollStateChanged(state: Int) {}
override fun onPageScrolled(p0: Int, p1: Float, p2: Int) {}

override fun onPageSelected(page: Int) {
val itemId = indexToPage[page] ?: R.id.home
if (bottom_nav.selectedItemId != itemId)
bottom_nav.selectedItemId = itemId
}

private fun setItem(position: Int) {
main_pager.currentItem = position
backStack.push(position)
}
    // ... Other stuff
}

With the code above, we now have the MainActivity Implementing the change listeners for both the ViewPager and the BottomNav.

The two functions to pay attention to are the onPageSelected; which sets the selected menu item using the current page, and the onNavigationItemSelected which set the sets the current page using the selected menu item.

The map indexToPage is used to map a page-index of the ViewPager to the menu-id of the BottomNav.

The backStack field is used as the overall back-stack for the activity, so we override the onBackPressed and whenever the on-screen’s back button is pressed, it first jumps back to your previous page until there is only one root page left in the stack and then the default behavior is executed.

With all these, you can now navigate from one root destination to another and the state is preserved, and return to previously selected pages (root destinations) whenever you press the back button.

But there is still an issue, if you navigate within any page (root destination) and press the back-button, it doesn’t navigate up within that root destination.

For instance, if you navigate from the library page to the settings page, and then navigate within settings page to security settings, when you then press the back button you jump to library page instead of navigating up to settings page. This is happening because we have not synced the entire back-nav with the individual up-nav of each page.

Syncing With The Overall Back-Stack

Now that we are able to navigate between each root destination, we want to achieve an overall back-navigation that syncs with the up-navigation of the current page (root destination).

To achieve this, we have to do two things:

  • We have to check if the current page (BaseFragment) can navigate up, thus, we add a method to BaseFragment that performs this check.
  • We have to extend the functionality of MainActivity.onBackPressed to cater for this check, before performing the default behavior.

Add the following method to BaseFragment:

class BaseFragment: Fragment() {
    //... other stuff

fun onBackPressed(): Boolean {
return requireActivity()
.findNavController(navHostId)
.navigateUp(appBarConfig)

}

//... other stuff
}

And change MainActivity’s onBackPressed to the following:

class MainActivity : AppCompatActivity() {
    //... other stuff

override fun onBackPressed() {
// get the current page
val fragment = fragments[main_pager.currentItem]
        // check if the page navigates up
val navigatedUp = fragment.onBackPressed()
        // if no fragments were popped
if (!navigatedUp) {
if (backStack.size > 1) {
// remove current position from stack
backStack.pop()
// set the next item in stack as current
main_pager.currentItem = backStack.peek()

} else super.onBackPressed()
}
}
    //... other stuff

}

The BaseFragment’s onBackPressed tries to navigate up within the containing nav-graph of its root destination.

With that in place, when you press back, the back-press is triggered in the root destination before being handled by the activity itself, making both the overall back-stack and the current page's (root destination’s) back-stack in sync.

Overall BackStack in action

Setting up DeepLinking

Now that we have the navigation set up the way Instagram and YouTube are, what is left is to address the DeepLinking.

The Issue

DeepLinking is set up to a specific destination within a navGraph. This wouldn’t be a problem if we didn’t have multiple main navGraphs, because it would be just one main navGraph and it would handle the intent by navigating to the specific destination within its graph or a sub-graph.

The Solution

We have to make sure that all the main navGraphs (BaseFragments) are created on the activity’s startup and then check to see which containing navGraph can handle the DeepLink embedded in the intent.

Note: When I say all BaseFragments must be created, I mean attached to an activity.

To achieve this solution, three specific changes need to be made:

  • We have to force the ViewPager to create all BaseFragments in onCreate
  • We have to provide a means to check if a BaseFragment can handle the intent, and this must be done after the viewPager has created all BaseFragments, and not before.
  • We set the current page of the viewPager, based on who handles the DeepLink.

We will start with the second one, so add the following method to BaseFragment:

class BaseFragment: Fragment() {

//... other stuff

fun handleDeepLink(intent: Intent): Boolean =
requireActivity()
.findNavController(navHostId)
.handleDeepLink(intent)

//... other stuff
}

With this handleDeepLink method, we are using the NavController’s handleDeeplink method to validate if the intent passed can be handled by the current page’s navController which in turn will use its navGraph to determine that.

To implement the first and third, change the MainActivity to look like so:

class MainActivity : AppCompatActivity() {
    //... other stuff
    override fun onCreate(savedInstanceState: Bundle?) {

//... other stuff
 // check deeplink only after viewPager is setup
main_pager.post(this::checkDeepLink)

// force viewPager to create all fragments
main_pager.offscreenPageLimit = fragments.size
        //... other stuff
}

private fun checkDeepLink() {
fragments.forEachIndexed { index, fragment ->
val hasDeepLink = fragment.handleDeepLink(intent)
if (hasDeepLink) setItem(index)
}
}
}

The first thing you notice is that checkDeepLink is put in the viewPager’s post method, this is done so that the viewPager and all its pages are created before the checkDeepLink gets called.

Then you have the viewPager’s setOffScreenPageLimit called next, this sets the limit of the number of pages the viewPager can create offScreen. This is set to the fragments’ list size to make sure that all the fragments are created and attached to MainActivity once the ViewPager is created.

With that setup, let's add a deepLink to the library graph. Go to the library_graph.xml file and add the following:

<?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"
android:id="@+id/nav_graph_library"
app:startDestination="@id/library_dest">
    <!-- other stuff -->
    <fragment
android:id="@+id/book_dest"
android:name="com.example.viewpagernavigation.modules
.library.BookFragment"
android:label="Book">
        <deepLink
android:id="@+id/deepLink"
app:uri="www.nav.viewpager.com/{title}/{date}" />
    </fragment>
   <!-- other stuff -->
</navigation>

now that we have added a deep link to the book destination, add the following to your Manifest in MainActivity and intent-filter block.

<!-- other stuff -->
<activity
android:name=".MainActivity"
android:theme="@style/AppTheme.NoActionBar">

<nav-graph android:value="@navigation/nav_graph_library" />


<intent-filter>
        <!-- other stuff -->
 <category android:name="android.intent.category.DEFAULT" />

</intent-filter>
</activity>
<!-- other stuff -->

Now run the app, so the latest changes to the apk are installed, and then run the following in android studio terminal:

adb shell am start 
-W -a android.intent.action.VIEW
-d https://www.nav.viewpager.com/Game%20of%20Thrones/2019
<com.app-package>

NOTE: substitute <com.app-package> with your application’s package, com.example.viewpagernav for instance.

After running this you should see the screen below:

Deep Link from URL in action

Now let's try and achieve the same for Notifications. Add the following to the HandleNotification’s Class:

object HandleNotifications {

private const val SMALL_ICON = R.drawable.ic_nav_notification
private const val ONGOING_NOTIFICATION_ID = 50120
private const val CHANNEL_NAME = "MAX notification Channel"
private val CHANNEL_ID = "${getRandomNumber()}"

fun showNotification(context: Context) {
val isPreOreo =
Build.VERSION.SDK_INT < Build.VERSION_CODES.O

val notification =
if (isPreOreo) PreO.createNotification(context)
else O.createNotification(context)

// display notification
NotificationManagerCompat
.from(context)
.notify(ONGOING_NOTIFICATION_ID, notification)
}

private fun getIntent(context: Context): PendingIntent {
val bundle = Bundle().apply {
putString(BookFragment.KEY_TITLE,
"Game of Thrones: The short night")
putString(BookFragment.KEY_DATE, "2019")
}

return NavDeepLinkBuilder(context)
.setGraph(R.navigation.nav_graph_library)
.setDestination(R.id.book_dest)
.setArguments(bundle)
.createPendingIntent()
}

private fun getNotification(context: Context, channelId: String): NotificationCompat.Builder {

// Create Pending Intents.
val piLaunchMainActivity = getIntent(context)

return NotificationCompat.Builder(context, channelId)
.setContentTitle("Game of Thrones")
.setContentText("The short night")
.setStyle(NotificationCompat.BigTextStyle())
.setAutoCancel(true)
.setSmallIcon(SMALL_ICON)
.setContentIntent(piLaunchMainActivity)
}

//
// Pre O specific versions.
//

@TargetApi(25)
object PreO {

fun createNotification(context: Context): Notification {

// Create a notification.
val builder = getNotification(context, CHANNEL_ID)

// build notification
return builder.build()
}
}


//
// Oreo and Above Specific versions.
//

@TargetApi(26)
object O {
fun createNotification(context: Context): Notification {
val channelId = createChannel(context)
// Create a notification.
val builder = getNotification(context, channelId)
return builder.build()
}

private fun createChannel(context: Context): String {
// Create a channel.
val notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE)
as NotificationManager
            val importance = NotificationManager.IMPORTANCE_HIGH

val notificationChannel =
NotificationChannel(CHANNEL_ID, CHANNEL_NAME, importance)
            notificationManager
.createNotificationChannel(notificationChannel)
            return CHANNEL_ID
}
}


private fun getRandomNumber(): Int {
return Random().nextInt(100000)
}
}

Here are things to note in this class: there are two singleton classes PreO and O, each targeting PreOreo and Oreo devices respectively and are responsible for creating notification based on its designated version bracket. The showNotification method is responsible for creating and displaying the created notification.

The pending intent passed into the notification is created using the NavDeepLinkBuilder from the Navigation Component Library. We pass in the navGraph, nav’s destination and destination’s arguments, to create our desired pending intent that will launch our deepLink into the book destination.

Let’s add a button in home-destination that will trigger this notification for us.

Add the following to your home screen (fragment_home.xml):

<android.support.constraint.ConstraintLayout >


<!-- Other stuff -->

<Button
android:id="@+id/btn_notify"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Notify"
app:layout_constraintTop_toBottomOf="@+id/title_text"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="20dp"
/>


</android.support.constraint.ConstraintLayout>

Then we add a click listener to trigger the notification:

class HomeFragment : Fragment() {

// other stuff
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
 btn_notify.setOnClickListener { 
HandleNotifications.showNotification(requireContext())
}
}

// other stuff
}

Finally, run the application and click the button, and then click the notification, it should produce a result as you have in the gif below:

Deep Link from Notification

Bonus

For a bonus I want to also add one more thing, what happens when a user reselects the current root destination, currently, nothing happens. What we would like, is to achieve what YouTube has, if you reselect a root destination, it should navigate up to the start destination.

Here is what we want to achieve:

Re-Select Nav Item in Action

To achieve this, add this to the BaseFragment:

class BaseFragment: Fragment() {

// other stuff
    fun popToRoot() {
val navController =
requireActivity().findNavController(navHostId)
 // navigate to the start destination
navController.popBackStack(
navController.graph.startDestination, false)
}



// other stuff
}

And finally, we want to be notified when the user reselects a navigation item, to do that we implement the following in MainActivity.

class MainActivity : AppCompatActivity(),
BottomNavigationView.OnNavigationItemReselectedListener {


override fun onCreate(savedInstanceState: Bundle?) {
        // other stuff 
 bottom_nav.setOnNavigationItemReselectedListener(this)

}


override fun onNavigationItemReselected(item: MenuItem) {
val position = indexToPage.values.indexOf(item.itemId)
val fragment = fragments[position]
fragment.popToRoot()
}


// other stuff
}

And that is it now when you reselect a navigation item the user will be navigated to the start destination.

Re-Select Nav Item in Action

Conclusion

In this post you have learned:

  • How to set up navigation using ViewPager and BottomNavigation and keeping both of them in sync
  • How to sync individual page’s up-navigation with the overall back-stack
  • Setting up DeepLinking triggered either; from a URL or from a Notification bar

I have shown you how to achieve Instagram and YouTube-style navigation. This can serve as a base sample which you can extend the functionality to your taste, but with this sample, you have captured the key main features of the navigation library with a custom implementation.

It took me hours of research to come up with this, I hope it saves someone else that time, and if you have any way of improving this, or you have a better approach please let me know in the comment section.

Thanks for reading.