Eugene Zubkov
Posted on June 13, 2023
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:
- Part I - improving the legacy approach and building delegates,
- Part II - making transformations, testing and summing-up the results.
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.
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)
}
}
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
}
}
}
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
}
}
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
}
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)
}
}
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()
)
)
}
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
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)
}
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)
}
}
}
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)
}
}
}
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.
Posted on June 13, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 21, 2024