-
Notifications
You must be signed in to change notification settings - Fork 727
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* 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
1 parent
6a9de53
commit 124913b
Showing
7 changed files
with
750 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
198 changes: 198 additions & 0 deletions
198
epoxy-paging3/src/main/java/com/airbnb/epoxy/paging3/PagedDataModelCache.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)) | ||
} | ||
} | ||
} |
162 changes: 162 additions & 0 deletions
162
epoxy-paging3/src/main/java/com/airbnb/epoxy/paging3/PagingDataEpoxyController.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} | ||
} |
Oops, something went wrong.