/element-adapter

Just another one easy-to-use adapter for RecyclerView :rocket:

Primary LanguageKotlinApache License 2.0Apache-2.0

Elementary RecyclerView Adapter

Maven Central API License: Apache 2

Another one easy-to-use adapter for RecyclerView 🚀

Features:

  • DSL-like methods for building adapters similar to Jetpack Compose but designed for RecyclerView
  • no view holders; bind any model object directly to auto-generated view bindings
  • support of multiple item types
  • build-in click listeners
  • the library uses DiffUtil under the hood for fast updating of your list
  • support of integration either with your own adapters or with third-party adapters
  • upd: now payloads are supported starting from v0.4

cats-screenshot

Usage example

This library adds a couple of methods for easier implementation of ListAdapter. It relies on View Binding so you don't need to create view holders.

Simple example (1 item type)

Let's image you have Cat model class and R.layout.item_cat (View Binding generates ItemCatBinding class for this layout). Then you can write the following code:

val adapter = simpleAdapter<Cat, ItemCatBinding> {
     areItemsSame = { oldCat, newCat -> oldCat.id == newCat.id }
     bind { cat ->
         catNameTextView.text = cat.name
         catDescriptionTextView.text = cat.description
     }
     listeners {
         root.onClick { cat ->
             showCatDetails(cat)
         }
     }
 }

 recyclerView.adapter = adapter

 viewModel.catsLiveData.observe(viewLifecycleOwner) { list ->
     adapter.submitList(list)
 }

As you see, simpleAdapter<Item, ViewBinding> accepts 2 types:

  • any type of your model (Cat)
  • an implementation of ViewBinding which you don't need to write because the official View Binding library can do it.

Then use bind and listeners methods to bind your item to views and assign listeners respectively. You can access all views from you binding class inside the bind and the listeners sections by this reference (which can be also omitted):

val adapter = simpleAdapter<Cat, ItemCatBinding> {
    bind { cat -> // <--- your item to bind
        // access views by 'this' reference
        this.myTextView.text = cat.name
        // or directly by name in the generated binding class:
        myTextView.text = cat.name
    }
}

It's highly recommended to use a separate listeners section to assign click and long-click listeners to your views to avoid unnecessary object creation during item binding:

val adapter = simpleAdapter<Cat, ItemCatBinding> {
    // ...
    listeners {
        // onClick for clicks
        deleteButton.onClick { cat ->
            viewModel.delete(cat)
        }
        // onLongClick for long clicks
        root.onLongClick { cat ->
            Toast.makeText(requireContext(), "Oooops", Toast.LENGTH_SHORT).show()
            true
        }
    }
}

Optionally you can adjust the logic of comparing old and new items by using areItemsSame and areContentsSame properties. They work in the same way as methods of DiffUtil.ItemCallback (click here for details). By default areItemsSame and areContentsSame compare items in terms of equals/hashCode so usually you don't need to use areContentsSame for data classes. But it's recommended to implement at least areItemsSame to compare your items by identifiers.

Typical example:

val adapter = simpleAdapter<Cat, ItemCatBinding> {
    // compare by ID
    areItemsSame = { oldCat, newCat -> oldCat.id == newCat.id }
    // compare content
    areContentsSame = { oldCat, newCat -> oldCat == newCat }
}

Another example (2 item types)

Let's add headers after every 10th cat to the list. For example, we can define the following structure:

sealed class ListItem {

    data class Header(
        val id: Int,
        val fromIndex: Int,
        val toIndex: Int
    ) : ListItem()

    data class Cat(
        val id: Long,
        val name: String,
        val description: String
    ) : ListItem()

}

Add layout for each item type: R.layout.item_cat (ItemCatBinding will be generated) and R.layout.item_header (ItemHeaderBinding will be generated).

Then we can write an adapter by using adapter and addBinding methods:

val adapter = adapter<ListItem> { // <--- Base type

    // map concrete subtype ListItem.Cat to the ItemCatBinding:
    addBinding<ListItem.Cat, ItemCatBinding> {
        areItemsSame = { oldCat, newCat -> oldCat.id == newCat.id }
        bind { cat ->
            catNameTextView.text = cat.name
            catDescriptionTextView.text = cat.description
        }
        listeners {
            deleteImageView.onClick(viewModel::deleteCat)
            root.onClick { cat ->
                viewModel.openDetails(cat)
            }
        }
    }

    // map concrete subtype ListItem.Header to the ItemHeaderBinding:
    addBinding<ListItem.Header, ItemHeaderBinding> {
        areItemsSame = { oldHeader, newHeader -> oldHeader.id == newHeader.id }
        bind { header ->
            titleTextView.text = "Cats ${header.fromIndex}...${header.toIndex}"
        }
    }
}

Then assign the list with cats and headers to the adapter by using submitList method:

val list: List<ListItem> = getListFromSomewhere()
adapter.submitList(list)

Advanced usage

Working with indexes

You can take into account an element's index within bind { ... } block and within event callbacks such as onClick { ... } and so on.

⚠️ Please note if you want to render items differently depending on element index then you need to specify areContentsSame callback which should take into account index changes.

For example:

val adapter = simpleAdapter<Cat, ItemCatBinding> {
    areContentsSame = { oldCat, newCat ->
      // here you should pass an argument to the index() because
      // indexes may be different for old and new items.
      oldCat == newCat && index(oldCat) == index(newCat)
    }
    bind { item ->
        // here index() is called without args because it refers to the current item being rendered
        root.background = if (index() % 2 == 0) Color.GRAY else Color.WHITE
        // ... render other properties
    }
}

Referencing to indexes within event callbacks is very simple (for this case you don't need to check indexes in areContentsSame):

val adapter = simpleAdapter<Cat, ItemCatBinding> {
    areContentsSame = { oldCat, newCat -> oldCat == newCat }
    bind {
        // ... render item
    }
    listeners {
        button.onClick {
            val elementIndex = index()
            Toast.makeText(context(), "Clicked on index: ${elementIndex}", Toast.LENGTH_SHORT).show()
        }
        customView.onCustomListener {
            customView.setOnMyCustomListener {
                val elementIndex = index()
                Toast.makeText(context(), "Custom event on index: ${elementIndex}", Toast.LENGTH_SHORT).show()
            }
        }
    }
}

Multi-choice / single-choice

We recommend to implement multi-choice, single-choice, expand/collapse logic and so on in the view-model. And then just submit the result list to the adapter via either LiveData or StateFlow.

But in case if you don't care about this, you can check the example of simple multi-choice implementation in the example app module (see SimpleMultiChoiceActivity).

Payloads

Sometimes you need to implement custom animations in your list or update only specific views. In this case you can use payloads.

  1. Specify changePayload property:

    • in the addBinding block (for adapter method)
    • directly in the simpleAdapter block
  2. Then use bindWithPayload instead of bind. The bindWithPayload block sends you 2 arguments instead of one: the second argument is a payload list which is exactly the same as in a typical RecyclerView.Adapter.onBindViewHolder method:

        val adapter = simpleAdapter<Cat, ItemCatBinding> {
            bindWithPayload { cat, payloads ->
                // draw cat
                // use payloads
            }
        }
  3. Usage example with adapter (see example-add module in the sources for more details):

    val catsAdapter = adapter<CatListItem> {
         addBinding<CatListItem.Cat, ItemCatBinding> {
    
             // ... areItemsSame, areContentsSame here ...
    
             // payloads callback:
             changePayload = { oldCat, newCat ->
                 if (!oldCat.isFavorite && newCat.isFavorite) {
                     FAVORITE_FLAG_CHANGED
                 } else {
                     NO_ANIMATION
                 }
             }
    
             // bind with payloads
             bindWithPayloads { cat, payloads ->
    
                 // ... render the cat here ...
    
                 // if the payload list contains FAVORITE_FLAG_CHANGED:
                 if (payloads.any { it == FAVORITE_FLAG_CHANGED }) {
                     // render changes with animation
                     favoriteImageView.startAnimation(buildMyAwesomeAnimation())
                 }
             }
    
         }
    
         // ... bind some other item types here
    
     }

Custom listeners

Sometimes simple clicks and long clicks are not enough for your list items. To integrate custom listeners, you can use onCustomListener { ... } method.

Usage example (let's assume some view can accept a double tap listener):

val adapter = simpleAdapter<Cat, ItemCatBinding> {
    // ...
    listeners {
        someDoubleTapView.onCustomListener {
            someDoubleTapView.setOnDoubleTapListener { // <-- this is a method of the view
                // use item() call for getting the current item data
                val cat = item()
                viewModel.onDoubleTap(cat)
            }
        }
    }
}

Integration with other libraries

It's possible to tie together your own adapters or adapters from other third-party libraries with this library. You can use adapterDelegate() or simpleAdapterDelegate() calls in order to create a bridge between libraries.

For example, you can tie the PagingDataAdapter (see Paging Library V3) and this library.

Usage example:

  1. Implement a subclass of PagingDataAdapter (add AdapterDelegate to the constructor):

    class PagingDataAdapterBridge<T : Any>(
        private val delegate: AdapterDelegate<T>
    ) : PagingDataAdapter<T, BindingHolder>(
        delegate.noIndexItemCallback()
    ) {
    
        override fun onBindViewHolder(holder: BindingHolder, position: Int, payloads: MutableList<Any>) {
            // please note, NULL values are not supported!
            val item = getItem(position) ?: return
            delegate.onBindViewHolder(holder, position, item, payloads)
        }
    
        override fun onBindViewHolder(holder: BindingHolder, position: Int) {
        }
    
        override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder {
            return delegate.onCreateViewHolder(parent, viewType)
        }
    
        override fun getItemViewType(position: Int): Int {
            // please note, NULL values are not supported!
            val item = getItem(position) ?: return 0
            return delegate.getItemViewType(item)
        }
    
    }
  2. Write a method for creating instances of PagingDataAdapter:

    inline fun <reified T : Any, reified B : ViewBinding> pagingAdapter(
        noinline block: ConcreteItemTypeScope<T, B>.() -> Unit
    ): PagingDataAdapter<T, BindingHolder> {
        val delegate = simpleAdapterDelegate(block)
        return PagingDataAdapterBridge(delegate)
    }
  3. Now you can use pagingAdapter { ... } call for creating instances of PagingDataAdapter from Paging Library V3

    val adapter = pagingAdapter<Cat, ItemCatBinding> {
        areItemsSame = { oldCat, newCat -> oldCat.id == newCat.id }
        bind { cat ->
            catNameTextView.text = cat.name
            catDescriptionTextView.text = cat.description
        }
        listeners {
            root.onClick { cat ->
                Toast.makeText(context(), "${cat.name} meow-meows", Toast.LENGTH_SHORT).show()
            }
        }
    }
    
    recyclerView.adapter = adapter
    
    lifecycleScope.launch {
        viewModel.catsPagingDataFlow.collectLatest {
            adapter.submitData(it)
        }
    }
    

Installation

  • Add View Binding to your build.gradle file:

    android {
        ...
        buildFeatures {
            viewBinding true
        }
        ...
    }
    
  • Add the library to the dependencies section of your build.gradle script:

    dependencies {
        ...
        implementation 'com.elveum:element-adapter:0.6'
    }
    

Changelog

v0.6

  • Upgraded gradle plugin and dependencies
  • Changed target SDK to 33
  • Now you can specify defaultAreItemsSame, defaultAreContentsSame and defaultChangePayload callbacks directly in the adapter { ... } block. They will be used as default callbacks for all addBinding { ... } sub-blocks.
  • Default implementation of areItemsSame now compares items by reference (e.g. oldItem === newItem instead of oldItem == newItem)

v0.5

  • Added index() method which can be called within:
    • bind { ... } block
    • onClick { ... }, onLongClick { ... } blocks
    • onCustomListener { view.onMyListener { ... } } block
  • Added index(item) method to areContentsSame { ... }, areItemsSame { ... } and changePayload { ... } blocks. For these blocks you should call index() with arg because there is a need to specify for which item (oldItem or newItem) you want to get an index.

v0.4

  • Added support of RecyclerView payloads

v0.3

  • Added a couple of extension methods for getting resources to the bind and listeners block
  • Added onCustomListener { ... } method for assigning custom listeners
  • Added adapterDelegate { ... } and simpleAdapterDelegate { ... } methods for easier integration with third-party adapters

v0.2

  • Added context() extension method
  • Updated minSDK from 23 to 21

v0.1

  • The first release

License

Apache License 2.0