Skip to content

Commit

Permalink
Support for Paging3 (#1126)
Browse files Browse the repository at this point in the history
* Adds Support for paging v3.

Adds controller which uses PagingData class and a cache class which uses this PagingData

* ktlintFormat

* Update epoxy-paging3/src/main/java/com/airbnb/epoxy/paging3/PagedDataModelCache.kt

Co-authored-by: Osip Fatkullin <[email protected]>

* Update epoxy-paging3/src/main/java/com/airbnb/epoxy/paging3/PagedDataModelCache.kt

Co-authored-by: Osip Fatkullin <[email protected]>

* Bump paging3 to the latest version - alpha13

* Add check before triggerLoadAround

* Add simple implementation for getRefreshKey abstract function

* Add kdoc for PagingDataEpoxyController

* Use default diffing handler and default model building handler

* Support retry(), refresh(), addLoadStateListener(), removeLoadStateListener() in PagingDataEpoxyController

* Update epoxy-paging3/src/main/java/com/airbnb/epoxy/paging3/PagedDataModelCache.kt

Co-authored-by: Osip Fatkullin <[email protected]>

Co-authored-by: farid <[email protected]>
Co-authored-by: Osip Fatkullin <[email protected]>
  • Loading branch information
3 people authored Feb 18, 2021
1 parent 6a9de53 commit 124913b
Show file tree
Hide file tree
Showing 7 changed files with 750 additions and 1 deletion.
4 changes: 3 additions & 1 deletion blessedDeps.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ rootProject.ext.ANDROIDX_APPCOMPAT = "1.0.0"
rootProject.ext.ANDROIDX_CARDVIEW = "1.0.0"
rootProject.ext.ANDROIDX_LEGACY = "1.0.0"
rootProject.ext.ANDROIDX_PAGING = "2.0.0"
rootProject.ext.ANDROIDX_PAGING3 = "3.0.0-alpha05"
rootProject.ext.ANDROIDX_PAGING3 = "3.0.0-alpha13"
rootProject.ext.ANDROIDX_ROOM = "2.2.5"
rootProject.ext.ANDROIDX_RUNTIME = "2.0.0"
rootProject.ext.ANDROIDX_DATABINDING_COMPILER = "3.2.1"
Expand All @@ -35,6 +35,7 @@ rootProject.ext.ANDROID_TEST_RUNNER = "1.0.2"
rootProject.ext.SQUARE_JAVAPOET_VERSION = "1.11.1"
rootProject.ext.SQUARE_KOTLINPOET_VERSION = "1.7.2"
rootProject.ext.KOTLIN_COROUTINES_VERSION = "1.3.9"
rootProject.ext.KOTLIN_COROUTINES_TEST_VERSION = "1.4.1"
rootProject.ext.GLIDE_VERSION = "4.9.0"

rootProject.ext.ASSERTJ_VERSION = "1.7.1"
Expand All @@ -55,6 +56,7 @@ rootProject.ext.INCAP_VERSION = "0.2"
rootProject.ext.deps = [
kotlin : "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$KOTLIN_VERSION",
kotlinCoroutines : "org.jetbrains.kotlinx:kotlinx-coroutines-core:$KOTLIN_COROUTINES_VERSION",
kotlinCoroutinesTest : "org.jetbrains.kotlinx:kotlinx-coroutines-test:$KOTLIN_COROUTINES_TEST_VERSION",
autoValue : "com.google.auto.value:auto-value:$AUTO_VALUE_VERSION",
androidRuntime : "com.google.android:android:$ANDROID_RUNTIME_VERSION",
androidAppcompat : "androidx.appcompat:appcompat:$ANDROIDX_APPCOMPAT",
Expand Down
1 change: 1 addition & 0 deletions epoxy-paging3/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ dependencies {
testImplementation rootProject.deps.junit
testImplementation rootProject.deps.robolectric
testImplementation rootProject.deps.mockito
testImplementation rootProject.deps.kotlinCoroutinesTest
androidTestImplementation rootProject.deps.junit
androidTestImplementation rootProject.deps.androidArchCoreTesting
androidTestImplementation rootProject.deps.androidTestRunner
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
package com.airbnb.epoxy.paging3

import android.os.Handler
import android.os.Looper
import androidx.paging.AsyncPagingDataDiffer
import androidx.paging.CombinedLoadStates
import androidx.paging.PagingData
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListUpdateCallback
import com.airbnb.epoxy.EpoxyModel
import kotlinx.coroutines.android.asCoroutineDispatcher

/**
* A pagingData stream wrapper that caches models built for each item. It tracks changes in paged Data and caches
* models for each item when they are invalidated to avoid rebuilding models for the whole list when pagingData is
* updated.
*
* The pagingData submitted to this cache must be kept in sync with the model cache. To do this,
* the executor of the pagingData differ is set to the same thread as the model building handler.
* However, change notifications from the PageList happen on that list's notify executor which is
* out of our control, and we require the user to configure that properly, or an error is thrown.
*
* There are two special cases:
*
* 1. The first time models are built happens synchronously for immediate UI. In this case we don't
* use the model cache (to avoid data synchronization issues), but attempt to fill the cache with
* the models later.
*
* 2. When a pagingData is submitted it can trigger update callbacks synchronously. Since we don't control
* that thread we allow a special case of cache modification when a new list is being submitted,
* and all cache access is marked with @Synchronize to ensure safety when this happens.
*/
internal class PagedDataModelCache<T : Any>(
private val modelBuilder: (itemIndex: Int, item: T?) -> EpoxyModel<*>,
private val rebuildCallback: () -> Unit,
itemDiffCallback: DiffUtil.ItemCallback<T>,
private val modelBuildingHandler: Handler
) {
/**
* Backing list for built models. This is a full array list that has null items for not yet build models.
*/
private val modelCache = arrayListOf<EpoxyModel<*>?>()

/**
* Tracks the last accessed position so that we can report it back to the paged list when models are built.
*/
private var lastPosition: Int? = null

/**
* Set to true while a new list is being submitted, so that we can ignore the update callback
* thread restriction.
*/
private var inSubmitList: Boolean = false

/**
* Observer for the PagedList changes that invalidates the model cache when data is updated.
*/
private val updateCallback = object : ListUpdateCallback {
override fun onChanged(position: Int, count: Int, payload: Any?) = synchronizedWithCache {
assertUpdateCallbacksAllowed()
(position until (position + count)).forEach {
modelCache[it] = null
}
rebuildCallback()
}

override fun onMoved(fromPosition: Int, toPosition: Int) = synchronizedWithCache {
assertUpdateCallbacksAllowed()
val model = modelCache.removeAt(fromPosition)
modelCache.add(toPosition, model)
rebuildCallback()
}

override fun onInserted(position: Int, count: Int) = synchronizedWithCache {
assertUpdateCallbacksAllowed()
repeat(count) {
modelCache.add(position, null)
}
rebuildCallback()
}

override fun onRemoved(position: Int, count: Int) = synchronizedWithCache {
assertUpdateCallbacksAllowed()
repeat(count) {
modelCache.removeAt(position)
}
rebuildCallback()
}

private fun synchronizedWithCache(block: () -> Unit) {
synchronized(this@PagedDataModelCache) {
block()
}
}
}

/**
* Changes to the paged list must happen on the same thread as changes to the model cache to
* ensure they stay in sync.
*
* We can't force this to happen, and must instead rely on user's configuration, but we can alert
* when it is not configured correctly.
*
* An exception is thrown if the callback happens due to a new paged list being submitted, which can
* trigger a synchronous callback if the list goes from null to non null, or vice versa.
*
* Synchronization on [submitData] and other model cache access methods prevent issues when
* that happens.
*/
private fun assertUpdateCallbacksAllowed() {
require(inSubmitList || Looper.myLooper() == modelBuildingHandler.looper) {
"The notify executor for your PagedList must use the same thread as the model building handler set in PagedListEpoxyController.modelBuildingHandler"
}
}

private val dispatcher = modelBuildingHandler.asCoroutineDispatcher()
private val asyncDiffer = AsyncPagingDataDiffer(
diffCallback = itemDiffCallback,
updateCallback = updateCallback,
mainDispatcher = dispatcher,
workerDispatcher = dispatcher
)

@Synchronized
suspend fun submitData(pagingData: PagingData<T>) {
inSubmitList = true
asyncDiffer.submitData(pagingData)
inSubmitList = false
}

@Synchronized
fun getModels(): List<EpoxyModel<*>> {
val currentList = asyncDiffer.snapshot()

// The first time models are built the EpoxyController does so synchronously, so that
// the UI can be ready immediately. To avoid concurrent modification issues with the PagedList
// and model cache we can't allow that first build to touch the cache.
if (Looper.myLooper() != modelBuildingHandler.looper) {
return currentList.mapIndexed { position, item ->
modelBuilder(position, item)
}
}

(0 until modelCache.size).forEach { position ->
if (modelCache[position] == null) {
modelCache[position] = modelBuilder(position, currentList[position])
}
}

lastPosition?.let {
triggerLoadAround(it)
}
@Suppress("UNCHECKED_CAST")
return modelCache as List<EpoxyModel<*>>
}

/**
* Clears all cached models to force them to be rebuilt next time models are retrieved.
* This is posted to the model building thread to maintain data synchronicity.
*/
fun clearModels() {
modelBuildingHandler.post {
clearModelsSynchronized()
}
}

fun retry() {
asyncDiffer.retry()
}

fun refresh() {
asyncDiffer.refresh()
}

fun addLoadStateListener(listener: (CombinedLoadStates) -> Unit) {
asyncDiffer.addLoadStateListener(listener)
}

fun removeLoadStateListener(listener: (CombinedLoadStates) -> Unit) {
asyncDiffer.removeLoadStateListener(listener)
}

@Synchronized
private fun clearModelsSynchronized() {
modelCache.fill(null)
}

fun loadAround(position: Int) {
triggerLoadAround(position)
lastPosition = position
}

private fun triggerLoadAround(position: Int) {
if (asyncDiffer.itemCount > 0) {
asyncDiffer.getItem(position.coerceIn(0, asyncDiffer.itemCount - 1))
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
package com.airbnb.epoxy.paging3

import android.annotation.SuppressLint
import android.os.Handler
import androidx.paging.CombinedLoadStates
import androidx.paging.LoadState
import androidx.paging.LoadType
import androidx.paging.LoadType.REFRESH
import androidx.paging.PagingData
import androidx.paging.PagingSource
import androidx.paging.RemoteMediator
import androidx.recyclerview.widget.DiffUtil
import com.airbnb.epoxy.EpoxyAsyncUtil
import com.airbnb.epoxy.EpoxyController
import com.airbnb.epoxy.EpoxyModel
import com.airbnb.epoxy.EpoxyViewHolder
import kotlinx.coroutines.ObsoleteCoroutinesApi

/**
* An [EpoxyController] that can work with a [PagingData].
*
* Internally, it caches the model for each item in the [PagingData]. You must override
* [buildItemModel] method to build the model for the given item. Since [PagingData] might include
* `null` items if placeholders are enabled, this method needs to handle `null` values in the list.
*
* By default, the model for each item is added to the model list. To change this behavior (to
* filter items or inject extra items), you can override [addModels] function and manually add built
* models.
*
* @param T The type of the item in the [PagingData].
*/
@ObsoleteCoroutinesApi
abstract class PagingDataEpoxyController<T : Any>(
/**
* The handler to use for building models. By default this uses the main thread, but you can use
* [EpoxyAsyncUtil.getAsyncBackgroundHandler] to do model building in the background.
*
* The notify thread of your PagedList (from setNotifyExecutor in the pagingData Builder) must be
* the same as this thread. Otherwise Epoxy will crash.
*/
modelBuildingHandler: Handler = defaultModelBuildingHandler,
/**
* The handler to use when calculating the diff between built model lists.
* By default this uses the main thread, but you can use
* [EpoxyAsyncUtil.getAsyncBackgroundHandler] to do diffing in the background.
*/
diffingHandler: Handler = defaultDiffingHandler,
/**
* [PagingDataEpoxyController] uses an [DiffUtil.ItemCallback] to detect changes between
* [PagingData]s. By default, it relies on simple object equality but you can provide a custom
* one if you don't use all fields in the object in your models.
*/
itemDiffCallback: DiffUtil.ItemCallback<T> = DEFAULT_ITEM_DIFF_CALLBACK as DiffUtil.ItemCallback<T>
) : EpoxyController(modelBuildingHandler, diffingHandler) {
// this is where we keep the already built models
private val modelCache = PagedDataModelCache(
modelBuilder = { pos, item ->
buildItemModel(pos, item)
},
rebuildCallback = {
requestModelBuild()
},
itemDiffCallback = itemDiffCallback,
modelBuildingHandler = modelBuildingHandler
)

final override fun buildModels() {
addModels(modelCache.getModels())
}

/**
* This function adds all built models to the adapter. You can override this method to add extra
* items into the model list or remove some.
*/
open fun addModels(models: List<EpoxyModel<*>>) {
super.add(models)
}

/**
* Builds the model for a given item. This must return a single model for each item. If you want
* to inject headers etc, you can override [addModels] function.
*
* If the `item` is `null`, you should provide the placeholder. If your [PagingData] is
* configured without placeholders, you don't need to handle the `null` case.
*/
abstract fun buildItemModel(currentPosition: Int, item: T?): EpoxyModel<*>

override fun onModelBound(
holder: EpoxyViewHolder,
boundModel: EpoxyModel<*>,
position: Int,
previouslyBoundModel: EpoxyModel<*>?
) {
// TODO the position may not be a good value if there are too many injected items.
modelCache.loadAround(position)
}

/**
* Submit a new pagingData.
*
* A diff will be calculated between this pagingData and the previous pagingData so you may still get calls
* to [buildItemModel] with items from the previous PagingData.
*/
open suspend fun submitData(pagingData: PagingData<T>) {
modelCache.submitData(pagingData)
}

/**
* Retry any failed load requests that would result in a [LoadState.Error] update to this
* [PagingDataEpoxyController]
*
* [LoadState.Error] can be generated from two types of load requests:
* * [PagingSource.load] returning [PagingSource.LoadResult.Error]
* * [RemoteMediator.load] returning [RemoteMediator.MediatorResult.Error]
*/
fun retry() {
modelCache.retry()
}

/**
* Refresh the data presented by this [PagingDataEpoxyController].
*
* [refresh] triggers the creation of a new [PagingData] with a new instance of [PagingSource]
* to represent an updated snapshot of the backing dataset. If a [RemoteMediator] is set,
* calling [refresh] will also trigger a call to [RemoteMediator.load] with [LoadType] [REFRESH]
* to allow [RemoteMediator] to check for updates to the dataset backing [PagingSource].
*/
fun refresh() {
modelCache.refresh()
}

/**
* Add a [CombinedLoadStates] listener to observe the loading state of the current [PagingData].
*
* As new [PagingData] generations are submitted and displayed, the listener will be notified to
* reflect the current [CombinedLoadStates].
*/
fun addLoadStateListener(listener: (CombinedLoadStates) -> Unit) {
modelCache.addLoadStateListener(listener)
}

/**
* Remove a previously registered [CombinedLoadStates] listener.
*/
fun removeLoadStateListener(listener: (CombinedLoadStates) -> Unit) {
modelCache.removeLoadStateListener(listener)
}

companion object {
/**
* [PagingDataEpoxyController] calculates a diff on top of the PagingData to check which
* models are invalidated.
* This is the default [DiffUtil.ItemCallback] which uses object equality.
*/
val DEFAULT_ITEM_DIFF_CALLBACK = object : DiffUtil.ItemCallback<Any>() {
override fun areItemsTheSame(oldItem: Any, newItem: Any) = oldItem == newItem

@SuppressLint("DiffUtilEquals")
override fun areContentsTheSame(oldItem: Any, newItem: Any) = oldItem == newItem
}
}
}
Loading

0 comments on commit 124913b

Please sign in to comment.