Load and display images from the Internet

1. Welcome

Introduction

In the previous codelab, you learned how to get data from a web service and parse the response into a Kotlin 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.

Prerequisites

  • How to create and use fragments.
  • How to retrieve JSON from a REST web service and parse that data into Kotlin objects using the Retrofit and Moshi libraries.
  • How to construct a grid layout with a RecyclerView.
  • How Adapter, ViewHolder, and DiffUtil work.

What you'll learn

  • How to use the Coil library to load and display an image from a web URL.
  • How to use a RecyclerView and a grid adapter to display a grid of images.
  • How to handle potential errors as the images download and display.

What you'll build

  • Modify the MarsPhotos app to get the image URL from the Mars data, and use Coil to load and display that image.
  • Add a loading animation and error icon to the app.
  • Use a RecyclerView to display a grid of Mars images.
  • Add status and error handling to the RecyclerView.

What you need

  • A computer with a modern web browser, such as the latest version of Chrome.
  • Internet access on your computer.

2. App overview

In this codelab, you will continue working with the app from the previous codelab called MarsPhotos. The MarsPhotos app connects to a web service to retrieve and display the number of Kotlin objects retrieved using Retrofit. These Kotlin objects contain the URL's of the real-life photos from Mars surface captured from NASA's Mars rovers.

The version of the app you will build in this codelab fills in the overview page, which displays Mars photos in a grid of images. The images are part of the data that your app retrieved from the Mars web service. Your app will use the Coil 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.

1b33675b009bee15.png

3. Display an internet image

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, internally stored, 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.

Fortunately, you can use a community-developed library called Coil to download, buffer, decode, and cache your images. Without the use of Coil, you would have much more work to do.

Coil basically needs two things:

  • The URL of the image you want to load and display.
  • An ImageView object to actually display that image.

In this task, you learn how to use Coil to display a single image from the Mars web service. You display the image of the first Mars photo in the list of photos that the web service returns. Here are the before and after screenshots:

Add Coil dependency

  1. Open the MarsPhotos solution app from the previous codelab.
  2. Run the app to see what it does. (It shows the total number of Mars photos retrieved).
  3. Open build.gradle (Module: app).
  4. In the dependencies section, add this line for the Coil library:
    // Coil
    implementation "io.coil-kt:coil:1.1.1"

Check and update the latest version of the library from the Coil documentation page.

  1. Coil library is hosted and available on the mavenCentral() repository. In build.gradle (Project: MarsPhotos) add mavenCentral() in the top repositories block.
repositories {
   google()
   jcenter()
   mavenCentral()
}
  1. Click Sync Now to rebuild the project with the new dependency.

Update the ViewModel

In this step, you will add a LiveData property to the OverviewViewModel class to store the Kotlin object received, MarsPhoto.

  1. Open overview/OverviewViewModel.kt. Just below the _status property declaration, add a new mutable property called _photos, of the type MutableLiveData that can store a single MarsPhoto object.
private val _photos = MutableLiveData<MarsPhoto>()

Import the com.example.android.marsphotos.network.MarsPhoto when requested.

  1. Just below the _photos declaration, add a public backing field called photos of the type, LiveData<MarsPhoto>.
val photos: LiveData<MarsPhoto> = _photos
  1. In the getMarsPhotos() method, inside the try{} block, find the line that sets the data retrieved from the web service to listResult.
try {
   val listResult = MarsApi.retrofitService.getPhotos()
   ...
}
  1. Assign the first Mars photo retrieved to the new variable _photos. Change the listResult to _photos.value. Assign the first photos url at the index 0. This will throw an error, you will fix it later.
try {
   _photos.value = MarsApi.retrofitService.getPhotos()[0]
   ...
}
  1. In the next line, update the status.value to the following. Use the data from the new property instead of listResult. Display the first image URL from the photos List.
try {
   ...
   _status.value = "   First Mars image URL : ${_photos.value!!.imgSrcUrl}"

}
  1. The complete try{} block now looks like this:
try {
   _photos.value = MarsApi.retrofitService.getPhotos()[0]
   _status.value = "   First Mars image URL : ${_photos.value!!.imgSrcUrl}"
}
  1. Run the app. The TextView now displays the URL of the first Mars photo. All you've done so far is set up the ViewModel and the LiveData for that URL.

b8ac93805b69b03a.png

Use Binding Adapters

Binding Adapters are annotated methods used to create custom setters for custom properties of your view.

Usually when you set an attribute in your XML using the code: android:text="Sample Text". The Android system automatically looks for a setter with the same name as the text attribute, which is set by the setText(String: text) method. The setText(String: text) method is a setter method for some views provided by the Android Framework. Similar behavior can be customized using the binding adapters; you can provide a custom attribute and custom logic that will be called by the Data binding library.

Example:

To do something more complex than simply calling a setter on the Image view, that sets an drawable image. Consider loading images off the UI thread (main thread), from the internet. First, choose a custom attribute to assign the image to an ImageView. In the following example it is imageUrl.

<ImageView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    app:imageUrl="@{product.imageUrl}"/>

If you do not add any further code, the system would look for a setImageUrl(String) method on ImageView and not find it, throwing an error because this is a custom attribute not provided by the framework. You have to create a way to implement and set the app:imageUrl attribute to the ImageView. You will use Binding adapters (annotated methods) to do this.

Example of a Binding Adapter:

@BindingAdapter("imageUrl")
fun bindImage(imgView: ImageView, imgUrl: String?) {
    imgUrl?.let {
        // Load the image in the background using Coil.
        }
    }
}

The @BindingAdapter annotation takes the attribute name as its parameter.

In the bindImage method, the first method parameter is the type of the target View and the second is the value being set to the attribute.

Inside the method, the Coil library loads the image off the UI thread and sets it into the ImageView.

Create a binding adapter and use Coil

  1. In com.example.android.marsphotos package, create a Kotlin file called BindingAdapters. This file will hold the binding adapters that you use throughout the app.

a04afbd6ae8ccfcd.png

  1. In BindingAdapters.kt. Create a bindImage() function that takes an ImageView and a String as parameters.
fun bindImage(imgView: ImageView, imgUrl: String?) {

}

Import android.widget.ImageView when requested.

  1. Annotate the function with @BindingAdapter. The @BindingAdapter annotation tells data binding to execute this binding adapter when a View item has the imageUrl attribute.
@BindingAdapter("imageUrl")
fun bindImage(imgView: ImageView, imgUrl: String?) {

}

Import androidx.databinding.BindingAdapter when requested.

let scope function

let is one of Kotlin's Scope functions which lets you execute a code block within the context of an object. There are five Scope functions in Kotlin, refer to the documentation to learn more.

Usage:

let is used to invoke one or more functions on results of call chains.

The let function along with safe call operator( ?.) is used to perform a null safe operation on the object. In this case, the let code block will only be executed if the object is not null.

  1. Inside the bindImage() function, add a let{} block to the imageURL argument, using the safe call operator.
imgUrl?.let { 
}
  1. Inside the let{} block, add the following line to convert the URL string to a Uri object using the toUri() method. To use the HTTPS scheme, append buildUpon.scheme("https") to the toUri builder. Call build() to build the object.
val imgUri = imgUrl.toUri().buildUpon().scheme("https").build()

Import androidx.core.net.toUri when requested.

  1. Inside let{} block, after the imgUri declaration, use the load(){} from Coil, to load the image from the imgUri object into the imgView.
imgView.load(imgUri) {
}

Import coil.load when requested.

  1. Your complete method should look like below:
@BindingAdapter("imageUrl")
fun bindImage(imgView: ImageView, imgUrl: String?) {
    imgUrl?.let {
        val imgUri = imgUrl.toUri().buildUpon().scheme("https").build()
        imgView.load(imgUri) 
    }
}

Update the layout and fragments

In the previous section, you used the Coil image library to load the image. To see the image on screen, the next step is to update the ImageView with the new attribute to display a single image.

Later in the codelab you will use res/layout/grid_view_item.xml as the layout resource file each grid item in the RecyclerView. In this task, you will use this file temporarily to display the image using the image URL that you retrieved in the previous task. Temporarily, you will be using this layout file in place of fragment_overview.xml.

  1. Open res/layout/grid_view_item.xml.
  2. Above the <ImageView> element, add a <data> element for the data binding, and bind to the OverviewViewModel class:
<data>
   <variable
       name="viewModel"
       type="com.example.android.marsphotos.overview.OverviewViewModel" />
</data>
  1. Add an app:imageUrl attribute to the ImageView element to use the new image loading binding adapter. Remember that the photos contain a list MarsPhotos retrieved from the server. Assign the first entry URL to the imageUrl attribute.
    <ImageView
        android:id="@+id/mars_image"
        ...
        app:imageUrl="@{viewModel.photos.imgSrcUrl}"
        ... />
  1. Open overview/OverviewFragment.kt. In the onCreateView() method, comment out the line that inflates the FragmentOverviewBinding class and assigns it to the binding variable. You will see errors due to removing this line. This is only temporary; you'll fix them later.
//val binding = FragmentOverviewBinding.inflate(inflater)
  1. Use grid_view_item.xml instead of fragment_overview.xml. Add the following line to inflate the GridViewItemBinding class instead.
val binding = GridViewItemBinding.inflate(inflater)

Import com.example.android.marsphotos. databinding.GridViewItemBinding if requested.

  1. Run the app. Now you should see a single Mars image.

e59b6e849e63ae2b.png

Add loading and error images

Using Coil you 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 will add that functionality to the binding adapter.

  1. Open res/drawable/ic_broken_image.xml, and click the Design 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.

467c213c859e1904.png

  1. Open res/drawable/loading_animation.xml. This drawable is an animation that rotates an image drawable, loading_img.xml, around the center point. (You don't see the animation in the preview.) d

6c1f87d1c932c762.png

  1. Return to the BindingAdapters.kt file. In the bindImage() method, update the call to imgView.load(imgUri) to add a trailing lambda as follows: This code sets the placeholder loading image to use while loading (the loading_animation drawable). This code also sets an image to use if image loading fails (the broken_image drawable).
imgView.load(imgUri) {
   placeholder(R.drawable.loading_animation)
   error(R.drawable.ic_broken_image)
}
  1. 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()
        imgView.load(imgUri) {
            placeholder(R.drawable.loading_animation)
            error(R.drawable.ic_broken_image)
        }
    }
}
  1. Run the app. Depending on the speed of your network connection, you might briefly see the loading image as Glide downloads and displays the property image. But you won't see the broken-image icon yet, even if you turn off your network—you will fix that in the last task of the codelab.

80553d5e5c7641de.gif

  1. Revert the temporary changes you made in overview/OverviewFragment.kt. In the method onCreateview(), uncomment the line that inflates FragmentOverviewBinding. Delete or comment out the line that inflates GridViewIteMBinding.
val binding = FragmentOverviewBinding.inflate(inflater)
 // val binding = GridViewItemBinding.inflate(inflater)

4. Display a grid of images with a RecyclerView

Your app now loads a Mars photo from the internet. Using the data from the first MarsPhoto list item, you've created a LiveData property in the ViewModel, and you've used the image URL from that Mars photo data to populate an ImageView. But the goal is for your app to display a grid of images, so in this task you will use a RecyclerView with a Grid layout manager to display the grid of images.

Update the view model

In the previous task, in the OverviewViewModel, you have added an LiveData object called _photos that holds one MarsPhoto object—the first one in the response list from the web service. In this step, you will change this LiveData to hold the entire list of MarsPhoto objects.

  1. Open overview/OverviewViewModel.kt.
  2. Change the _photos type to be a list of MarsPhoto objects.
private val _photos = MutableLiveData<List<MarsPhoto>>()
  1. Replace the backing property photos type to the List<MarsPhoto> type as well:
 val photos: LiveData<List<MarsPhoto>> = _photos
  1. Scroll down to the try {} block inside the getMarsPhotos() method. The MarsApi.retrofitService.getPhotos()

returns a list of MarsPhoto objects, you can just assign it to _photos.value.

_photos.value = MarsApi.retrofitService.getPhotos()
_status.value = "Success: Mars properties retrieved"
  1. The entire try/catch block now looks like this:
try {
    _photos.value = MarsApi.retrofitService.getPhotos()
    _status.value = "Success: Mars properties retrieved"
} catch (e: Exception) {
    _status.value = "Failure: ${e.message}"
}

Grid Layout

The GridLayoutManager for RecyclerView lays out the data as a scrollable grid, as shown below.

fcf0fc4b78f8650.png

From a design perspective, Grid Layout is best for lists that can be represented as icons or images, such as lists within your Mars photo browsing app.

How Grid layout lays out items

Grid layout arranges items in a grid of rows and columns. Assuming vertical scrolling, by default, each item in a row takes up one "span." An item can occupy multiple spans. In the case below, one span is equivalent to the width of one column that is 3.

In the two examples shown below, each row is made up of three spans. By default, the GridLayoutManager lays out each item in one span until the span count, which you specify. When it reaches the span count, it wraps to the next line.

Add Recyclerview

In this step you will change the app's layout to use a recycler view with a grid layout, rather than the single image view.

  1. Open layout/gridview_item.xml. Remove the viewModel data variable.
  2. Inside the <data> tag, add the following photo variable of the type MarsPhoto.
<data>
   <variable
       name="photo"
       type="com.example.android.marsphotos.network.MarsPhoto" />
</data>
  1. In the <ImageView>, change the app:imageUrl attribute to refer to the image URL in the MarsPhoto object. These changes undo the temporary changes you made in this previous task.
app:imageUrl="@{photo.imgSrcUrl}"
  1. Open layout/fragment_overview.xml. Delete the entire <TextView> element.
  2. Add the following <RecyclerView> element instead. Set the ID to photos_grid, width and height attributes to 0dp, so it fills the parent ConstraintLayout. You will be using a Grid layout, so set the layoutManager attribute to androidx.recyclerview.widget.GridLayoutManager. Set a spanCount to 2 so you'll have two columns.
<androidx.recyclerview.widget.RecyclerView
    android:id="@+id/photos_grid"
    android:layout_width="0dp"
    android:layout_height="0dp"
    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" />
  1. To see a preview of what the above code looks like in the Design view, use tools:itemCount to set the number of items displayed in our layout to 16. The itemCount attribute specifies the number of items the layout editor should render in the Preview window. Set the layout of list items to grid_view_item using tools:listitem.
<androidx.recyclerview.widget.RecyclerView
            ...
            tools:itemCount="16"
            tools:listitem="@layout/grid_view_item" />
  1. Switch to Design view, you should see a preview something like the following screenshot. This does not look like Mars photos, but this will show you what your recyclerview grid layout will look like. Preview uses the padding and the grid_view_item layout for every single grid item the recyclerview.

20742824367c3952.png

  1. According to the Material design guidelines, you should have 8dp of space at the top, bottom, and sides of the list, and 4dp of space between the items. You can achieve this with a combination of padding in fragment_overview.xml layout and in gridview_item.xml layout.

a3561fa85fea7a8f.png

  1. Open layout/gridview_item.xml. Notice the padding attribute, you already have 2dp of padding between the outside of the item and the content. This will get us 4dp of space between item content, and 2dp along the outside edges, which means we'll need an additional 6dp of padding on the outside edges to match the design guidelines.
  2. Go back to layout/fragment_overview.xml. Add 6dp of padding for the RecyclerView, so you'll have 8dp on the outside and 4dp on the inside as the guidelines.
<androidx.recyclerview.widget.RecyclerView
            ...
            android:padding="6dp"
            ...  />
  1. The complete <RecyclerView> element should look like the following.
<androidx.recyclerview.widget.RecyclerView
    android:id="@+id/photos_grid"
    android:layout_width="0dp"
    android:layout_height="0dp"
    android:padding="6dp"
    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"  />

Add the photo grid adapter

Now the fragment_overview layout has a RecyclerView with a grid layout. In this step, you'll bind the data retrieved from the web server to the RecyclerView through a RecyclerView adapter.

ListAdapter (Refresher)

ListAdapter is a subclass of the RecyclerView.Adapter class for presenting List data in a RecyclerView, including computing diffs between Lists on a background thread.

In this app you will use the DiffUtil implementation in the ListAdapter. The advantage of using DiffUtil is every time some item in the RecyclerView is added, removed or changed, the whole list doesn't get refreshed. Only the items that have been changed are refreshed.

Add the ListAdapter to your app.

  1. In the overview package, create a Kotlin class called PhotoGridAdapter.kt.
  2. Extend the PhotoGridAdapter class from ListAdapter 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.
class PhotoGridAdapter : ListAdapter<MarsPhoto,
        PhotoGridAdapter.MarsPhotoViewHolder>(DiffCallback) {
}

Import the androidx.recyclerview.widget.ListAdapter and com.example.android.marsphoto.network.MarsPhoto classes if requested. In the following steps, you'll implement the other missing implementations of this constructor that are producing errors.

  1. To resolve the above errors, you will add the required methods in this step and implement them later in this task. Click on the PhotoGridAdapter class, click on the red bulb, from the dropdown menu select Implement members. In the popup displayed, select the ListAdapter methods, the onCreateViewHolder() and onBindViewHolder(). Android Studio will still show you errors which you will fix by the end of this task.
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PhotoGridAdapter.MarsPhotoViewHolder {
   TODO("Not yet implemented") 
}

override fun onBindViewHolder(holder: PhotoGridAdapter.MarsPhotoViewHolder, position: Int) {
   TODO("Not yet implemented") 
}

To implement onCreateViewHolder and onBindViewHolder methods, you need MarsPhotoViewHolder, which you will add in the next step.

  1. Inside the PhotoGridAdapter, add an inner class definition for MarsPhotoViewHolder, which extends RecyclerView.ViewHolder. You need the GridViewItemBinding variable for binding the MarsPhoto to the layout, so pass the variable into the MarsPhotoViewHolder. The base ViewHolder class requires a view in its constructor, you pass it the binding root view.
class MarsPhotoViewHolder(private var binding: 
                   GridViewItemBinding):
       RecyclerView.ViewHolder(binding.root) {
}

Import androidx.recyclerview.widget.RecyclerView and com.example.android.marsrealestate.databinding.GridViewItemBinding if requested.

  1. In MarsPhotoViewHolder, create a bind() method that takes a MarsPhoto 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(MarsPhoto: MarsPhoto) {
   binding.photo = MarsPhoto
   binding.executePendingBindings()
}
  1. Still inside the PhotoGridAdapter class in onCreateViewHolder(), remove the TODO and add the line shown below. The onCreateViewHolder() method needs to return a new MarsPhotoViewHolder, created by inflating the GridViewItemBinding and using the LayoutInflater from your parent ViewGroup context.
   return MarsPhotoViewHolder(GridViewItemBinding.inflate(
      LayoutInflater.from(parent.context)))

Import android.view.LayoutInflater if requested.

  1. In the onBindViewHolder() method, remove the TODO and add the lines shown below. Here you call getItem() to get the MarsPhoto object associated with the current RecyclerView position, and then pass that property to the bind() method in the MarsPhotoViewHolder.
val marsPhoto = getItem(position)
holder.bind(marsPhoto)
  1. Inside the PhotoGridAdapter, add a companion object definition for DiffCallback, as shown below.
    The DiffCallback object extends DiffUtil.ItemCallback with the generic type of object you want to compare—MarsPhoto. You will compare two Mars photo objects inside this implementation.
companion object DiffCallback : DiffUtil.ItemCallback<MarsPhoto>() {
}

Import androidx.recyclerview.widget.DiffUtil when requested.

  1. Press the red bulb to implement the comparator methods for the DiffCallback object, which are areItemsTheSame() and areContentsTheSame().
override fun areItemsTheSame(oldItem: MarsPhoto, newItem: MarsPhoto): Boolean {
   TODO("Not yet implemented") 
}

override fun areContentsTheSame(oldItem: MarsPhoto, newItem: MarsPhoto): Boolean {
   TODO("Not yet implemented") }
  1. In the areItemsTheSame() method, remove the TODO. This method is called by DiffUtil to decide whether two objects represent the same Item. DiffUtil uses this method to figure out if the new MarsPhoto object is the same as the old MarsPhoto object. The ID of every item(MarsPhoto object) is unique. Compare the IDs of oldItem and newItem, and return the result.
override fun areItemsTheSame(oldItem: MarsPhoto, newItem: MarsPhoto): Boolean {
   return oldItem.id == newItem.id
}
  1. In areContentsTheSame(), remove the TODO. This method is called by DiffUtil when it wants to check whether two items have the same data. The important data in the MarsPhoto is the image URL. Compare the URLs of oldItem and newItem, and return the result.
override fun areContentsTheSame(oldItem: MarsPhoto, newItem: MarsPhoto): Boolean {
   return oldItem.imgSrcUrl == newItem.imgSrcUrl
}

Make sure you are able to compile and run the app without any errors, but the emulator displays a blank screen. You have the recyclerview ready but there is no data passed to it, which you will implement in the next step.

Add the binding adapter and connect the parts

In this step, you will use a BindingAdapter to initialize the PhotoGridAdapter with the list of MarsPhoto objects. Using a BindingAdapter to set the RecyclerView data causes data binding to automatically observe the LiveData for the list of MarsPhoto objects. Then the binding adapter is called automatically when the MarsPhoto list changes.

  1. Open BindingAdapters.kt.
  2. At the end of the file, add a bindRecyclerView() method that takes a RecyclerView and a list of MarsPhoto objects as arguments. Annotate that method with a @BindingAdapter with listData attribute.
@BindingAdapter("listData")
fun bindRecyclerView(recyclerView: RecyclerView, 
    data: List<MarsPhoto>?) {
}

Import androidx.recyclerview.widget.RecyclerView and com.example.android.marsphotos.network.MarsPhoto if requested.

  1. Inside the bindRecyclerView() function, cast recyclerView.adapter to PhotoGridAdapter and assign it to a new val property adapter.
val adapter = recyclerView.adapter as PhotoGridAdapter
  1. At the end of bindRecyclerView() function, call adapter.submitList() with the Mars photos list data. This tells the RecyclerView when a new list is available.
adapter.submitList(data)

Import com.example.android.marsrealestate.overview.PhotoGridAdapter if requested.

  1. The complete bindRecyclerView binding adapter should look like this:
@BindingAdapter("listData")
fun bindRecyclerView(recyclerView: RecyclerView,
                    data: List<MarsPhoto>?) {
   val adapter = recyclerView.adapter as PhotoGridAdapter
   adapter.submitList(data)

}
  1. To connect everything together, open res/layout/fragment_overview.xml. Add the app:listData attribute to the RecyclerView element and set it to viewmodel.photos using data binding. This is similar to what you have done for ImageView in a previous task.
app:listData="@{viewModel.photos}"
  1. Open overview/OverviewFragment.kt. In onCreateView(), just before the return statement, initialize the RecyclerView adapter in binding.photosGrid to a new PhotoGridAdapter object.
binding.photosGrid.adapter = PhotoGridAdapter()
  1. Run the app. You should see a grid of scrolling Mars images. As you scroll to see new images but it looks a bit strange. The padding remains at the top and bottom of the RecyclerView as you're scrolling, so it never really looks like the list is being scrolled under the Action Bar.

5d03641aa1589842.png

  1. To fix this, you need to tell the RecyclerView not to clip the inner contents to the padding using android:clipToPadding attribute. This makes it draw the scrolling view in the padding area. Go back to layout/fragment_overview.xml. Add android:clipToPadding attribute for the RecyclerView, set it to false.
<androidx.recyclerview.widget.RecyclerView
            ...
            android:clipToPadding="false"
            ...  />
  1. Run your app. Notice the app also shows the loading-progress icon before displaying the image itself, as expected. This is the placeholder loading image that you passed to the Coil image library.

3128b84aa22ef97e.png

  1. While the app is running, turn on airplane mode. Scroll the images in the emulator. Images that have not yet loaded appear as broken-image icons. This is the image drawable that you passed to the Coil image library to display in case any network error or image could not be fetched.

28d2cbba564f35ff.png

Congratulations, you are almost done. In the next final task you will further improve the user experience by adding more error handling to the app.

5. Add error handling in RecyclerView

The MarsPhotos app displays the broken-image icon when an image cannot be fetched. But when there's no network, the app shows a blank screen. You will verify the blank screen in the next step.

  1. Turn on the airplane mode on your device or emulator. Run the app from the Android Studio. Notice the blank screen.

492011786c2dd7f7.png

This is not a great user experience. In this task, you will add a basic error handling, to give the user a better idea of what is happening. If the internet is not available, the app will show the connection-error icon and while the app is fetching the MarsPhoto list, the app will show the loading animation.

Add status to the ViewModel

In this task, you will create a property in the OverviewViewModel 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. The success status is when we retrieve data successfully from the webservice. The failure status indicates any network or connection errors.

Enum Classes in Kotlin

To represent these three states in your application, you will use enum. enum is short for enumeration, which means ordered listing of all the items in a collection. Each enum constant is an object of the enum class.

In Kotlin, an enum is a data type that can hold a set of constants. They are defined by adding the keyword enum in front of a class definition as shown below. Enum constants are separated with commas.

Definition:

enum class Direction {
    NORTH, SOUTH, WEST, EAST
}

Usage:

var direction = Direction.NORTH;

As shown above, the enum objects can be referenced using the class name followed by a dot (.) operator and the name of the constant.

Add the enum class definition with the status values in the Viewmodel.

  1. Open 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 }
  1. Scroll to the definition of _status and status properties, change the types from String to MarsApiStatus. MarsApiStatus is the enum class you defined in the previous step.
private val _status = MutableLiveData<MarsApiStatus>()

val status: LiveData<MarsApiStatus> = _status
  1. In the getMarsPhotos() method, change the "Success: ..." string to the MarsApiStatus.DONE state, and the "Failure..." string to MarsApiStatus.ERROR.
try {
    _photos.value = MarsApi.retrofitService.getPhotos()
    _status.value = MarsApiStatus.DONE
} catch (e: Exception) 
     _status.value = MarsApiStatus.ERROR
}
  1. Set the status to MarsApiStatus.LOADING above the try {} block. This is the initial status while the coroutine is running and you're waiting for data. The complete viewModelScope.launch {} block now looks like this:
viewModelScope.launch {
            _status.value = MarsApiStatus.LOADING
            try {
                _photos.value = MarsApi.retrofitService.getPhotos()
                _status.value = MarsApiStatus.DONE
            } catch (e: Exception) {
                _status.value = MarsApiStatus.ERROR
            }
        }
  1. After the error state in the catch {} block, set the _photos to an empty list. This clears the Recycler view.
} catch (e: Exception) {
   _status.value = MarsApiStatus.ERROR
   _photos.value = listOf()
}
  1. The complete getMarsPhotos() method should look like this:
private fun getMarsPhotos() {
   viewModelScope.launch {
        _status.value = MarsApiStatus.LOADING
        try {
           _photos.value = MarsApi.retrofitService.getPhotos()
           _status.value = MarsApiStatus.DONE
        } catch (e: Exception) {
           _status.value = MarsApiStatus.ERROR
           _photos.value = listOf()
        }
    }
}

You have defined enum states for the status and set the loading state at the beginning of the coroutine, set done when your app is finished retrieving the data from the web server, and set error when there is an exception. In the next task you will use a binding adapter to display the corresponding icons.

Add a binding adapter for the status ImageView

You have set up the MarsApiStatus in the OverviewViewModel, using a set of enum states. In this step, you will make it appear in the app. You use a Binding adapter for an ImageView, 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.

  1. Open BindingAdapters.kt, scroll to the end of the file to add another adaptor. Add a new binding adapter called bindStatus() that takes an ImageView and a MarsApiStatus value as arguments. Annotate the method with @BindingAdapter passing in the custom attribute marsApiStatus as parameter.
@BindingAdapter("marsApiStatus")
fun bindStatus(statusImageView: ImageView, 
          status: MarsApiStatus?) {
}

Import com.example.android.marsrealestate.overview.MarsApiStatus if requested.

  1. Add a when {} block inside the bindStatus() method to switch between the different statuses.
when (status) {

}
  1. Inside the 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 Coil in the previous task.
when (status) {
   MarsApiStatus.LOADING -> {
      statusImageView.visibility = View.VISIBLE
      statusImageView.setImageResource(R.drawable.loading_animation)
   }
}

Import android.view.View if requested.

  1. Add a case for the error state, which is MarsApiStatus.ERROR. Similarly to what you did for the LOADING state, set the status ImageView to visible and use the connection-error drawable.
MarsApiStatus.ERROR -> {
   statusImageView.visibility = View.VISIBLE
   statusImageView.setImageResource(R.drawable.ic_connection_error)
}
  1. Add a case for the done state, which is MarsApiStatus.DONE. Here you have a successful response, so set the visibility of the status ImageView to View.GONE to hide it.
MarsApiStatus.DONE -> {
   statusImageView.visibility = View.GONE
}

You have set up the binding adapter for the status Image view, in the next step you will add an Image view which uses the new binding adapter.

Add the status ImageView

In this step, you will add the Image view in the fragment_overview.xml that will show the status you defined earlier.

  1. Open res/layout/fragment_overview.xml. Inside the ConstraintLayout, below the RecyclerView element, add the ImageView shown below.
<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}" />

The above 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 set to viewModel.status, which calls your BindingAdapter when the status property in the ViewModel changes.

  1. To test the above code, simulate the network connection error by turning on airplane mode in your emulator or device. Compile and run the app, and notice that the error image appears:

a91ddb1c89f2efec.png

  1. Tap the Back button to close the app, and turn off airplane mode. Use the recents screen to return the app. Depending on the speed of your network connection, you may see an extremely brief loading spinner when the app queries the web service before the images begin to load.

Congratulations on completing this codelab and building out the MarsPhotos app! It's time to show off your app with real life Mars pictures with your family and friends.

6. Solution code

The solution code for this codelab is in the project shown below. Use the main branch to pull or download the code.

To get the code for this codelab and open it in Android Studio, do the following.

Get the code

  1. Click on the provided URL. This opens the GitHub page for the project in a browser.
  2. On the GitHub page for the project, click the Code button, which brings up a dialog.

Eme2bJP46u-pMpnXVfm-bS2N2dlyq6c0jn1DtQYqBaml7TUhzXDWpYoDI0lGKi4xndE_uJw8sKfwfOZ1fC503xCVZrbh10JKJ4iEHdLDwFfdvnOheNxkokITW1LW6UZTncVJJUZ5Fw

  1. In the dialog, click the Download ZIP button to save the project to your computer. Wait for the download to complete.
  2. Locate the file on your computer (likely in the Downloads folder).
  3. Double-click the ZIP file to unpack it. This creates a new folder that contains the project files.

Open the project in Android Studio

  1. Start Android Studio.
  2. In the Welcome to Android Studio window, click Open an existing Android Studio project.

Tdjf5eS2nCikM9KdHgFaZNSbIUCzKXP6WfEaKVE2Oz1XIGZhgTJYlaNtXTHPFU1xC9pPiaD-XOPdIxVxwZAK8onA7eJyCXz2Km24B_8rpEVI_Po5qlcMNN8s4Tkt6kHEXdLQTDW7mg

Note: If Android Studio is already open, instead, select the File > New > Import Project menu option.

PaMkVnfCxQqSNB1LxPpC6C6cuVCAc8jWNZCqy5tDVA6IO3NE2fqrfJ6p6ggGpk7jd27ybXaWU7rGNOFi6CvtMyHtWdhNzdAHmndzvEdwshF_SG24Le01z7925JsFa47qa-Q19t3RxQ

  1. In the Import Project dialog, navigate to where the unzipped project folder is located (likely in your Downloads folder).
  2. Double-click on that project folder.
  3. Wait for Android Studio to open the project.
  4. Click the Run button j7ptomO2PEQNe8jFt4nKCOw_Oc_Aucgf4l_La8fGLCMLy0t9RN9SkmBFGOFjkEzlX4ce2w2NWq4J30sDaxEe4MaSNuJPpMgHxnsRYoBtIV3-GUpYYcIvRJ2HrqR27XGuTS4F7lKCzg to build and run the app. Make sure it works as expected.
  5. Browse the project files in the Project tool window to see how the app was implemented.

7. Summary

  • The Coil library simplifies the process of managing images, such as download, buffer, decode, and cache images in your app.
  • Binding adapters are extension methods that sit between a view and that view's bound data. Binding adapters provide custom behavior when the data changes, for example, to call Coil to load an image from a URL into an ImageView.
  • Binding adapters are extension methods annotated with the @BindingAdapter annotation.
  • To display a grid of images, use a RecyclerView with a GridLayoutManager.
  • To update the list of properties when it changes, use a binding adapter between the RecyclerView and the layout.

8. Learn more

Android developer documentation:

Other: