Exploring Android Navigation Architecture Component - MVVM - Kotlin
Navigation Architecture Component is released as a part of JetPack and androidx package. Its aim is to simplify the implementation of Navigation in our Android App. It defines a set of principles to build an in-app navigation with consistent and predictable user experience. Its main concept is to use single-activity architecture. It also has support for both Activity and Fragment.
Let's create navigation graph first, by right click on res and then select New -> Android Resource Directory. Then choose navigation on Resource type on Dialog. Then create a Navigation Resource file, under that directory.
It can contain either Fragment or Activity. But first, it should have app:startDestination, from where the app will start. And then the destination have to be included as action. We can also declare parameters and its type here.
Principle of Navigation:
The following are the principles of Navigation.
- App should have fixed starting destination
- A stack is used to represent the navigation state of app
- The 'Up' button never exits the app
- Up and Back are equivalent within your app's task
- Deep linking to a destination or navigating to the same destination should yield the same stack
(i) Navigation Graph - It's nothing but a map/blueprint of our in-app navigation. It has a list of all fragments and activities. We can also specify argument, actions to the destinations.
(ii) Actions - It specifies the destination fragment, transitions, arguments to that fragment and its pop behavior.
<fragment
android:id="@+id/main_frag"
android:name="com.example.app.MainFragment"
android:label="Home"
tools:layout="@layout/main_fragment">
<action
android:id="@+id/open_details"
app:destination="@id/details_fragment" />
</fragment>
(iii) Arguments - Arguments can be passed either by old-fashioned bundle or with safeargs plugin. If you have boilerplate code, then you can go for safeargs, it is also type safe. We have add argument in destination activity/fragment. And it will generate two classes namely, HomeActivityDirections (Source class name+'Directions') class and DetailActivityArgs (destination class name + 'Args') class. Using this we can pass and get the arguments easily.
<activity
android:id="@+id/details_fragment"
android:name="com.example.app.ui.detail.DetailActivity"
android:label="@string/post_detail"
tools:layout="@layout/detail_page">
<argument
android:name="id"
app:type="integer" />
</activity>
We can also add default value for the argument.
Pass Argument's value :
(ii) In app-level gradle:
Pass Argument's value :
val direction = HomeFragmentDirections.Open_details(user.id) findNavController(itemView).navigate(direction)
Get Argument's value :
val id = DetailActivityArgs.fromBundle(intent.extras).id //In Activity
val id = DetailActivityArgs.fromBundle(arguments).id // In Fragment
Setup
We have to use Android Studio 3.2 Canary 14 to use Navigation Architecture component and JetPack.
- Create a New Project with it. You will find a new option while creating it like, Actvitiy+Fragment+ViewModel to take advantage of Android Architecture Components. Choose that and complete the project creation.
- For now we can't use androidx, since it is under development. So we can use like below for now:
implementation 'android.arch.lifecycle:extensions:1.1.1'
implementation 'android.arch.navigation:navigation-fragment-ktx:1.0.0-alpha01'
implementation 'android.arch.navigation:navigation-ui-ktx:1.0.0-alpha01'
- Also, add navigation-safeargs, if you would like to pass parameter/arguments to other Fragment or Activity.
buildscript {
dependencies {
classpath 'android.arch.navigation:navigation-safe-args-gradle-plugin:1.0.0-alpha01'
}
}
(ii) In app-level gradle:
apply plugin: 'androidx.navigation.safeargs'
Implementation:
(i) Using BottomNavigation:
First, let's create a Home page, that has BottomNavigationView of 2 tabs, home and profile and a Detail page, where the post's detail which the user clicked on Home, will be shown.
Basic Structure
Our basic structure of our app's package will be as follows :
nav_graph.xml
nav_gragh.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"
app:startDestination="@id/home_frag">
<fragment
android:id="@+id/home_frag"
android:name="com.yamikrish.app.slicedemo.ui.home.HomeFragment"
android:label="Home"
tools:layout="@layout/home_fragment">
<action
android:id="@+id/open_details"
app:destination="@id/details_fragment" />
</fragment>
<activity
android:id="@+id/details_fragment"
android:name="com.yamikrish.app.slicedemo.ui.detail.DetailActivity"
android:label="@string/post_detail"
tools:layout="@layout/detail_page">
<argument
android:name="id"
app:type="integer" />
</activity>
<fragment
android:id="@+id/profile_frag"
android:name="com.yamikrish.app.slicedemo.ui.profile.ProfileFragment"
android:label="@string/profile"
tools:layout="@layout/profile_fragment" />
</navigation>
BaseActivity.kt
This is the launching activity of our Application, from where the app should navigate to other pages. We gonna have BottomNavigationView with two tabs, Home and Profile. Also, we have to integrate BottomNavigationView with NavController to manage the navigations, using the following line:
bottomTab.let { NavigationUI.setupWithNavController(bottomTab, controller.navController)
Also don't forget to add, nav_graph with app:navGraph attribute to the fragment.
class BaseActivity : AppCompatActivity() {
lateinit var controller: NavHostFragment
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.base_activity)
controller = supportFragmentManager.findFragmentById(R.id.container) as NavHostFragment
bottomTab.let { NavigationUI.setupWithNavController(bottomTab, controller.navController) }
}
override fun onSupportNavigateUp(): Boolean {
return findNavController(R.id.container).navigateUp()
}
}
base_acivity.xml
In this layout, we can have BottomNavigationView and a Fragment, where the pages to be loaded and navigated.
<?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">
<android.support.design.widget.BottomNavigationView
android:id="@+id/bottomTab"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:menu="@menu/bottom_navigation"
app:elevation="4dp"/>
<View
android:id="@+id/view"
android:layout_width="0dp"
android:layout_height="0.5dp"
android:background="@android:color/darker_gray"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintBottom_toTopOf="@+id/bottomTab"/>
<fragment 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/container"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@+id/view"
app:defaultNavHost="true"
app:navGraph="@navigation/nav_graph"
tools:context=".BaseActivity" />
</android.support.constraint.ConstraintLayout>
bottom_navigation.xml
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@id/home_frag"
android:icon="@drawable/home"
android:title="@string/home"/>
<item
android:id="@id/profile_frag"
android:icon="@drawable/profile"
android:title="@string/profile"/>
</menu>
HomeFragment.kt
It is one of the tabs in BottomViewNavigation. It is to display the list of posts and while on a particular post, it will be redirected to DetailActivity, where the post's detail will be shown. HomeViewModel is used here to fetch posts from API using APIService.
class HomeFragment : Fragment() {
companion object {
fun newInstance() = HomeFragment()
}
private lateinit var viewModel: HomeViewModel
lateinit var adapter : PostAdapter
var data: List<Post> = ArrayList()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View {
return inflater.inflate(R.layout.home_fragment, container, false)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
userList.layoutManager = LinearLayoutManager(context)
adapter = PostAdapter(context!!, data)
userList.adapter = adapter
viewModel = ViewModelProviders.of(this).get(HomeViewModel::class.java)
viewModel.fetchAllData().observe(this, object: Observer<List<Post>> {
override fun onChanged(t: List<Post>?) {
Log.v("users","users=="+t)
adapter.addItems(t)
}
})
}
}
home_fragment.xml
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto">
<android.support.v7.widget.RecyclerView
android:id="@+id/userList"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</android.support.constraint.ConstraintLayout>
PostAdapter.kt
Its an Adapter class to show the post's items. On clicking an item, it should open DetailActivity. We can navigate as follows:
findNavController(view).navigate(page)
findNavController(view).navigate(page)
We already discussed how to pass arguments for the class , above. So let's jump to code directly.
class PostAdapter (val context: Context, var data : List<Post>?) : RecyclerView.Adapter<PostAdapter.Holder>() {
override fun onBindViewHolder(holder: Holder, position: Int) {
holder.bindItems(data?.get(position))
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
val v = LayoutInflater.from(context).inflate(R.layout.post_item, parent, false)
return Holder(v)
}
override fun getItemCount(): Int = data?.size?:0
fun addItems(t: List<Post>?) {
data = t
notifyDataSetChanged()
}
inner class Holder(itemView: View?) : RecyclerView.ViewHolder(itemView){
fun bindItems(user: Post?){
itemView.title.text = user?.title
itemView.description.text = user?.body
itemView.setOnClickListener {
val direction = HomeFragmentDirections.Open_details(user!!.id)
findNavController(itemView).navigate(direction)
}
}
}
}
post_item.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"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:padding="5dp"
android:textAppearance="@android:style/TextAppearance.Medium"
android:textStyle="bold"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/description"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:padding="5dp"
android:textAppearance="@android:style/TextAppearance.Small"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@+id/title" />
<View
android:layout_width="0dp"
android:layout_height="0.5dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@+id/description"
android:background="@android:color/darker_gray"/>
</android.support.constraint.ConstraintLayout>
HomeViewModel.kt
class HomeViewModel : ViewModel() {
var list: LiveData<List<Post>>
init {
list = APIService.getUserList()
}
fun fetchAllData() : LiveData<List<Post>> = list
}
ProfileFragment.kt
This activity is to display the user details like, Name, phone number and Address. Let's get those details using ProfileViewModel, where the API will be called and data will be fetched with APIService.
class ProfileFragment : Fragment() {
companion object {
fun newInstance() = ProfileFragment()
}
private lateinit var viewModel: ProfileViewModel
lateinit var data: User
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View {
return inflater.inflate(R.layout.profile_fragment, container, false)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
viewModel = ViewModelProviders.of(this).get(ProfileViewModel::class.java)
viewModel.fetchUser().observe(this, object : Observer<User> {
override fun onChanged(t: User?) {
Log.v("users", "users==" + t)
setDataOnUI(t)
}
})
}
private fun setDataOnUI(user: User?) {
user?.let {
name.text = it.name
email.text = it.email
phone.text = it.phone
address.text = it.address.suite + ", " + it.address.street+ ", " +
it.address.city+ " - " + it.address.zipcode
}
}
}
profile_fragment.xml
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto">
<ImageView
android:id="@+id/image"
android:layout_width="100dp"
android:layout_height="100dp"
android:src="@drawable/user"
android:layout_margin="20dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"/>
<TextView
android:id="@+id/name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:padding="10dp"
android:textStyle="bold"
android:textAppearance="@android:style/TextAppearance.Medium"
app:layout_constraintTop_toBottomOf="@+id/image"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"/>
<TextView
android:id="@+id/email"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:padding="10dp"
android:textAppearance="@android:style/TextAppearance.Small"
android:drawableLeft="@drawable/email"
android:drawablePadding="5dp"
app:layout_constraintTop_toBottomOf="@+id/name"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"/>
<TextView
android:id="@+id/phone"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:padding="10dp"
android:drawableLeft="@drawable/phone"
android:drawablePadding="5dp"
android:textAppearance="@android:style/TextAppearance.Small"
app:layout_constraintTop_toBottomOf="@+id/email"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"/>
<TextView
android:id="@+id/address"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:padding="10dp"
android:drawableLeft="@drawable/address"
android:drawablePadding="5dp"
android:textAppearance="@android:style/TextAppearance.Small"
app:layout_constraintTop_toBottomOf="@+id/phone"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"/>
</android.support.constraint.ConstraintLayout>
ProfileViewModel.kt
class ProfileViewModel : ViewModel() {
var post: LiveData<User>
init {
post = APIService.getUser()
}
fun fetchUser() : LiveData<User> = post
}
DetailActivity.kt
class DetailActivity : AppCompatActivity() {
private lateinit var viewModel: DetailViewModel
lateinit var post: Post
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.detail_page)
val id = DetailActivityArgs.fromBundle(intent.extras).id
val factory = CustomViewModelFactory(id)
supportActionBar?.let {
it.title = getString(R.string.post_detail)
it.setDisplayShowHomeEnabled(true)
it.setDisplayHomeAsUpEnabled(true)
}
viewModel = ViewModelProviders.of(this, factory).get(DetailViewModel::class.java)
viewModel.fetchPostById().observe(this, object : Observer<Post> {
override fun onChanged(t: Post?) {
postTitle.text = t?.title
description.text = t?.body
}
})
}
class CustomViewModelFactory(private val test: Int) : ViewModelProvider.NewInstanceFactory() {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return DetailViewModel(test) as T
}
}
override fun onSupportNavigateUp(): Boolean {
finish()
return true;
}
}
detail_page.xml
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto">
<TextView
android:id="@+id/postTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textAppearance="@android:style/TextAppearance.Medium"
android:padding="10dp"
android:textStyle="bold"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/description"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textAppearance="@android:style/TextAppearance.Small"
android:padding="10dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@+id/postTitle" />
</android.support.constraint.ConstraintLayout>
DetailViewModel.kt
class DetailViewModel(id: Int) : ViewModel() {
var post: LiveData<Post>
init {
post = APIService.getPostById(id)
}
fun fetchPostById() : LiveData<Post> = post
}
APIInterface.kt
interface APIInterface{
@GET("posts")
fun getPosts(): Call<List<Post>>
@GET("posts/{postId}")
fun getPostById(@Path(value = "postId", encoded = true) postId : Int): Call<Post>
@GET("users/1")
fun getUser(): Call<User>
}
APIService.kt
class APIService {
companion object {
var apiInterface: APIInterface
init {
val retrofit = Retrofit.Builder()
.baseUrl(Utils.BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.build()
apiInterface = retrofit.create(APIInterface::class.java)
}
fun getUserList(): LiveData<List<Post>> {
val data = MutableLiveData<List<Post>>()
apiInterface.getPosts().enqueue(object : Callback<List<Post>> {
override fun onResponse(call: Call<List<Post>>, response: Response<List<Post>>) {
data.setValue(response.body())
}
override fun onFailure(call: Call<List<Post>>, t: Throwable) {
data.setValue(null)
t.printStackTrace()
}
})
return data
}
fun getPostById(id:Int): LiveData<Post> {
val data = MutableLiveData<Post>()
apiInterface.getPostById(id).enqueue(object : Callback<Post> {
override fun onResponse(call: Call<Post>, response: Response<Post>) {
data.setValue(response.body())
}
override fun onFailure(call: Call<Post>, t: Throwable) {
data.setValue(null)
t.printStackTrace()
}
})
return data
}
fun getUser(): LiveData<User> {
val data = MutableLiveData<User>()
apiInterface.getUser().enqueue(object : Callback<User> {
override fun onResponse(call: Call<User>, response: Response<User>) {
data.setValue(response.body())
}
override fun onFailure(call: Call<User>, t: Throwable) {
data.setValue(null)
t.printStackTrace()
}
})
return data
}
}
}
Post.kt
data class Post(val id: Int, val title: String, val body: String)
User.kt
data class User(val name: String, val email: String, val phone: String, val address: Address) {
data class Address(val suite: String, val street: String, val city: String, val zipcode: String)
}
Utils.kt
class Utils {
companion object {
val BASE_URL = "https://jsonplaceholder.typicode.com/"
}
}
Run Application:
You can find the project on Github, here.
(ii) Using DrawerLayout:
I am gonna reuse the same pages and layouts, except BaseActivity.kt and its layout, base_activity.xml, where we have to use DrawerLayout and its NavigationView. Also make sure that, the menu option's id should be same as that of corresponding Fragment's id in nav_graph.xml.
menu/bottom_navigation.xml
menu/bottom_navigation.xml
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/home_frag"
android:icon="@drawable/home"
android:title="@string/home"/>
<item
android:id="@+id/profile_frag"
android:icon="@drawable/profile"
android:title="@string/profile"/>
</menu>
base_activity.xml
<?xml version="1.0" encoding="utf-8"?>
<android.support.v4.widget.DrawerLayout 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/drawerLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<include
layout="@layout/tool_bar"/>
<fragment 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/container"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:defaultNavHost="true"
app:navGraph="@navigation/nav_graph"
tools:context=".BaseActivity" />
</LinearLayout>
<android.support.design.widget.NavigationView
android:id="@+id/navigationView"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="start"
android:fitsSystemWindows="true"
app:elevation="4dp"
app:menu="@menu/bottom_navigation" />
</android.support.v4.widget.DrawerLayout>
BaseActivity.kt
In this activity, ActionBarDrawerToggle will be used along with DrawerLayout, to sync its action. Then the navigationController should be set up for DrawerLayout. That's it!! nav_graph will handle the fragment adding/replacing operations for you.
You can find the project on Github, here.
In this activity, ActionBarDrawerToggle will be used along with DrawerLayout, to sync its action. Then the navigationController should be set up for DrawerLayout. That's it!! nav_graph will handle the fragment adding/replacing operations for you.
class BaseActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.base_activity)
val toolbar: Toolbar = findViewById(R.id.toolbar)
setSupportActionBar(toolbar)
val actionbar: ActionBar? = supportActionBar
actionbar?.apply {
setDisplayHomeAsUpEnabled(true)
}
val mDrawerToggle = object : ActionBarDrawerToggle(this, drawerLayout, toolbar,
R.string.open, R.string.close) {
override fun onDrawerClosed(view: View) {
super.onDrawerClosed(view)
}
override fun onDrawerOpened(drawerView: View) {
super.onDrawerOpened(drawerView)
}
}
drawerLayout.addDrawerListener(mDrawerToggle)
mDrawerToggle.syncState()
val controller = Navigation.findNavController(this, R.id.container)
NavigationUI.setupWithNavController(navigationView, controller)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
android.R.id.home -> {
drawerLayout.openDrawer(GravityCompat.START)
true
}
else -> super.onOptionsItemSelected(item)
}
}
}
You can find the project on Github, here.
Could you share the code on github please? Btw thanks for sharing your post,
ReplyDelete