PagingDataDiffer.kt
/*
* Copyright 2019 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.paging
import androidx.annotation.RestrictTo
import androidx.paging.LoadType.APPEND
import androidx.paging.LoadType.PREPEND
import androidx.paging.LoadType.REFRESH
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.ConflatedBroadcastChannel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import java.util.concurrent.CopyOnWriteArrayList
/** @suppress */
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
abstract class PagingDataDiffer<T : Any>(
private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main
) {
private var presenter: PagePresenter<T> = PagePresenter.initial()
private var receiver: UiReceiver? = null
private val dataRefreshedListeners = CopyOnWriteArrayList<(isEmpty: Boolean) -> Unit>()
private val collectFromRunner = SingleRunner()
/**
* Track whether [lastAccessedIndex] points to a loaded item in the list or a placeholder
* after applying transformations to loaded pages. `true` if [lastAccessedIndex] points to a
* placeholder, `false` if [lastAccessedIndex] points to a loaded item after transformations.
*
* [lastAccessedIndexUnfulfilled] is used to track whether resending [lastAccessedIndex] as a
* hint is necessary, since in cases of aggressive filtering, an index may be unfulfilled
* after being sent to [PageFetcher], which is only capable of handling prefetchDistance
* before transformations.
*/
@Volatile
private var lastAccessedIndexUnfulfilled: Boolean = false
/**
* Track last index access so it can be forwarded to new generations after DiffUtil runs and
* it is transformed to an index in the new list.
*/
@Volatile
private var lastAccessedIndex: Int = 0
/**
* @return Transformed result of [lastAccessedIndex] as an index of [newList] using the diff
* result between [previousList] and [newList]. Null if [newList] or [previousList] lists are
* empty, where it does not make sense to transform [lastAccessedIndex].
*/
abstract suspend fun performDiff(
previousList: NullPaddedList<T>,
newList: NullPaddedList<T>,
newCombinedLoadStates: CombinedLoadStates,
lastAccessedIndex: Int
): Int?
open fun postEvents(): Boolean = false
suspend fun collectFrom(
pagingData: PagingData<T>,
callback: PresenterCallback
) = collectFromRunner.runInIsolation {
receiver = pagingData.receiver
pagingData.flow.collect { event ->
withContext<Unit>(mainDispatcher) {
if (event is PageEvent.Insert && event.loadType == REFRESH) {
lastAccessedIndexUnfulfilled = false
val newPresenter = PagePresenter(event)
val transformedLastAccessedIndex = performDiff(
previousList = presenter,
newList = newPresenter,
newCombinedLoadStates = event.combinedLoadStates,
lastAccessedIndex = lastAccessedIndex
)
presenter = newPresenter
// Dispatch ListUpdate as soon as we are done diffing.
dataRefreshedListeners.forEach { listener ->
listener(event.pages.all { page -> page.data.isEmpty() })
}
// Transform the last loadAround index from the old list to the new list
// by passing it through the DiffResult, and pass it forward as a
// ViewportHint within the new list to the next generation of Pager.
// This ensures prefetch distance for the last ViewportHint from the old
// list is respected in the new list, even if invalidation interrupts
// the prepend / append load that would have fulfilled it in the old
// list.
transformedLastAccessedIndex?.let { newIndex ->
lastAccessedIndex = newIndex
receiver?.addHint(presenter.indexToHint(newIndex))
}
} else {
if (postEvents()) {
yield()
}
// Send event to presenter to be shown to the UI.
presenter.processEvent(event, callback)
// Reset lastAccessedIndexUnfulfilled if a page is dropped, to avoid infinite
// loops when maxSize is insufficiently large.
if (event is PageEvent.Drop) {
lastAccessedIndexUnfulfilled = false
}
// If index points to a placeholder after transformations, resend it unless
// there are no more items to load.
if (event is PageEvent.Insert) {
val prependDone = event.combinedLoadStates.prepend.endOfPaginationReached
val appendDone = event.combinedLoadStates.append.endOfPaginationReached
val canContinueLoading = !(event.loadType == PREPEND && prependDone) &&
!(event.loadType == APPEND && appendDone)
if (!canContinueLoading) {
// Reset lastAccessedIndexUnfulfilled since endOfPaginationReached
// means there are no more pages to load that could fulfill this index.
lastAccessedIndexUnfulfilled = false
} else if (lastAccessedIndexUnfulfilled) {
// `null` if lastAccessedHint does not point to a placeholder.
val lastAccessedIndexAsHint = presenter.placeholderIndexToHintOrNull(
lastAccessedIndex
)
// lastIndex fulfilled, so reset lastAccessedIndexUnfulfilled.
if (lastAccessedIndexAsHint != null) {
receiver?.addHint(lastAccessedIndexAsHint)
} else {
lastAccessedIndexUnfulfilled = false
}
}
}
}
}
}
}
operator fun get(index: Int): T? {
lastAccessedIndexUnfulfilled = true
lastAccessedIndex = index
receiver?.addHint(presenter.indexToHint(index))
return presenter.get(index)
}
/**
* Retry any failed load requests that would result in a [LoadState.Error] update to this
* [PagingDataDiffer].
*
* [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() {
receiver?.retry()
}
/**
* Refresh the data presented by this [PagingDataDiffer].
*
* [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].
*
* Note: This API is intended for UI-driven refresh signals, such as swipe-to-refresh.
* Invalidation due repository-layer signals, such as DB-updates, should instead use
* [PagingSource.invalidate].
*
* @see PagingSource.invalidate
*
* @sample androidx.paging.samples.refreshSample
*/
fun refresh() {
receiver?.refresh()
}
val size: Int
get() = presenter.size
@OptIn(ExperimentalCoroutinesApi::class)
private val _dataRefreshCh = ConflatedBroadcastChannel<Boolean>()
/**
* A [Flow] of [Boolean] that is emitted when new [PagingData] generations are submitted and
* displayed. The [Boolean] that is emitted is `true` if the new [PagingData] is empty,
* `false` otherwise.
*/
@ExperimentalPagingApi
@OptIn(FlowPreview::class)
val dataRefreshFlow: Flow<Boolean> = _dataRefreshCh.asFlow()
init {
@OptIn(ExperimentalCoroutinesApi::class, ExperimentalPagingApi::class)
addDataRefreshListener { _dataRefreshCh.offer(it) }
}
/**
* Add a listener to observe new [PagingData] generations.
*
* @param listener called whenever a new [PagingData] is submitted and displayed. `true` is
* passed to the [listener] if the new [PagingData] is empty, `false` otherwise.
*
* @see removeDataRefreshListener
*/
@ExperimentalPagingApi
fun addDataRefreshListener(listener: (isEmpty: Boolean) -> Unit) {
dataRefreshedListeners.add(listener)
}
/**
* Remove a previously registered listener for new [PagingData] generations.
*
* @param listener Previously registered listener.
*
* @see addDataRefreshListener
*/
@ExperimentalPagingApi
fun removeDataRefreshListener(listener: (isEmpty: Boolean) -> Unit) {
dataRefreshedListeners.remove(listener)
}
}