Recycli is a Kotlin library for Android RecyclerView that simplifies the creation of multiple view types lists. Featuring DiffUtils inside, annotation-based adapter generator and MVI pattern as philosophy, it is both a simple and powerful tool for rapid development of RecyclerView-based screens.
Installation
First steps
Use Views or ViewHolders
Reaction on clicks and state changes
Sealed classes as states
Sealed classes and binding functions
One item state and several views
Horizontal sub lists
Multi-module applications
Endless scrolling lists
Paging 3
Sticky Headers
License
-
Add Maven Central to you repositories in the
build.gradle
file at the project or module level:allprojects { repositories { mavenCentral() } }
-
Add KSP plugin to plugins section of your
build.gradle
at the project level. Select KSP version that matches your Kotlin version (1.8.22
is the version of Kotlin the plugin matches). Minimum supported version of Kotlin is1.8.xx
. If your version is lower, please use 1.9.0 version of Recycli, that works with any Kotlin version using KAPT instead of KSP. See the documentation https://github.com/detmir/recycli/tree/kaptplugins { id 'com.google.devtools.ksp' version '1.8.22-1.0.11' }
-
Add KSP plugin and Recycli dependencies to your 'build.gradle' at the module level:
apply plugin: 'com.google.devtools.ksp' dependencies { implementation 'com.detmir.recycli:adapters:2.2.0' compileOnly 'com.detmir.recycli:annotations:2.2.0' ksp 'com.detmir.recycli:processors:2.2.0' }
-
Create Kotlin data classes that are annotated with
@RecyclerItemState
and are extendingRecyclerItem
. A unique (for this adapter) stringid
must be provided. Those classes describe recycler items states. Let's create two data classes - Header and User items:@RecyclerItemState data class HeaderItem( val id: String, val title: String ) : RecyclerItem { override fun provideId() = id }
@RecyclerItemState data class UserItem( val id: String, val firstName: String ) : RecyclerItem { override fun provideId() = id }
-
Add two view classes
HeaderItemView
andUserItemView
that extend anyView
orViewGroup
container. Annotate these classes with@RecyclerItemView
annotation. Also, add a method with recycler item state as a parameter and annotate it with@RecyclerItemStateBinder
.@RecyclerItemView class HeaderItemView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : FrameLayout(context, attrs, defStyleAttr) { private val title: TextView init { LayoutInflater.from(context).inflate(R.layout.header_view, this) title = findViewById(R.id.header_view_title) } @RecyclerItemStateBinder fun bindState(headerItem: HeaderItem) { title.text = headerItem.title } }
@RecyclerItemView class UserItemView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : FrameLayout(context, attrs, defStyleAttr) { private val firstName: TextView init { LayoutInflater.from(context).inflate(R.layout.user_view, this) firstName = findViewById(R.id.user_view_first_name) } @RecyclerItemStateBinder fun bindState(userItem: UserItem) { firstName.text = userItem.firstName } }
Those views will be used in
onCreateViewHolder
functions inRecyclerView.Adapter
for corresponding states.bindState
will be called whenonBindViewHolder
called in the adapter. -
Create
RecyclerView
and bind the list ofRecyclerItems
to it withbindState
method. The Recycler Adpater class is generated by Recycli lib under the hood using the annotations mentioned earlier.class DemoActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val recyclerView = findViewById<RecyclerView>(R.id.activity_case_0100_recycler) recyclerView.layoutManager = LinearLayoutManager(this) recyclerView.bindState( listOf( HeaderItem( id = "HEADER_USERS", title = "Users" ), UserItem( id = "USER_ANDREW", firstName = "Andrew", online = true ), UserItem( id = "USER_MAX", firstName = "Max", online = true ) ) ) } }
The RecyclerView
will display:
In the example earlier, we used classes that extend ViewGroup
or View
to provide RecyclerView
with the corresponding view. If you prefer to inflate views directly in RecyclerView.ViewHolder
, you can do it with @RecyclerItemViewHolder
and @RecyclerItemViewHolderCreator
annotations. Note that @RecyclerItemViewHolderCreator
must be a function located in the companion class of ViewHolder
.
See the full example below:
-
Recycler item state:
@RecyclerItemState data class ServerItem( val id: String, val serverAddress: String ) : RecyclerItem { override fun provideId() = id }
-
View holder that can bind
ServerItem
state:@RecyclerItemViewHolder class ServerItemViewHolder(view: View) : RecyclerView.ViewHolder(view) { private val serverAddress: TextView = view.findViewById(R.id.server_item_title) @RecyclerItemStateBinder fun bindState(serverItem: ServerItem) { serverAddress.text = serverItem.serverAddress } companion object { @RecyclerItemViewHolderCreator fun provideViewHolder(context: Context): ServerItemViewHolder { val view = LayoutInflater.from(context).inflate(R.layout.server_item_view, null) return ServerItemViewHolder(view) } } }
-
Bind items to
RecyclerView
:class Case0101SimpleVHActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val recyclerView = findViewById<RecyclerView>(R.id.activity_case_0101_recycler) recyclerView.layoutManager = LinearLayoutManager(this) recyclerView.bindState( listOf( HeaderItem( id = "HEADER_SERVERS", title = "Servers" ), ServerItem( id = "SERVER1", serverAddress = "124.45.22.12" ), ServerItem( id = "SERVER2", serverAddress = "90.0.0.28" ) ) ) } }
The result:
Click reaction is handled in MVI manner. Recycler item provides the intent via its state function invocation. ViewModel handles the intent, recalculates the state and binds it to the adapter.
-
Provide the recycler item state with click reaction functions:
@RecyclerItemState data class UserItem( val id: String, val firstName: String, val onCardClick: (String) -> Unit, val onMoveToOnline: (String) -> Unit, val onMoveToOffline: (String) -> Unit ) : RecyclerItem { override fun provideId() = id }
-
Add on-click listeners to the view:
@RecyclerItemView class UserItemView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : FrameLayout(context, attrs, defStyleAttr) { private lateinit var userItem: UserItem init { ... toOnlineButton.setOnClickListener { userItem.onMoveToOnline.invoke(userItem.firstName) } toOfflineButton.setOnClickListener { userItem.onMoveToOffline.invoke(userItem.firstName) } holder.setOnClickListener { userItem.onCardClick.invoke(userItem.firstName) } } @RecyclerItemStateBinder fun bindState(userItem: UserItem) { this.userItem = userItem firstName.text = userItem.firstName } }
-
In your ViewModel you can handle the clicks, recreate the state if needed and bind it to your recyclerView using
bindState
:private val onlineUserNames = mutableListOf("James","Mary","Robert","Patricia") private val offlineUserNames = mutableListOf("Michael","Linda","William","Elizabeth","David") private fun updateRecycler() { val recyclerItems = mutableListOf<RecyclerItem>() recyclerItems.add( HeaderItem( id = "HEADER_ONLINE_OPERATORS", title = "Online operators ${onlineUserNames.size}" ) ) onlineUserNames.forEach { name -> recyclerItems.add( UserItem( id = name, firstName = name, online = true, onCardClick = ::cardClicked, onMoveToOffline = ::moveToOffline ) ) } recyclerItems.add( HeaderItem( id = "HEADER_OFFLINE_OPERATORS", title = "Offline operators ${offlineUserNames.size}" ) ) offlineUserNames.forEach { recyclerItems.add( UserItem( id = it, firstName = it, online = false, onCardClick = ::cardClicked, onMoveToOnline = ::moveToOnline ) ) } recyclerView.bindState(recyclerItems) } private fun cardClicked(name: String) { Toast.makeText(this, name, Toast.LENGTH_SHORT).show() } private fun moveToOffline(name: String) { onlineUserNames.remove(name) offlineUserNames.add(0, name) updateRecycler() } private fun moveToOnline(name: String) { offlineUserNames.remove(name) onlineUserNames.add(name) updateRecycler() }
Note that we have implemented all the logic inside Activity for simplification purposes.
The result:
Using sealed classes as UI states is a common thing. You can create sealed class state items and bind them easily.
-
Create a sealed class:
@RecyclerItemState sealed class ProjectItem : RecyclerItem { abstract val id: String abstract val title: String data class Failed( override val id: String, override val title: String, val why: String ) : ProjectItem() data class New( override val id: String, override val title: String ) : ProjectItem() sealed class Done: ProjectItem() { data class BeforeDeadline( override val id: String, override val title: String ) : Done() data class AfterDeadline( override val id: String, override val title: String, val why: String ) : Done() } override fun provideId() = id }
-
Use Kotlin
when
to handle different sealed class states:@RecyclerItemView class ProjectItemView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : FrameLayout(context, attrs, defStyleAttr) { @RecyclerItemStateBinder fun bindState(projectItem: ProjectItem) { projectTitle.text = projectItem.title when (projectItem) { is ProjectItem.Failed -> projectDescription.text = "Failed" is ProjectItem.New -> projectDescription.text = "New" is ProjectItem.Done.AfterDeadline -> projectDescription.text = "After deadline" is ProjectItem.Done.BeforeDeadline -> projectDescription.text = "Before deadline" } } }
-
Create and bind the recycler state:
recyclerView.bindState( listOf( ProjectItem.Failed( id = "FAILED", title = "Failed project", why = "" ), ProjectItem.New( id = "NEW", title = "New project" ), ProjectItem.Done.BeforeDeadline( id = "BEFORE_DEAD_LINE", title = "Done before deadline project" ), ProjectItem.Done.AfterDeadline( id = "AFTER_DEAD_LINE", title = "Done after deadline project", why = "" ) ) )
The result:
You can create binding functions for every subclass of a sealed state (or even for sealed sub classes of a sealed class).
Sealed class recycler item state:
@RecyclerItemState
sealed class PipeLineItem : RecyclerItem {
data class Input(
val id: String,
val from: String
) : PipeLineItem() {
override fun provideId() = id
}
data class Output(
val id: String,
val to: String
) : PipeLineItem() {
override fun provideId() = id
}
}
@RecyclerItemView
class PipeLineItemView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
...
@RecyclerItemStateBinder
fun bindState(input: PipeLineItem.Input) {
destination.text = input.from
}
@RecyclerItemStateBinder
fun bindState(output: PipeLineItem.Output) {
destination.text = output.to
}
}
See:
Sometimes one needs several view variants for one recycler item state class. You can define which view to use by overriding the withView()
method of RecyclerItem
:
@RecyclerItemState
data class CloudItem(
val id: String,
val serverName: String,
val intoView: Class<out Any>
) : RecyclerItem {
override fun provideId() = id
override fun withView() = intoView
}
-
Create several views or view holders that can bind CloudItem:
@RecyclerItemView class CloudAzureItemView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : FrameLayout(context, attrs, defStyleAttr) { .... @RecyclerItemStateBinder fun bindState(cloudItem: CloudItem) { name.text = cloudItem.serverName } }
@RecyclerItemView class CloudAmazonItemView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : FrameLayout(context, attrs, defStyleAttr) { .... @RecyclerItemStateBinder fun bindState(cloudItem: CloudItem) { name.text = cloudItem.serverName } }
@RecyclerItemViewHolder class DigitalOceanViewHolder(view: View) : RecyclerView.ViewHolder(view) { @RecyclerItemStateBinder fun bindState(cloudItem: CloudItem) { name.text = cloudItem.serverName } companion object { @RecyclerItemViewHolderCreator fun provideViewHolder(context: Context): DigitalOceanViewHolder { return DigitalOceanViewHolder(LayoutInflater.from(context).inflate(R.layout.cloud_digital_ocean_item_view, null)) } } }
-
Then, fill the recyclerView with items:
class DemoActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { .... recyclerView.bindState( listOf( CloudItem( id = "GOOGLE", serverName = "Google server", intoView = CloudGoogleItemView::class.java ), CloudItem( id = "AMAZON", serverName = "Amazon server", intoView = CloudAmazonItemView::class.java ), CloudItem( id = "AZURE", serverName = "Azure server", intoView = CloudAzureItemView::class.java ), CloudItem( id = "DIGITAL_OCEAN", serverName = "Digital ocean server", intoView = DigitalOceanViewHolder::class.java ) ) ) } }
The result:
It's common to have horizontal scrolling lists inside the vertical scrolling container, and recycli supports this feature.
-
Create a container state and view for horizontal list. This is just another list of items, recycler with horizontal layout manager and adapter:
@RecyclerItemState data class SimpleContainerItem( val id: String, val recyclerState: List<RecyclerItem> ): RecyclerItem { override fun provideId(): String { return id } }
@RecyclerItemView class SimpleContainerItemView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : FrameLayout(context, attrs, defStyleAttr) { private val recycler: RecyclerView private val recyclerAdapter: RecyclerAdapter init { val view = LayoutInflater.from(context).inflate(R.layout.simple_recycler_conteiner_view, this, true) layoutParams = ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT ) recyclerAdapter = RecyclerAdapter() recycler = view.findViewById(R.id.simple_recycler_container_recycler) recycler.run { isNestedScrollingEnabled = false layoutManager = LinearLayoutManager(context, RecyclerView.HORIZONTAL, false) adapter = recyclerAdapter } } @RecyclerItemStateBinder fun bindState(state: SimpleContainerItem) { recyclerAdapter.bindState(state.recyclerState) } }
-
Now, populate recycler items and sublist items in a usual way:
class DemoActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { ... recyclerAdapter.bindState( listOf( HeaderItem( id = "HEADER_SUB_TASKS", title = "Subtasks" ), SimpleContainerItem( id = "SUB_TASKS_CONTAINER", recyclerState = (0..100).map { SubTaskItem( id = "SUB_TASK_$it", title = "Sub task $it", description = "It is a long established ..." ) } ), BigTaskItem( id = "TASK", title = "The second task title", description = "It is a long established ..." ) ) ) } }
The result:
If your app has several modules and your recycler item states and views are located in different modules, Recycli will manage needed adapters for you under the hood
See:
One of the main features of Recycli - support for infinite scroll lists. It handles paging loading callbacks, displain bottom progress bars and errors.
To create an infinite scroll list, just pass RecyclerAdapter.Callbacks
to the recyclerView, this will switch it to infinite scroll. BottomLoading
is optional. It is responsible for displaying bottom progress bar, error page loading and for dummy item that provides some extra space for better load position detection:
recyclerView.setInfinityCallbacks(this)
recyclerView.setBottomLoading(BottomLoading())
-
You need to provide the
loadRange
function to implement infinite callback interfaceRecyclerAdapter.Callbacks
. Adapter does not initiate loading of the first page, so we have to callloadRange(0)
to initiate loading. All the later pages will loaded by the adapter when you scroll the recycler. -
When the adapter invokes
loadRange
, you need to bindInfinityState
withrequestState = InfinityState.Request.LOADING
first: the adapter will understand that loading process has started, and will stop callingloadRange
. You also need to pass current items, loading page number and provide booleanendReached
to indicate there are no more data:recyclerView.bindState( InfinityState( requestState = InfinityState.Request.LOADING, items = items, page = curPage, endReached = curPage == 10 ) )
-
Once you load data, add it to your items and pass it to adapter with the IDLE state:
if (curPage == 0) items.clear() items.addAll(it) recyclerView.bindState( InfinityState( requestState = InfinityState.Request.IDLE, items = items, page = curPage, endReached = curPage == 10 ) )
-
If you encounter an error while loading data, bind
InfinityState
withInfinityState.Request.ERROR
. Consider the example below:- We load 10 pages (20 items per page).
- On page 4, we emulate error and bind error state to show error button appears at the bottom.
- When page 10 is loaded, we set
endReached
to true and adapter stops asking for more data. - We use RX to emulate loading process with 2 seconds data loading delay.
class DemoActivity : AppCompatActivity(), RecyclerAdapter.Callbacks { private val items = mutableListOf<RecyclerItem>() private val PAGE_SIZE = 20 override fun onCreate(savedInstanceState: Bundle?) { recyclerView.setInfinityCallbacks(this) recyclerView.setBottomLoading(BottomLoading()) loadRange(0) } override fun loadRange(curPage: Int) { val delay = if (curPage == 0) 0L else 2000L Single.timer(delay, TimeUnit.MILLISECONDS) .flatMap { Single.just((curPage * PAGE_SIZE until (curPage * PAGE_SIZE + PAGE_SIZE)).map { UserItem( id = "$it", firstName = "John $it", online = it < 5 ) }) } .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .map { if (curPage == 4 && !infiniteItemsErrorThrown) { infiniteItemsErrorThrown = true throw Exception("error") } it } .doOnSubscribe { recyclerAdapterrecyclerView.bindState( InfinityState( requestState = InfinityState.Request.LOADING, items = items, page = curPage, endReached = curPage == 10 ) ) } .doOnError { recyclerView.bindState( InfinityState( requestState = InfinityState.Request.ERROR, items = items, page = curPage, endReached = curPage == 10 ) ) } .doOnSuccess { if (curPage == 0) items.clear() items.addAll(it) recyclerView.bindState( InfinityState( requestState = InfinityState.Request.IDLE, items = items, page = curPage, endReached = curPage == 10 ) ) } .subscribe({}, {}) } }
Keep in mind that you need to implement the
RecyclerBottomLoading
interface and pass it to adapter to provideDummy
,Progress
,Error
andButton
recycler items states that will be displayed while you scroll. This is optional, but in production apps it is a standart UI you have to implement:class BottomLoading : RecyclerBottomLoading { @RecyclerItemState sealed class State : RecyclerItem { override fun provideId(): String { return "bottom" } object Dummy : State() object Progress : State() data class Error(val reload: () -> Unit) : State() data class Button(val next: () -> Unit) : State() } override fun provideProgress(): RecyclerItem { return State.Progress } override fun provideDummy(): RecyclerItem { return State.Dummy } override fun provideError(reload: () -> Unit): RecyclerItem { return State.Error(reload) } override fun provideButton(next: () -> Unit): RecyclerItem { return State.Button(next) } }
@RecyclerItemView class BottomLoadingView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : FrameLayout(context, attrs, defStyleAttr) { ... @RecyclerItemStateBinder fun bindState(state: BottomLoading.State) { when (state) { is BottomLoading.State.Progress -> { buttonError.visibility = View.GONE progress.visibility = View.VISIBLE } is BottomLoading.State.Button -> { buttonError.visibility = View.GONE progress.visibility = View.GONE } is BottomLoading.State.Dummy -> { buttonError.visibility = View.GONE progress.visibility = View.GONE } is BottomLoading.State.Error -> { buttonError.visibility = View.VISIBLE progress.visibility = View.GONE } } } }
Note that we scroll fast, so you can see loader that displays progress for 2 seconds. In reality users don't scroll that fast and loading process starts when 5 elements are left at the bottom.
The result:
You can use low level Recycli adapter RecyclerBaseAdapter
to provide ViewHolders and bindings and use Paging 3 library for all needed infinity scroll logic
You can use standart Item decorator technique to support sticky headers
Copyright 2021 Detsky Mir Group
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.