Elementary RecyclerView Adapter
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
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.
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.
-
Specify
changePayload
property:- in the
addBinding
block (foradapter
method) - directly in the
simpleAdapter
block
- in the
-
Then use
bindWithPayload
instead ofbind
. ThebindWithPayload
block sends you 2 arguments instead of one: the second argument is a payload list which is exactly the same as in a typicalRecyclerView.Adapter.onBindViewHolder
method:val adapter = simpleAdapter<Cat, ItemCatBinding> { bindWithPayload { cat, payloads -> // draw cat // use payloads } }
-
Usage example with
adapter
(seeexample-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:
-
Implement a subclass of
PagingDataAdapter
(addAdapterDelegate
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) } }
-
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) }
-
Now you can use
pagingAdapter { ... }
call for creating instances ofPagingDataAdapter
from Paging Library V3val 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 yourbuild.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
anddefaultChangePayload
callbacks directly in theadapter { ... }
block. They will be used as default callbacks for alladdBinding { ... }
sub-blocks. - Default implementation of
areItemsSame
now compares items by reference (e.g.oldItem === newItem
instead ofoldItem == newItem
)
v0.5
- Added
index()
method which can be called within:bind { ... }
blockonClick { ... }
,onLongClick { ... }
blocksonCustomListener { view.onMyListener { ... } }
block
- Added
index(item)
method toareContentsSame { ... }
,areItemsSame { ... }
andchangePayload { ... }
blocks. For these blocks you should callindex()
with arg because there is a need to specify for which item (oldItem
ornewItem
) 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
andlisteners
block - Added
onCustomListener { ... }
method for assigning custom listeners - Added
adapterDelegate { ... }
andsimpleAdapterDelegate { ... }
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