
Efficient paging library offering intelligent preloading, caching, and coroutine support. Features include debounced loading, error handling, thread safety, and a reactive Flow-based API.
Lightweight and efficient paging library for Kotlin Multiplatform with intelligent preloading, caching, and coroutines support.
Prerequisites: Kotlin 2.3.0, org.jetbrains.kotlinx:kotlinx-collections-immutable 0.4.0 or higher, repository
mavenCentral().
// build.gradle.kts
dependencies {
implementation("ua.wwind.paging:paging-core:2.2.6")
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.4.0")
}data class User(val id: Int, val name: String, val email: String)
val pager = Pager<User>(
loadSize = 20,
preloadSize = 60,
cacheSize = 100,
readData = { position, loadSize ->
flow {
val users = repository.getUsers(position, loadSize)
emit(
DataPortion(
totalSize = repository.getTotalCount(),
values = users.mapIndexed { index, user ->
(position + index) to user
}.toMap()
.toPersistentMap()
)
)
// Optionally emit more portions progressively if your source supports it
}
}
)
// Observe paging data
pager.flow.collect { pagingData ->
when (pagingData.loadState) {
LoadState.Loading -> showLoader()
LoadState.Success -> hideLoader()
is LoadState.Error -> pagingData.retry(pagingData.loadState.key)
}
// Access items by position
when (val firstUser = pagingData.data[0]) {
EntryState.Loading -> showItemLoader()
is EntryState.Success -> displayUser(firstUser.value)
}
}For caching and paging to work correctly, items must be addressable by an integer positional key (Int):
startPosition + indexInPortion when
building DataPortion.values.Pager and PagingMediator can merge and
window data reliably.@Composable
fun UserList() {
val pagingData by pager.flow.collectAsState()
LazyColumn {
items(count = pagingData.data.size) { index ->
when (val entry = pagingData.data[index]) {
EntryState.Loading -> LoadingItem()
is EntryState.Success -> UserItem(entry.value)
}
}
}
// Handle loading state
when (pagingData.loadState) {
LoadState.Loading -> CircularProgressIndicator()
is LoadState.Error -> ErrorMessage(pagingData.loadState.throwable) {
pagingData.retry(pagingData.loadState.key)
}
LoadState.Success -> Unit
}
}Android (API 21+) • JVM (Java 17+) • iOS • macOS • Linux • Windows • JavaScript • WebAssembly
The paging-samples module contains complete working examples:
You can transform items of PagingData while preserving loading state and retry logic.
// Given: PagingData<User>
val mapped: PagingData<String> = pagingData.map { user -> "${user.id}: ${user.name}" }
// Notes:
// - Only currently loaded items are transformed
// - loadState and retry remain unchangedCoordinate a local cache with a remote source while preserving positional paging. PagingMediator<T, Q> emits
Flow<PagingData<T>> per query, serving local data first and fetching missing ranges from the network.
Key capabilities:
Define data sources:
class UserLocalDataSource(
private val dao: UserDao
) : LocalDataSource<User> {
override suspend fun read(startPosition: Int, size: Int, query: Unit): DataPortion<User> =
dao.readPortion(startPosition, size)
override suspend fun save(portion: DataPortion<User>) {
dao.upsertPortion(portion)
}
override suspend fun clear() {
dao.clearAll()
}
}
class UserRemoteDataSource(
private val api: UserApi
) : RemoteDataSource<User, Unit> {
override suspend fun fetch(startPosition: Int, size: Int, query: Unit): DataPortion<User> =
api.fetchUsers(startPosition, size)
}Create mediator and collect:
val mediator = PagingMediator(
local = UserLocalDataSource(dao),
remote = UserRemoteDataSource(api),
config = PagingMediatorConfig(
loadSize = 20, // Number of items loaded per page
prefetchSize = 60, // Number of items to preload around current position
cacheSize = 100, // Max number of items to keep in memory cache
concurrency = 2, // Number of concurrent fetches allowed
isRecordStale = { false }, // Function to check if a record is outdated
fetchFullRangeOnMiss = false, // Whether to refetch full range if data is missing or inconsistent
emitOutdatedRecords = false, // Emit outdated records while fetching new ones
emitIntermediateResults = true, // Emit partial/intermediate load results during fetch
)
)
// Each query owns its own paging flow; use Unit if no filtering is needed
mediator.flow(Unit).collect { pagingData ->
// Same UI handling as with Pager
}If your data source is streaming and you can emit updates for the total item count and for individual portions
independently, use StreamingPager. It opens/closes portion flows dynamically around the last accessed position, while
a dedicated total-size flow keeps the item count in sync.
readTotal(): Flow<Int> emits global total count updates.readPortion(start, size): Flow<Map<Int, T>> emits only data maps for the requested range (no total).config.loadSize and preloading via config.preloadSize.config.closeThreshold from a range.config.cacheSize.LoadState across opened ranges (Loading > Error > Success).@Serializable
data class User(val id: Int, val name: String, val email: String)
// Ktor HttpClient with SSE
val client = HttpClient(CIO) {
install(SSE)
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
}
// SSE stream with total size updates (server emits integer in `data:` of each event)
fun totalCountFlow(): Flow<Int> = flow {
client.sse(method = HttpMethod.Get, urlString = "https://api.example.com/users/total/stream") {
incoming.collect { event ->
val value = event.data.trim().toIntOrNull() ?: return@collect
emit(value)
}
}
}
// SSE stream with portion updates; server emits JSON array of users for requested range
fun userPortionFlow(position: Int, size: Int): Flow<Map<Int, User>> = flow {
val url = "https://api.example.com/users/portion?start=$position&size=$size"
client.sse(method = HttpMethod.Get, urlString = url) {
incoming.collect { event ->
val users: List<User> = Json.decodeFromString(event.data)
// Map to absolute positions: position..position+size-1
val values: Map<Int, User> = users.mapIndexed { idx, user -> (position + idx) to user }.toMap()
emit(values)
}
}
}
// Create StreamingPager
@OptIn(ExperimentalStreamingPagerApi::class)
val pager = StreamingPager<User>(
config = StreamingPagerConfig(
loadSize = 20,
preloadSize = 60,
cacheSize = 100,
closeThreshold = 20,
keyDebounceMs = 300
),
readTotal = { totalCountFlow() },
readPortion = { position, size -> userPortionFlow(position, size) }
)
// Observe paging data the same way as with Pager
pager.flow.collect { pagingData ->
when (pagingData.loadState) {
LoadState.Loading -> showLoader()
LoadState.Success -> hideLoader()
is LoadState.Error -> pagingData.retry(pagingData.loadState.key)
}
// Access items by position
when (val firstUser = pagingData.data[0]) {
EntryState.Loading -> showItemLoader()
is EntryState.Success -> displayUser(firstUser.value)
}
}
// Notes:
// - Positions must be absolute across the dataset (same as with `Pager`).
// - When total size shrinks, the pager cancels out-of-bounds flows and prunes cached values automatically.This project is licensed under the Apache License 2.0. See LICENSE for details.
PRs and discussions are welcome. Please maintain code style and add examples to paging-samples for new features.
Lightweight and efficient paging library for Kotlin Multiplatform with intelligent preloading, caching, and coroutines support.
Prerequisites: Kotlin 2.3.0, org.jetbrains.kotlinx:kotlinx-collections-immutable 0.4.0 or higher, repository
mavenCentral().
// build.gradle.kts
dependencies {
implementation("ua.wwind.paging:paging-core:2.2.6")
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.4.0")
}data class User(val id: Int, val name: String, val email: String)
val pager = Pager<User>(
loadSize = 20,
preloadSize = 60,
cacheSize = 100,
readData = { position, loadSize ->
flow {
val users = repository.getUsers(position, loadSize)
emit(
DataPortion(
totalSize = repository.getTotalCount(),
values = users.mapIndexed { index, user ->
(position + index) to user
}.toMap()
.toPersistentMap()
)
)
// Optionally emit more portions progressively if your source supports it
}
}
)
// Observe paging data
pager.flow.collect { pagingData ->
when (pagingData.loadState) {
LoadState.Loading -> showLoader()
LoadState.Success -> hideLoader()
is LoadState.Error -> pagingData.retry(pagingData.loadState.key)
}
// Access items by position
when (val firstUser = pagingData.data[0]) {
EntryState.Loading -> showItemLoader()
is EntryState.Success -> displayUser(firstUser.value)
}
}For caching and paging to work correctly, items must be addressable by an integer positional key (Int):
startPosition + indexInPortion when
building DataPortion.values.Pager and PagingMediator can merge and
window data reliably.@Composable
fun UserList() {
val pagingData by pager.flow.collectAsState()
LazyColumn {
items(count = pagingData.data.size) { index ->
when (val entry = pagingData.data[index]) {
EntryState.Loading -> LoadingItem()
is EntryState.Success -> UserItem(entry.value)
}
}
}
// Handle loading state
when (pagingData.loadState) {
LoadState.Loading -> CircularProgressIndicator()
is LoadState.Error -> ErrorMessage(pagingData.loadState.throwable) {
pagingData.retry(pagingData.loadState.key)
}
LoadState.Success -> Unit
}
}Android (API 21+) • JVM (Java 17+) • iOS • macOS • Linux • Windows • JavaScript • WebAssembly
The paging-samples module contains complete working examples:
You can transform items of PagingData while preserving loading state and retry logic.
// Given: PagingData<User>
val mapped: PagingData<String> = pagingData.map { user -> "${user.id}: ${user.name}" }
// Notes:
// - Only currently loaded items are transformed
// - loadState and retry remain unchangedCoordinate a local cache with a remote source while preserving positional paging. PagingMediator<T, Q> emits
Flow<PagingData<T>> per query, serving local data first and fetching missing ranges from the network.
Key capabilities:
Define data sources:
class UserLocalDataSource(
private val dao: UserDao
) : LocalDataSource<User> {
override suspend fun read(startPosition: Int, size: Int, query: Unit): DataPortion<User> =
dao.readPortion(startPosition, size)
override suspend fun save(portion: DataPortion<User>) {
dao.upsertPortion(portion)
}
override suspend fun clear() {
dao.clearAll()
}
}
class UserRemoteDataSource(
private val api: UserApi
) : RemoteDataSource<User, Unit> {
override suspend fun fetch(startPosition: Int, size: Int, query: Unit): DataPortion<User> =
api.fetchUsers(startPosition, size)
}Create mediator and collect:
val mediator = PagingMediator(
local = UserLocalDataSource(dao),
remote = UserRemoteDataSource(api),
config = PagingMediatorConfig(
loadSize = 20, // Number of items loaded per page
prefetchSize = 60, // Number of items to preload around current position
cacheSize = 100, // Max number of items to keep in memory cache
concurrency = 2, // Number of concurrent fetches allowed
isRecordStale = { false }, // Function to check if a record is outdated
fetchFullRangeOnMiss = false, // Whether to refetch full range if data is missing or inconsistent
emitOutdatedRecords = false, // Emit outdated records while fetching new ones
emitIntermediateResults = true, // Emit partial/intermediate load results during fetch
)
)
// Each query owns its own paging flow; use Unit if no filtering is needed
mediator.flow(Unit).collect { pagingData ->
// Same UI handling as with Pager
}If your data source is streaming and you can emit updates for the total item count and for individual portions
independently, use StreamingPager. It opens/closes portion flows dynamically around the last accessed position, while
a dedicated total-size flow keeps the item count in sync.
readTotal(): Flow<Int> emits global total count updates.readPortion(start, size): Flow<Map<Int, T>> emits only data maps for the requested range (no total).config.loadSize and preloading via config.preloadSize.config.closeThreshold from a range.config.cacheSize.LoadState across opened ranges (Loading > Error > Success).@Serializable
data class User(val id: Int, val name: String, val email: String)
// Ktor HttpClient with SSE
val client = HttpClient(CIO) {
install(SSE)
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
}
// SSE stream with total size updates (server emits integer in `data:` of each event)
fun totalCountFlow(): Flow<Int> = flow {
client.sse(method = HttpMethod.Get, urlString = "https://api.example.com/users/total/stream") {
incoming.collect { event ->
val value = event.data.trim().toIntOrNull() ?: return@collect
emit(value)
}
}
}
// SSE stream with portion updates; server emits JSON array of users for requested range
fun userPortionFlow(position: Int, size: Int): Flow<Map<Int, User>> = flow {
val url = "https://api.example.com/users/portion?start=$position&size=$size"
client.sse(method = HttpMethod.Get, urlString = url) {
incoming.collect { event ->
val users: List<User> = Json.decodeFromString(event.data)
// Map to absolute positions: position..position+size-1
val values: Map<Int, User> = users.mapIndexed { idx, user -> (position + idx) to user }.toMap()
emit(values)
}
}
}
// Create StreamingPager
@OptIn(ExperimentalStreamingPagerApi::class)
val pager = StreamingPager<User>(
config = StreamingPagerConfig(
loadSize = 20,
preloadSize = 60,
cacheSize = 100,
closeThreshold = 20,
keyDebounceMs = 300
),
readTotal = { totalCountFlow() },
readPortion = { position, size -> userPortionFlow(position, size) }
)
// Observe paging data the same way as with Pager
pager.flow.collect { pagingData ->
when (pagingData.loadState) {
LoadState.Loading -> showLoader()
LoadState.Success -> hideLoader()
is LoadState.Error -> pagingData.retry(pagingData.loadState.key)
}
// Access items by position
when (val firstUser = pagingData.data[0]) {
EntryState.Loading -> showItemLoader()
is EntryState.Success -> displayUser(firstUser.value)
}
}
// Notes:
// - Positions must be absolute across the dataset (same as with `Pager`).
// - When total size shrinks, the pager cancels out-of-bounds flows and prunes cached values automatically.This project is licensed under the Apache License 2.0. See LICENSE for details.
PRs and discussions are welcome. Please maintain code style and add examples to paging-samples for new features.