├── example-app ├── .gitignore ├── src │ └── main │ │ ├── res │ │ ├── mipmap-hdpi │ │ │ ├── ic_launcher.webp │ │ │ └── ic_launcher_round.webp │ │ ├── mipmap-mdpi │ │ │ ├── ic_launcher.webp │ │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xhdpi │ │ │ ├── ic_launcher.webp │ │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxhdpi │ │ │ ├── ic_launcher.webp │ │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxxhdpi │ │ │ ├── ic_launcher.webp │ │ │ └── ic_launcher_round.webp │ │ ├── drawable │ │ │ ├── circle.xml │ │ │ └── ic_launcher_background.xml │ │ ├── mipmap-anydpi-v26 │ │ │ ├── ic_launcher.xml │ │ │ └── ic_launcher_round.xml │ │ ├── values │ │ │ ├── strings.xml │ │ │ ├── colors.xml │ │ │ └── themes.xml │ │ ├── drawable-v24 │ │ │ ├── ic_delete.xml │ │ │ ├── ic_favorite.xml │ │ │ ├── ic_favorite_not.xml │ │ │ └── ic_launcher_foreground.xml │ │ └── layout │ │ │ ├── item_header.xml │ │ │ ├── activity_simple_multichoice.xml │ │ │ ├── activity_main.xml │ │ │ ├── item_selectable.xml │ │ │ └── item_cat.xml │ │ ├── java │ │ └── com │ │ │ └── elveum │ │ │ └── elementadapter │ │ │ └── app │ │ │ ├── App.kt │ │ │ ├── model │ │ │ ├── Cat.kt │ │ │ └── CatsRepository.kt │ │ │ ├── CatListItem.kt │ │ │ ├── MainViewModel.kt │ │ │ ├── SimpleMultiChoiceActivity.kt │ │ │ └── MainActivity.kt │ │ └── AndroidManifest.xml ├── proguard-rules.pro └── build.gradle ├── recyclerview-element-adapter ├── .gitignore ├── consumer-rules.pro ├── src │ └── main │ │ ├── AndroidManifest.xml │ │ ├── res │ │ └── values │ │ │ └── identifiers.xml │ │ └── java │ │ └── com │ │ └── elveum │ │ └── elementadapter │ │ ├── dsl │ │ ├── CustomListenerScope.kt │ │ ├── IndexScope.kt │ │ ├── MultiAdapter.kt │ │ ├── ItemCallbackDelegate.kt │ │ ├── AdapterScope.kt │ │ └── ConcreteItemTypeScope.kt │ │ ├── CommonBindings.kt │ │ ├── delegate │ │ ├── DslDelegateEntry.kt │ │ └── AdapterDelegate.kt │ │ ├── ElementListAdapter.kt │ │ └── DslEntry.kt ├── proguard-rules.pro └── build.gradle ├── docs └── screenshot.png ├── .github └── FUNDING.yml ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── .gitignore ├── settings.gradle ├── gradle.properties ├── publish.gradle ├── publish-library.gradle ├── gradlew.bat ├── gradlew ├── LICENSE └── README.md /example-app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /recyclerview-element-adapter/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /recyclerview-element-adapter/consumer-rules.pro: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/romychab/element-adapter/HEAD/docs/screenshot.png -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | custom: ["https://www.buymeacoffee.com/romychab"] 4 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/romychab/element-adapter/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /example-app/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/romychab/element-adapter/HEAD/example-app/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /example-app/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/romychab/element-adapter/HEAD/example-app/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /example-app/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/romychab/element-adapter/HEAD/example-app/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /example-app/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/romychab/element-adapter/HEAD/example-app/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /example-app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/romychab/element-adapter/HEAD/example-app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /example-app/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/romychab/element-adapter/HEAD/example-app/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /example-app/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/romychab/element-adapter/HEAD/example-app/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /example-app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/romychab/element-adapter/HEAD/example-app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /example-app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/romychab/element-adapter/HEAD/example-app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /example-app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/romychab/element-adapter/HEAD/example-app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /recyclerview-element-adapter/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /example-app/src/main/java/com/elveum/elementadapter/app/App.kt: -------------------------------------------------------------------------------- 1 | package com.elveum.elementadapter.app 2 | 3 | import android.app.Application 4 | import dagger.hilt.android.HiltAndroidApp 5 | 6 | @HiltAndroidApp 7 | class App : Application() -------------------------------------------------------------------------------- /example-app/src/main/res/drawable/circle.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /recyclerview-element-adapter/src/main/res/values/identifiers.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /example-app/src/main/java/com/elveum/elementadapter/app/model/Cat.kt: -------------------------------------------------------------------------------- 1 | package com.elveum.elementadapter.app.model 2 | 3 | data class Cat( 4 | val id: Long, 5 | val name: String, 6 | val photoUrl: String, 7 | val description: String, 8 | val isFavorite: Boolean 9 | ) -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Tue Sep 06 16:40:34 EEST 2022 2 | distributionBase=GRADLE_USER_HOME 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip 4 | distributionPath=wrapper/dists 5 | zipStorePath=wrapper/dists 6 | zipStoreBase=GRADLE_USER_HOME 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | .cxx 15 | local.properties 16 | -------------------------------------------------------------------------------- /example-app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | dependencyResolutionManagement { 2 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 3 | repositories { 4 | google() 5 | mavenCentral() 6 | } 7 | } 8 | rootProject.name = "RecyclerView Element Adapter" 9 | include ':example-app' 10 | include ':recyclerview-element-adapter' 11 | -------------------------------------------------------------------------------- /example-app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /example-app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Cats 😼 3 | Cats: %1$d … %2$d 4 | 5 | @string/app_name 6 | Multi Choice 7 | 8 | Total selected: %1$d 9 | -------------------------------------------------------------------------------- /example-app/src/main/res/drawable-v24/ic_delete.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /example-app/src/main/res/layout/item_header.xml: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | -------------------------------------------------------------------------------- /example-app/src/main/res/drawable-v24/ic_favorite.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /example-app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | 11 | @color/purple_700 12 | #ababab 13 | 14 | #223700B3 15 | -------------------------------------------------------------------------------- /example-app/src/main/res/drawable-v24/ic_favorite_not.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /example-app/src/main/java/com/elveum/elementadapter/app/CatListItem.kt: -------------------------------------------------------------------------------- 1 | package com.elveum.elementadapter.app 2 | 3 | sealed class CatListItem { 4 | 5 | data class Header( 6 | val headerId: Int, 7 | val fromIndex: Int, 8 | val toIndex: Int 9 | ) : CatListItem() 10 | 11 | data class Cat( 12 | val originCat: com.elveum.elementadapter.app.model.Cat 13 | ) : CatListItem() { 14 | val id: Long get() = originCat.id 15 | val name: String get() = originCat.name 16 | val photoUrl: String get() = originCat.photoUrl 17 | val description: String get() = originCat.description 18 | val isFavorite: Boolean get() = originCat.isFavorite 19 | } 20 | 21 | } -------------------------------------------------------------------------------- /example-app/src/main/res/layout/activity_simple_multichoice.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 13 | 14 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /example-app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /recyclerview-element-adapter/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /recyclerview-element-adapter/src/main/java/com/elveum/elementadapter/dsl/CustomListenerScope.kt: -------------------------------------------------------------------------------- 1 | package com.elveum.elementadapter.dsl 2 | 3 | import android.view.View 4 | import com.elveum.elementadapter.R 5 | 6 | interface CustomListenerIndexScope { 7 | 8 | /** 9 | * Get the current index of an element for which a callback has been called. 10 | */ 11 | fun index(): Int 12 | } 13 | 14 | interface CustomListenerScope : CustomListenerIndexScope { 15 | /** 16 | * Get the current item attached to the view 17 | */ 18 | fun item(): T 19 | } 20 | 21 | @Suppress("UNCHECKED_CAST") 22 | internal class CustomListenerScopeImpl( 23 | private val view: View 24 | ) : CustomListenerScope { 25 | override fun item(): T = view.getTag(R.id.element_entity_tag) as T 26 | override fun index(): Int = view.getTag(R.id.element_index_tag) as Int 27 | } 28 | -------------------------------------------------------------------------------- /example-app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 17 | 18 | -------------------------------------------------------------------------------- /recyclerview-element-adapter/src/main/java/com/elveum/elementadapter/dsl/IndexScope.kt: -------------------------------------------------------------------------------- 1 | package com.elveum.elementadapter.dsl 2 | 3 | class ElementWithIndex( 4 | val index: Int, 5 | val element: T, 6 | ) 7 | 8 | interface IndexScope { 9 | /** 10 | * Get an index of `oldItem` or `newItem`. 11 | */ 12 | fun index(item: T): Int 13 | } 14 | 15 | internal class IndexScopeImpl( 16 | private val oldElementWithIndex: ElementWithIndex, 17 | private val newElementWithIndex: ElementWithIndex, 18 | ) : IndexScope { 19 | override fun index(item: T): Int { 20 | if (item === oldElementWithIndex.element) return oldElementWithIndex.index 21 | if (item === newElementWithIndex.element) return newElementWithIndex.index 22 | throw IllegalArgumentException("Unknown item! You can pass only newItem or oldItem as an argument for index() call") 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /example-app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | -------------------------------------------------------------------------------- /recyclerview-element-adapter/src/main/java/com/elveum/elementadapter/CommonBindings.kt: -------------------------------------------------------------------------------- 1 | package com.elveum.elementadapter 2 | 3 | import android.content.Context 4 | import android.content.res.ColorStateList 5 | import android.content.res.Resources 6 | import android.widget.ImageView 7 | import androidx.annotation.ColorRes 8 | import androidx.annotation.StringRes 9 | import androidx.core.content.ContextCompat 10 | import androidx.viewbinding.ViewBinding 11 | 12 | fun ImageView.setTintColor(@ColorRes colorRes: Int) { 13 | imageTintList = ColorStateList.valueOf( 14 | ContextCompat.getColor( 15 | context, 16 | colorRes 17 | ) 18 | ) 19 | } 20 | 21 | fun B.context(): Context = this.root.context 22 | 23 | fun B.resources(): Resources = context().resources 24 | 25 | fun B.getString(@StringRes stringRes: Int, vararg formatArgs: Any?): String { 26 | return context().getString(stringRes, *formatArgs) 27 | } 28 | 29 | fun B.getColor(@ColorRes colorRes: Int): Int { 30 | return ContextCompat.getColor(context(), colorRes) 31 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app"s APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Automatically convert third-party libraries to use AndroidX 19 | android.enableJetifier=true 20 | # Kotlin code style for this project: "official" or "obsolete": 21 | kotlin.code.style=official 22 | -------------------------------------------------------------------------------- /example-app/src/main/res/layout/item_selectable.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 16 | 17 | 29 | 30 | -------------------------------------------------------------------------------- /recyclerview-element-adapter/src/main/java/com/elveum/elementadapter/dsl/MultiAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.elveum.elementadapter.dsl 2 | 3 | import android.view.ViewGroup 4 | import androidx.recyclerview.widget.RecyclerView 5 | import androidx.viewbinding.ViewBinding 6 | import com.elveum.elementadapter.ElementListAdapter 7 | import com.elveum.elementadapter.delegate.AdapterDelegate 8 | 9 | class BindingHolder( 10 | val binding: ViewBinding 11 | ) : RecyclerView.ViewHolder(binding.root) 12 | 13 | internal class MultiAdapter( 14 | private val adapterDelegate: AdapterDelegate 15 | ) : ElementListAdapter(adapterDelegate.itemCallback()) { 16 | 17 | override fun getItemViewType(position: Int): Int { 18 | return adapterDelegate.getItemViewType(getItem(position)) 19 | } 20 | 21 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { 22 | return adapterDelegate.onCreateViewHolder(parent, viewType) 23 | } 24 | 25 | override fun onBindViewHolder( 26 | holder: BindingHolder, 27 | position: Int, 28 | payloads: MutableList 29 | ) { 30 | adapterDelegate.onBindViewHolder(holder, position, getItem(position), payloads) 31 | } 32 | 33 | override fun onBindViewHolder(holder: BindingHolder, position: Int) { 34 | } 35 | 36 | } 37 | 38 | -------------------------------------------------------------------------------- /publish.gradle: -------------------------------------------------------------------------------- 1 | ext["signing.keyId"] = '' 2 | ext["signing.password"] = '' 3 | ext["signing.key"] = '' 4 | ext["ossrhTokenUsername"] = '' 5 | ext["ossrhTokenPassword"] = '' 6 | ext["sonatypeStagingProfileId"] = '' 7 | 8 | File localProperties = project.rootProject.file('local.properties') 9 | if (localProperties.exists()) { 10 | Properties p = new Properties() 11 | new FileInputStream(localProperties).withCloseable { is -> p.load(is) } 12 | p.each { name, value -> ext[name] = value } 13 | } else { 14 | ext["ossrhTokenUsername"] = System.getenv('OSSRH_TOKEN_USERNAME') 15 | ext["ossrhTokenPassword"] = System.getenv('OSSRH_TOKEN_PASSWORD') 16 | ext["sonatypeStagingProfileId"] = System.getenv('SONATYPE_STAGING_PROFILE_ID') 17 | ext["signing.keyId"] = System.getenv('SIGNING_KEY_ID') 18 | ext["signing.password"] = System.getenv('SIGNING_PASSWORD') 19 | ext["signing.key"] = System.getenv('SIGNING_KEY') 20 | } 21 | 22 | nexusPublishing { 23 | repositories { 24 | sonatype { 25 | stagingProfileId = sonatypeStagingProfileId 26 | username = ossrhTokenUsername 27 | password = ossrhTokenPassword 28 | nexusUrl.set(uri("https://s01.oss.sonatype.org/service/local/")) 29 | snapshotRepositoryUrl.set(uri("https://s01.oss.sonatype.org/content/repositories/snapshots/")) 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /example-app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 15 | 16 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /recyclerview-element-adapter/src/main/java/com/elveum/elementadapter/delegate/DslDelegateEntry.kt: -------------------------------------------------------------------------------- 1 | package com.elveum.elementadapter.delegate 2 | 3 | import androidx.viewbinding.ViewBinding 4 | import com.elveum.elementadapter.adapter 5 | import com.elveum.elementadapter.addBinding 6 | import com.elveum.elementadapter.dsl.AdapterScope 7 | import com.elveum.elementadapter.dsl.AdapterScopeImpl 8 | import com.elveum.elementadapter.dsl.ConcreteItemTypeScope 9 | import com.elveum.elementadapter.simpleAdapter 10 | 11 | /** 12 | * Create an adapter delegate which can be used as a bridge between this library 13 | * and either other third-party libraries or your own custom adapters. 14 | * 15 | * This method is similar to [adapter] but it returns a delegate instead of adapter. 16 | */ 17 | fun adapterDelegate(block: AdapterScope.() -> Unit): AdapterDelegate { 18 | val adapterScope = AdapterScopeImpl() 19 | adapterScope.block() 20 | return adapterScope.toAdapterDelegate() 21 | } 22 | 23 | /** 24 | * Create an adapter delegate which can be used as a bridge between this library 25 | * and either other third-party libraries or your own custom adapters. 26 | * 27 | * This method is similar to [simpleAdapter] but it returns a delegate instead of adapter. 28 | */ 29 | inline fun simpleAdapterDelegate( 30 | noinline block: ConcreteItemTypeScope.() -> Unit 31 | ): AdapterDelegate { 32 | return adapterDelegate { addBinding(block) } 33 | } 34 | -------------------------------------------------------------------------------- /recyclerview-element-adapter/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.library' 3 | id 'org.jetbrains.kotlin.android' 4 | id 'org.jetbrains.dokka' 5 | } 6 | 7 | android { 8 | compileSdk 35 9 | namespace "com.elveum.elementadapter" 10 | defaultConfig { 11 | minSdk 21 12 | targetSdk 35 13 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 14 | consumerProguardFiles "consumer-rules.pro" 15 | } 16 | buildTypes { 17 | release { 18 | minifyEnabled false 19 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 20 | } 21 | } 22 | compileOptions { 23 | sourceCompatibility JavaVersion.VERSION_1_8 24 | targetCompatibility JavaVersion.VERSION_1_8 25 | } 26 | kotlinOptions { 27 | jvmTarget = '1.8' 28 | } 29 | buildFeatures { 30 | viewBinding true 31 | } 32 | publishing { 33 | singleVariant("release") { 34 | withSourcesJar() 35 | withJavadocJar() 36 | } 37 | } 38 | } 39 | 40 | kotlin { 41 | jvmToolchain(17) 42 | } 43 | 44 | dependencies { 45 | compileOnly 'androidx.recyclerview:recyclerview:1.3.2' 46 | } 47 | 48 | ext { 49 | PUBLISH_GROUP_ID = 'com.elveum' 50 | PUBLISH_VERSION = '0.7' 51 | PUBLISH_ARTIFACT_ID = 'element-adapter' 52 | PUBLISH_DESCRIPTION = "Another one easy-to-use adapter for RecyclerView" 53 | } 54 | 55 | apply from: "${rootProject.projectDir}/publish-library.gradle" -------------------------------------------------------------------------------- /recyclerview-element-adapter/src/main/java/com/elveum/elementadapter/ElementListAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.elveum.elementadapter 2 | 3 | import androidx.recyclerview.widget.* 4 | import com.elveum.elementadapter.dsl.BindingHolder 5 | import com.elveum.elementadapter.dsl.ElementWithIndex 6 | 7 | abstract class ElementListAdapter( 8 | diffCallback: DiffUtil.ItemCallback>, 9 | ) : RecyclerView.Adapter() { 10 | 11 | private val differ: AsyncListDiffer> = AsyncListDiffer( 12 | AdapterListUpdateCallback(this), 13 | AsyncDifferConfig.Builder(diffCallback).build() 14 | ) 15 | 16 | private val listener = 17 | AsyncListDiffer.ListListener { previousList, currentList -> 18 | this@ElementListAdapter.onCurrentListChanged( 19 | previousList, 20 | currentList 21 | ) 22 | } 23 | 24 | val currentList: List get() = differ.currentList.map { it.element } 25 | 26 | fun submitList(list: List?) { 27 | differ.submitList(list?.mapIndexed { index, t -> ElementWithIndex(index, t) }) 28 | } 29 | 30 | fun submitList(list: List?, commitCallback: Runnable?) { 31 | differ.submitList(list?.mapIndexed { index, t -> ElementWithIndex(index, t) }, commitCallback) 32 | } 33 | 34 | protected fun getItem(position: Int): T { 35 | return differ.currentList[position].element 36 | } 37 | 38 | override fun getItemCount(): Int { 39 | return differ.currentList.size 40 | } 41 | 42 | open fun onCurrentListChanged(previousList: List, currentList: List) {} 43 | 44 | } -------------------------------------------------------------------------------- /example-app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.application' 3 | id 'kotlin-android' 4 | id 'dagger.hilt.android.plugin' 5 | id 'kotlin-kapt' 6 | } 7 | 8 | android { 9 | compileSdk 35 10 | namespace "com.elveum.elementadapter.app" 11 | defaultConfig { 12 | applicationId "com.elveum.elementadapter.app" 13 | minSdk 21 14 | targetSdk 35 15 | versionCode 1 16 | versionName "1.0" 17 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 18 | } 19 | 20 | buildTypes { 21 | release { 22 | minifyEnabled false 23 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 24 | } 25 | } 26 | compileOptions { 27 | sourceCompatibility JavaVersion.VERSION_1_8 28 | targetCompatibility JavaVersion.VERSION_1_8 29 | } 30 | kotlinOptions { 31 | jvmTarget = '1.8' 32 | } 33 | buildFeatures { 34 | viewBinding true 35 | } 36 | } 37 | 38 | kotlin { 39 | jvmToolchain(17) 40 | } 41 | 42 | dependencies { 43 | implementation 'androidx.core:core-ktx:1.15.0' 44 | implementation 'androidx.appcompat:appcompat:1.7.0' 45 | implementation 'com.google.android.material:material:1.12.0' 46 | implementation 'androidx.constraintlayout:constraintlayout:2.2.0' 47 | implementation "com.google.dagger:hilt-android:$hilt_version" 48 | kapt "com.google.dagger:hilt-compiler:$hilt_version" 49 | implementation 'com.github.javafaker:javafaker:1.0.2' 50 | implementation 'androidx.activity:activity-ktx:1.9.3' 51 | implementation 'io.coil-kt:coil:2.7.0' 52 | implementation project(':recyclerview-element-adapter') 53 | } -------------------------------------------------------------------------------- /example-app/src/main/java/com/elveum/elementadapter/app/MainViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.elveum.elementadapter.app 2 | 3 | import androidx.lifecycle.LiveData 4 | import androidx.lifecycle.MutableLiveData 5 | import androidx.lifecycle.ViewModel 6 | import androidx.lifecycle.viewModelScope 7 | import com.elveum.elementadapter.app.model.Cat 8 | import com.elveum.elementadapter.app.model.CatsRepository 9 | import dagger.hilt.android.lifecycle.HiltViewModel 10 | import kotlinx.coroutines.flow.collectLatest 11 | import kotlinx.coroutines.launch 12 | import javax.inject.Inject 13 | 14 | @HiltViewModel 15 | class MainViewModel @Inject constructor( 16 | private val catsRepository: CatsRepository 17 | ) : ViewModel() { 18 | 19 | private val _catsLiveData = MutableLiveData>() 20 | val catsLiveData: LiveData> = _catsLiveData 21 | 22 | init { 23 | viewModelScope.launch { 24 | catsRepository.getCats().collectLatest { catsList -> 25 | _catsLiveData.value = mapCats(catsList) 26 | } 27 | } 28 | } 29 | 30 | fun deleteCat(cat: CatListItem.Cat) { 31 | catsRepository.delete(cat.originCat) 32 | } 33 | 34 | fun toggleFavorite(cat: CatListItem.Cat) { 35 | catsRepository.toggleIsFavorite(cat.originCat) 36 | } 37 | 38 | private fun mapCats(cats: List): List { 39 | val size = 10 40 | return cats 41 | .chunked(size) 42 | .mapIndexed { index, list -> 43 | val fromIndex = index * size + 1 44 | val toIndex = fromIndex + list.size - 1 45 | val header: CatListItem = CatListItem.Header(index, fromIndex, toIndex) 46 | listOf(header) + list.map { CatListItem.Cat(it) } 47 | } 48 | .flatten() 49 | } 50 | 51 | } -------------------------------------------------------------------------------- /example-app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /recyclerview-element-adapter/src/main/java/com/elveum/elementadapter/dsl/ItemCallbackDelegate.kt: -------------------------------------------------------------------------------- 1 | package com.elveum.elementadapter.dsl 2 | 3 | import androidx.recyclerview.widget.DiffUtil 4 | import androidx.viewbinding.ViewBinding 5 | 6 | typealias CompareItemCallback = IndexScope.(oldItem: T, newItem: T) -> Boolean 7 | 8 | typealias ChangePayloadCallback = IndexScope.(oldItem: T, newItem: T) -> Any? 9 | 10 | internal class ItemCallbackDelegate( 11 | private val adapterScope: AdapterScope, 12 | private val concreteItemTypeScopes: List> 13 | ) : DiffUtil.ItemCallback>() { 14 | 15 | override fun areItemsTheSame(oldItem: ElementWithIndex, newItem: ElementWithIndex): Boolean { 16 | val oldScope = findScope(oldItem.element) 17 | val newScope = findScope(newItem.element) 18 | val indexScope = IndexScopeImpl(oldItem, newItem) 19 | if (oldScope !== newScope) { 20 | return adapterScope.defaultAreItemsSame.invoke(indexScope, oldItem.element, newItem.element) 21 | } 22 | return newScope.areItemsSame(indexScope, oldItem.element, newItem.element) 23 | } 24 | 25 | override fun areContentsTheSame(oldItem: ElementWithIndex, newItem: ElementWithIndex): Boolean { 26 | val oldScope = findScope(oldItem.element) 27 | val newScope = findScope(newItem.element) 28 | val indexScope = IndexScopeImpl(oldItem, newItem) 29 | if (oldScope !== newScope) { 30 | return adapterScope.defaultAreContentsSame.invoke(indexScope, oldItem.element, newItem.element) 31 | } 32 | return newScope.areContentsSame(indexScope, oldItem.element, newItem.element) 33 | } 34 | 35 | override fun getChangePayload(oldItem: ElementWithIndex, newItem: ElementWithIndex): Any? { 36 | val oldScope = findScope(oldItem.element) 37 | val newScope = findScope(newItem.element) 38 | val indexScope = IndexScopeImpl(oldItem, newItem) 39 | if (oldScope !== newScope) { 40 | return adapterScope.defaultChangePayload.invoke(indexScope, oldItem.element, newItem.element) 41 | } 42 | return newScope.changePayload(indexScope, oldItem.element, newItem.element) 43 | } 44 | 45 | private fun findScope(item: T): ConcreteItemTypeScopeImpl { 46 | return concreteItemTypeScopes.first { 47 | it.predicate(item) 48 | } 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /publish-library.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'maven-publish' 2 | apply plugin: 'signing' 3 | 4 | task androidSourcesJar(type: Jar) { 5 | archiveClassifier.set('sources') 6 | if (project.plugins.findPlugin("com.android.library")) { 7 | from android.sourceSets.main.java.srcDirs 8 | from android.sourceSets.main.kotlin.srcDirs 9 | } else { 10 | from sourceSets.main.java.srcDirs 11 | from sourceSets.main.kotlin.srcDirs 12 | } 13 | } 14 | 15 | artifacts { 16 | archives androidSourcesJar 17 | } 18 | 19 | group = PUBLISH_GROUP_ID 20 | version = PUBLISH_VERSION 21 | 22 | afterEvaluate { 23 | publishing { 24 | publications { 25 | release(MavenPublication) { 26 | groupId PUBLISH_GROUP_ID 27 | artifactId PUBLISH_ARTIFACT_ID 28 | version PUBLISH_VERSION 29 | if (project.plugins.findPlugin("com.android.library")) { 30 | from components.release 31 | } else { 32 | from components.java 33 | } 34 | 35 | pom { 36 | name = PUBLISH_ARTIFACT_ID 37 | description = 'Another one elementary and easy-to-use adapter for RecyclerView' 38 | url = 'https://github.com/romychab/element-adapter' 39 | licenses { 40 | license { 41 | name = 'Apache License 2.0' 42 | url = 'https://github.com/romychab/element-adapter/blob/main/LICENSE' 43 | } 44 | } 45 | developers { 46 | developer { 47 | id = 'romychab' 48 | name = 'Roman Andrushchenko' 49 | email = 'rom.andrushchenko@gmail.com' 50 | } 51 | } 52 | scm { 53 | connection = 'scm:git:github.com/romychab/element-adapter.git' 54 | developerConnection = 'scm:git:ssh://github.com/romychab/element-adapter.git' 55 | url = 'https://github.com/romychab/element-adapter/tree/main' 56 | } 57 | } 58 | } 59 | } 60 | } 61 | } 62 | 63 | signing { 64 | useInMemoryPgpKeys( 65 | rootProject.ext["signing.keyId"], 66 | rootProject.ext["signing.key"], 67 | rootProject.ext["signing.password"], 68 | ) 69 | sign publishing.publications 70 | } -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /example-app/src/main/res/layout/item_cat.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 19 | 20 | 35 | 36 | 49 | 50 | 60 | 61 | 71 | 72 | -------------------------------------------------------------------------------- /recyclerview-element-adapter/src/main/java/com/elveum/elementadapter/dsl/AdapterScope.kt: -------------------------------------------------------------------------------- 1 | package com.elveum.elementadapter.dsl 2 | 3 | import android.view.LayoutInflater 4 | import android.view.ViewGroup 5 | import androidx.viewbinding.ViewBinding 6 | import com.elveum.elementadapter.delegate.AdapterDelegate 7 | import com.elveum.elementadapter.delegate.AdapterDelegateImpl 8 | 9 | interface AdapterScope { 10 | 11 | /** 12 | * A callback for checking whether items are the same or not. 13 | * Usually the callback should compare identifiers 14 | */ 15 | var defaultAreItemsSame: CompareItemCallback 16 | 17 | /** 18 | * A callback for checking whether items' contents are equal or 19 | * not. 20 | */ 21 | var defaultAreContentsSame: CompareItemCallback 22 | 23 | /** 24 | * A callback for creating payloads which indicate the concrete difference 25 | * between an old item and a new item. May be useful for animation, 26 | * optimizations, etc. 27 | */ 28 | var defaultChangePayload: ChangePayloadCallback 29 | 30 | } 31 | 32 | class AdapterScopeImpl internal constructor() : AdapterScope { 33 | 34 | private val concreteTypeScopes = mutableListOf>() 35 | 36 | override var defaultAreItemsSame: CompareItemCallback = { oldItem, newItem -> oldItem === newItem } 37 | override var defaultAreContentsSame: CompareItemCallback = { oldItem, newItem -> oldItem == newItem } 38 | override var defaultChangePayload: ChangePayloadCallback = { _, _ -> null } 39 | 40 | fun addBinding( 41 | clazz: Class, 42 | predicate: (T) -> Boolean, 43 | block: ConcreteItemTypeScope.() -> Unit 44 | ) { 45 | val concreteItemTypeScopeImpl = ConcreteItemTypeScopeImpl( 46 | areItemsSame = this.defaultAreItemsSame as CompareItemCallback, 47 | areContentsSame = this.defaultAreContentsSame as CompareItemCallback, 48 | changePayload = this.defaultChangePayload as ChangePayloadCallback, 49 | bindingCreator = { inflater, parent -> 50 | instantiateBinding(clazz, inflater, parent) 51 | }, 52 | predicate = predicate 53 | ) 54 | concreteItemTypeScopeImpl.block() 55 | concreteTypeScopes.add( 56 | concreteItemTypeScopeImpl as ConcreteItemTypeScopeImpl 57 | ) 58 | } 59 | 60 | fun toAdapterDelegate(): AdapterDelegate { 61 | if (concreteTypeScopes.isEmpty()) { 62 | throw IllegalStateException("Have you added at least one addBinding { ... } / universalBinding { ... } section?") 63 | } 64 | 65 | return AdapterDelegateImpl( 66 | concreteTypeScopes, 67 | ItemCallbackDelegate(this, concreteTypeScopes) 68 | ) 69 | } 70 | 71 | private fun instantiateBinding( 72 | clazz: Class, 73 | inflater: LayoutInflater, 74 | parent: ViewGroup 75 | ): B { 76 | val method = clazz.getMethod("inflate", LayoutInflater::class.java, ViewGroup::class.java, Boolean::class.java) 77 | return method.invoke(null, inflater, parent, false) as B 78 | } 79 | 80 | } 81 | -------------------------------------------------------------------------------- /example-app/src/main/java/com/elveum/elementadapter/app/SimpleMultiChoiceActivity.kt: -------------------------------------------------------------------------------- 1 | package com.elveum.elementadapter.app 2 | 3 | import android.graphics.drawable.ColorDrawable 4 | import android.os.Bundle 5 | import androidx.appcompat.app.AppCompatActivity 6 | import androidx.recyclerview.widget.DefaultItemAnimator 7 | import androidx.recyclerview.widget.LinearLayoutManager 8 | import com.elveum.elementadapter.SimpleBindingAdapter 9 | import com.elveum.elementadapter.app.databinding.ActivitySimpleMultichoiceBinding 10 | import com.elveum.elementadapter.app.databinding.ItemSelectableBinding 11 | import com.elveum.elementadapter.getColor 12 | import com.elveum.elementadapter.simpleAdapter 13 | 14 | /* 15 | This is a very simple example of multi-choice list. 16 | 17 | Please note that in real projects it's better to: 18 | - use immutable entities ('val isChecked' instead of 'var isChecked') 19 | - implement multi-choice logic e.g. in the view-model 20 | - hold data list at least in the view-model, not in the activity 21 | */ 22 | 23 | data class SelectableItem( 24 | val id: Long, 25 | val name: String, 26 | var isChecked: Boolean = false 27 | ) 28 | 29 | class SimpleMultiChoiceActivity : AppCompatActivity() { 30 | 31 | private val items = listOf( 32 | SelectableItem(1, "Charlie"), 33 | SelectableItem(2, "Millie"), 34 | SelectableItem(3, "Lucky"), 35 | SelectableItem(4, "Poppy"), 36 | SelectableItem(5, "Oliver"), 37 | SelectableItem(6, "Sam"), 38 | SelectableItem(7, "Tiger"), 39 | ) 40 | 41 | private val adapter: SimpleBindingAdapter by lazy { createAdapter() } 42 | 43 | private val binding by lazy { 44 | ActivitySimpleMultichoiceBinding.inflate(layoutInflater) 45 | } 46 | 47 | override fun onCreate(savedInstanceState: Bundle?) { 48 | super.onCreate(savedInstanceState) 49 | setContentView(binding.root) 50 | with(binding) { 51 | multiChoiceRecyclerView.layoutManager = LinearLayoutManager(this@SimpleMultiChoiceActivity) 52 | (multiChoiceRecyclerView.itemAnimator as? DefaultItemAnimator)?.supportsChangeAnimations = false 53 | multiChoiceRecyclerView.adapter = adapter 54 | } 55 | adapter.submitList(items) 56 | updateTotalSelected() 57 | } 58 | 59 | private fun updateTotalSelected() { 60 | val count = items.count { it.isChecked } 61 | binding.totalSelectedTextView.text = getString(R.string.total_selected, count) 62 | } 63 | 64 | private fun createAdapter() = simpleAdapter { 65 | areContentsSame = { oldItem, newItem -> oldItem == newItem } // this works fine for data classes 66 | areItemsSame = { oldItem, newItem -> oldItem.id == newItem.id } 67 | 68 | bind { item -> 69 | nameTextView.text = item.name 70 | checkbox.isChecked = item.isChecked 71 | if (item.isChecked) { 72 | root.background = ColorDrawable(getColor(R.color.selected_background)) 73 | } else { 74 | root.background = null 75 | } 76 | } 77 | 78 | listeners { 79 | checkbox.onClick { item -> 80 | item.isChecked = !item.isChecked 81 | val indexToUpdate = adapter.currentList.indexOfFirst { item.id == it.id } 82 | adapter.notifyItemChanged(indexToUpdate) 83 | updateTotalSelected() 84 | } 85 | } 86 | } 87 | } -------------------------------------------------------------------------------- /example-app/src/main/java/com/elveum/elementadapter/app/model/CatsRepository.kt: -------------------------------------------------------------------------------- 1 | package com.elveum.elementadapter.app.model 2 | 3 | import com.github.javafaker.Faker 4 | import kotlinx.coroutines.flow.* 5 | import java.util.* 6 | import javax.inject.Inject 7 | import javax.inject.Singleton 8 | 9 | @Singleton 10 | class CatsRepository @Inject constructor() { 11 | 12 | private val photos = listOf( 13 | "https://images.unsplash.com/photo-1615454299901-de13b71ecaae?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8Y2F0fHx8fHx8MTY2MjQ3ODAyNw&ixlib=rb-1.2.1&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=1080", 14 | "https://images.unsplash.com/photo-1604916287784-c324202b3205?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8Y2F0fHx8fHx8MTY2MjQ3ODAzMQ&ixlib=rb-1.2.1&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=1080", 15 | "https://images.unsplash.com/photo-1596854372407-baba7fef6e51?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8Y2F0fHx8fHx8MTY2MjQ3ODAzOA&ixlib=rb-1.2.1&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=1080", 16 | "https://images.unsplash.com/photo-1608032364895-0da67af36cd2?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8Y2F0fHx8fHx8MTY2MjQ3ODA0Mw&ixlib=rb-1.2.1&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=1080", 17 | "https://images.unsplash.com/photo-1571988840298-3b5301d5109b?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8Y2F0fHx8fHx8MTY2MjQ3ODA0Ng&ixlib=rb-1.2.1&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=1080", 18 | "https://images.unsplash.com/photo-1494256997604-768d1f608cac?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8Y2F0fHx8fHx8MTY2MjQ3ODEyMQ&ixlib=rb-1.2.1&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=1080", 19 | "https://images.unsplash.com/flagged/photo-1557427161-4701a0fa2f42?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8Y2F0fHx8fHx8MTY2MjQ3ODEyNQ&ixlib=rb-1.2.1&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=1080", 20 | "https://images.unsplash.com/photo-1640384974326-3e72680e0fb3?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8Y2F0fHx8fHx8MTY2MjQ3ODI0MA&ixlib=rb-1.2.1&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=1080", 21 | "https://images.unsplash.com/photo-1623876159473-5e79be88f7ac?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8Y2F0fHx8fHx8MTY2MjQ3ODIzMw&ixlib=rb-1.2.1&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=1080" 22 | ) 23 | 24 | private val random = Random(42) 25 | private val faker = Faker.instance(random) 26 | 27 | private val catsFlow = MutableStateFlow( 28 | List(100) { index -> randomCat(id = index + 1L) } 29 | ) 30 | 31 | fun getCats(): Flow> { 32 | return catsFlow 33 | } 34 | 35 | fun delete(cat: Cat) { 36 | catsFlow.update { oldList -> 37 | oldList.filter { it.id != cat.id } 38 | } 39 | } 40 | 41 | fun toggleIsFavorite(cat: Cat) { 42 | catsFlow.update { oldList -> 43 | oldList.map { 44 | if (it.id == cat.id) { 45 | cat.copy(isFavorite = !cat.isFavorite) 46 | } else { 47 | it 48 | } 49 | } 50 | } 51 | } 52 | 53 | private fun randomCat(id: Long): Cat = Cat( 54 | id = id, 55 | name = faker.cat().name(), 56 | photoUrl = photos[(id.rem(photos.size)).toInt()], 57 | isFavorite = false, 58 | description = faker.lorem().sentences(2).joinToString(" ") 59 | ) 60 | 61 | } -------------------------------------------------------------------------------- /recyclerview-element-adapter/src/main/java/com/elveum/elementadapter/DslEntry.kt: -------------------------------------------------------------------------------- 1 | package com.elveum.elementadapter 2 | 3 | import androidx.recyclerview.widget.ListAdapter 4 | import androidx.viewbinding.ViewBinding 5 | import com.elveum.elementadapter.delegate.adapterDelegate 6 | import com.elveum.elementadapter.dsl.* 7 | import com.elveum.elementadapter.dsl.AdapterScopeImpl 8 | 9 | 10 | typealias SimpleBindingAdapter = ElementListAdapter 11 | 12 | /** 13 | * Crate an instance of a [ListAdapter] for the specified base type [T]. 14 | * This method should be used when you need to support more than 1 view binding type. 15 | * Otherwise it's better to use [simpleAdapter] instead. 16 | * 17 | * Usage example. Let's image you have a base class `BaseListItem` and the following 18 | * subclasses: 19 | * - `ListHeaderItem` 20 | * - `ListContentItem` 21 | * 22 | * And you want to create a list which displays those items in a different way: like 23 | * you are going to use 2 layouts: 24 | * - one for header items (`R.layout.item_header` aka `ItemHeaderBinding`) 25 | * - and one for content items (`R.layout.item_content` aka `ItemContentBinding`) 26 | 27 | * Let's do this: 28 | * 29 | * ``` 30 | * val adapter = adapter { 31 | * addBinding { 32 | * areItemsSame = { oldHeader, newHeader -> oldHeader.id == newHeader.id } 33 | * areContentsSame = { oldHeader, newHeader -> oldHeader == newHeader } 34 | * bind { header -> 35 | * titleTextView.text = header.title 36 | * descriptionTextView.text = header.description 37 | * } 38 | * listeners { 39 | * root.onClick { header -> 40 | * viewModel.toggle(header) 41 | * } 42 | * } 43 | * } 44 | * 45 | * addBinding { 46 | * areItemsSame = { oldContent, newContent -> oldContent.id == newContent.id } 47 | * areContentsSame = { oldContent, newContent -> oldContent == newContent } 48 | * bind { contentItem -> 49 | * contentTextView.text = contentItem.content 50 | * previewImageView.setImageResource(contentItem.previewImageRes) 51 | * } 52 | * listeners { 53 | * deleteButton.onClick { contentItem -> 54 | * viewModel.delete(contentItem) 55 | * } 56 | * root.onClick { contentItem -> 57 | * viewModel.openDetails(contentItem) 58 | * } 59 | * } 60 | * } 61 | * } 62 | * ``` 63 | */ 64 | fun adapter(block: AdapterScope.() -> Unit): SimpleBindingAdapter { 65 | return MultiAdapter(adapterDelegate(block)) 66 | } 67 | 68 | /** 69 | * Add a new view binding type to the adapter. 70 | */ 71 | inline fun AdapterScope.addBinding( 72 | noinline block: ConcreteItemTypeScope.() -> Unit 73 | ) { 74 | (this as AdapterScopeImpl).addBinding( 75 | B::class.java, 76 | { item -> item is T }, 77 | block 78 | ) 79 | } 80 | 81 | /** 82 | * Create a [ListAdapter] which binds items of the type [T] to 83 | * the specified view binding of the type [B]. 84 | * Usage example: 85 | * ``` 86 | * val adapter = simpleAdapter { 87 | * areItemsSame = { oldCat, newCat -> oldCat.id == newCat.id } 88 | * areContentsSame = { oldCat, newCat -> oldCat == newCat } 89 | * bind { cat -> 90 | * catNameTextView.text = cat.name 91 | * catDescriptionTextView.text = cat.description 92 | * } 93 | * listeners { 94 | * root.onClick { cat -> 95 | * showCatDetails(cat) 96 | * } 97 | * } 98 | * } 99 | * 100 | * recyclerView.adapter = adapter 101 | * 102 | * viewModel.catsLiveData.observer(viewLifecycleOwner) { list -> 103 | * adapter.submitList(list) 104 | * } 105 | * ``` 106 | */ 107 | inline fun simpleAdapter( 108 | noinline block: ConcreteItemTypeScope.() -> Unit 109 | ): SimpleBindingAdapter { 110 | return adapter { addBinding(block) } 111 | } 112 | -------------------------------------------------------------------------------- /recyclerview-element-adapter/src/main/java/com/elveum/elementadapter/delegate/AdapterDelegate.kt: -------------------------------------------------------------------------------- 1 | package com.elveum.elementadapter.delegate 2 | 3 | import android.view.LayoutInflater 4 | import android.view.View 5 | import android.view.ViewGroup 6 | import androidx.recyclerview.widget.DiffUtil 7 | import androidx.recyclerview.widget.DiffUtil.ItemCallback 8 | import androidx.recyclerview.widget.RecyclerView 9 | import androidx.viewbinding.ViewBinding 10 | import com.elveum.elementadapter.R 11 | import com.elveum.elementadapter.dsl.BindingHolder 12 | import com.elveum.elementadapter.dsl.ConcreteItemTypeScopeImpl 13 | import com.elveum.elementadapter.dsl.ElementWithIndex 14 | 15 | interface AdapterDelegate { 16 | 17 | /** 18 | * Use the [DiffUtil.ItemCallback] returned by this method in your own 19 | * adapter. 20 | */ 21 | fun itemCallback(): DiffUtil.ItemCallback> 22 | 23 | /** 24 | * Use this item callback instead of [itemCallback] if you don't want to 25 | * support index() method. 26 | */ 27 | fun noIndexItemCallback(): DiffUtil.ItemCallback 28 | 29 | /** 30 | * Call this method from [RecyclerView.Adapter.getItemViewType] 31 | */ 32 | fun getItemViewType(item: T): Int 33 | 34 | /** 35 | * Call this method from [RecyclerView.Adapter.onCreateViewHolder] 36 | */ 37 | fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder 38 | 39 | /** 40 | * Call this method from [RecyclerView.Adapter.onBindViewHolder] 41 | */ 42 | fun onBindViewHolder(holder: BindingHolder, position: Int, item: T, payloads: List = emptyList()) 43 | 44 | } 45 | 46 | internal class AdapterDelegateImpl( 47 | private val concreteItemTypeScopesImpl: List>, 48 | private val itemCallback: DiffUtil.ItemCallback> 49 | ) : AdapterDelegate { 50 | 51 | override fun itemCallback(): DiffUtil.ItemCallback> { 52 | return itemCallback 53 | } 54 | 55 | override fun noIndexItemCallback(): DiffUtil.ItemCallback { 56 | return itemWithoutIndexCallback 57 | } 58 | 59 | override fun getItemViewType(item: T): Int { 60 | val index = concreteItemTypeScopesImpl.indexOfFirst { it.predicate(item) } 61 | if (index == -1) { 62 | throw IllegalStateException("Have you covered all subtypes in the adapter { ... } " + 63 | "by using addBinding<${item::class.java.canonicalName}, YOUR_BINDING> { ... } section?") 64 | } 65 | return index 66 | } 67 | 68 | override fun onCreateViewHolder( 69 | parent: ViewGroup, 70 | viewType: Int 71 | ): BindingHolder { 72 | val inflater = LayoutInflater.from(parent.context) 73 | val concreteTypeScope = concreteItemTypeScopesImpl[viewType] 74 | val viewBinding = concreteTypeScope.bindingCreator(inflater, parent) 75 | concreteTypeScope.uponCreating = true 76 | concreteTypeScope.listenersBlock?.invoke(viewBinding) 77 | concreteTypeScope.uponCreating = false 78 | return BindingHolder(viewBinding) 79 | } 80 | 81 | override fun onBindViewHolder(holder: BindingHolder, position: Int, item: T, payloads: List) { 82 | holder.binding.root.setTag(R.id.element_entity_tag, item) 83 | holder.binding.root.setTag(R.id.element_index_tag, position) 84 | val concreteTypeScope = concreteItemTypeScopesImpl.first { it.predicate(item) } 85 | concreteTypeScope.viewsWithListeners.forEach { 86 | holder.binding.root.findViewById(it)?.setTag(R.id.element_entity_tag, item) 87 | holder.binding.root.findViewById(it)?.setTag(R.id.element_index_tag, position) 88 | } 89 | concreteTypeScope.currentIndex = position 90 | concreteTypeScope.bindBlock?.invoke(holder.binding, item, payloads) 91 | concreteTypeScope.currentIndex = null 92 | } 93 | 94 | private val itemWithoutIndexCallback = object : ItemCallback() { 95 | override fun areItemsTheSame(oldItem: T, newItem: T): Boolean { 96 | val oldIndexItem = ElementWithIndex(-1, oldItem) 97 | val newIndexItem = ElementWithIndex(-1, newItem) 98 | return itemCallback.areItemsTheSame(oldIndexItem, newIndexItem) 99 | } 100 | 101 | override fun areContentsTheSame(oldItem: T, newItem: T): Boolean { 102 | val oldIndexItem = ElementWithIndex(-1, oldItem) 103 | val newIndexItem = ElementWithIndex(-1, newItem) 104 | return itemCallback.areContentsTheSame(oldIndexItem, newIndexItem) 105 | } 106 | } 107 | 108 | } -------------------------------------------------------------------------------- /recyclerview-element-adapter/src/main/java/com/elveum/elementadapter/dsl/ConcreteItemTypeScope.kt: -------------------------------------------------------------------------------- 1 | package com.elveum.elementadapter.dsl 2 | 3 | import android.view.LayoutInflater 4 | import android.view.View 5 | import android.view.ViewGroup 6 | import androidx.viewbinding.ViewBinding 7 | 8 | interface ConcreteItemTypeScope { 9 | 10 | /** 11 | * A callback for checking whether items are the same or not. 12 | * Usually the callback should compare identifiers 13 | */ 14 | var areItemsSame: CompareItemCallback 15 | 16 | /** 17 | * A callback for checking whether items' contents are equal or 18 | * not. 19 | */ 20 | var areContentsSame: CompareItemCallback 21 | 22 | /** 23 | * A callback for creating payloads which indicate the concrete difference 24 | * between an old item and a new item. May be useful for animation, 25 | * optimizations, etc. 26 | */ 27 | var changePayload: ChangePayloadCallback 28 | 29 | /** 30 | * Start a binding section where you can assign data from your model 31 | * list item to the view binding. 32 | */ 33 | fun bind(block: B.(T) -> Unit) 34 | 35 | /** 36 | * Start a binding section where you can: 37 | * 1) assign data from your model list item to the view binding. 38 | * 2) use RecyclerView payloads for updating/animating views 39 | */ 40 | fun bindWithPayloads(block: B.(item: T, payloads: List) -> Unit) 41 | 42 | /** 43 | * Start a listeners section where you can assign click listeners. Now 44 | * [onClick], [onLongClick] and [onCustomListener] are supported. 45 | */ 46 | fun listeners(block: B.() -> Unit) 47 | 48 | fun View.onClick(listener: CustomListenerIndexScope.(T) -> Unit) 49 | 50 | fun View.onLongClick(listener: CustomListenerIndexScope.(T) -> Boolean) 51 | 52 | /** 53 | * Setup custom view listener. 54 | * 55 | * Usage example: 56 | * 57 | * ``` 58 | * view.onCustomListener { 59 | * view.setOnDoubleTapListener { 60 | * val item = item() // get the data attached to this view 61 | * // do something here 62 | * } 63 | * } 64 | * ``` 65 | */ 66 | fun View.onCustomListener(block: CustomListenerScope.() -> Unit) 67 | 68 | /** 69 | * Get the index of an element being rendered. 70 | * This method can be called within bind { ... } block. 71 | * 72 | * Please note that you need to customize `areContentsSame` block and 73 | * take into account index changes there if you decide to use this method 74 | * in your `bind { ... }` blocks. 75 | */ 76 | fun index(): Int 77 | 78 | } 79 | 80 | internal class ConcreteItemTypeScopeImpl( 81 | override var areContentsSame: CompareItemCallback, 82 | override var areItemsSame: CompareItemCallback, 83 | override var changePayload: ChangePayloadCallback, 84 | val bindingCreator: (inflater: LayoutInflater, parent: ViewGroup) -> B, 85 | val predicate: (T) -> Boolean 86 | ) : ConcreteItemTypeScope { 87 | 88 | var bindBlock: (B.(item: T, payloads: List) -> Unit)? = null 89 | var listenersBlock: (B.() -> Unit)? = null 90 | var uponCreating: Boolean = false 91 | var currentIndex: Int? = null 92 | 93 | val viewsWithListeners = mutableSetOf() 94 | 95 | override fun bind(block: B.(T) -> Unit) { 96 | this.bindBlock = { item, _ -> 97 | block(item) 98 | } 99 | } 100 | 101 | override fun bindWithPayloads(block: B.(item: T, payloads: List) -> Unit) { 102 | this.bindBlock = block 103 | } 104 | 105 | override fun listeners(block: B.() -> Unit) { 106 | this.listenersBlock = block 107 | } 108 | 109 | override fun index(): Int { 110 | return currentIndex ?: throw IllegalStateException("index() can be called only within bind { ... } block") 111 | } 112 | 113 | override fun View.onClick(listener: CustomListenerIndexScope.(T) -> Unit) { 114 | onCustomListener { 115 | setOnClickListener { 116 | listener(item()) 117 | } 118 | } 119 | } 120 | 121 | override fun View.onLongClick(listener: CustomListenerIndexScope.(T) -> Boolean) { 122 | onCustomListener { 123 | setOnLongClickListener { 124 | listener(item()) 125 | } 126 | } 127 | } 128 | 129 | override fun View.onCustomListener(block: CustomListenerScope.() -> Unit) { 130 | assertListenerCall() 131 | val scope = CustomListenerScopeImpl(this) 132 | scope.block() 133 | } 134 | 135 | private fun View.assertListenerCall() { 136 | if (!uponCreating) throw IllegalStateException("View.onClick() should be called only within listeners { ... } section.") 137 | viewsWithListeners.add(id) 138 | } 139 | 140 | } 141 | -------------------------------------------------------------------------------- /example-app/src/main/java/com/elveum/elementadapter/app/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.elveum.elementadapter.app 2 | 3 | import android.os.Bundle 4 | import android.view.animation.Animation 5 | import android.view.animation.AnimationSet 6 | import android.view.animation.ScaleAnimation 7 | import android.widget.Toast 8 | import androidx.activity.viewModels 9 | import androidx.appcompat.app.AppCompatActivity 10 | import androidx.recyclerview.widget.DefaultItemAnimator 11 | import androidx.recyclerview.widget.LinearLayoutManager 12 | import coil.load 13 | import coil.transform.CircleCropTransformation 14 | import com.elveum.elementadapter.* 15 | import com.elveum.elementadapter.app.databinding.ActivityMainBinding 16 | import com.elveum.elementadapter.app.databinding.ItemCatBinding 17 | import com.elveum.elementadapter.app.databinding.ItemHeaderBinding 18 | import com.elveum.elementadapter.app.model.Cat 19 | import dagger.hilt.android.AndroidEntryPoint 20 | 21 | @AndroidEntryPoint 22 | class MainActivity : AppCompatActivity() { 23 | 24 | private val viewModel by viewModels() 25 | 26 | override fun onCreate(savedInstanceState: Bundle?) { 27 | super.onCreate(savedInstanceState) 28 | val binding = ActivityMainBinding.inflate(layoutInflater) 29 | setContentView(binding.root) 30 | 31 | val adapter = createCatsAdapter() 32 | (binding.catsRecyclerView.itemAnimator as? DefaultItemAnimator)?.supportsChangeAnimations = false 33 | binding.catsRecyclerView.layoutManager = LinearLayoutManager(this) 34 | binding.catsRecyclerView.adapter = adapter 35 | viewModel.catsLiveData.observe(this, adapter::submitList) 36 | } 37 | 38 | // Example of adapter { ... } usage 39 | private fun createCatsAdapter() = adapter { 40 | addBinding { 41 | areItemsSame = { oldCat, newCat -> oldCat.id == newCat.id } 42 | changePayload = { oldCat, newCat -> 43 | if (!oldCat.isFavorite && newCat.isFavorite) { 44 | FAVORITE_FLAG_CHANGED 45 | } else { 46 | NO_ANIMATION 47 | } 48 | } 49 | 50 | bindWithPayloads { cat, payloads -> 51 | catNameTextView.text = cat.name 52 | catDescriptionTextView.text = cat.description 53 | catImageView.load(cat.photoUrl) { 54 | transformations(CircleCropTransformation()) 55 | placeholder(R.drawable.circle) 56 | } 57 | favoriteImageView.setImageResource( 58 | if (cat.isFavorite) R.drawable.ic_favorite 59 | else R.drawable.ic_favorite_not 60 | ) 61 | favoriteImageView.setTintColor( 62 | if (cat.isFavorite) R.color.highlighted_action 63 | else R.color.action 64 | ) 65 | if (payloads.any { it == FAVORITE_FLAG_CHANGED }) { 66 | favoriteImageView.startAnimation(animationForFavoriteFlag) 67 | } 68 | } 69 | 70 | listeners { 71 | deleteImageView.onClick { viewModel.deleteCat(it) } 72 | favoriteImageView.onClick { viewModel.toggleFavorite(it) } 73 | root.onClick { cat -> 74 | Toast.makeText(context(), "${cat.name} meow-meows, index: ${index()}", Toast.LENGTH_SHORT).show() 75 | } 76 | } 77 | } 78 | 79 | addBinding { 80 | areItemsSame = { oldHeader, newHeader -> oldHeader.headerId == newHeader.headerId } 81 | bind { header -> 82 | titleTextView.text = getString(R.string.cats, header.fromIndex, header.toIndex) 83 | } 84 | } 85 | 86 | } 87 | 88 | // Example of simpleAdapter { ... } usage: 89 | private fun createOnlyCatsAdapter() = simpleAdapter { 90 | areItemsSame = { oldCat, newCat -> oldCat.id == newCat.id } 91 | areContentsSame = { oldCat, newCat -> oldCat == newCat } 92 | bind { cat -> 93 | catNameTextView.text = cat.name 94 | catDescriptionTextView.text = cat.description 95 | } 96 | listeners { 97 | deleteImageView.onClick { cat -> 98 | // delete the cat here 99 | } 100 | root.onClick { cat -> 101 | Toast.makeText(context(), "${cat.name} meow-meows", Toast.LENGTH_SHORT).show() 102 | } 103 | } 104 | } 105 | 106 | private val animationForFavoriteFlag by lazy(LazyThreadSafetyMode.NONE) { 107 | val toSmall = ScaleAnimation(1f, 0.8f, 1f, 0.8f, Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f) 108 | val smallToLarge = ScaleAnimation(1f, 1.5f, 1f, 1.5f, Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f) 109 | val largeToNormal = ScaleAnimation(1f, 0.83f, 1f, 0.83f, Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f) 110 | val animationSet = AnimationSet(true).apply { 111 | addAnimation(toSmall) 112 | addAnimation(smallToLarge) 113 | addAnimation(largeToNormal) 114 | } 115 | animationSet.animations.forEachIndexed { index, animation -> 116 | animation.duration = 100L 117 | animation.startOffset = index * 100L 118 | } 119 | animationSet 120 | } 121 | 122 | private companion object { 123 | val FAVORITE_FLAG_CHANGED = Any() 124 | val NO_ANIMATION = Any() 125 | } 126 | } -------------------------------------------------------------------------------- /example-app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Elementary RecyclerView Adapter 2 | 3 | [![Maven Central](https://img.shields.io/maven-central/v/com.elveum/element-adapter.svg?label=Maven%20Central)](https://elveum.com/sh/adapter) 4 | ![API](https://img.shields.io/badge/API-21%2B-brightgreen.svg?style=flat) 5 | [![License: Apache 2](https://img.shields.io/github/license/romychab/element-adapter)](LICENSE) 6 | 7 | > Another one easy-to-use adapter for `RecyclerView` :rocket: 8 | 9 | __Features:__ 10 | 11 | - DSL-like methods for building adapters similar to Jetpack Compose but designed for `RecyclerView` 12 | - no view holders; bind any model object directly to auto-generated [view bindings](https://developer.android.com/topic/libraries/view-binding) 13 | - support of multiple item types 14 | - build-in click listeners 15 | - the library uses `DiffUtil` under the hood for fast updating of your list 16 | - support of integration either with your own adapters or with third-party adapters 17 | - upd: now payloads are supported starting from v0.4 18 | 19 | ![cats-screenshot](docs/screenshot.png) 20 | 21 | ## Installation 22 | 23 | - Add [View Binding](https://developer.android.com/topic/libraries/view-binding) to 24 | your `build.gradle` file: 25 | 26 | ``` 27 | android { 28 | ... 29 | buildFeatures { 30 | viewBinding true 31 | } 32 | ... 33 | } 34 | ``` 35 | 36 | - Add the library to the `dependencies` section of your `build.gradle` script: 37 | 38 | ``` 39 | dependencies { 40 | ... 41 | implementation 'com.elveum:element-adapter:0.7' 42 | } 43 | ``` 44 | 45 | ## Usage example 46 | 47 | This library adds a couple of methods for easier implementation of `ListAdapter`. It relies on [View Binding](https://developer.android.com/topic/libraries/view-binding) so you don't need to create view holders. 48 | 49 | __Simple example (1 item type)__ 50 | 51 | Let's image you have `Cat` model class and `R.layout.item_cat` ([View Binding](https://developer.android.com/topic/libraries/view-binding) generates `ItemCatBinding` class for this layout). Then you can write the following code: 52 | 53 | ```kotlin 54 | val adapter = simpleAdapter { 55 | areItemsSame = { oldCat, newCat -> oldCat.id == newCat.id } 56 | bind { cat -> 57 | catNameTextView.text = cat.name 58 | catDescriptionTextView.text = cat.description 59 | } 60 | listeners { 61 | root.onClick { cat -> 62 | showCatDetails(cat) 63 | } 64 | } 65 | } 66 | 67 | recyclerView.adapter = adapter 68 | 69 | viewModel.catsLiveData.observe(viewLifecycleOwner) { list -> 70 | adapter.submitList(list) 71 | } 72 | ``` 73 | 74 | As you see, `simpleAdapter` accepts 2 types: 75 | - any type of your model (`Cat`) 76 | - an implementation of `ViewBinding` which you don't need to write because the official [View Binding](https://developer.android.com/topic/libraries/view-binding) library can do it. 77 | 78 | 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): 79 | 80 | ```kotlin 81 | val adapter = simpleAdapter { 82 | bind { cat -> // <--- your item to bind 83 | // access views by 'this' reference 84 | this.myTextView.text = cat.name 85 | // or directly by name in the generated binding class: 86 | myTextView.text = cat.name 87 | } 88 | } 89 | ``` 90 | 91 | 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: 92 | 93 | ```kotlin 94 | val adapter = simpleAdapter { 95 | // ... 96 | listeners { 97 | // onClick for clicks 98 | deleteButton.onClick { cat -> 99 | viewModel.delete(cat) 100 | } 101 | // onLongClick for long clicks 102 | root.onLongClick { cat -> 103 | Toast.makeText(requireContext(), "Oooops", Toast.LENGTH_SHORT).show() 104 | true 105 | } 106 | } 107 | } 108 | 109 | ``` 110 | 111 | 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](https://developer.android.com/reference/androidx/recyclerview/widget/DiffUtil.ItemCallback) 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. 112 | 113 | Typical example: 114 | 115 | ```kotlin 116 | val adapter = simpleAdapter { 117 | // compare by ID 118 | areItemsSame = { oldCat, newCat -> oldCat.id == newCat.id } 119 | // compare content 120 | areContentsSame = { oldCat, newCat -> oldCat == newCat } 121 | } 122 | ``` 123 | 124 | __Another example (2 item types)__ 125 | 126 | Let's add headers after every 10th cat to the list. For example, we can define the following structure: 127 | 128 | ```kotlin 129 | sealed class ListItem { 130 | 131 | data class Header( 132 | val id: Int, 133 | val fromIndex: Int, 134 | val toIndex: Int 135 | ) : ListItem() 136 | 137 | data class Cat( 138 | val id: Long, 139 | val name: String, 140 | val description: String 141 | ) : ListItem() 142 | 143 | } 144 | ``` 145 | 146 | Add layout for each item type: `R.layout.item_cat` (`ItemCatBinding` will be generated) and `R.layout.item_header` (`ItemHeaderBinding` will be generated). 147 | 148 | Then we can write an adapter by using `adapter` and `addBinding` methods: 149 | 150 | ```kotlin 151 | val adapter = adapter { // <--- Base type 152 | 153 | // map concrete subtype ListItem.Cat to the ItemCatBinding: 154 | addBinding { 155 | areItemsSame = { oldCat, newCat -> oldCat.id == newCat.id } 156 | bind { cat -> 157 | catNameTextView.text = cat.name 158 | catDescriptionTextView.text = cat.description 159 | } 160 | listeners { 161 | deleteImageView.onClick(viewModel::deleteCat) 162 | root.onClick { cat -> 163 | viewModel.openDetails(cat) 164 | } 165 | } 166 | } 167 | 168 | // map concrete subtype ListItem.Header to the ItemHeaderBinding: 169 | addBinding { 170 | areItemsSame = { oldHeader, newHeader -> oldHeader.id == newHeader.id } 171 | bind { header -> 172 | titleTextView.text = "Cats ${header.fromIndex}...${header.toIndex}" 173 | } 174 | } 175 | } 176 | ``` 177 | 178 | Then assign the list with cats and headers to the adapter by using `submitList` method: 179 | 180 | ```kotlin 181 | val list: List = getListFromSomewhere() 182 | adapter.submitList(list) 183 | ``` 184 | 185 | ## Advanced usage 186 | 187 | ### Working with indexes 188 | 189 | You can take into account an element's index within `bind { ... }` block and 190 | within event callbacks such as `onClick { ... }` and so on. 191 | 192 | :warning: Please note if you want to render items differently depending on element index then 193 | you need to specify `areContentsSame` callback which should take into account index changes. 194 | 195 | For example: 196 | 197 | ```kotlin 198 | val adapter = simpleAdapter { 199 | areContentsSame = { oldCat, newCat -> 200 | // here you should pass an argument to the index() because 201 | // indexes may be different for old and new items. 202 | oldCat == newCat && index(oldCat) == index(newCat) 203 | } 204 | bind { item -> 205 | // here index() is called without args because it refers to the current item being rendered 206 | root.background = if (index() % 2 == 0) Color.GRAY else Color.WHITE 207 | // ... render other properties 208 | } 209 | } 210 | ``` 211 | 212 | Referencing to indexes within event callbacks is very simple (for this case you don't 213 | need to check indexes in `areContentsSame`): 214 | 215 | ```kotlin 216 | val adapter = simpleAdapter { 217 | areContentsSame = { oldCat, newCat -> oldCat == newCat } 218 | bind { 219 | // ... render item 220 | } 221 | listeners { 222 | button.onClick { 223 | val elementIndex = index() 224 | Toast.makeText(context(), "Clicked on index: ${elementIndex}", Toast.LENGTH_SHORT).show() 225 | } 226 | customView.onCustomListener { 227 | customView.setOnMyCustomListener { 228 | val elementIndex = index() 229 | Toast.makeText(context(), "Custom event on index: ${elementIndex}", Toast.LENGTH_SHORT).show() 230 | } 231 | } 232 | } 233 | } 234 | ``` 235 | 236 | 237 | ### Multi-choice / single-choice 238 | 239 | We recommend to implement multi-choice, single-choice, expand/collapse logic and so on 240 | in the view-model. And then just submit the result list to the adapter via 241 | either `LiveData` or `StateFlow`. 242 | 243 | But in case if you don't care about this, you can check the example of simple multi-choice 244 | implementation in the example app module (see [SimpleMultiChoiceActivity](example-app/src/main/java/com/elveum/elementadapter/app/SimpleMultiChoiceActivity.kt)). 245 | 246 | ### Payloads 247 | 248 | Sometimes you need to implement custom animations in your list or update only 249 | specific views. In this case you can use *payloads*. 250 | 251 | 1. Specify `changePayload` property: 252 | - in the `addBinding` block (for `adapter` method) 253 | - directly in the `simpleAdapter` block 254 | 2. Then use `bindWithPayload` instead of `bind`. The `bindWithPayload` block sends you 2 arguments 255 | instead of one: the second argument is a payload list which is exactly the same as in a typical 256 | `RecyclerView.Adapter.onBindViewHolder` method: 257 | 258 | ```kotlin 259 | val adapter = simpleAdapter { 260 | bindWithPayload { cat, payloads -> 261 | // draw cat 262 | // use payloads 263 | } 264 | } 265 | ``` 266 | 3. Usage example with `adapter` (see `example-add` module in the sources for more details): 267 | 268 | ```kotlin 269 | val catsAdapter = adapter { 270 | addBinding { 271 | 272 | // ... areItemsSame, areContentsSame here ... 273 | 274 | // payloads callback: 275 | changePayload = { oldCat, newCat -> 276 | if (!oldCat.isFavorite && newCat.isFavorite) { 277 | FAVORITE_FLAG_CHANGED 278 | } else { 279 | NO_ANIMATION 280 | } 281 | } 282 | 283 | // bind with payloads 284 | bindWithPayloads { cat, payloads -> 285 | 286 | // ... render the cat here ... 287 | 288 | // if the payload list contains FAVORITE_FLAG_CHANGED: 289 | if (payloads.any { it == FAVORITE_FLAG_CHANGED }) { 290 | // render changes with animation 291 | favoriteImageView.startAnimation(buildMyAwesomeAnimation()) 292 | } 293 | } 294 | 295 | } 296 | 297 | // ... bind some other item types here 298 | 299 | } 300 | ``` 301 | 302 | ### Custom listeners 303 | 304 | Sometimes simple clicks and long clicks are not enough for your list items. 305 | To integrate custom listeners, you can use `onCustomListener { ... }` method. 306 | 307 | Usage example (let's assume some view can accept a double tap listener): 308 | 309 | ```kotlin 310 | val adapter = simpleAdapter { 311 | // ... 312 | listeners { 313 | someDoubleTapView.onCustomListener { 314 | someDoubleTapView.setOnDoubleTapListener { // <-- this is a method of the view 315 | // use item() call for getting the current item data 316 | val cat = item() 317 | viewModel.onDoubleTap(cat) 318 | } 319 | } 320 | } 321 | } 322 | ``` 323 | 324 | ### Integration with other libraries 325 | 326 | It's possible to tie together your own adapters or adapters from other third-party libraries 327 | with this library. You can use `adapterDelegate()` or `simpleAdapterDelegate()` calls in order 328 | to create a bridge between libraries. 329 | 330 | For example, you can tie the `PagingDataAdapter` (see [Paging Library V3](https://developer.android.com/topic/libraries/architecture/paging/v3-overview)) 331 | and this library. 332 | 333 | Usage example: 334 | 335 | 1. Implement a subclass of `PagingDataAdapter` (add `AdapterDelegate` to the constructor): 336 | 337 | ```kotlin 338 | class PagingDataAdapterBridge( 339 | private val delegate: AdapterDelegate 340 | ) : PagingDataAdapter( 341 | delegate.noIndexItemCallback() 342 | ) { 343 | 344 | override fun onBindViewHolder(holder: BindingHolder, position: Int, payloads: MutableList) { 345 | // please note, NULL values are not supported! 346 | val item = getItem(position) ?: return 347 | delegate.onBindViewHolder(holder, position, item, payloads) 348 | } 349 | 350 | override fun onBindViewHolder(holder: BindingHolder, position: Int) { 351 | } 352 | 353 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { 354 | return delegate.onCreateViewHolder(parent, viewType) 355 | } 356 | 357 | override fun getItemViewType(position: Int): Int { 358 | // please note, NULL values are not supported! 359 | val item = getItem(position) ?: return 0 360 | return delegate.getItemViewType(item) 361 | } 362 | 363 | } 364 | ``` 365 | 366 | 2. Write a method for creating instances of `PagingDataAdapter`: 367 | 368 | ```kotlin 369 | inline fun pagingAdapter( 370 | noinline block: ConcreteItemTypeScope.() -> Unit 371 | ): PagingDataAdapter { 372 | val delegate = simpleAdapterDelegate(block) 373 | return PagingDataAdapterBridge(delegate) 374 | } 375 | ``` 376 | 377 | 3. Now you can use `pagingAdapter { ... }` call for creating instances of `PagingDataAdapter` from 378 | [Paging Library V3](https://developer.android.com/topic/libraries/architecture/paging/v3-overview) 379 | 380 | ```kotlin 381 | val adapter = pagingAdapter { 382 | areItemsSame = { oldCat, newCat -> oldCat.id == newCat.id } 383 | bind { cat -> 384 | catNameTextView.text = cat.name 385 | catDescriptionTextView.text = cat.description 386 | } 387 | listeners { 388 | root.onClick { cat -> 389 | Toast.makeText(context(), "${cat.name} meow-meows", Toast.LENGTH_SHORT).show() 390 | } 391 | } 392 | } 393 | 394 | recyclerView.adapter = adapter 395 | 396 | lifecycleScope.launch { 397 | viewModel.catsPagingDataFlow.collectLatest { 398 | adapter.submitData(it) 399 | } 400 | } 401 | 402 | ``` 403 | 404 | ## Changelog 405 | 406 | ### v0.7 407 | 408 | - Updated plugins, targetSdk and libraries 409 | 410 | ### v0.6 411 | 412 | - Upgraded gradle plugin and dependencies 413 | - Changed target SDK to 33 414 | - Now you can specify `defaultAreItemsSame`, `defaultAreContentsSame` and `defaultChangePayload` callbacks 415 | directly in the `adapter { ... }` block. They will be used as default callbacks for 416 | all `addBinding { ... }` sub-blocks. 417 | - Default implementation of `areItemsSame` now compares items by reference 418 | (e.g. `oldItem === newItem` instead of `oldItem == newItem`) 419 | 420 | ### v0.5 421 | 422 | - Added `index()` method which can be called within: 423 | - `bind { ... }` block 424 | - `onClick { ... }`, `onLongClick { ... }` blocks 425 | - `onCustomListener { view.onMyListener { ... } }` block 426 | - Added `index(item)` method to `areContentsSame { ... }`, 427 | `areItemsSame { ... }` and `changePayload { ... }` blocks. For these blocks 428 | you should call `index()` with arg because there is a need to specify for 429 | which item (`oldItem` or `newItem`) you want to get an index. 430 | 431 | ### v0.4 432 | 433 | - Added support of RecyclerView payloads 434 | 435 | ### v0.3 436 | 437 | - Added a couple of extension methods for getting resources to the `bind` and `listeners` block 438 | - Added `onCustomListener { ... }` method for assigning custom listeners 439 | - Added `adapterDelegate { ... }` and `simpleAdapterDelegate { ... }` methods 440 | for easier integration with third-party adapters 441 | 442 | ### v0.2 443 | 444 | - Added `context()` extension method 445 | - Updated minSDK from 23 to 21 446 | 447 | ### v0.1 448 | 449 | - The first release 450 | 451 | ## License 452 | 453 | [Apache License 2.0](LICENSE) --------------------------------------------------------------------------------