Skip to content
/ recycli Public

Recycli is a Kotlin Android library that simplifies building complex multiple view types screens in a RecyclerView

License

Notifications You must be signed in to change notification settings

detmir/recycli

Repository files navigation

recycli_logo

GitHub GitHub release

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.

ezgif-6-e9d7bd416187

Table of Contents

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

  1. Add Maven Central to you repositories in the build.gradle file at the project or module level:

    allprojects {
        repositories {
            mavenCentral()
        }
    }
  2. 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 is 1.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/kapt

    plugins {
        id 'com.google.devtools.ksp' version '1.8.22-1.0.11'
    }
  3. 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'
    }
    
  1. Create Kotlin data classes that are annotated with @RecyclerItemState and are extending RecyclerItem. A unique (for this adapter) string id 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
    }
  2. Add two view classes HeaderItemView and UserItemView that extend any View or ViewGroup 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 in RecyclerView.Adapter for corresponding states. bindState will be called when onBindViewHolder called in the adapter.

  3. Create RecyclerView and bind the list of RecyclerItems to it with bindState 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:

Screenshot_20210423-151457_KKppt3

Demo Activity

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:

Screenshot_20210423-150530_KKppt3

Demo Activity

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.

  1. 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
    }
  2. 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
        }
    }
  3. 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:

ezgif-6-a9ba26ee168f

Demo Activity

Using sealed classes as UI states is a common thing. You can create sealed class state items and bind them easily.

  1. 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
    }
  2. 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"
            }
        }
    }
  3. 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:

Screenshot_20210423-170301_KKppt3

Demo Activity

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:

Demo Activity

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
}
  1. 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))
            }
        }
    }
  2. 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:

Screenshot_20210424-214134_KKppt3

Demo Activity

It's common to have horizontal scrolling lists inside the vertical scrolling container, and recycli supports this feature.

  1. 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)
        }
    }
  2. 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:

ezgif-2-9cf09cc92026

Demo Activity

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:

Demo Activity

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())
  1. You need to provide the loadRange function to implement infinite callback interface RecyclerAdapter.Callbacks. Adapter does not initiate loading of the first page, so we have to call loadRange(0) to initiate loading. All the later pages will loaded by the adapter when you scroll the recycler.

  2. When the adapter invokes loadRange, you need to bind InfinityState with requestState = InfinityState.Request.LOADING first: the adapter will understand that loading process has started, and will stop calling loadRange. You also need to pass current items, loading page number and provide boolean endReached to indicate there are no more data:

        recyclerView.bindState(
            InfinityState(
                requestState = InfinityState.Request.LOADING,
                items = items,
                page = curPage,
                endReached = curPage == 10
            )
        )
  3. 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
            )
        )
  4. If you encounter an error while loading data, bind InfinityState with InfinityState.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 provide Dummy, Progress, Error and Button 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:

ezgif-6-5b5d2a89fbdb

Demo Activity

You can use low level Recycli adapter RecyclerBaseAdapter to provide ViewHolders and bindings and use Paging 3 library for all needed infinity scroll logic

paging

Demo Activity

You can use standart Item decorator technique to support sticky headers

sticky

Demo Activity

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.