DEV Community

Cover image for Displaying images in Android app: maintainable, testable, painless. Part I
Eugene Zubkov
Eugene Zubkov

Posted on

Displaying images in Android app: maintainable, testable, painless. Part I

If your app shows dozens of thousands of images, these images would most likely come from various sources: downloaded from the web, loaded locally, generated, etc.

Writing additional code for displaying every type is time-consuming, inefficient, not scalable and potentially causing a lot of pain and bugs.

Every time you add a new screen you will get swamped copypasting your essential code and swamped again fixing the bugs you created while copypasting.

What you really need instead of suffering this agony is a slick and elegant system to centralize the whole displaying process and fit the maintainable, testable and painless criteria.

Here is how we did it in Revolut's Android app.

The article is divided into two parts:


Revolut app

There are several types of image use in Revolut app:

  • Transaction lists with various icons,
  • Cards lists,
  • Lottie animations,
  • GIFs.

Let's take a look at the transactions list. It may contain dozens of cell types, however, we'll pick five of them as an example.

Image description

Each image has its own source or can be generated.

The legacy approach

Following the legacy approach to displaying the list you would start with creating the adapter:

class TransactionsAdapter : RecyclerView.Adapter<TransactionsAdapter.ViewHolder>() {
    private var items = mutableListOf<Transaction>()

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val view = LayoutInflater.from(parent.context)
            .inflate(R.layout.view_transaction, parent, false)
        return ViewHolder(view)
    }

    override fun onBindViewHolder(holder: ViewHandler, position: Int) = Unit

    override fun getItemCount() = items.size

    class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        private val imageView: ImageView = itemView.findViewById(R.id.image)
    }
}
Enter fullscreen mode Exit fullscreen mode

That’s how a standard adapter for RecyclerView looks like. Binding would be the next step here:

override fun onBindViewHolder(holder: ViewHandler, position: Int) {
    val transaction = items[position]
    when {
        transaction.isContactWithAvatar() -> {
            //avatar loading and displaying
        }
        !transaction.isContactWithAvatar() -> {
            //avatar displaying
        }
        transaction.isMerchantWithAvatar() -> {
            //avatar loading and displaying
        }
        !transaction.isMerchantWithAvatar() -> {
            //loading an image from resources 
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

We get a long list of conditions because every type of transaction has its own display logic in the adapter. It can be even more complicated if we use a separate ViewType for each type (it is also led by the adapter contract):

override fun getItemViewType(position: Int): Int {
    val transaction = items[position]
    return when {
        transaction.isContactWithAvatar() -> VIEW_TYPE_CONTACT_WITH_AVATAR
        !transaction.isContactWithAvatar() -> VIEW_TYPE_CONTACT_WITHOUT_AVATAR
        transaction.isMerchantWithAvatar() -> VIEW_TYPE_MERCHANT_WITH_AVATAR
        !transaction.isMerchantWithAvatar() -> VIEW_TYPE_MERCHANT_WITHOUT_AVATAR
        else -> VIEW_TYPE_UNKNOWN
    }
}
Enter fullscreen mode Exit fullscreen mode

As we know, there might be dozens of transaction types, and we can’t use this approach to build the adapter.

Improving the adapter

There are two basic approaches to the adapter’s extension — ViewType and delegates.

The ViewType approach can be used when the app is simple and contains only one list or a couple of screens. This is not our case because such an adapter can’t be reused. If we continue to extend the adapter and to add new ViewTypes, the adapter will constantly grow. Moreover, we’ll have to build new adapters for each screen in the app.

The delegates approach seems to be easier: we don’t need separate adapters for every screen. Four years ago Hannes Dorfmann described the approach, and today you may easily find a library for its implementation. We’ll use Dorfmann’s library.

Take a look at the simple delegate that displays ProgressBar:

class LoadingDelegate :
    AbsListItemAdapterDelegate<LoadingDelegate.Model, ListItem, LoadingDelegate.ViewHandler>() {
    override fun onCreateViewHolder(parent: ViewGroup): ViewHandler =
        ViewHandler(LayoutInflater.from(parent.context).inflate(R.layout.view_loading, parent, false))

    override fun isForViewType(item: ListItem, items: MutableList<ListItem>, position: Int): Boolean = item is Model

    override fun onBindViewHolder(item: Model, holder: ViewHandler, payloads: MutableList<Any>) = Unit

    data class Model(override val listId: String) : ListItem

    class ViewHandler(itemView: View) : RecyclerView.ViewHolder(itemView)
}

interface ListItem {
    val listId: String

    fun calculatePayload(oldItem: ListItem): Any? = null
}
Enter fullscreen mode Exit fullscreen mode

We create a ViewHolder in the delegate as if we did it in standard adapters. After that, we go for binding. The main difference is that each delegate has its own model which will be used to display the cell type needed. Also, each model implements interface ListItem with the listId field and calculatePayloads method.

Let’s create an adapter that can display delegates:

class DiffAdapter(
    delegates: List<AdapterDelegate<List<ListItem>>>
) : AsyncListDifferDelegationAdapter<ListItem>(ListDiffCallback()) {
    init {
        delegates.forEach { delegate -> delegatesManager.addDelegate(delegate) }
    }

    private class ListDiffCallback<T : ListItem> : DiffUtil.ItemCallback<T>() {
        override fun areItemsTheSame(oldItem: T, newItem: T): Boolean = 
            oldItem.listId == newItem.listId

        override fun areContentsTheSame(oldItem: T, newItem: T): Boolean = 
            oldItem.equals(newItem)

        override fun getChangePayload(oldItem: T, newItem: T): Any? = 
            newItem.calculatePayload(oldItem)
    }
}
Enter fullscreen mode Exit fullscreen mode

As you may have noticed, we can easily use the ListItem interface within the ListDiffCallback class, so that DiffUtil doesn’t refresh unchanged cells and doesn’t launch extra animations. Besides, as we use data class for models, equals are available out of the box. All we need to do with DiffUtil is to create the right model of the delegate.

The adapter for every screen is being created by declaring a list of delegates that the screen must support:

private val adapter by lazy {
    DiffAdapter(
        listOf(
            EmptyDelegate(),
            ErrorDelegate(),
            LoadingDelegate(),
            LoadMoreDelegate(),
            CardDelegate()
        )
    )
}
Enter fullscreen mode Exit fullscreen mode

Displaying images

We remove the logic of loading and displaying images from the adapter, and we ease onBindViewHolder. Basically, we need to create two things — an image model and the delegate which will be able to load and display the image. Here is the example of a model where we load the image from sources:

interface Image : Parcelable

@Parcelize
data class ResourceImage(
    @DrawableRes val drawableRes: Int,
    @ColorRes val colorRes: Int? = null
) : Image
Enter fullscreen mode Exit fullscreen mode

First, we build the interface Image. Then we describe a set of parameters for ResourceImage, that can be used to set the image. Particularly — image resource id and colors, if we want to paint it over.

After that, we move to delegate and create its interface. You can see why we need the interface Image.

interface ImageDisplayDelegate {

    fun suitsFor(image: Image): Boolean

    fun displayTo(image: Image, to: ImageView)

}
Enter fullscreen mode Exit fullscreen mode

Each delegate must be able to:

  • understand if it can display the image or not,
  • display the image in ImageView.

Loading images from resources will look like this:

class ResourceImagesDisplayDelegate : ImageDisplayDelegate {

    override fun suitsFor(image: Image) = image is ResourceImage

    override fun displayTo(image: Image, to: ImageView) {
        Glide.with(to.context).clear(to)
        with(image as ResourceImage) {
            val drawable = ContextCompat.getDrawable(to.context, drawableRes)
            colorRes?.let { drawable?.setTint(ContextCompat.getColor(to.context, it)) }
            to.setImageDrawable(drawable)
        }
    }

}
Enter fullscreen mode Exit fullscreen mode

You can see that:

  • Method suitsFor verifies that image is ResourceImage,
  • We set image in ImageView within the displayTo method, and if colorRes is not null, we set tint.

It’s the simplest of all possible delegates.

Combining delegates

It’s about time to combine all supported delegates in one place and to cut down the interface to the method displayTo.

class ImagesDisplayeDelegates : ImageDisplayer {
    protected val delegates = listOf(
        ResourceImagesDisplayDelegate(),
        UriImageDisplayDelegate(),
        LottieImageDelegate(),
        CountryImageLoader(),
        CurrencyImageDisplayDelegate(),
        BitmapImageDelegate(),
        GifResourseImageDisplayDelegate(),
        CardImagesDisplayDelegate(),
        GrayedOutImageDecoratorDisplayDelegate()
    )

    override fun displayTo(image: Image?, to: ImageView) {
        if (image != null) {
            //begin
            delegates.first { delegate -> delegate.suitsFor(image) }
                .displayTo(image, to)
            //end
        } else {
            to.setImageDrawable(null)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Check the highlighter line. Using the first method we find the first suitable delegate to display the image. If it’s not found, there will be a crash. And it’s not an error in the architecture: we intentionally use the fail-fast approach to get rid of unobvious behavior. Otherwise, if the image is not displayed, it’s hard to tell what’s causing the issue.


Stay tuned for the second part of the article to be released next week.

Top comments (0)