blob: b01c0ab7e3ff1110fc720534e162b9b4d3354266 [file] [log] [blame]
/*
* 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.
*/
@file:Suppress("KDocUnresolvedReference")
package androidx.paging
import androidx.annotation.IntRange
import androidx.annotation.MainThread
import androidx.annotation.RestrictTo
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import java.lang.ref.WeakReference
import java.util.AbstractList
import java.util.concurrent.Executor
/**
* Lazy loading list that pages in immutable content from a [PagingSource].
*
* A [PagedList] is a [List] which loads its data in chunks (pages) from a [PagingSource]. Items can
* be accessed with [get], and further loading can be triggered with [loadAround]. To display a
* [PagedList], see [androidx.paging.PagedListAdapter], which enables the binding of a [PagedList]
* to a [androidx.recyclerview.widget.RecyclerView].
*
* ### Loading Data
*
* All data in a [PagedList] is loaded from its [PagingSource]. Creating a [PagedList] loads the
* first chunk of data from the [PagingSource] immediately, and should for this reason be done on a
* background thread. The constructed [PagedList] may then be passed to and used on the UI thread.
* This is done to prevent passing a list with no loaded content to the UI thread, which should
* generally not be presented to the user.
*
* A [PagedList] initially presents this first partial load as its content, and expands over time as
* content is loaded in. When [loadAround] is called, items will be loaded in near the passed
* list index. If placeholder `null`s are present in the list, they will be replaced as
* content is loaded. If not, newly loaded items will be inserted at the beginning or end of the
* list.
*
* [PagedList] can present data for an unbounded, infinite scrolling list, or a very large but
* countable list. Use [Config] to control how many items a [PagedList] loads, and when.
*
* If you use [androidx.paging.LivePagedListBuilder] to get a [androidx.lifecycle.LiveData], it will
* initialize [PagedList]s on a background thread for you.
*
* ### Placeholders
*
* There are two ways that [PagedList] can represent its not-yet-loaded data - with or without
* `null` placeholders.
*
* With placeholders, the [PagedList] is always the full size of the data set. `get(N)` returns
* the `N`th item in the data set, or `null` if its not yet loaded.
*
* Without `null` placeholders, the [PagedList] is the sublist of data that has already been
* loaded. The size of the [PagedList] is the number of currently loaded items, and `get(N)`
* returns the `N`th *loaded* item. This is not necessarily the `N`th item in the
* data set.
*
* Placeholders have several benefits:
*
* * They express the full sized list to the presentation layer (often a
* [androidx.paging.PagedListAdapter]), and so can support scrollbars (without jumping as pages are
* loaded or dropped) and fast-scrolling to any position, loaded or not.
* * They avoid the need for a loading spinner at the end of the loaded list, since the list
* is always full sized.
*
* They also have drawbacks:
*
* * Your Adapter needs to account for `null` items. This often means providing default
* values in data you bind to a [androidx.recyclerview.widget.RecyclerView.ViewHolder].
* * They don't work well if your item views are of different sizes, as this will prevent
* loading items from cross-fading nicely.
* * They require you to count your data set, which can be expensive or impossible, depending
* on your [PagingSource].
*
* Placeholders are enabled by default, but can be disabled in two ways. They are disabled if the
* [PagingSource] does not count its data set in its initial load, or if `false` is passed to
* [Config.Builder.setEnablePlaceholders] when building a [Config].
*
* ### Mutability and Snapshots
*
* A [PagedList] is *mutable* while loading, or ready to load from its [PagingSource].
* As loads succeed, a mutable [PagedList] will be updated via Runnables on the main thread. You can
* listen to these updates with a [Callback]. (Note that [androidx.paging.PagedListAdapter] will
* listen to these to signal RecyclerView about the updates/changes).
*
* If a [PagedList] attempts to load from an invalid [PagingSource], it will [detach] from the
* [PagingSource], meaning that it will no longer attempt to load data. It will return true from
* [isImmutable], and a new [PagingSource] / [PagedList] pair must be created to load further data.
*
* See [PagingSource] and [androidx.paging.LivePagedListBuilder] for how new [PagedList]s are created
* to represent changed data.
*
* A [PagedList] snapshot is simply an immutable shallow copy of the current state of the
* [PagedList] as a `List`. It will reference the same inner items, and contain the same `null`
* placeholders, if present.
*
* @param T The type of the entries in the list.
*/
@Deprecated("PagedList is deprecated and has been replaced by PagingData")
abstract class PagedList<T : Any> internal constructor(
/**
* The [PagingSource] that provides data to this [PagedList].
*
* @suppress
*/
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
open val pagingSource: PagingSource<*, T>,
internal val storage: PagedStorage<T>,
/**
* Return the Config used to construct this PagedList.
*
* @return the Config of this PagedList
*/
val config: Config
) : AbstractList<T>() {
/**
* @suppress
*/
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
companion object {
/**
* Create a [PagedList] which loads data from the provided data source on a background
* thread, posting updates to the main thread.
*
* @param pagingSource [PagingSource] providing data to the [PagedList]
* @param notifyDispatcher [CoroutineDispatcher] that will use and consume data from the
* [PagedList]. Generally, this is the UI/main thread.
* @param fetchDispatcher Data loading jobs will be dispatched to this
* [CoroutineDispatcher] - should be a background thread.
* @param boundaryCallback Optional boundary callback to attach to the list.
* @param config [PagedList.Config], which defines how the [PagedList] will load data.
* @param K Key type that indicates to the [PagingSource] what data to load.
* @param T Type of items to be held and loaded by the [PagedList].
*
* @return The newly created [PagedList], which will page in data from the [PagingSource] as
* needed.
*
* @suppress
*/
@Suppress("DEPRECATION")
@JvmStatic
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
fun <K : Any, T : Any> create(
pagingSource: PagingSource<K, T>,
initialPage: PagingSource.LoadResult.Page<K, T>?,
coroutineScope: CoroutineScope,
notifyDispatcher: CoroutineDispatcher,
fetchDispatcher: CoroutineDispatcher,
boundaryCallback: BoundaryCallback<T>?,
config: Config,
key: K?
): PagedList<T> {
val resolvedInitialPage = when (initialPage) {
null -> {
// Compatibility codepath - perform the initial load immediately, since caller
// hasn't done it. We block in this case, but it's only used in the legacy path.
val params = PagingSource.LoadParams(
LoadType.REFRESH,
key,
config.initialLoadSizeHint,
config.enablePlaceholders,
config.pageSize
)
runBlocking {
val initialResult = withContext(DirectDispatcher) {
pagingSource.load(params)
}
when (initialResult) {
is PagingSource.LoadResult.Page -> initialResult
is PagingSource.LoadResult.Error -> throw initialResult.throwable
}
}
}
else -> initialPage
}
return ContiguousPagedList(
pagingSource,
coroutineScope,
notifyDispatcher,
fetchDispatcher,
boundaryCallback,
config,
resolvedInitialPage,
key
)
}
/**
* Extremely naive diff dispatch: mark entire list as modified (essentially,
* notifyDataSetChanged). We do this because previous logic was incorrect, and
* could dispatch invalid diffs when pages are dropped. Instead of passing a
* snapshot, we now recommend to strictly use the addWeakCallback variant that
* only accepts a callback.
*/
internal fun dispatchNaiveUpdatesSinceSnapshot(
currentSize: Int,
snapshotSize: Int,
callback: Callback
) {
if (snapshotSize < currentSize) {
if (snapshotSize > 0) {
callback.onChanged(0, snapshotSize)
}
val diffCount = currentSize - snapshotSize
if (diffCount > 0) {
callback.onInserted(snapshotSize, diffCount)
}
} else {
if (currentSize > 0) {
callback.onChanged(0, currentSize)
}
val diffCount = snapshotSize - currentSize
if (diffCount != 0) {
callback.onRemoved(currentSize, diffCount)
}
}
}
}
/**
* Builder class for [PagedList].
*
* [pagingSource], [config], [notifyDispatcher] and [fetchDispatcher] must all be provided.
*
* A [PagedList] queries initial data from its [PagingSource] during construction, to avoid empty
* PagedLists being presented to the UI when possible. It's preferred to present initial data,
* so that the UI doesn't show an empty list, or placeholders for a few frames, just before
* showing initial content.
*
* [androidx.paging.LivePagedListBuilder] does this creation on a background thread
* automatically, if you want to receive a `LiveData<PagedList<...>>`.
*
* @param Key Type of key used to load data from the [PagingSource].
* @param Value Type of items held and loaded by the [PagedList].
*/
@Deprecated(
message = "PagedList is deprecated and has been replaced by PagingData, which no " +
"longer supports constructing snapshots of loaded data manually.",
replaceWith = ReplaceWith("PagingDataFlow", "androidx.paging.PagingDataFlow")
)
class Builder<Key : Any, Value : Any> {
private val pagingSource: PagingSource<Key, Value>?
private var dataSource: DataSource<Key, Value>?
private val initialPage: PagingSource.LoadResult.Page<Key, Value>?
private val config: Config
private var coroutineScope: CoroutineScope = GlobalScope
private var notifyDispatcher: CoroutineDispatcher? = null
private var fetchDispatcher: CoroutineDispatcher? = null
private var boundaryCallback: BoundaryCallback<Value>? = null
private var initialKey: Key? = null
/**
* Create a [PagedList.Builder] with the provided [DataSource] and [Config].
*
* @param dataSource [DataSource] the [PagedList] will load from.
* @param config [Config] that defines how the [PagedList] loads data from its [DataSource].
*/
constructor(dataSource: DataSource<Key, Value>, config: Config) {
this.pagingSource = null
this.dataSource = dataSource
this.initialPage = null
this.config = config
}
/**
* Create a [PagedList.Builder] with the provided [DataSource] and page size.
*
* This method is a convenience for:
* ```
* PagedList.Builder(dataSource,
* new PagedList.Config.Builder().setPageSize(pageSize).build());
* ```
*
* @param dataSource [DataSource] the [PagedList] will load from.
* @param pageSize [Config] that defines how the [PagedList] loads data from its
* [DataSource].
*/
constructor(dataSource: DataSource<Key, Value>, pageSize: Int) : this(
dataSource = dataSource,
config = Config(pageSize)
)
/**
* Create a [PagedList.Builder] with the provided [PagingSource],
* initial [PagingSource.LoadResult.Page], and [Config].
*
* @param pagingSource [PagingSource] the [PagedList] will load from.
* @param initialPage Initial page loaded from the [PagingSource].
* @param config [Config] that defines how the [PagedList] loads data from its
* [PagingSource].
*/
constructor(
pagingSource: PagingSource<Key, Value>,
initialPage: PagingSource.LoadResult.Page<Key, Value>,
config: Config
) {
this.pagingSource = pagingSource
this.dataSource = null
this.initialPage = initialPage
this.config = config
}
/**
* Create a [PagedList.Builder] with the provided [PagingSource],
* initial [PagingSource.LoadResult.Page], and page size.
*
* This method is a convenience for:
* ```
* PagedList.Builder(
* pagingSource,
* page,
* PagedList.Config.Builder().setPageSize(pageSize).build()
* )
* ```
*
* @param pagingSource [PagingSource] the [PagedList] will load from.
* @param initialPage Initial page loaded from the [PagingSource].
* @param pageSize [Config] that defines how the [PagedList] loads data from its
* [PagingSource].
*/
constructor(
pagingSource: PagingSource<Key, Value>,
initialPage: PagingSource.LoadResult.Page<Key, Value>,
pageSize: Int
) : this(
pagingSource = pagingSource,
initialPage = initialPage,
config = Config(pageSize)
)
/**
* Set the [CoroutineScope] that page loads should be launched within.
*
* The set [coroutineScope] allows a [PagingSource] to cancel running load operations when
* the results are no longer needed - for example, when the containing Activity is
* destroyed.
*
* Defaults to [GlobalScope].
*
* @param coroutineScope
* @return this
*/
fun setCoroutineScope(coroutineScope: CoroutineScope) = this.apply {
this.coroutineScope = coroutineScope
}
/**
* The [Executor] defining where page loading updates are dispatched.
*
* @param notifyExecutor [Executor] that receives [PagedList] updates, and where [Callback]
* calls are dispatched. Generally, this is the ui/main thread.
* @return this
*/
@Deprecated(
message = "Passing an executor will cause it get wrapped as a CoroutineDispatcher, " +
"consider passing a CoroutineDispatcher directly",
replaceWith = ReplaceWith(
"setNotifyDispatcher(fetchExecutor.asCoroutineDispatcher())",
"kotlinx.coroutines.asCoroutineDispatcher"
)
)
fun setNotifyExecutor(notifyExecutor: Executor) = apply {
this.notifyDispatcher = notifyExecutor.asCoroutineDispatcher()
}
/**
* The [CoroutineDispatcher] defining where page loading updates are dispatched.
*
* @param notifyDispatcher [CoroutineDispatcher] that receives [PagedList] updates, and where
* [Callback] calls are dispatched. Generally, this is the ui/main thread.
* @return this
*/
fun setNotifyDispatcher(notifyDispatcher: CoroutineDispatcher) = apply {
this.notifyDispatcher = notifyDispatcher
}
/**
* The [Executor] used to fetch additional pages from the [PagingSource].
*
* Does not affect initial load, which will be done immediately on whichever thread the
* [PagedList] is created on.
*
* @param fetchExecutor [Executor] used to fetch from [PagingSource]s, generally a background
* thread pool for e.g. I/O or network loading.
* @return this
*/
@Deprecated(
message = "Passing an executor will cause it get wrapped as a CoroutineDispatcher, " +
"consider passing a CoroutineDispatcher directly",
replaceWith = ReplaceWith(
"setFetchDispatcher(fetchExecutor.asCoroutineDispatcher())",
"kotlinx.coroutines.asCoroutineDispatcher"
)
)
fun setFetchExecutor(fetchExecutor: Executor) = apply {
this.fetchDispatcher = fetchExecutor.asCoroutineDispatcher()
}
/**
* The [CoroutineDispatcher] used to fetch additional pages from the [PagingSource].
*
* Does not affect initial load, which will be done immediately on whichever thread the
* [PagedList] is created on.
*
* @param fetchDispatcher [CoroutineDispatcher] used to fetch from [PagingSource]s,
* generally a background thread pool for e.g. I/O or network loading.
* @return this
*/
fun setFetchDispatcher(fetchDispatcher: CoroutineDispatcher) = apply {
this.fetchDispatcher = fetchDispatcher
}
/**
* The [BoundaryCallback] for out of data events.
*
* Pass a [BoundaryCallback] to listen to when the [PagedList] runs out of data to load.
*
* @param boundaryCallback [BoundaryCallback] for listening to out-of-data events.
* @return this
*/
fun setBoundaryCallback(boundaryCallback: BoundaryCallback<Value>?) = apply {
this.boundaryCallback = boundaryCallback
}
/**
* Sets the initial key the [PagingSource] should load around as part of initialization.
*
* @param initialKey Key the [PagingSource] should load around as part of initialization.
* @return this
*/
fun setInitialKey(initialKey: Key?) = apply {
this.initialKey = initialKey
}
/**
* Creates a [PagedList] with the given parameters.
*
* This call will dispatch the [androidx.paging.PagingSource]'s loadInitial method
* immediately on the current thread, and block the current on the result. This method
* should always be called on a worker thread to prevent blocking the main thread.
*
* It's fine to create a [PagedList] with an async [PagingSource] on the main thread, such as
* in the constructor of a ViewModel. An async network load won't block the initial call to
* the Load function. For a synchronous [PagingSource] such as one created from a Room
* database, a `LiveData<PagedList>` can be safely constructed with
* [androidx.paging.LivePagedListBuilder] on the main thread, since actual construction work
* is deferred, and done on a background thread.
*
* While [build] will always return a [PagedList], it's important to note that the
* [PagedList] initial load may fail to acquire data from the [PagingSource]. This can happen
* for example if the [PagingSource] is invalidated during its initial load. If this happens,
* the [PagedList] will be immediately [detached][PagedList.isDetached], and you can retry
* construction (including setting a new [PagingSource]).
*
* @throws IllegalArgumentException if [notifyDispatcher] or [fetchDispatcher] are not set.
*
* @return The newly constructed [PagedList]
*/
@Suppress("DEPRECATION")
fun build(): PagedList<Value> {
val fetchDispatcher = fetchDispatcher ?: Dispatchers.IO
val pagingSource = pagingSource
?: dataSource?.let { LegacyPagingSource(it) }
check(pagingSource != null) {
"PagedList cannot be built without a PagingSource or DataSource"
}
return create(
pagingSource,
initialPage,
coroutineScope,
notifyDispatcher ?: Dispatchers.Main.immediate,
fetchDispatcher,
boundaryCallback,
config,
initialKey
)
}
}
/**
* Callback signaling when content is loaded into the list.
*
* Can be used to listen to items being paged in and out. These calls will be dispatched on
* the dispatcher defined by [Builder.setNotifyDispatcher], which is generally the main/UI
* thread.
*/
abstract class Callback {
/**
* Called when null padding items have been loaded to signal newly available data, or when
* data that hasn't been used in a while has been dropped, and swapped back to null.
*
* @param position Position of first newly loaded items, out of total number of items
* (including padded nulls).
* @param count Number of items loaded.
*/
abstract fun onChanged(position: Int, count: Int)
/**
* Called when new items have been loaded at the end or beginning of the list.
*
* @param position Position of the first newly loaded item (in practice, either `0` or
* `size - 1`.
* @param count Number of items loaded.
*/
abstract fun onInserted(position: Int, count: Int)
/**
* Called when items have been removed at the end or beginning of the list, and have not
* been replaced by padded nulls.
*
* @param position Position of the first newly loaded item (in practice, either `0` or
* `size - 1`.
* @param count Number of items loaded.
*/
abstract fun onRemoved(position: Int, count: Int)
}
/**
* Configures how a [PagedList] loads content from its [PagingSource].
*
* Use [Config.Builder] to construct and define custom loading behavior, such as
* [Builder.setPageSize], which defines number of items loaded at a time.
*/
class Config internal constructor(
/**
* Size of each page loaded by the PagedList.
*/
@JvmField
val pageSize: Int,
/**
* Prefetch distance which defines how far ahead to load.
*
* If this value is set to 50, the paged list will attempt to load 50 items in advance of
* data that's already been accessed.
*
* @see PagedList.loadAround
*/
@JvmField
val prefetchDistance: Int,
/**
* Defines whether the [PagedList] may display null placeholders, if the [PagingSource]
* provides them.
*/
@JvmField
val enablePlaceholders: Boolean,
/**
* Size hint for initial load of PagedList, often larger than a regular page.
*/
@JvmField
val initialLoadSizeHint: Int,
/**
* Defines the maximum number of items that may be loaded into this pagedList before pages
* should be dropped.
*
* If set to [MAX_SIZE_UNBOUNDED], pages will never be dropped.
*
* @see MAX_SIZE_UNBOUNDED
* @see Builder.setMaxSize
*/
@JvmField
val maxSize: Int
) {
/**
* Builder class for [Config].
*
* You must at minimum specify page size with [setPageSize].
*/
class Builder {
private var pageSize = -1
private var prefetchDistance = -1
private var initialLoadSizeHint = -1
private var enablePlaceholders = true
private var maxSize = MAX_SIZE_UNBOUNDED
/**
* Defines the number of items loaded at once from the [PagingSource].
*
* Should be several times the number of visible items onscreen.
*
* Configuring your page size depends on how your data is being loaded and used. Smaller
* page sizes improve memory usage, latency, and avoid GC churn. Larger pages generally
* improve loading throughput, to a point (avoid loading more than 2MB from SQLite at
* once, since it incurs extra cost).
*
* If you're loading data for very large, social-media style cards that take up most of
* a screen, and your database isn't a bottleneck, 10-20 may make sense. If you're
* displaying dozens of items in a tiled grid, which can present items during a scroll
* much more quickly, consider closer to 100.
*
* @param pageSize Number of items loaded at once from the [PagingSource].
* @return this
*/
fun setPageSize(@IntRange(from = 1) pageSize: Int) = apply {
if (pageSize < 1) {
throw IllegalArgumentException("Page size must be a positive number")
}
this.pageSize = pageSize
}
/**
* Defines how far from the edge of loaded content an access must be to trigger further
* loading.
*
* Should be several times the number of visible items onscreen.
*
* If not set, defaults to page size.
*
* A value of 0 indicates that no list items will be loaded until they are specifically
* requested. This is generally not recommended, so that users don't observe a
* placeholder item (with placeholders) or end of list (without) while scrolling.
*
* @param prefetchDistance Distance the [PagedList] should prefetch.
* @return this
*/
fun setPrefetchDistance(@IntRange(from = 0) prefetchDistance: Int) = apply {
this.prefetchDistance = prefetchDistance
}
/**
* Pass false to disable null placeholders in [PagedList]s using this [Config].
*
* If not set, defaults to true.
*
* A [PagedList] will present null placeholders for not-yet-loaded content if two
* conditions are met:
*
* 1) Its [PagingSource] can count all unloaded items (so that the number of nulls to
* present is known).
*
* 2) placeholders are not disabled on the [Config].
*
* Call `setEnablePlaceholders(false)` to ensure the receiver of the PagedList
* (often a [androidx.paging.PagedListAdapter]) doesn't need to account for null items.
*
* If placeholders are disabled, not-yet-loaded content will not be present in the list.
* Paging will still occur, but as items are loaded or removed, they will be signaled
* as inserts to the [PagedList.Callback].
*
* [PagedList.Callback.onChanged] will not be issued as part of loading, though a
* [androidx.paging.PagedListAdapter] may still receive change events as a result of
* [PagedList] diffing.
*
* @param enablePlaceholders `false` if null placeholders should be disabled.
* @return this
*/
fun setEnablePlaceholders(enablePlaceholders: Boolean) = apply {
this.enablePlaceholders = enablePlaceholders
}
/**
* Defines how many items to load when first load occurs.
*
* This value is typically larger than page size, so on first load data there's a large
* enough range of content loaded to cover small scrolls.
*
* If not set, defaults to three times page size.
*
* @param initialLoadSizeHint Number of items to load while initializing the [PagedList]
* @return this
*/
fun setInitialLoadSizeHint(@IntRange(from = 1) initialLoadSizeHint: Int) = apply {
this.initialLoadSizeHint = initialLoadSizeHint
}
/**
* Defines how many items to keep loaded at once.
*
* This can be used to cap the number of items kept in memory by dropping pages. This
* value is typically many pages so old pages are cached in case the user scrolls back.
*
* This value must be at least two times the [prefetchDistance][setPrefetchDistance]
* plus the [pageSize][setPageSize]). This constraint prevent loads from being
* continuously fetched and discarded due to prefetching.
*
* The max size specified here best effort, not a guarantee. In practice, if [maxSize]
* is many times the page size, the number of items held by the [PagedList] will not
* grow above this number. Exceptions are made as necessary to guarantee:
* * Pages are never dropped until there are more than two pages loaded. Note that
* a [PagingSource] may not be held strictly to [requested pageSize][Config.pageSize], so
* two pages may be larger than expected.
* * Pages are never dropped if they are within a prefetch window (defined to be
* `pageSize + (2 * prefetchDistance)`) of the most recent load.
*
* If not set, defaults to [MAX_SIZE_UNBOUNDED], which disables page dropping.
*
* @param maxSize Maximum number of items to keep in memory, or [MAX_SIZE_UNBOUNDED] to
* disable page dropping.
* @return this
*
* @see Config.MAX_SIZE_UNBOUNDED
* @see Config.maxSize
*/
fun setMaxSize(@IntRange(from = 2) maxSize: Int) = apply {
this.maxSize = maxSize
}
/**
* Creates a [Config] with the given parameters.
*
* @return A new [Config].
*/
fun build(): Config {
if (prefetchDistance < 0) {
prefetchDistance = pageSize
}
if (initialLoadSizeHint < 0) {
initialLoadSizeHint = pageSize * DEFAULT_INITIAL_PAGE_MULTIPLIER
}
if (!enablePlaceholders && prefetchDistance == 0) {
throw IllegalArgumentException(
"Placeholders and prefetch are the only ways" +
" to trigger loading of more data in the PagedList, so either" +
" placeholders must be enabled, or prefetch distance must be > 0."
)
}
if (maxSize != MAX_SIZE_UNBOUNDED && maxSize < pageSize + prefetchDistance * 2) {
throw IllegalArgumentException(
"Maximum size must be at least pageSize + 2*prefetchDist" +
", pageSize=$pageSize, prefetchDist=$prefetchDistance" +
", maxSize=$maxSize"
)
}
return Config(
pageSize,
prefetchDistance,
enablePlaceholders,
initialLoadSizeHint,
maxSize
)
}
internal companion object {
internal const val DEFAULT_INITIAL_PAGE_MULTIPLIER = 3
}
}
internal companion object {
/**
* When [maxSize] is set to [MAX_SIZE_UNBOUNDED], the maximum number of items loaded is
* unbounded, and pages will never be dropped.
*/
const val MAX_SIZE_UNBOUNDED = Int.MAX_VALUE
}
}
/**
* Signals when a PagedList has reached the end of available data.
*
* When local storage is a cache of network data, it's common to set up a streaming pipeline:
* Network data is paged into the database, database is paged into UI. Paging from the database
* to UI can be done with a `LiveData<PagedList>`, but it's still necessary to know when to
* trigger network loads.
*
* [BoundaryCallback] does this signaling - when a [PagingSource] runs out of data at the end of
* the list, [onItemAtEndLoaded] is called, and you can start an async network load that will
* write the result directly to the database. Because the database is being observed, the UI
* bound to the `LiveData<PagedList>` will update automatically to account for the new items.
*
* Note that a BoundaryCallback instance shared across multiple PagedLists (e.g. when passed to
* [androidx.paging.LivePagedListBuilder.setBoundaryCallback], the callbacks may be issued
* multiple times. If for example [onItemAtEndLoaded] triggers a network load, it should avoid
* triggering it again while the load is ongoing.
*
* The database + network Repository in the
* [PagingWithNetworkSample](https://github.com/googlesamples/android-architecture-components/blob/master/PagingWithNetworkSample/README.md)
* shows how to implement a network BoundaryCallback using
* [Retrofit](https://square.github.io/retrofit/), while handling swipe-to-refresh,
* network errors, and retry.
*
* ### Requesting Network Data
* [BoundaryCallback] only passes the item at front or end of the list when out of data. This
* makes it an easy fit for item-keyed network requests, where you can use the item passed to
* the [BoundaryCallback] to request more data from the network. In these cases, the source of
* truth for next page to load is coming from local storage, based on what's already loaded.
*
* If you aren't using an item-keyed network API, you may be using page-keyed, or page-indexed.
* If this is the case, the paging library doesn't know about the page key or index used in the
* [BoundaryCallback], so you need to track it yourself. You can do this in one of two ways:
*
* <h5>Local storage Page key</h5>
* If you want to perfectly resume your query, even if the app is killed and resumed, you can
* store the key on disk. Note that with a positional/page index network API, there's a simple
* way to do this, by using the `listSize` as an input to the next load (or
* `listSize / NETWORK_PAGE_SIZE`, for page indexing).
*
* The current list size isn't passed to the BoundaryCallback though. This is because the
* PagedList doesn't necessarily know the number of items in local storage. Placeholders may be
* disabled, or the [PagingSource] may not count total number of items.
*
* Instead, for these positional cases, you can query the database for the number of items, and
* pass that to the network.
* <h5>In-Memory Page key</h5>
* Often it doesn't make sense to query the next page from network if the last page you fetched
* was loaded many hours or days before. If you keep the key in memory, you can refresh any time
* you start paging from a network source.
*
* Store the next key in memory, inside your BoundaryCallback. When you create a new
* BoundaryCallback when creating a new `LiveData`/`Observable` of
* `PagedList`, refresh data. For example,
* [in the Paging Codelab](https://codelabs.developers.google.com/codelabs/android-paging/index.html#8),
* the GitHub network page index is stored in memory.
*
* @param T Type loaded by the [PagedList].
*/
@MainThread
abstract class BoundaryCallback<T : Any> {
/**
* Called when zero items are returned from an initial load of the PagedList's data source.
*/
open fun onZeroItemsLoaded() {}
/**
* Called when the item at the front of the PagedList has been loaded, and access has
* occurred within [Config.prefetchDistance] of it.
*
* No more data will be prepended to the PagedList before this item.
*
* @param itemAtFront The first item of PagedList
*/
open fun onItemAtFrontLoaded(itemAtFront: T) {}
/**
* Called when the item at the end of the PagedList has been loaded, and access has
* occurred within [Config.prefetchDistance] of it.
*
* No more data will be appended to the [PagedList] after this item.
*
* @param itemAtEnd The first item of [PagedList]
*/
open fun onItemAtEndLoaded(itemAtEnd: T) {}
}
/**
* @suppress
*/
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
abstract class LoadStateManager {
var refreshState: LoadState = LoadState.Idle
var startState: LoadState = LoadState.Idle
var endState: LoadState = LoadState.Idle
fun setState(type: LoadType, state: LoadState) {
// deduplicate signals
when (type) {
LoadType.REFRESH -> {
if (refreshState == state) return
refreshState = state
}
LoadType.START -> {
if (startState == state) return
startState = state
}
LoadType.END -> {
if (endState == state) return
endState = state
}
}
onStateChanged(type, state)
}
/**
* @suppress
*/
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // protected otherwise.
abstract fun onStateChanged(type: LoadType, state: LoadState)
fun dispatchCurrentLoadState(callback: (LoadType, LoadState) -> Unit) {
callback(LoadType.REFRESH, refreshState)
callback(LoadType.START, startState)
callback(LoadType.END, endState)
}
}
/**
* @suppress
*/
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // protected otherwise
fun getNullPaddedList(): NullPaddedList<T> = storage
internal var refreshRetryCallback: Runnable? = null
/**
* Last access location in list.
*
* Used by list diffing to re-initialize loading near viewport.
*
* @suppress
*/
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
fun lastLoad(): Int = storage.lastLoadAroundIndex
internal val requiredRemainder = config.prefetchDistance * 2 + config.pageSize
private val callbacks = mutableListOf<WeakReference<Callback>>()
private val loadStateListeners = mutableListOf<WeakReference<(LoadType, LoadState) -> Unit>>()
/**
* Size of the list, including any placeholders (not-yet-loaded null padding).
*
* To get the number of loaded items, not counting placeholders, use [loadedCount].
*
* @see loadedCount
*/
override val size
get() = storage.size
/**
* @throws IllegalStateException if this [PagedList] was instantiated without a
* wrapping a backing [DataSource]
*/
@Deprecated(
message = "DataSource is deprecated and has been replaced by PagingSource. PagedList " +
"offers indirect ways of controlling fetch ('loadAround()', 'retry()') so that " +
"you should not need to access the DataSource/PagingSource."
)
val dataSource: DataSource<*, T>
get() {
val pagingSource = pagingSource
@Suppress("UNCHECKED_CAST")
if (pagingSource is LegacyPagingSource<*, *>) {
return pagingSource.dataSource as DataSource<*, T>
}
throw IllegalStateException(
"Attempt to access dataSource on a PagedList that was instantiated with a " +
"${pagingSource::class.java.simpleName} instead of a DataSource"
)
}
/**
* Return the key for the position passed most recently to [loadAround].
*
* When a PagedList is invalidated, you can pass the key returned by this function to initialize
* the next PagedList. This ensures (depending on load times) that the next PagedList that
* arrives will have data that overlaps. If you use androidx.paging.LivePagedListBuilder, it
* will do this for you.
*
* @return Key of position most recently passed to [loadAround].
*/
abstract val lastKey: Any?
/**
* True if the [PagedList] has detached the [PagingSource] it was loading from, and will no
* longer load new data.
*
* A detached list is [immutable][isImmutable].
*
* @return True if the data source is detached.
*/
abstract val isDetached: Boolean
/**
* @suppress
*/
@RestrictTo(RestrictTo.Scope.LIBRARY)
abstract fun dispatchCurrentLoadState(callback: (LoadType, LoadState) -> Unit)
/**
* @suppress
*/
@RestrictTo(RestrictTo.Scope.LIBRARY)
abstract fun loadAroundInternal(index: Int)
/**
* Detach the [PagedList] from its [PagingSource], and attempt to load no more data.
*
* This is called automatically when a [PagingSource] is observed to be invalid, which is a
* signal to stop loading. The [PagedList] will continue to present existing data, but will not
* initiate new loads.
*/
abstract fun detach()
/**
* Returns the number of items loaded in the [PagedList].
*
* Unlike [size] this counts only loaded items, not placeholders.
*
* If placeholders are [disabled][Config.enablePlaceholders], this method is equivalent to
* [size].
*
* @return Number of items currently loaded, not counting placeholders.
*
* @see size
*/
val loadedCount
get() = storage.storageCount
/**
* Returns whether the list is immutable.
*
* Immutable lists may not become mutable again, and may safely be accessed from any thread.
*
* In the future, this method may return true when a PagedList has completed loading from its
* [PagingSource]. Currently, it is equivalent to [isDetached].
*
* @return `true` if the [PagedList] is immutable.
*/
open val isImmutable
get() = isDetached
/**
* Position offset of the data in the list.
*
* If the PagingSource backing this PagedList is counted, the item returned from `get(i)` has
* a position in the original data set of `i + getPositionOffset()`.
*
* If placeholders are enabled, this value is always `0`, since `get(i)` will return either
* the data in its original index, or null if it is not loaded.
*/
val positionOffset: Int
get() = storage.positionOffset
/**
* @suppress
*/
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
open fun setInitialLoadState(loadType: LoadType, loadState: LoadState) {
}
/**
* Retry any errors associated with this [PagedList].
*
* If for example a network [PagingSource] append timed out, calling this method will retry the
* failed append load.
*
* You can observe loading state via [addWeakLoadStateListener], though generally this is done
* through the [PagedListAdapter][androidx.paging.PagedListAdapter] or
* [AsyncPagedListDiffer][androidx.paging.AsyncPagedListDiffer].
*
* @see addWeakLoadStateListener
* @see removeWeakLoadStateListener
*/
open fun retry() {}
/**
* @suppress
*/
@RestrictTo(RestrictTo.Scope.LIBRARY)
fun setRetryCallback(refreshRetryCallback: Runnable?) {
this.refreshRetryCallback = refreshRetryCallback
}
internal fun dispatchStateChange(type: LoadType, state: LoadState) {
loadStateListeners.removeAll { it.get() == null }
loadStateListeners.forEach { it.get()?.invoke(type, state) }
}
/**
* Get the item in the list of loaded items at the provided index.
*
* @param index Index in the loaded item list. Must be >= 0, and < [size]
* @return The item at the passed index, or `null` if a `null` placeholder is at the specified
* position.
*
* @see size
*/
override fun get(index: Int) = storage[index]
/**
* Load adjacent items to passed index.
*
* @param index Index at which to load.
*/
fun loadAround(index: Int) {
if (index < 0 || index >= size) {
throw IndexOutOfBoundsException("Index: $index, Size: $size")
}
storage.lastLoadAroundIndex = index
loadAroundInternal(index)
}
/**
* Returns an immutable snapshot of the [PagedList] in its current state.
*
* If this [PagedList] [is immutable][isImmutable] due to its [PagingSource] being invalid, it
* will be returned.
*
* @return Immutable snapshot of [PagedList] data.
*/
fun snapshot(): List<T> = when {
isImmutable -> this
else -> SnapshotPagedList(this)
}
/**
* Add a listener to observe the loading state of the [PagedList].
*
* @param listener Listener to receive updates.
*
* @see removeWeakLoadStateListener
*/
fun addWeakLoadStateListener(listener: (LoadType, LoadState) -> Unit) {
// Clean up any empty weak refs.
loadStateListeners.removeAll { it.get() == null }
// Add the new one.
loadStateListeners.add(WeakReference(listener))
dispatchCurrentLoadState(listener)
}
/**
* Remove a previously registered load state listener.
*
* @param listener Previously registered listener.
*
* @see addWeakLoadStateListener
*/
fun removeWeakLoadStateListener(listener: (LoadType, LoadState) -> Unit) {
loadStateListeners.removeAll { it.get() == null || it.get() === listener }
}
/**
* Adds a callback, and issues updates since the [previousSnapshot] was created.
*
* If [previousSnapshot] is passed, the [callback] will also immediately be dispatched any
* differences between the previous snapshot, and the current state. For example, if the
* previousSnapshot was of 5 nulls, 10 items, 5 nulls, and the current state was 5 nulls,
* 12 items, 3 nulls, the callback would immediately receive a call of`onChanged(14, 2)`.
*
* This allows an observer that's currently presenting a snapshot to catch up to the most recent
* version, including any changes that may have been made.
*
* The callback is internally held as weak reference, so [PagedList] doesn't hold a strong
* reference to its observer, such as a [androidx.paging.PagedListAdapter]. If an adapter were
* held with a strong reference, it would be necessary to clear its [PagedList] observer before
* it could be GC'd.
*
* @param previousSnapshot Snapshot previously captured from this List, or `null`.
* @param callback Callback to dispatch to.
*
* @see removeWeakCallback
*/
@Deprecated(
"Dispatching a diff since snapshot created is behavior that can be instead " +
"tracked by attaching a Callback to the PagedList that is mutating, and tracking " +
"changes since calling PagedList.snapshot()."
)
fun addWeakCallback(previousSnapshot: List<T>?, callback: Callback) {
if (previousSnapshot != null && previousSnapshot !== this) {
dispatchNaiveUpdatesSinceSnapshot(size, previousSnapshot.size, callback)
}
addWeakCallback(callback)
}
/**
* Adds a callback.
*
* The callback is internally held as weak reference, so [PagedList] doesn't hold a strong
* reference to its observer, such as a [androidx.paging.PagedListAdapter]. If an adapter were
* held with a strong reference, it would be necessary to clear its [PagedList] observer before
* it could be GC'd.
*
* @param callback Callback to dispatch to.
*
* @see removeWeakCallback
*/
fun addWeakCallback(callback: Callback) {
// first, clean up any empty weak refs
callbacks.removeAll { it.get() == null }
// then add the new one
callbacks.add(WeakReference(callback))
}
/**
* Removes a previously added callback.
*
* @param callback Callback, previously added.
*
* @see addWeakCallback
*/
fun removeWeakCallback(callback: Callback) {
callbacks.removeAll { it.get() == null || it.get() === callback }
}
internal fun notifyInserted(position: Int, count: Int) {
if (count == 0) return
callbacks.reversed().forEach { it.get()?.onInserted(position, count) }
}
/**
* @suppress
*/
@RestrictTo(RestrictTo.Scope.LIBRARY)
fun notifyChanged(position: Int, count: Int) {
if (count == 0) return
callbacks.reversed().forEach { it.get()?.onChanged(position, count) }
}
/**
* @suppress
*/
@RestrictTo(RestrictTo.Scope.LIBRARY)
fun notifyRemoved(position: Int, count: Int) {
if (count == 0) return
callbacks.reversed().forEach { it.get()?.onRemoved(position, count) }
}
}
/**
* Constructs a [PagedList], convenience for [PagedList.Builder].
*
* @param Key Type of key used to load data from the [DataSource].
* @param Value Type of items held and loaded by the [PagedList].
* @param dataSource [DataSource] the [PagedList] will load from.
* @param config Config that defines how the [PagedList] loads data from its [DataSource].
* @param notifyExecutor [Executor] that receives [PagedList] updates, and where
* [PagedList.Callback] calls are dispatched. Generally, this is the UI/main thread.
* @param fetchExecutor [Executor] used to fetch from [DataSource]s, generally a background thread
* pool for e.g. I/O or network loading.
* @param boundaryCallback [PagedList.BoundaryCallback] for listening to out-of-data events.
* @param initialKey [Key] the [DataSource] should load around as part of initialization.
*/
@Suppress("FunctionName", "DEPRECATION")
@Deprecated("DataSource is deprecated and has been replaced by PagingSource")
fun <Key : Any, Value : Any> PagedList(
dataSource: DataSource<Key, Value>,
config: PagedList.Config,
notifyExecutor: Executor,
fetchExecutor: Executor,
boundaryCallback: PagedList.BoundaryCallback<Value>? = null,
initialKey: Key? = null
): PagedList<Value> {
@Suppress("DEPRECATION")
return PagedList.Builder(dataSource, config)
.setNotifyExecutor(notifyExecutor)
.setFetchExecutor(fetchExecutor)
.setBoundaryCallback(boundaryCallback)
.setInitialKey(initialKey)
.build()
}