Android Architecture Components Tutorial
Android architecture components are a collection of libraries that help you design robust, testable, and maintainable apps. Start with classes for managing your UI component lifecycle and handling data persistence. (https://developer.android.com/topic/libraries/architecture)
This tutorial will cover LiveData
, ViewModel
, Room
, DataBinding
and some other features in Android Architecture Component, and build a photo browser app according to the best practice from the Guide to app architecture.
Prerequisite
This app will fetch photos from Pexels, so before starting build the app, we need to create a Pexels account if you don’t have one. We won’t focus on how to create the Pexels account, because it is out of scope of this tutorial. But It is pretty easy to create new account based on the instruction on Pexels.
We need to save the Authorization Token
in safe place, which is the key to access Pexels API and will be used in the app.
Then we can do some testing to the Pexels API with Postman to make sure our Authorization Token
is correct and we are able to fetch photos for Pexels.
Pay attention you need to replace the Authorization Token
in the Headers
with the real value you got when you created the Pexels account.
And then if we are lucky, we will get a response in JSON format like this:
{
"page": 1,
"per_page": 2,
"photos": [{
"id": 6546164,
"width": 3265,
"height": 5000,
"url": "https://www.pexels.com/photo/person-taking-bowl-with-hawaiian-dish-6546164/",
"photographer": "Larissa Deruzzi",
"photographer_url": "https://www.pexels.com/@deruzzi",
"photographer_id": 20198481,
"avg_color": "#BE695C",
"src": {
"original": "https://images.pexels.com/photos/6546164/pexels-photo-6546164.jpeg",
"large2x": "https://images.pexels.com/photos/6546164/pexels-photo-6546164.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=650&w=940",
"large": "https://images.pexels.com/photos/6546164/pexels-photo-6546164.jpeg?auto=compress&cs=tinysrgb&h=650&w=940",
"medium": "https://images.pexels.com/photos/6546164/pexels-photo-6546164.jpeg?auto=compress&cs=tinysrgb&h=350",
"small": "https://images.pexels.com/photos/6546164/pexels-photo-6546164.jpeg?auto=compress&cs=tinysrgb&h=130",
"portrait": "https://images.pexels.com/photos/6546164/pexels-photo-6546164.jpeg?auto=compress&cs=tinysrgb&fit=crop&h=1200&w=800",
"landscape": "https://images.pexels.com/photos/6546164/pexels-photo-6546164.jpeg?auto=compress&cs=tinysrgb&fit=crop&h=627&w=1200",
"tiny": "https://images.pexels.com/photos/6546164/pexels-photo-6546164.jpeg?auto=compress&cs=tinysrgb&dpr=1&fit=crop&h=200&w=280"
},
"liked": false
},
{
"id": 6805855,
"width": 5126,
"height": 4101,
"url": "https://www.pexels.com/photo/group-of-tourists-walking-on-snowy-hilly-terrain-6805855/",
"photographer": "Harry Cooke",
"photographer_url": "https://www.pexels.com/@harry-cooke",
"photographer_id": 3933683,
"avg_color": "#738080",
"src": {
"original": "https://images.pexels.com/photos/6805855/pexels-photo-6805855.jpeg",
"large2x": "https://images.pexels.com/photos/6805855/pexels-photo-6805855.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=650&w=940",
"large": "https://images.pexels.com/photos/6805855/pexels-photo-6805855.jpeg?auto=compress&cs=tinysrgb&h=650&w=940",
"medium": "https://images.pexels.com/photos/6805855/pexels-photo-6805855.jpeg?auto=compress&cs=tinysrgb&h=350",
"small": "https://images.pexels.com/photos/6805855/pexels-photo-6805855.jpeg?auto=compress&cs=tinysrgb&h=130",
"portrait": "https://images.pexels.com/photos/6805855/pexels-photo-6805855.jpeg?auto=compress&cs=tinysrgb&fit=crop&h=1200&w=800",
"landscape": "https://images.pexels.com/photos/6805855/pexels-photo-6805855.jpeg?auto=compress&cs=tinysrgb&fit=crop&h=627&w=1200",
"tiny": "https://images.pexels.com/photos/6805855/pexels-photo-6805855.jpeg?auto=compress&cs=tinysrgb&dpr=1&fit=crop&h=200&w=280"
},
"liked": false
}
],
"total_results": 8000,
"next_page": "https://api.pexels.com/v1/search/?page=2&per_page=2&query=people"
}
If we analyse the JSON object, we would get the schema is
{
page: Int,
per_page: Int,
photos: List<Photo>,
total_result: Int,
next_page: String
}
and the schema of Photo
is
{
id: Int,
width: Int,
height: Int,
url: String,
photographer: String,
photographer_url: String,
photographer_id: Int,
avg_color: String,
src: Src,
liked: Boolean,
}
and the schema of Src
is
{
original: String,
large2x: String,
large: String,
medium: String,
small: String,
portrait: String,
landscape: String,
tiny: String
}
We need these schemas when we create data model
And then we can start to build the app once we verified the Pexels API is accessible.
Create new project
Let us open Android Studio
and select Create a new project
from the menu or launch wizard and choose Bottom Navigation Activity
template
and the click next
and complete the configuration page like this:
and then click finish
button.
Then the Android Studio
would start build this new project and download some dependencies. It might take several minutes.
Run the empty app
We need to create a new emulator if we don’t have any, and launch the emulator, and then run the app. It would show a UI like this:
It is an empty app from the Bottom Navigation Activity
template, which contains 3 pages, Home
, Dashboard
and Notification
and the Home
page is displaying when the app launches.
And then we will implement the app step by step in upcoming chapters
Create Data Model
We would build this app on MVVM architecture, so let’s implement the Model
first.
Firstly, we need to add following dependencies to Build.gradle(Module: Pexels.app)
// Moshi
implementation 'com.squareup.moshi:moshi-kotlin:1.9.3'
Sync Now
as Android Studio
instructed
Secondly, let’s create a new package, data
for sake of tidiness of the whole project.
And then we need to create several data classes, Data
, Data.Photo
and Data.Photo.Src
according the schemas of the JSON object we got in Prerequisite
step.
So let’s create a new file, Data.kt
in the data
package, and add data class as followed:
package com.ovlesser.pexels.data
data class Data(
val page: Int,
@Json(name = "per_page") val perPage: Int,
val photos: List<Photo>,
@Json(name = "total_results") val totalResults: Int,
@Json(name = "next_page") val nextPage: String
) {
data class Photo(
val id: Int,
val width: Int,
val height: Int,
val url: String,
val photographer: String,
@Json(name = "photographer_url") val photographerUrl: String,
@Json(name = "photographer_id") val photographerId: Int,
@Json(name = "avg_color") val avgColor: String,
val src: Src,
val liked: Boolean,
) {
data class Src(
val original: String,
val large2x: String,
val large: String,
val medium: String,
val small: String,
val portrait: String,
val landscape: String,
val tiny: String
)
}
}
Be careful the annotations like @Json(name = "photographer_url")
, which maps the name in the JSON object to the name in the data class
Then we have created the data models, and completed the Model
of the MVVM. We will implement the ViewModel
in next chapter
Create ViewModel for Home
page
You might notice there has been a file named HomeViewModel.kt
in the ./ui/home
package, which is de default ViewModel generated along with the template. We need to replace the code of HomeViewModel
class with
// The internal MutableLiveData Data that stores the most recent data
private val _data = MutableLiveData<Data>()
// The external immutable LiveData for the response Data
val data: LiveData<Data>
get() = _data
init {
getDataFromSample()
}
private fun getDataFromSample() {
_data.value = Data(0, 0, emptyList(), 0, "")
}
And the Android Studio
might ask you to solve Data
by importing com.ovlesser.pexels.data.Data
, just do it.
Next step, we would modify the getDataFromSample method with more meaningful data. Do you still remember the response when we test the Pexels API with Postman? We can use that response.
Let’s create a new file, SampleData.kt
in the data
package, and define a variable sampleData
in it and copy and paste the response as the value of this string as followed:
package com.ovlesser.pexelsval sampleData =
"""
{
"page": 1,
"per_page": 2,
"photos": [
{
"id": 6546164,
"width": 3265,
"height": 5000,
"url": "https://www.pexels.com/photo/person-taking-bowl-with-hawaiian-dish-6546164/",
"photographer": "Larissa Deruzzi",
"photographer_url": "https://www.pexels.com/@deruzzi",
"photographer_id": 20198481,
"avg_color": "#BE695C",
"src": {
"original": "https://images.pexels.com/photos/6546164/pexels-photo-6546164.jpeg",
"large2x": "https://images.pexels.com/photos/6546164/pexels-photo-6546164.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=650&w=940",
"large": "https://images.pexels.com/photos/6546164/pexels-photo-6546164.jpeg?auto=compress&cs=tinysrgb&h=650&w=940",
"medium": "https://images.pexels.com/photos/6546164/pexels-photo-6546164.jpeg?auto=compress&cs=tinysrgb&h=350",
"small": "https://images.pexels.com/photos/6546164/pexels-photo-6546164.jpeg?auto=compress&cs=tinysrgb&h=130",
"portrait": "https://images.pexels.com/photos/6546164/pexels-photo-6546164.jpeg?auto=compress&cs=tinysrgb&fit=crop&h=1200&w=800",
"landscape": "https://images.pexels.com/photos/6546164/pexels-photo-6546164.jpeg?auto=compress&cs=tinysrgb&fit=crop&h=627&w=1200",
"tiny": "https://images.pexels.com/photos/6546164/pexels-photo-6546164.jpeg?auto=compress&cs=tinysrgb&dpr=1&fit=crop&h=200&w=280"
},
"liked": false
},
{
"id": 6805855,
"width": 5126,
"height": 4101,
"url": "https://www.pexels.com/photo/group-of-tourists-walking-on-snowy-hilly-terrain-6805855/",
"photographer": "Harry Cooke",
"photographer_url": "https://www.pexels.com/@harry-cooke",
"photographer_id": 3933683,
"avg_color": "#738080",
"src": {
"original": "https://images.pexels.com/photos/6805855/pexels-photo-6805855.jpeg",
"large2x": "https://images.pexels.com/photos/6805855/pexels-photo-6805855.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=650&w=940",
"large": "https://images.pexels.com/photos/6805855/pexels-photo-6805855.jpeg?auto=compress&cs=tinysrgb&h=650&w=940",
"medium": "https://images.pexels.com/photos/6805855/pexels-photo-6805855.jpeg?auto=compress&cs=tinysrgb&h=350",
"small": "https://images.pexels.com/photos/6805855/pexels-photo-6805855.jpeg?auto=compress&cs=tinysrgb&h=130",
"portrait": "https://images.pexels.com/photos/6805855/pexels-photo-6805855.jpeg?auto=compress&cs=tinysrgb&fit=crop&h=1200&w=800",
"landscape": "https://images.pexels.com/photos/6805855/pexels-photo-6805855.jpeg?auto=compress&cs=tinysrgb&fit=crop&h=627&w=1200",
"tiny": "https://images.pexels.com/photos/6805855/pexels-photo-6805855.jpeg?auto=compress&cs=tinysrgb&dpr=1&fit=crop&h=200&w=280"
},
"liked": false
}
],
"total_results": 8000,
"next_page": "https://api.pexels.com/v1/search/?page=2&per_page=2&query=people"
}
""".trimIndent()
We would test out app with this sample data without implementing the network module.
So let’s modify the getDataFromSample
function as shown below:
private fun getDataFromSample() {
val moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
val jsonAdapter = moshi.adapter(Data::class.java)
val sampleData = jsonAdapter.fromJson(sampleData)
_data.value = sampleData ?: Data(0, 0, emptyList(), 0, "")
}
We are converting the sample data from a JSON string to a Data
object with Moshi
. We will use same way to convert the data from Pexels API to Data
object later.
Now, we have implemented the skeleton of the ViewModel
of Home
page. We would implement the View
in next chapter
Build the View of the Home page
We will use the Home
fragment as out main page, in which we will display the photos with the grid layout.
Firstly, we need to add some UI related dependencies. Let’s open the Build.gradle(Module: Pexels.app)
file and add several extra lines into the plugins
section as followed:
plugins {
id 'com.android.application'
id 'kotlin-android' id 'kotlin-android-extensions'
}
id 'kotlin-kapt'
The bold lines are newly added.
And we also need to enable the DataBinding
by add following lines under the buildTypes
section,
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
buildFeatures {
dataBinding true
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
and add following dependencies into dependencies
section.
//RecyclerView
implementation "androidx.recyclerview:recyclerview:1.1.0"
// For control over item selection of both touch and mouse driven selection
implementation "androidx.recyclerview:recyclerview-selection:1.1.0"
And we need to click Sync now
.
Secondly, we need to define the layout of the list item in the photo grid.
Let’s create a new layout file, named grid_view_item.xml
in the layout
folder and replace the content with following code
<?xml version="1.0" encoding="utf-8"?>
<layout
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">
<data>
<variable
name="photo"
type="com.ovlesser.pexels.data.Data.Photo" />
</data>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="4dp"
android:minHeight="100dp">
<ImageView
android:id="@+id/pexel_image"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="100dp"
android:minWidth="100dp"
android:scaleType="fitXY"
android:adjustViewBounds="true"
app:imageUrl="@{photo.src.medium}"
android:background="@color/teal_700"
tools:src="@tools:sample/backgrounds/scenic"/>
</FrameLayout>
</layout>
You might noticed we declared a variable, photo
with com.ovlesser.pexels.data.Data.Photo
type, which is the would pass value of photo to this layout file.
It would fail if you build the app because we haven’t implement the data binding of imageUrl
. We will fix it later.
And then we need to modify the layout of the Home
page itself. Let’s open fragment_home.xml
and replace the whole TextView
section with
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/photos_grid"
android:layout_width="0dp"
android:layout_height="0dp"
android:padding="6dp"
android:clipToPadding="false"
app:layoutManager="androidx.recyclerview.widget.StaggeredGridLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:spanCount="2"
tools:itemCount="16"
tools:listitem="@layout/grid_view_item" />
This is a RecyclerView
with StaggeredGridLayoutManager
in which the photos would be displayed.
We also need to wrap the whole ConstraintLayout
section inside a layout
tag and move the properties of ConstraintLayout
to layout
tag as followed in order to use DataBinding
<?xml version="1.0" encoding="utf-8"?>
<layout
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">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.home.HomeFragment"> ...... </androidx.constraintlayout.widget.ConstraintLayout>
</layout>
Next step is to modify the HomeFragment.kt
, which is the UI part of the Home
fragment.
We need to change the definition of property homeViewModel
from
private lateinit var homeViewModel: HomeViewModel
to
private val homeViewModel: HomeViewModel by lazy {
ViewModelProvider(this).get(HomeViewModel::class.java)
}
And we also need to replace the contents in the onCreateView
method with
val binding = FragmentHomeBinding.inflate(inflater)
// Allows Data Binding to Observe LiveData with the lifecycle of this Fragment
binding.lifecycleOwner = this
return binding.root
This piece of code means we would use DataBinding
to pass data between source code and the layout.
We will implement an adapter for the RecyclerView
in next step, which maps the list of photos into the list items.
Let’s create a new file, PhotoGridAdapter
and implement it as followed:
class PhotoGridAdapter: ListAdapter<Data.Photo, PhotoGridAdapter.PhotoGridViewHolder>(DiffCallback) {
class PhotoGridViewHolder(private var binding: GridViewItemBinding): RecyclerView.ViewHolder(binding.root) {
fun bind(photo: Data.Photo) {
binding.photo = photo
binding.executePendingBindings()
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PhotoGridViewHolder {
return PhotoGridViewHolder( GridViewItemBinding.inflate( LayoutInflater.from( parent.context)))
}
override fun onBindViewHolder(holder: PhotoGridViewHolder, position: Int) {
val photo = getItem(position)
holder.bind(photo)
}
companion object DiffCallback: DiffUtil.ItemCallback<Data.Photo>() {
override fun areItemsTheSame(oldItem: Data.Photo, newItem: Data.Photo): Boolean {
return oldItem.id === newItem.id
}
override fun areContentsTheSame(oldItem: Data.Photo, newItem: Data.Photo): Boolean {
return oldItem == newItem
}
}
}
And then we need to create another file, BindingAdapters.kt
, and implement a function with annotation BindingAdapter("liveData")
, which would be binding the adapter to the RecyclerView.
@BindingAdapter("listData")
fun bindRecyclerView(recyclerView: RecyclerView,
photos: List<Data.Photo>?) {
val adapter = recyclerView.adapter as PhotoGridAdapter
adapter.submitList(photos)
}
And then let’s open fragment_home.xml
and add data
section above all components and add app:listData="@{viewModel.data.photos}"
inside the RecyclerView
to bind the ViewModel to the View as followed:
<?xml version="1.0" encoding="utf-8"?>
<layout
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">
<data>
<variable
name="viewModel"
type="com.ovlesser.pexels.ui.home.HomeViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.home.HomeFragment">
<androidx.recyclerview.widget.RecyclerView
......
app:spanCount="2"
tools:itemCount="16"
app:listData="@{viewModel.data.photos}"
tools:listitem="@layout/grid_view_item" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
The name in the RecyclerView and the name of the BindingAdapter must be same, in this case, it is listData
And then, we need to open HomeFragment.kt
and add following lines into onCreateView
method as shown below.
val binding = FragmentHomeBinding.inflate(inflater)
// Allows Data Binding to Observe LiveData with the lifecycle of this Fragment
binding.lifecycleOwner = this
// Giving the binding access to the OverviewViewModel
binding.viewModel = homeViewModel
binding.photosGrid.adapter = PhotoGridAdapter()
return binding.root
Now the ViewModel is binding to the View of Home
page, and an adapter is binging to the RecyclerView.
Next step, we will display the photo in the RecyclerView by binding imageUrl
in the grid_view_item
layout with another BindingAdapter.
Firstly, we need to add following dependency to build.gradle(Module:Pexels.app)
// Glide
implementation 'com.github.bumptech.glide:glide:4.11.0'
Secondly, we need to add following BindingAdapter
to BindingAdapters.kt
@BindingAdapter("imageUrl")
fun bindImageView(imageView: ImageView, imageUrl: String?) {
imageUrl?.let {
val imageUri = imageUrl.toUri().buildUpon().scheme("https").build()
Glide.with(imageView.context)
.load(imageUri)
.apply(
RequestOptions()
.placeholder(R.drawable.loading_animation)
.error(R.drawable.ic_broken_image))
.into(imageView)
}
}
You might notice the image loading_animation
and ic_broken_image
are missing, so let’s create them or you may download it somewhere.
Let’s create a new drawable, loading_animation
and replace its content with
<?xml version="1.0" encoding="utf-8"?>
<animated-rotate xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/loading_img"
android:pivotX="50%"
android:pivotY="50%" />
and create another drawable, loading_img
and replace its content with
<vector android:height="24dp" android:viewportHeight="72"
android:viewportWidth="72" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#cccccf"
android:pathData="M36.06,28.92L36.06,32.18"
android:strokeColor="#e7e7e7" android:strokeLineCap="round" android:strokeWidth="1"/>
<path android:fillColor="#c8c8cc"
android:pathData="M39.45,29.88L37.82,32.71"
android:strokeColor="#cacaca" android:strokeLineCap="round" android:strokeWidth="1"/>
<path android:fillColor="#bbbbbe"
android:pathData="M42.12,32.32L39.3,33.95"
android:strokeColor="#cdcdcd" android:strokeLineCap="round" android:strokeWidth="1"/>
<path android:fillColor="#b2b2b7"
android:pathData="M39.8,35.98L43.06,35.98"
android:strokeColor="#cbcbcb" android:strokeLineCap="round" android:strokeWidth="1"/>
<path android:fillColor="#d0d0d4"
android:pathData="M32.77,29.99L34.4,32.81"
android:strokeColor="#ededed" android:strokeLineCap="round" android:strokeWidth="1"/>
<path android:fillColor="#949497"
android:pathData="M30.1,32.42L32.92,34.05"
android:strokeColor="#525252" android:strokeLineCap="round" android:strokeWidth="1"/>
<path android:fillColor="#97979b"
android:pathData="M32.42,35.98L29.16,35.98"
android:strokeColor="#6e6e6e" android:strokeLineCap="round" android:strokeWidth="1"/>
<path android:fillColor="#a8a8ac"
android:pathData="M36.06,43.08L36.06,39.82"
android:strokeColor="#a0a0a0" android:strokeLineCap="round" android:strokeWidth="1"/>
<path android:fillColor="#cacaca"
android:pathData="M39.7,41.99L38.07,39.16"
android:strokeColor="#cacaca" android:strokeLineCap="round" android:strokeWidth="1"/>
<path android:fillColor="#b6b6ba"
android:pathData="M42.19,39.4L39.37,37.77"
android:strokeColor="#ccc" android:strokeLineCap="round" android:strokeWidth="1"/>
<path android:fillColor="#a1a1a5"
android:pathData="M32.46,41.98L34.09,39.16"
android:strokeColor="#909090" android:strokeLineCap="round" android:strokeWidth="1"/>
<path android:fillColor="#9d9da0"
android:pathData="M29.85,39.4L32.67,37.77"
android:strokeColor="#7a7a7a" android:strokeLineCap="round" android:strokeWidth="1"/>
</vector>
and the third drawable, ic_broken_image
, replace its content with
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#A9A9AC"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M21,5v6.59l-3,-3.01 -4,4.01 -4,-4 -4,4 -3,-3.01L3,5c0,-1.1 0.9,-2 2,-2h14c1.1,0 2,0.9 2,2zM18,11.42l3,3.01L21,19c0,1.1 -0.9,2 -2,2L5,21c-1.1,0 -2,-0.9 -2,-2v-6.58l3,2.99 4,-4 4,4 4,-3.99z" />
</vector>
Ok. The error of missing symbol in the bindImageView
function is fixed.
And it should be able to build after we fixed the the missing binding of imageUrl
in the grid_view_item.xml
.
Now we have bind all HomeViewModel
and Data
and HomeFragment
with its adapter together. So you might want to run it. But it would fail with an error Permission denied (missing INTERNET permission?)
. Let’s fix it in next chapter.
Update the User Permission
Let’s open AndroidManifest.xml
and add permission above the application
section as followed:
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
......
<uses-permission android:name="android.permission.INTERNET" />
<application
......
Run it again and it would appear like this:
But be careful, the photos are from our sampleData
. We will add the real network operation in next chapter.
Add Network Operation
We have displayed the photos in a grid with DataBinding on MVVM architecture in previous chapter, although the data are fake. So let’s fetch the real photos from Pexels API on the runtime. To implement it, we need to integrate Retrofit
.
Firstly, we need to add following dependencies into build.gradle(Modules:Pexels.app)
// Retrofit
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-scalars:2.9.0'
implementation 'com.squareup.retrofit2:converter-moshi:2.9.0'
Sync Now
Secondly, we need to implement a network service. Let’s create a new package, network,
to keep the code clean and tidy, then create a new interface, PexelsApiService
, in this package, and replace it with following code
private val BASE_URL = "https://api.pexels.com/v1/"
private val moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
private val retrofit = Retrofit.Builder()
.addConverterFactory(MoshiConverterFactory.create(moshi))
.baseUrl(BASE_URL)
.build()
interface PexelsApiService {
@GET("search")
@Headers("Authorization:563492ad6f917000010000011a5094093e6e4ee3978287979ad139ac")
fun getData(@Query("query") keyword: String,
@Query("page") pageIndex: Int = 0,
@Query("per_page") perPage: Int = 20): Call<Data>
}
object PexelApi {
val retrofitService: PexelsApiService by lazy {
retrofit.create(PexelsApiService::class.java)
}
}
And we need to call this network API in the init block of the HomeViewModel
class
Let’s add a new method getDataFromNetwork
with following implementation into the HomeViewModel
class
private fun getDataFromNetwork() {
PexelApi.retrofitService.getData(keyword = "panda").enqueue(
object: Callback<Data> {
override fun onResponse(call: Call<Data>, response: Response<Data>) {
_data.value = response.body()
}
override fun onFailure(call: Call<Data>, t: Throwable) {
_data.value = Data(0, 0, emptyList(), 0, "")
}
}
)
}
and replace the getDataFromSample
in the init
block with new getDataFromNetwork
function
And you would see lots of photo of panda, which is the hardcoded keyword now, displaying on the screen like this:
As we saw, we have implemented the first version of network operation and integrated it into our app. It works with traditional Retrofit Callback, but let’s implement a coroutine version of same API in next chapter
Coroutine version of network API
We all know the coroutine can make the Kotlin code more readable, so let’s try it.
Let’s add another method, getDataCoroutine
into PexelsApiService
as followed:
@GET("search")
@Headers("Authorization:563492ad6f917000010000011a5094093e6e4ee3978287979ad139ac")
suspend fun getDataCoroutine(@Query("query") keyword: String,
@Query("page") pageIndex: Int = 0,
@Query("per_page") perPage: Int = 20): Data
It is a suspend function and returns Data
object instead of Call<Data>
.
And Let’s create another method, getDataFromNetworkCoroutine
, with following implementation in HomeViewModel
class
private fun getDataFromNetworkCoroutine() {
viewModelScope.launch {
try {
_data.value = PexelApi.retrofitService.getDataCoroutine(keyword = "panda")
} catch (e: Exception) {
_data.value = Data(0, 0, emptyList(), 0, "")
}
}
}
and call it in the init
block instead of getDataFromNetwork
.
And we will get same result if we run the app again.
Now we have implemented the network operation with coroutine
. it’s no magic, quite simple.
Now we have been able to fetch photos from Pexels API, but what would happen if network is unavailable or backend is down? So in next chapter, we will add more properties into the HomeViewModel
class to handle the status of network.
Add status into HomeViewModel
Let’s open HomeViewModel.kt
and add an enum class PexelsApiStatus
as followed:
enum class PexelsApiStatus { LOADING, ERROR, DONE}
class HomeViewModel : ViewModel() {
......
and add two new properties, response
and status
into HomeViewModel
class as shown below:
// The internal MutableLiveData Data that stores the most recent data
private val _data = MutableLiveData<Data>()
private val _response = MutableLiveData<String>()
private val _status = MutableLiveData<PexelsApiStatus>()
// The external immutable LiveData for the response Data
val data: LiveData<Data>
get() = _data
val response: LiveData<String>
get() = _response
val status: LiveData<PexelsApiStatus>
get() = _status
and update these two LiveData in getDataFromSample
method like this:
private fun getDataFromSample() {
val moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
val jsonAdapter = moshi.adapter(Data::class.java)
val sampleData = jsonAdapter.fromJson(sampleData)
_data.value = sampleData ?: Data(0, 0, emptyList(), 0, "")
_response.value = "Success: init with sample data"
_status.value = PexelsApiStatus.DONE
}
update the getDataFromNetwork
as well
private fun getDataFromNetwork() {
val keyword = "panda"
_status.value = PexelsApiStatus.LOADING
PexelApi.retrofitService.getData(keyword = keyword).enqueue(
object: Callback<Data> {
override fun onResponse(call: Call<Data>, response: Response<Data>) {
_data.value = response.body()
_response.value = "Success: Pexel data about ${keyword} is fetched"
_status.value = PexelsApiStatus.DONE
}
override fun onFailure(call: Call<Data>, t: Throwable) {
_data.value = Data(0, 0, emptyList(), 0, "")
_response.value = "Failure: ${t.message}"
_status.value = PexelsApiStatus.ERROR
}
}
)
}
and same update to getDataFromNetworkCoroutine
private fun getDataFromNetworkCoroutine() {
val keyword = "panda"
_status.value = PexelsApiStatus.LOADING
viewModelScope.launch {
try {
_data.value = PexelApi.retrofitService.getDataCoroutine(keyword = keyword)
_response.value = "Success: Pexel data about ${keyword} is fetched"
_status.value = PexelsApiStatus.DONE
} catch (e: Exception) {
_data.value = Data(0, 0, emptyList(), 0, "")
_response.value = "Failure: ${e.message}"
_status.value = PexelsApiStatus.ERROR
}
}
}
We are using response
to keep the error message if the network request is failed, and using status
to save the status of the network request.
So we need to show users some difference if the network request is successful or failed.
The first step we need to do is adding another BindingAdapter, bindStatus
into BindingAdapters.kt
as followed:
@BindingAdapter("marsApiStatus")
fun bindStatus(statusImageView: ImageView,
status: PexelsApiStatus) {
when (status) {
PexelsApiStatus.LOADING -> {
statusImageView.visibility = View.VISIBLE
statusImageView.setImageResource(R.drawable.loading_animation)
}
PexelsApiStatus.ERROR -> {
statusImageView.visibility = View.VISIBLE
statusImageView.setImageResource(R.drawable.ic_connection_error)
}
PexelsApiStatus.DONE -> {
statusImageView.visibility = View.GONE
}
}
}
We need another drawable, ic_connection_error
, you can create such a drawable with
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="100dp"
android:height="100dp"
android:tint="#A9A9AC"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#A9A9AC"
android:pathData="M19.35,10.04C18.67,6.59 15.64,4 12,4c-1.48,0 -2.85,0.43 -4.01,1.17l1.46,1.46C10.21,6.23 11.08,6 12,6c3.04,0 5.5,2.46 5.5,5.5v0.5H19c1.66,0 3,1.34 3,3 0,1.13 -0.64,2.11 -1.56,2.62l1.45,1.45C23.16,18.16 24,16.68 24,15c0,-2.64 -2.05,-4.78 -4.65,-4.96zM3,5.27l2.75,2.74C2.56,8.15 0,10.77 0,14c0,3.31 2.69,6 6,6h11.73l2,2L21,20.73 4.27,4 3,5.27zM7.73,10l8,8H6c-2.21,0 -4,-1.79 -4,-4s1.79,-4 4,-4h1.73z" />
</vector>
or download it somewhere like material design.
And then we have implemented the update of the status in HomeViewModel
and bond it to the property pexelsApiStatus
in the layout file, but where is the property? Let’s add it to fragment_home.xml
Let’s add following ImageView
under the RecyclerView
section
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.home.HomeFragment">
<androidx.recyclerview.widget.RecyclerView
......
tools:listitem="@layout/grid_view_item" />
<ImageView
android:id="@+id/status_image"
android:layout_width="200dp"
android:layout_height="200dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:pexelsApiStatus="@{viewModel.status}" />
</androidx.constraintlayout.widget.ConstraintLayout>
And then we can turn on the airplane mode and run the app.
It would display a loading spinner first, and the connection_error
icon instead of photos like this:
We have implemented all mandatory functions of this Photo App. And let’s consider what improvement we can do?
One improvement we can do is to add a persistent storage to cache the data which is previously fetched, and display this cached data when the app launches. And sending the network request at same time, when new data gets fetched, the app would refresh the list to display the new photos. In the case the network is unavailable, the app can still display cached data. It would be a better user experience.
Add Persistent storage
We will introduce Room
to save the data.
The Room persistence library provides an abstraction layer over SQLite to allow fluent database access while harnessing the full power of SQLite. In particular, Room provides the following benefits:
Compile-time verification of SQL queries.
Convenience annotations that minimize repetitive and error-prone boilerplate code.
Streamlined database migration paths.
Because of these considerations, we highly recommend that you use Room instead of using the SQLite APIs directly.
(https://developer.android.com/training/data-storage/room)
Firstly, we need to add following dependencies into build.gradle(Project:Pexels)
// Room
implementation 'androidx.room:room-runtime:2.3.0-rc01'
kapt 'androidx.room:room-compiler:2.3.0-rc01'
and Sync Now
Secondly, we need to create entity
class. Let’s create a new package, database
, in which all database related files are.
And we need to create a database entity class as followed:
class DatabasePexelsPhoto constructor(
@PrimaryKey
val id: String,
val width: Int = 0,
val height: Int = 0,
val url: String = "",
val photographer: String = "",
val photographerUrl: String = "",
val photographerId: Int = 0,
val avgColor: String = "",
val src: String = "",
val liked: Boolean = false)
And we will save photos only in the database, not the whole Data
object, because it doesn’t make sense to save other properties likepage
, perPage
, totalResults
and nextPage
in database.
And we also need to implement a data conversion function because the types of fields in DatabasePexelsPhoto
are different to them in Data.Photo
. We need to convert the src
from String
in the DB to Data.Photo.Src
object.
We got an error at line src = jsonAdapter.fromJson(it.src),
. Just leave it, we will fix it later.
And we also need to implement another conversion function in Data.kt
to convert the photo data from Data.Photo
to DatabasePexelsPhoto
as shown below:
fun List<Data.Photo>.asDatabaseModel(): List<DatabasePexelsPhoto> {
val moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
val jsonAdapter = moshi.adapter(Data.Photo.Src::class.java)
return map {
DatabasePexelsPhoto(
id = it.id.toString(),
width = it.width,
height = it.height,
url = it.url,
photographer = it.photographer,
photographerUrl = it.photographerUrl,
photographerId = it.photographerId,
avgColor = it.avgColor,
src =jsonAdapter.toJson(it.src),
liked = it.liked
)
}
}
and update the line val src: Src,
to val src: Src?,
to fix previous error.
And then it’s time to create the DAO. Let’s create a new file, Room.kt
in database
package and declare an interface PexelsPhotoDao
, with two methods, getPhotos
and insertAll
. When these methods are called, The Room
would get / insert the data from / into database.
@Dao
interface PexelsPhotoDao {
@Query("select * from databasepexelsphoto")
fun getPhotos(): LiveData<List<DatabasePexelsPhoto>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertAll(photos: List<DatabasePexelsPhoto>)
}
And we need to create a Room instance, INSTANCE
as followed, which is a singleton object across the whole app.
@Database(entities = [DatabasePexelsPhoto::class], version = 1)
abstract class PexelsPhotoDatabase: RoomDatabase() {
abstract val pexelsPhotoDao: PexelsPhotoDao
}
private lateinit var INSTANCE: PexelsPhotoDatabase
fun getDatabase(context: Context): PexelsPhotoDatabase {
synchronized(PexelsPhotoDatabase::class.java) {
if (!::INSTANCE.isInitialized) {
INSTANCE = Room.databaseBuilder(context.applicationContext,
PexelsPhotoDatabase::class.java,
"pexelsphoto").build()
}
}
return INSTANCE
}
Now we have implemented an interface to get / put data from / into database with Room, but it is isolated with the network interface. We need to integrate them.
And according the Guide to app architecture, it suggests single source of truth, so we will fetch the data only from database. Even though new data fetched from network interface would be saved into database rather than be displayed directly. So we need to add a repository module.
Let’s create a new package, repository
, and create a new repository class, PexelsPhotoReposiroty
in this package. We also need to implement a property data
, which reads data from database and wraps in a LiveData, and a method, in which a PexelApi
call is made and saves the fetched data into database.
class PexelsPhotoRepository(private val database: PexelsPhotoDatabase) {
val data: LiveData<Data>
get() {
val photos = database.pexelsPhotoDao.getPhotos()
return Transformations.map(photos) {
Data(
page = 0,
perPage = 0,
photos = it.asDomainModel(),
totalResults = it.size,
nextPage = ""
)
}
}
suspend fun refreshPexelsPhoto(keyword: String) {
withContext(Dispatchers.IO) {
val data = PexelApi.retrofitService.getDataCoroutine(keyword)
database.pexelsPhotoDao.insertAll(data.photos.asDatabaseModel())
}
}
}
We are binding the LiveData<List<DatabasePexelsPhoto>>
to the LiveData<Data>
with the factory function Transformations.map()
, which is observing the change of input LiveData, and updates the output LiveData accordingly.
And then we will modify the HomeViewModel
class to let it fetch data from repository instead of network API.
We need to call getDatabse
in the HomeViewModel
to get the instance of the PexelsPhotoDatabse
, and instantiate an object of PexelsPhotoRepository
based on this database object. Let’s add following line at beginning of HomeViewModel
class
private val pexelsPhotoRepository = PexelsPhotoRepository(getDatabase(application))
You would notice we don’t have application instance so far, so we need to update the signature of the HomeViewModel
class with
class HomeViewModel : ViewModel()
to
class HomeViewModel(application: Application) : AndroidViewModel(application)
and change the lines
val data: LiveData<Data>
get() = _data
to
val data = pexelsPhotoRepository.data
It is as I mentioned before, we are fetching data from repository instead from backfield of data, which would be updated from network API directly.
It’s time to abandon the code in which we are fetching data from network API. We don’t need getDataFromNetwork
and getDataFromNetworkCoroutine
anymore.
But we need to implement another method refreshRepository as followed:
private fun refreshRepository() {
val keyword = "panda"
_status.value = PexelsApiStatus.LOADING
viewModelScope.launch {
try {
pexelsPhotoRepository.refreshPexelsPhoto(keyword)
_response.value = "Success: Pexel data about ${keyword} is fetched"
_status.value = PexelsApiStatus.DONE
} catch (e: Exception) {
_data.value = Data(0, 0, emptyList(), 0, "")
_response.value = "Failure: ${e.message}"
_status.value = PexelsApiStatus.ERROR
}
}
}
and call it in the init
block instead of calling getDataFromNetwork
or getDataFromNetworkCoroutine
With this piece of code, we will send network request to refresh the data in the database when the app launches.
We also need to add an inner class Factory
inside the HomeViewModel
class as followed:
class Factory(val app: Application): ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(HomeViewModel::class.java)) {
@Suppress("UNCHECKED_CAST")
return HomeViewModel(app) as T
}
throw IllegalArgumentException("Unable to construct viewmodel")
}
}
This class would be used when we instantiate the HomeViewModel
object in the HomeFragment
class
We also need the final change in HomeFragment
class to replace
private val homeViewModel: HomeViewModel by lazy {
ViewModelProvider(this)
.get(HomeViewModel::class.java)
}
to
private val homeViewModel: HomeViewModel by lazy {
val activity = requireNotNull( this.activity) {
"You can only access the viewModel after onActivityCreated()"
}
ViewModelProvider(this, HomeViewModel.Factory(activity.application))
.get(HomeViewModel::class.java)
}
when we instantiate the HomeViewModel
object.
Now we would notice the property PexelsPhotoRepository.data.get()
would called to update HomeViewModel.data
when the app launches and the method PexelsPhotoRepository.refreshPexelsPhoto()
would be called afterwards to update the database.
Let’s run the app with network connection first. It would load the photos as usual like this:
And the we kill the app and turn on the Airplane mode, launch the app again. You would find the data is read from the database would be displayed first, so the app would know the url of the photos, and then some cached photos by Retrofit
could be displayed as followed 2 screenshots.
But if you scroll down, you would find some photos were keeping loading. It is because they were not cached by Retrofit
although the url of the photos were correct.
Now we have completed the parts of fetching the data from network, saving the photos in the database, and displaying them in the RecyclerView with a single source of truth manner.
But you might notice that we fetched the data of 20 photos only once when the app launches, so it won’t fetch more photos. And we would also notice the Pexels
API actually supports paging with a query parameter page
, so in the next chapter, we would adding pagination to our app.
Add Pagination
What we plan to do is to fetch next page of photos when the user scrolls to the bottom of the list.
Let’s openPexelsPhotoRespository.kt
and add the backfield of the property data
because we need to save nextPage
url in the http response, but we don't want to save it in database. So modify the property data
as followed:
val _data = MutableLiveData<Data>()
val data: LiveData<Data>
get() {
val photos = database.pexelsPhotoDao.getPhotos()
return Transformations.map(photos) {
Data(
page = _data.value?.page ?: 0,
perPage = _data.value?.perPage ?: 0,
photos = it.asDomainModel(),
totalResults = it.size,
nextPage = _data.value?.nextPage ?: ""
)
}
}
and we also need to modify the method refreshPexelsPhoto
as shown below:
suspend fun refreshPexelsPhoto(keyword: String, pageIndex: Int) {
lateinit var data: Data
withContext(Dispatchers.IO) {
data = PexelApi.retrofitService.getDataCoroutine(keyword, pageIndex = pageIndex)
database.pexelsPhotoDao.insertAll(data.photos.asDatabaseModel())
}
_data.value = data
}
We made two changes, one is adding a new parameter, pageIndex
for letting PexelApi
know which page to fetch, another is to save the response data
.
And then we need to update the HomeViewModel
class as followed
Firstly, we need to change method refreshRepository
to public, so it can be invoked from the fragment. And we also need to update this method as followed:
fun refreshRepository() {
val keyword = "panda"
val nextPageUri = data.value?.nextPage?.toUri()
val pageIndex = nextPageUri?.getQueryParameter("page")?.toInt() ?: 0
_status.value = PexelsApiStatus.LOADING
viewModelScope.launch {
try {
pexelsPhotoRepository.refreshPexelsPhoto(keyword, pageIndex = pageIndex) ......
}
}
Secondly, let’s update the PhotoGridAdapter
class to do something when user scrolls to the bottom of the list.
We need to add a new parameters, onScrollToBottom
, which is a function passed in, to the signature of PhotoGridAdapter
as shown below:
class PhotoGridAdapter(private val onScrollToBottom: () -> Unit): ListAdapter<Data.Photo, PhotoGridAdapter.PhotoGridViewHolder>(DiffCallback)
and call this function onScrollToBottom
, when scrolling to the bottom of the list in onBindViewHolder
method as followed:
override fun onBindViewHolder(holder: PhotoGridViewHolder, position: Int) {
val photo = getItem(position)
if (position == itemCount - 1) {
onScrollToBottom()
}
holder.bind(photo)
}
At last, let’s pass a function, homeViewModel.getDataFromRepository()
, to PhotoGridAdapter
object when it is instantiated in the HomeFragment
class as shown below:
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val binding = FragmentHomeBinding.inflate(inflater)
// Allows Data Binding to Observe LiveData with the lifecycle of this Fragment
binding.lifecycleOwner = this
// Giving the binding access to the OverviewViewModel
binding.viewModel = homeViewModel
binding.photosGrid.adapter = PhotoGridAdapter() {homeViewModel.refreshRepository()}
return binding.root
}
Let’s run the app and scroll down to the bottom of the list. We would see next page of photos would be loaded. And if you continue scrolling, more and more photos would be loaded.
Now we have completed the pagination. It would fetch next page of data when scrolling to the bottom of current list. And also because all fetched photos would be added to database, so duplicated photos would be replaced.
So far so good. However, you might notice all photos are panda because the query keyword is hardcoded to panda. So in next chapter, we will add a SearchView to let user to input a keyword.
Add SearchView
Firstly, we need to add a new API to the PexelsPhotoDao
interface, clearAll
, which is used to clean the database table when the keyword gets changed. Let’s open the Room.kt
and add following method to PexelsPhotoDao
interface.
@Query("delete from databasepexelsphoto")
fun clearAll()
And we also need to add a new method, clearDatabase
, as followed into the PexelsPhotoRepository
class.
suspend fun clearDatabase() {
withContext(Dispatchers.IO) {
database.pexelsPhotoDao.clearAll()
}
}
It is a suspend function running on IO thread because database operation is long-running task as well.
And then we need to update the HomeViewModel
. Let’s open HomeViewModel.kt
and add a new parameter keyword
into refreshRepository
method as followed, and modify the implementation accordingly:
fun refreshRepository(keyword: String = "") {
if (keyword.isEmpty()) {
_status.value = PexelsApiStatus.DONE
} else {
val nextPageUri = data.value?.nextPage?.toUri()
.....
pexelsPhotoRepository.refreshPexelsPhoto(keyword, pageIndex = pageIndex)
......
_status.value = PexelsApiStatus.ERROR
}
}
This change shows we would do nothing except setting the status
to DONE
if the keyword is empty, or else, we will use this keyword for fetching data while calling pexelsPhotoRepository.refreshPexelsPhoto
method.
You might want to modify the methods getDataFromNetworkCoroutine
and getDataFromNetwork
with same change, although they are not in use anymore.
And then we need to add another private method clearDataFromDatabase
as followed in HomeViewModel
class
private fun clearDataFromDatabase() {
viewModelScope.launch {
try {
pexelsPhotoRepository.clearDatabase()
} catch (e: Exception) {
_response.value = "Failure: ${e.message}"
_status.value = PexelsApiStatus.DONE
}
}
}
this method would call pexelsPhotoRepository.clearDatabase()
to clean the data in database.
And then we need to add a new method updateKeyword
as followed, which would be invoked from HomeFragment
when the keyword gets changed.
fun updateKeyword( keyword: String) {
clearDataFromDatabase()
refreshRepository(keyword)
}
This method is pretty straightforward, just cleaning the database, and fetching new data with new keyword.
Now we have updated the PexelsPhotoDao
, PexelsPhotoRepository
, HomeViewModel
, it’s time to update the view, HomeFragment
.
The first thing we need to do is to add a SearchView
and correspondent listeners to HomeFragment
class to allow user to input the keyword.
Let’s create a new xml file, home.xml
in ./res/menu
folder, and add one item in it as followed:
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item android:id="@+id/action_search"
android:title=""
android:icon="@android:drawable/ic_menu_search"
app:actionViewClass="androidx.appcompat.widget.SearchView"
app:showAsAction="always"/>
</menu>
And then we need to open HomeFragment.kt
and add a new interface androidx.appcompat.widget.SearchView.OnQueryTextListener
to the HomeFragment
class as followed:
class HomeFragment : Fragment(), androidx.appcompat.widget.SearchView.OnQueryTextListener
and add a private property keyword
to save the keyword that user inputed as followed.
private var keyword = ""
and instantiate the SearchView
and implement the listeners under the onCreateView
method as shown below:
private var searchView: androidx.appcompat.widget.SearchView? = null
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.home, menu)
searchView = menu.findItem(R.id.action_search)?.let {
it.actionView as? androidx.appcompat.widget.SearchView
}?.apply {
(activity?.getSystemService(Context.SEARCH_SERVICE) as? SearchManager)?.also {
setSearchableInfo(it.getSearchableInfo(activity?.componentName))
}
maxWidth = Int.MAX_VALUE
setIconifiedByDefault(false)
isIconified = false
isSubmitButtonEnabled = false
setOnQueryTextListener(this@HomeFragment)
requestFocus()
layoutParams = ActionBar.LayoutParams(
ActionBar.LayoutParams.MATCH_PARENT,
ActionBar.LayoutParams.MATCH_PARENT)
}
}
override fun onQueryTextSubmit(query: String?): Boolean {
return query?.let {
if (it.length < 2) { return@let false }
keyword = it
homeViewModel.updateKeyword(keyword)
searchView?.clearFocus()
true
} ?: false
}
override fun onQueryTextChange(newText: String?): Boolean {
return true
}
In this piece of code, we would initialise a SearchView
and bind it the the action_search
in the home
menu, which implement the search behaviour. We also implemented listeners onQueryTextSubmit
, which would be called when the user clicks search
icon on the keyboard, and trigger the homeViewModel.updateKeyword()
method, and onQueryTextChange
which indicates the input text is changed.
And we also need to add at beginning of the onCreateView method to show the menu as followed:
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
setHasOptionsMenu(true)
val binding = FragmentHomeBinding.inflate(inflater)
...... binding.photosGrid.adapter = PhotoGridAdapter( PhotoGridAdapter.OnClickListener {
homeViewModel.displayPhotoDetails(it)
}) {homeViewModel.refreshRepository(keyword)} ......
}
Be careful, we also added a parameter keyword
when calling homeViewModel.refreshRepository
, or else it would fetch nothing.
Now let’s run the app, you will find a new SearchView
at the ActionBar
on the Home
page as followed screenshot. Then you can input the keyword, like gorilla
, in the SearchView
, and click search
icon on the keyboard.
It would fetch photos with new keyword, gorilla, and update the database, and then refresh the list like this:
So far, the app is pretty good and fully functional. What other enhancement do you need? Maybe another page to display the details of a single photo. It will navigate to the new Detail
page when you clicks a single photo from the list. Let’s implement it in next chapter.
Add Detail page
We will implement the navigation from Home
page to Detail
page with Android Navigation component, so we need to add following dependencies into dependencies
section in the build.gradle(Module:Pexls.app)
// Navigation
implementation 'androidx.navigation:navigation-fragment-ktx:2.3.4'
implementation 'androidx.navigation:navigation-ui-ktx:2.3.4'
And we will use a new feature, navigation-safe-args
, so we need to add more plugin and dependencies
Let’s open build.gradle(Project:Pexels)
, and add following lines in the dependencies
block as followed:
dependencies {
classpath "com.android.tools.build:gradle:4.1.1"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:2.3.4"
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
and open build.gradle(Module:Pexels.app) again to add another plugin into plugins section as shown below:
plugins {
id 'com.android.application'
id 'kotlin-android'
id 'kotlin-android-extensions'
id 'kotlin-kapt'
id 'androidx.navigation.safeargs.kotlin'
}
Sync now
We will add ViewModel
for the Detail
page in next step.
Let’s create a new package, detail
in ui
package first for sake of cleanness of the project.
And we need to create a new class, DetailViewModel
, inside this package, and update the code as followed:
class DetailViewModel(photo: Data.Photo, app: Application): AndroidViewModel(app) {
private val _photo = MutableLiveData<Data.Photo>()
val photo: LiveData<Data.Photo>
get() = _photo
init {
_photo.value = photo
}
}
It’s pretty similar to HomeViewModel
class as we just created a LiveData property, photo
, in it to represent the photo to be displayed.
We also need to add a subclass of ViewModelProvider.Factory
for checking the ViewModel would be created with correct type because we need to pass some parameter to it. Add the following code under the init
block.
class Factory( private val photo: Data.Photo,
private val app: Application): ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(DetailViewModel::class.java)) {
return DetailViewModel( photo, app) as T
}
throw IllegalArgumentException("Unknown View class")
}
}
Now, we have completed the ViewModel
for Detail
page. It’s time to implement the View
part.
Let’s create a new fragment, DetailFragment
with the BlankFragment
template. You would notice two new files, DetailFragment.kt
and fragment_detail.xml
, would be created.
Let’s update the fragment_detail.xml
first.
We need to remove the whole FrameLayout
section, and add a layout
section as followed in order to use DataBinding
.
<layout 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">
</layout>
And we need to add a data
block with variable
block inside the layout
block for declaring the name and type of the variable as shown below:
<data>
<variable
name="viewModel"
type="com.ovlesser.pexels.ui.detail.DetailViewModel" />
</data>
And we also need to add some components under the data
section as followed, image
, id
, size
(width * height), url
and photographer
<ScrollView
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp">
<ImageView
android:id="@+id/detail_photo"
android:layout_width="0dp"
android:layout_height="226dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:imageUrl="@{viewModel.photo.src.medium}"
tools:src="@tools:sample/backgrounds/scenic" />
<TextView
android:id="@+id/detail_id"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:textColor="#de000000"
android:text="@{String.valueOf(viewModel.photo.id)}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/detail_photo" />
<TextView
android:id="@+id/detail_size"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:textColor="#de000000"
android:text="@{viewModel.size}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/detail_id" />
<TextView
android:id="@+id/detail_url"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:textColor="#de000000"
android:text="@{viewModel.photo.url}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/detail_size" />
<TextView
android:id="@+id/detail_photographer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:textColor="#de000000"
android:text="@{viewModel.photo.photographer}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/detail_url" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
We put the ImageView
to display the photo image, and some other TextView
to display other info, in a ScrollView
to make the whole layout scrollable just in case the image is too large.
We also set app:imageUrl="@{viewModel.photo.src.medium}"
to set the source of the ImageView
You might notice an error highlighted at android:text=”@{viewModel.size}”
, that is because there is no property or method called size
in DetailViewModel
class. To fix this issue we need to add a property size
into DetailViewModel
as followed:
val size = Transformations.map(this.photo) {
"${it.width} * ${it.height}"
}
Then we would find the property DetailViewModel.size
Next step we would do is to update the data model because we need to pass argument to indicate which item is selected from the list at Home
page to Detail
page. And this argument must be serializable or parcelable, so we need to update Data
, Data.Photo
and Data.Photo.Src
as followed:
@Parcelize
data class Data(
val page: Int = 0,
@Json(name = "per_page") val perPage: Int = 0,
val photos: List<Photo>,
@Json(name = "total_results") val totalResults: Int = 0,
@Json(name = "next_page") val nextPage: String = ""
): Parcelable {
@Parcelize
data class Photo(
val id: Int,
val width: Int = 0,
val height: Int = 0,
val url: String = "",
val photographer: String = "",
@Json(name = "photographer_url") val photographerUrl: String = "",
@Json(name = "photographer_id") val photographerId: Int = 0,
@Json(name = "avg_color") val avgColor: String = "",
val src: Src? = null,
val liked: Boolean = false,
): Parcelable {
@Parcelize
data class Src(
val original: String = "",
val large2x: String = "",
val large: String = "",
val medium: String = "",
val small: String = "",
val portrait: String = "",
val landscape: String = "",
val tiny: String = ""
): Parcelable
}
}
We also added the default value to some propertied to simplify the instantiation of the Data
object later.
Now it’s an interesting part that we will implement the navigation with a navigation graph.
Let’s open ./res/layout/navigation/mobile-navagation.xml
file and add a new fragment section as shown below:
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
......
app:startDestination="@+id/navigation_home">
<fragment
......
tools:layout="@layout/fragment_notifications" />
<fragment
android:id="@+id/navigation_detail"
android:name="com.ovlesser.pexels.ui.detail.DetailFragment"
android:label="@string/title_detail"
tools:layout="@layout/fragment_detail" />
</navigation>
as a new navigation destination. We also add the string literal title_detail
into ./res/values/string.xml
as followed:
<string name="title_detail">Detail</string>
Then Let’s add action into Home
fragment as followed to makes us from Home
fragment to Detail
fragment
<fragment
android:id="@+id/navigation_home"
android:name="com.ovlesser.pexels.ui.home.HomeFragment"
android:label="@string/title_home"
tools:layout="@layout/fragment_home">
<action
android:id="@+id/action_showDetail"
app:destination="@id/navigation_detail" />
</fragment>
And we also need to add argument
block into Detail
fragment as followed to define the type of arguments which would be passed from Home
fragment to Detail
fragment.
<fragment
android:id="@+id/navigation_detail"
android:name="com.ovlesser.pexels.ui.detail.DetailFragment"
android:label="@string/title_detail"
tools:layout="@layout/fragment_detail">
<argument
android:name="photo"
app:argType="com.ovlesser.pexels.data.Data$Photo" />
</fragment>
Be careful the $
between Data
and Photo
because the Photo
is an inner class of Data
, and it is actually using reflection here.
After this step, you might need to close the project / clean the project to make sure class DetailFragmentArgs
is generated it it’s not generated automatically.
Now, we need to update the DetailFragment
class. Let’s open up the DetailFragment.kt
and remove all template code and replace the onCreateView method with following code
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val application = requireNotNull(activity).application
val binding = FragmentDetailBinding.inflate(inflater)
binding.lifecycleOwner = this
val photo = DetailFragmentArgs.fromBundle(requireArguments()).photo
val viewModelFactory = DetailViewModel.Factory( photo, application)
binding.viewModel = ViewModelProvider(
this, viewModelFactory).get(DetailViewModel::class.java)
return binding.root
}
We would get a Photo
object from requireArguments()
when the DetailFragment
gets initialised.
And then we need to update the PhotoGridAdapter
class to make it responds the click
event on list item.
Let’s open PhotoGridAdapter.kt
and add a new property, onClickListener
, to the PhotoGridAdapter
class as followed:
class PhotoGridAdapter(private val onClickListener: OnClickListener,
private val onScrollToBottom: () -> Unit): ListAdapter<Data.Photo, PhotoGridAdapter.PhotoGridViewHolder>(DiffCallback)
And we also need to implement a inner class OnClickListener
as shown below:
class OnClickListener(val clickListener: (photo: Data.Photo) -> Unit) {
fun onClick( photo: Data.Photo) = clickListener( photo)
}
and add the response of click
event to the onBindViewHolder
method as followed:
override fun onBindViewHolder(holder: PhotoGridViewHolder, position: Int) {
val photo = getItem(position)
if (position == itemCount - 1) {
onScrollToBottom()
}
holder.itemView.setOnClickListener {
onClickListener.onClick(photo)
}
holder.bind(photo)
}
Now, it would call the callback function onClickListener.onClick(photo)
when a list item gets clicked. This callback function is passed from HomeFragment
when the PhotoGridAdapter
object is instantiated.
Nest step is to update the HomeViewModel
class because we need to save which list item gets clicked.
Let’s open HomeViewModel.kt
file and add another property selectedPhoto
and its backfield _selectedPhoto
as shown below:
private val _status = MutableLiveData<PexelsApiStatus>()
private val _selectedPhoto = MutableLiveData<Data.Photo?>()
......
val status: LiveData<PexelsApiStatus>
get() = _status
val selectedPhoto: LiveData<Data.Photo?>
get() = _selectedPhoto
And we also need to add two methods displayPhotoDetails(photo: Data.Photo)
and displayPhotoDetailComplete()
as followed, in which the property selectedPhoto
would be updated.
fun displayPhotoDetails( photo: Data.Photo) {
_selectedPhoto.value = photo
}
fun displayPhotoDetailComplete() {
_selectedPhoto.value = null
}
Now, let’s update the HomeFragment
class to bind all changes together.
Let’s open HomeFragment.kt
and updated the instantiation of viewModelAdapter
as followed:
binding.photosGrid.adapter = PhotoGridAdapter( PhotoGridAdapter.OnClickListener {
homeViewModel.displayPhotoDetails(it)
}) {homeViewModel.refreshRepository()}
With this change, we would pass a lambda function PhotoGridAdapter.OnClickListener {
into the photoGridAdapter object. And the onClick() would set to
homeViewModel.displayPhotoDetails(it)
}homeViewModel.displayPhotoDetails(it)
. It mean homeViewModel.displayPhotoDetails(it)
would be called when the user clicks a list item. The parameter would be the photo object of the selected list item.
And we also need to add an observer as followed to observe the LiveData property selectedPhoto
and navigate to the destination fragment, DetailFragment
if selectedPhoto
is changed.
homeViewModel.selectedPhoto.observe(viewLifecycleOwner, Observer {
if (it != null) {
this.findNavController().navigate(
HomeFragmentDirections.actionShowDetail(it))
homeViewModel.displayPhotoDetailComplete()
}
})
You might need to restart the Android Studio
if you found findNavController
cannot be found.
We are almost done. Let’s run the app and click a photo from the list on Home
page, it would navigate to the Detail
page to show this photo with some info. It’s just what we expected. But you might notice a minor issue that you are unable to go back to Home
page by clicking Back
button on Detail
page. Let’s fix it.
We need to open MainActivity.kt
and add following override method to enable the back button.
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> {
onBackPressed()
return true
}
}
return super.onOptionsItemSelected(item)
}
Then let’s run the app again. It would display Home
page. And it will navigate to the Detail
page once you click a single photo, and go back to Home
page if you click Back
button on Detail
page like this:
Then the app would be able to go back to previous page just like pressing the Back
button once the user clicks the Back
button at the ActionBar
. It won't impact navigating between home
, dashboard
and notification
pages because these pages have been set as top level pages with following template code in the onCreate
method.
val appBarConfiguration = AppBarConfiguration(setOf(
R.id.navigation_home, R.id.navigation_dashboard, R.id.navigation_notifications))
setupActionBarWithNavController(navController, appBarConfiguration)
We have completed all function of this photo app for now. In future tutorials, we might add more feature to fill up other pages.
Thanks for browsing my tutorial and any feedbacks are welcome.
You could find the unit test of this project in https://kxie0124.medium.com/how-to-write-unit-test-of-app-based-on-android-architecture-components-and-coroutine-8e5473f6674c, if you are interested in how to write unit test for Android project.