This codelab is part of the Android Kotlin Fundamentals course. You'll get the most value out of this course if you work through the codelabs in sequence. All the course codelabs are listed on the Android Kotlin Fundamentals codelabs landing page.
In the previous codelab, you learned how to get data from a web service and parse the response into a data object. In this codelab, you build on that knowledge to load and display photos from a web URL. You also revisit how to build a RecyclerView
and use it to display a grid of images on the overview page.
LiveData
.RecyclerView
.Adapter
, ViewHolder
, and DiffUtil
work.RecyclerView
and a grid adapter to display a grid of images. RecyclerView
to display a grid of Mars property images. RecyclerView
.In this codelab (and related codelabs), you work with an app called MarsRealEstate, which shows properties for sale on Mars. The app connects to an internet server to retrieve and display property data, including details such as the price and whether the property is available for sale or rent. The images representing each property are real-life photos from Mars captured from NASA's Mars rovers.
The version of the app you build in this codelab fills in the overview page, which displays a grid of images. The images are part of the property data that your app gets from the Mars real estate web service. Your app will use the Glide library to load and display the images, and a RecyclerView
to create the grid layout for the images. Your app will also handle network errors gracefully.
Displaying a photo from a web URL might sound straightforward, but there is quite a bit of engineering to make it work well. The image has to be downloaded, buffered, and decoded from its compressed format to an image that Android can use. The image should be cached to an in-memory cache, a storage-based cache, or both. All this has to happen in low-priority background threads so the UI remains responsive. Also, for best network and CPU performance, you might want to fetch and decode more than one image at once. Learning how to effectively load images from the network could be a codelab in itself.
Fortunately, you can use a community-developed library called Glide to download, buffer, decode, and cache your images. Glide leaves you with a lot less work than if you had to do all of this from scratch.
Glide basically needs two things:
ImageView
object to display that image. In this task, you learn how to use Glide to display a single image from the real estate web service. You display the image that represents the first Mars property in the list of properties that the web service returns. Here are the before and after screenshots:
dependencies
section, add this line for the Glide library: implementation "com.github.bumptech.glide:glide:$version_glide"
Notice that the version number is already defined separately in the project Gradle file.
Next you update the OverviewViewModel
class to include live data for a single Mars property.
overview/OverviewViewModel.kt
. Just below the LiveData
for the _response
, add both internal (mutable) and external (immutable) live data for a single MarsProperty
object. MarsProperty
class (com.example.android.marsrealestate.network.MarsProperty
) when requested. private val _property = MutableLiveData<MarsProperty>()
val property: LiveData<MarsProperty>
get() = _property
getMarsRealEstateProperties()
method, find the line inside the try/catch {}
block that sets _response.value
to the number of properties. Add the test shown below. If MarsProperty
objects are available, this test sets the value of the _property
LiveData
to the first property in the listResult
. if (listResult.size > 0) {
_property.value = listResult[0]
}
The complete try/catch {}
block now looks like this:
try {
var listResult = getPropertiesDeferred.await()
_response.value = "Success: ${listResult.size} Mars properties retrieved"
if (listResult.size > 0) {
_property.value = listResult[0]
}
} catch (e: Exception) {
_response.value = "Failure: ${e.message}"
}
res/layout/fragment_overview.xml
file. In the <TextView>
element, change android:text
to bind to the imgSrcUrl
component of the property
LiveData
:android:text="@{viewModel.property.imgSrcUrl}"
TextView
displays only the URL of the image in the first Mars property. All you've done so far is set up the view model and the live data for that URL. Now you have the URL of an image to display, and it's time to start working with Glide to load that image. In this step, you use a binding adapter to take the URL from an XML attribute associated with an ImageView
, and you use Glide to load the image. Binding adapters are extension methods that sit between a view and bound data to provide custom behavior when the data changes. In this case, the custom behavior is to call Glide to load an image from a URL into an ImageView
.
BindingAdapters.kt
. This file will hold the binding adapters that you use throughout the app. bindImage()
function that takes an ImageView
and a String
as parameters. Annotate the function with @BindingAdapter
. The @BindingAdapter
annotation tells data binding that you want this binding adapter executed when an XML item has the imageUrl
attribute. androidx.databinding.BindingAdapter
and android.widget.ImageView
when requested.@BindingAdapter("imageUrl")
fun bindImage(imgView: ImageView, imgUrl: String?) {
}
bindImage()
function, add a let {}
block for the imgUrl
argument:imgUrl?.let {
}
let {}
block, add the line shown below to convert the URL string (from the XML) to a Uri
object. Import androidx.core.net.toUri
when requested.Uri
object to use the HTTPS scheme, because the server you pull the images from requires that scheme. To use the HTTPS scheme, append buildUpon.scheme("https")
to the toUri
builder. The toUri()
method is a Kotlin extension function from the Android KTX core library, so it just looks like it's part of the String
class.val imgUri = imgUrl.toUri().buildUpon().scheme("https").build()
let {}
, call Glide.with()
to load the image from the Uri
object into to the ImageView
. Import com.bumptech.glide.Glide
when requested.Glide.with(imgView.context)
.load(imgUri)
.into(imgView)
Although Glide has loaded the image, there's nothing to see yet. The next step is to update the layout and the fragments with an ImageView
to display the image.
res/layout/gridview_item.xml
. This is the layout resource file you'll use for each item in the RecyclerView
later in the codelab. You use it temporarily here to show just the single image.<ImageView>
element, add a <data>
element for the data binding, and bind to the OverviewViewModel
class: <data>
<variable
name="viewModel"
type="com.example.android.marsrealestate.overview.OverviewViewModel" />
</data>
app:imageUrl
attribute to the ImageView
element to use the new image loading binding adapter:app:imageUrl="@{viewModel.property.imgSrcUrl}"
overview/OverviewFragment.kt
. In the onCreateView()
method, comment out the line that inflates the FragmentOverviewBinding
class and assigns it to the binding variable. This is only temporary; you'll go back to it later. //val binding = FragmentOverviewBinding.inflate(inflater)
GridViewItemBinding
class instead. Import com.example.android.marsrealestate. databinding.GridViewItemBinding
when requested. val binding = GridViewItemBinding.inflate(inflater)
MarsProperty
in the result list. Glide can improve the user experience by showing a placeholder image while loading the image and an error image if the loading fails, for example if the image is missing or corrupt. In this step, you add that functionality to the binding adapter and to the layout.
res/drawable/ic_broken_image.xml
, and click the Preview tab on the right. For the error image, you're using the broken-image icon that's available in the built-in icon library. This vector drawable uses the android:tint
attribute to color the icon gray.res/drawable/loading_animation.xml
. This drawable is an animation that's defined with the <animate-rotate>
tag. The animation rotates an image drawable, loading_img.xml
, around the center point. (You don't see the animation in the preview.) BindingAdapters.kt
file. In the bindImage()
method, update the call to Glide.with()
to call the apply()
function between load()
and into()
. Import com.bumptech.glide.request.RequestOptions
when requested.loading_animation
drawable). The code also sets an image to use if image loading fails (the broken_image
drawable). The complete bindImage()
method now looks like this:@BindingAdapter("imageUrl")
fun bindImage(imgView: ImageView, imgUrl: String?) {
imgUrl?.let {
val imgUri =
imgUrl.toUri().buildUpon().scheme("https").build()
Glide.with(imgView.context)
.load(imgUri)
.apply(RequestOptions()
.placeholder(R.drawable.loading_animation)
.error(R.drawable.ic_broken_image))
.into(imgView)
}
}
Your app now loads property information from the internet. Using data from the first MarsProperty
list item, you've created a LiveData
property in the view model, and you've used the image URL from that property data to populate an ImageView
. But the goal is for your app to display a grid of images, so you want to use a RecyclerView
with a GridLayoutManager
.
Right now the view model has a _property
LiveData
that holds one MarsProperty
object—the first one in the response list from the web service. In this step, you change that LiveData
to hold the entire list of MarsProperty
objects.
overview/OverviewViewModel.kt
._property
variable to _properties
. Change the type to be a list of MarsProperty
objects. private val _properties = MutableLiveData<List<MarsProperty>>()
property
live data with properties
. Add the list to the LiveData
type here as well: val properties: LiveData<List<MarsProperty>>
get() = _properties
getMarsRealEstateProperties()
method. Inside the try {}
block, replace the entire test that you added in the previous task with the line shown below. Because the listResult
variable holds a list of MarsProperty
objects, you can just assign it to _properties.value
instead of testing for a successful response._properties.value = listResult
The entire try/catch
block now looks like this:
try {
var listResult = getPropertiesDeferred.await()
_response.value = "Success: ${listResult.size} Mars properties retrieved"
_properties.value = listResult
} catch (e: Exception) {
_response.value = "Failure: ${e.message}"
}
The next step is to change the app's layout and fragments to use a recycler view and a grid layout, rather than the single image view.
res/layout/gridview_item.xml
. Change the data binding from the OverviewViewModel
to MarsProperty
, and rename the variable to "property"
. <variable
name="property"
type="com.example.android.marsrealestate.network.MarsProperty" />
<ImageView>
, change the app:imageUrl
attribute to refer to the image URL in the MarsProperty
object:app:imageUrl="@{property.imgSrcUrl}"
overview/OverviewFragment.kt
. In onCreateview()
, uncomment the line that inflates FragmentOverviewBinding
. Delete or comment out the line that inflates GridViewBinding
. These changes undo the temporary changes you made in the last task. val binding = FragmentOverviewBinding.inflate(inflater)
// val binding = GridViewItemBinding.inflate(inflater)
res/layout/fragment_overview.xml
. Delete the entire <TextView>
element. <RecyclerView>
element instead, which uses a GridLayoutManager
and the grid_view_item
layout for a single item:<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.GridLayoutManager"
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" />
Now the fragment_overview
layout has a RecyclerView
while the grid_view_item
layout has a single ImageView
. In this step, you bind the data to the RecyclerView
through a RecyclerView
adapter.
overview/PhotoGridAdapter.kt
. PhotoGridAdapter
class, with the constructor parameters shown below. The PhotoGridAdapter
class extends ListAdapter
, whose constructor needs the list item type, the view holder, and a DiffUtil.ItemCallback
implementation. androidx.recyclerview.widget.ListAdapter
and com.example.android.marsrealestate.network.MarsProperty
classes when requested. In the following steps, you implement the other missing parts of this constructor that are producing errors.class PhotoGridAdapter : ListAdapter<MarsProperty,
PhotoGridAdapter.MarsPropertyViewHolder>(DiffCallback) {
}
PhotoGridAdapter
class and press Control+i
to implement the ListAdapter
methods, which are onCreateViewHolder()
and onBindViewHolder()
.override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PhotoGridAdapter.MarsPropertyViewHolder {
TODO("not implemented")
}
override fun onBindViewHolder(holder: PhotoGridAdapter.MarsPropertyViewHolder, position: Int) {
TODO("not implemented")
}
PhotoGridAdapter
class definition, after the methods you just added, add a companion object definition for DiffCallback
, as shown below. androidx.recyclerview.widget.DiffUtil
when requested. DiffCallback
object extends DiffUtil.ItemCallback
with the type of object you want to compare—MarsProperty
.companion object DiffCallback : DiffUtil.ItemCallback<MarsProperty>() {
}
Control+i
to implement the comparator methods for this object, which are areItemsTheSame()
and areContentsTheSame()
. override fun areItemsTheSame(oldItem: MarsProperty, newItem: MarsProperty): Boolean {
TODO("not implemented")
}
override fun areContentsTheSame(oldItem: MarsProperty, newItem: MarsProperty): Boolean {
TODO("not implemented") }
areItemsTheSame()
method, remove the TODO. Use Kotlin's referential equality operator (===
), which returns true
if the object references for oldItem
and newItem
are the same.override fun areItemsTheSame(oldItem: MarsProperty,
newItem: MarsProperty): Boolean {
return oldItem === newItem
}
areContentsTheSame()
, use the standard equality operator on just the ID of oldItem
and newItem
. override fun areContentsTheSame(oldItem: MarsProperty,
newItem: MarsProperty): Boolean {
return oldItem.id == newItem.id
}
PhotoGridAdapter
class, below the companion object, add an inner class definition for MarsPropertyViewHolder
, which extends RecyclerView.ViewHolder
.androidx.recyclerview.widget.RecyclerView
and com.example.android.marsrealestate.databinding.GridViewItemBinding
when requested.GridViewItemBinding
variable for binding the MarsProperty
to the layout, so pass the variable into the MarsPropertyViewHolder
. Because the base ViewHolder
class requires a view in its constructor, you pass it the binding root view. class MarsPropertyViewHolder(private var binding:
GridViewItemBinding):
RecyclerView.ViewHolder(binding.root) {
}
MarsPropertyViewHolder
, create a bind()
method that takes a MarsProperty
object as an argument and sets binding.property
to that object. Call executePendingBindings()
after setting the property, which causes the update to execute immediately. fun bind(marsProperty: MarsProperty) {
binding.property = marsProperty
binding.executePendingBindings()
}
onCreateViewHolder()
, remove the TODO and add the line shown below. Import android.view.LayoutInflater
when requested. onCreateViewHolder()
method needs to return a new MarsPropertyViewHolder
, created by inflating the GridViewItemBinding
and using the LayoutInflater
from your parent ViewGroup
context. return MarsPropertyViewHolder(GridViewItemBinding.inflate(
LayoutInflater.from(parent.context)))
onBindViewHolder()
method, remove the TODO and add the lines shown below. Here you call getItem()
to get the MarsProperty
object associated with current the RecyclerView
position, and then pass that property to the bind()
method in the MarsPropertyViewHolder
. val marsProperty = getItem(position)
holder.bind(marsProperty)
Finally, use a BindingAdapter
to initialize the PhotoGridAdapter
with the list of MarsProperty
objects. Using a BindingAdapter
to set the RecyclerView
data causes data binding to automatically observe the LiveData
for the list of MarsProperty
objects. Then the binding adapter is called automatically when the MarsProperty
list changes.
BindingAdapters.kt
. bindRecyclerView()
method that takes a RecyclerView
and a list of MarsProperty
objects as arguments. Annotate that method with a @BindingAdapter
. androidx.recyclerview.widget.RecyclerView
and com.example.android.marsrealestate.network.MarsProperty
when requested.@BindingAdapter("listData")
fun bindRecyclerView(recyclerView: RecyclerView,
data: List<MarsProperty>?) {
}
bindRecyclerView()
function, cast recyclerView.adapter
to PhotoGridAdapter
, and call adapter.submitList()
with the data. This tells the RecyclerView
when a new list is available. Import com.example.android.marsrealestate.overview.PhotoGridAdapter
when requested.
val adapter = recyclerView.adapter as PhotoGridAdapter
adapter.submitList(data)
res/layout/fragment_overview.xml
. Add the app:listData
attribute to the RecyclerView
element and set it to viewmodel.properties
using data binding.app:listData="@{viewModel.properties}"
overview/OverviewFragment.kt
. In onCreateView()
, just before the call to setHasOptionsMenu()
, initialize the RecyclerView
adapter in binding.photosGrid
to a new PhotoGridAdapter
object.binding.photosGrid.adapter = PhotoGridAdapter()
MarsProperty
images. As you scroll to see new images, the app shows the loading-progress icon before displaying the image itself. If you turn on airplane mode, images that have not yet loaded appear as broken-image icons.The MarsRealEstate app displays the broken-image icon when an image cannot be fetched. But when there's no network, the app shows a blank screen.
This isn't a great user experience. In this task, you add basic error handling, to give the user a better idea of what's happening. If the internet isn't available, the app will show the connection-error icon. While the app is fetching the MarsProperty
list, the app will show the loading animation.
To start, you create a LiveData
in the view model to represent the status of the web request. There are three states to consider—loading, success, and failure. The loading state happens while you're waiting for data in the call to await()
.
overview/OverviewViewModel.kt
. At the top of the file (after the imports, before the class definition), add an enum
to represent all the available statuses:enum class MarsApiStatus { LOADING, ERROR, DONE }
_response
live data definitions throughout the OverviewViewModel
class to _status
. Because you added support for the _properties
LiveData
earlier in this codelab, the complete web service response has been unused. You need a LiveData
here to keep track of the current status, so you can just rename the existing variables. Also, change the types from String
to MarsApiStatus.
private val _status = MutableLiveData<MarsApiStatus>()
val status: LiveData<MarsApiStatus>
get() = _status
getMarsRealEstateProperties()
method and update _response
to _status
here as well. Change the "Success"
string to the MarsApiStatus.DONE
state, and the "Failure"
string to MarsApiStatus.ERROR
. MarsApiStatus.LOADING
status to the top of the try {}
block, before the call to await()
. This is the initial status while the coroutine is running and you're waiting for data. The complete try/catch {}
block now looks like this: try {
_status.value = MarsApiStatus.LOADING
var listResult = getPropertiesDeferred.await()
_status.value = MarsApiStatus.DONE
_properties.value = listResult
} catch (e: Exception) {
_status.value = MarsApiStatus.ERROR
}
catch {}
block, set the _properties
LiveData
to an empty list. This clears the RecyclerView
. } catch (e: Exception) {
_status.value = MarsApiStatus.ERROR
_properties.value = ArrayList()
}
Now you have a status in the view model, but it's just a set of states. How do you make it appear in the app itself? In this step, you use an ImageView
, connected to data binding, to display icons for the loading and error states. When the app is in the loading state or the error state, the ImageView
should be visible. When the app is done loading, the ImageView
should be invisible.
BindingAdapters.kt
. Add a new binding adapter called bindStatus()
that takes an ImageView
and a MarsApiStatus
value as arguments. Import com.example.android.marsrealestate.overview.MarsApiStatus
when requested.@BindingAdapter("marsApiStatus")
fun bindStatus(statusImageView: ImageView,
status: MarsApiStatus?) {
}
when {}
inside the bindStatus()
method to switch between the different statuses. when (status) {
}
when {}
, add a case for the loading state (MarsApiStatus.LOADING
). For this state, set the ImageView
to visible, and assign it the loading animation. This is the same animation drawable you used for Glide in the previous task. Import android.view.View
when requested.when (status) {
MarsApiStatus.LOADING -> {
statusImageView.visibility = View.VISIBLE
statusImageView.setImageResource(R.drawable.loading_animation)
}
}
MarsApiStatus.ERROR
. Similarly to what you did for the LOADING
state, set the status ImageView
to visible and reuse the connection-error drawable.MarsApiStatus.ERROR -> {
statusImageView.visibility = View.VISIBLE
statusImageView.setImageResource(R.drawable.ic_connection_error)
}
MarsApiStatus.DONE
. Here you have a successful response, so turn off the visibility of the status ImageView
to hide it. MarsApiStatus.DONE -> {
statusImageView.visibility = View.GONE
}
res/layout/fragment_overview.xml
. Below the RecyclerView
element, inside the ConstraintLayout
, add the ImageView
shown below.ImageView
has the same constraints as the RecyclerView
. However, the width and height use wrap_content
to center the image rather than stretch the image to fill the view. Also notice the app:marsApiStatus
attribute, which has the view call your BindingAdapter
when the status property in the view model changes.<ImageView
android:id="@+id/status_image"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:marsApiStatus="@{viewModel.status}" />
Android Studio project: MarsRealEstateGrid
ImageView
object to put the image in. To specify these options, use the load()
and into()
methods with Glide.ImageView
. @BindingAdapter
annotation. apply()
method. For example, use apply()
with placeholder()
to specify a loading drawable, and use apply()
with error()
to specify an error drawable. RecyclerView
with a GridLayoutManager
.RecyclerView
and the layout. Udacity course:
Android developer documentation:
Other:
This section lists possible homework assignments for students who are working through this codelab as part of a course led by an instructor. It's up to the instructor to do the following:
Instructors can use these suggestions as little or as much as they want, and should feel free to assign any other homework they feel is appropriate.
If you're working through this codelab on your own, feel free to use these homework assignments to test your knowledge.
Which Glide method do you use to indicate the ImageView
that will contain the loaded image?
▢ into()
▢ with()
▢ imageview()
▢ apply()
How do you specify a placeholder image to show when Glide is loading?
▢ Use the into()
method with a drawable.
▢ Use RequestOptions()
and call the placeholder()
method with a drawable.
▢ Assign the Glide.placeholder
property to a drawable.
▢ Use RequestOptions()
and call the loadingImage()
method with a drawable.
How do you indicate that a method is a binding adapter?
▢ Call the setBindingAdapter()
method on the LiveData
.
▢ Put the method into a Kotlin file called BindingAdapters.kt
.
▢ Use the android:adapter
attribute in the XML layout.
▢ Annotate the method with @BindingAdapter
.
Start the next lesson:
For links to other codelabs in this course, see the Android Kotlin Fundamentals codelabs landing page.