
Powerful, flexible pagination toolkit with bidirectional navigation, arbitrary jumps, bookmarks, caching, element-level CRUD, capacity/incomplete-page handling, reactive snapshot flows, parallel loading and state serialization.
Paginator is a powerful, flexible pagination library for Kotlin Multiplatform (KMP) that goes far beyond simple "load next page" patterns. It provides a full-featured page management system with support for jumping to arbitrary pages, bidirectional navigation, bookmarks, page caching, element-level CRUD, incomplete page handling, capacity management, and reactive state via Kotlin Flows.
The library exposes two flavors that share the same page-state model, caches, CRUD, UI state and snapshot flows:
Paginator / MutablePaginator — offset/page-number addressing (MutableList-like).CursorPaginator / MutableCursorPaginator — cursor-based, prev/self/next linked
navigation (LinkedList-like). See
Cursor-Based Pagination.Built entirely with pure Kotlin and without platform-specific dependencies, Paginator can be seamlessly used across all layers of an application — from data to domain to presentation — while preserving Clean Architecture principles and proper layer separation.
Supported targets: Android · JVM · iosX64 · iosArm64 · iosSimulatorArm64
The library is published to Maven Central. No additional repository configuration needed.
Add the dependency to commonMain in your module's build.gradle.kts:
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.github.jamal-wia:paginator:8.3.1")
}
}
}Gradle automatically resolves the correct platform artifact (android, jvm, iosArm64, etc.)
from the KMP metadata.
dependencies {
implementation("io.github.jamal-wia:paginator:8.3.1")
}dependencies {
implementation("io.github.jamal-wia:paginator-jvm:8.3.1")
}The simplest way to create a MutablePaginator is via the DSL builder:
import com.jamal_aliev.paginator.dsl.mutablePaginator
import com.jamal_aliev.paginator.load.LoadResult
class MyViewModel : ViewModel() {
private val paginator = mutablePaginator<Item> {
load { page -> LoadResult(repository.loadPage(page)) }
}
}The load { } block is the only required call — every other knob (capacity, cache strategy,
logger, bookmarks, custom PageState factories) has sensible defaults. See
DSL Builder for the full configuration surface.
If you only need read-only navigation, use paginator<T> { … } instead — it returns a
Paginator<T>, so element-level mutations are not exposed at the call site.
The load lambda receives an Int page number and should return a LoadResult<T> wrapping
your data list. For the simplest case, just wrap with LoadResult(list). The direct constructor
form (MutablePaginator(load = { … })) is also still available if you prefer it.
Subscribe to paginator.uiState to receive UI updates, then start the paginator by jumping to the
first page:
init {
paginator.uiState
.onEach { state ->
when (state) {
is PaginatorUiState.Content -> showContent(state.items)
is PaginatorUiState.Loading -> showLoading()
is PaginatorUiState.Empty -> showEmpty()
is PaginatorUiState.Error -> showError(state.cause)
is PaginatorUiState.Idle -> Unit
}
}
.flowOn(Dispatchers.Main)
.launchIn(viewModelScope)
viewModelScope.launch {
paginator.jump(bookmark = BookmarkInt(page = 1))
}
}uiState emits Idle / Loading / Empty / Error / Content(items, prependState, appendState)
so your UI does not have to reason about individual PageStates. If you need raw page-level access,
collect paginator.core.snapshot instead. See
State, Transactions & Locks → PaginatorUiState.
// Load next page (triggered by scroll reaching the end)
fun loadMore() {
viewModelScope.launch { paginator.goNextPage() }
}
// Load previous page (triggered by scroll reaching the top)
fun loadPrevious() {
viewModelScope.launch { paginator.goPreviousPage() }
}When the paginator is no longer needed, release its resources:
override fun onCleared() {
paginator.release()
super.onCleared()
}Paginator works perfectly for a simple infinite scroll — and this is a first-class use case, not an afterthought.
Every feature in the library is strictly opt-in. If all you need is "load the next page when the
user scrolls down", the entire setup is what you already saw in Quick Start: one load lambda,
one uiState observer, and goNextPage() on scroll. Nothing else is required.
What you still get for free, with zero extra code:
ProgressPage while the next page loads — no manual loading flag neededErrorPage with the previously cached data intact — a failed request won't clear the screenStart with the simplest setup. Adopt advanced features only if and when your product actually needs them.
If your backend returns opaque continuation tokens instead of numeric page offsets (GraphQL connections, chat feeds, activity streams, Slack/Instagram/Reddit-style APIs), reach for the cursor variant:
import com.jamal_aliev.paginator.bookmark.CursorBookmark
import com.jamal_aliev.paginator.dsl.mutableCursorPaginator
import com.jamal_aliev.paginator.load.CursorLoadResult
val messages = mutableCursorPaginator<Message>(capacity = 50) {
load { cursor ->
val page = api.getMessages(cursor?.self as? String)
CursorLoadResult(
data = page.items,
bookmark = CursorBookmark(
prev = page.prevCursor, // null at the head of the feed
self = page.selfCursor, // required — cache key
next = page.nextCursor, // null at the tail of the feed
),
)
}
}
viewModelScope.launch {
messages.restart() // bootstrap from the first cursor (or initialCursor if set)
messages.goNextPage() // follows endContextCursor.next — throws EndOfCursorFeedException at tail
messages.goPreviousPage() // follows startContextCursor.prev — throws at head
}The cursor paginator shares caches, CRUD, UI state (paginator.uiState), snapshot flow,
transaction { }, prefetch controller, logger, and serialization with the offset variant — it
differs only in how pages are addressed. Read the full guide at
Cursor-Based Pagination.
Paginator (MutableList-like, numeric page
addressing) and cursor-based CursorPaginator (LinkedList-like, prev/self/next tokens)
sharing the same page-state model, caches, CRUD surface, UI state and snapshot flow. See
Cursor-Based Pagination
goNextPage) and backward (goPreviousPage)jump(bookmark)
jumpForward / jumpBack,
with optional recycling (wrap-around)goNextPage, showing cached data with a loading
indicatorfinalPage to enforce a maximum page boundary (typically from backend
metadata), throwing FinalPageExceededException when exceededPagingCore:
LRU, FIFO, TTL, and Sliding Window (context-only). Eviction listener callback for reacting to
page removalsnapshot Flow (visible pages) or asFlow() (
entire cache)paginator.uiState: Flow<PaginatorUiState<T>> collapses the raw
snapshot into Idle / Loading / Empty / Error / Content(items, prependState, appendState)
for screens that only need full-screen indicators and boundary activity markersload returns LoadResult<T>, an open wrapper that carries both
page data and arbitrary metadata from the API response (total count, cursors, etc.). Metadata
flows through initializer lambdas into custom PageState subclassesSuccessPage, ErrorPage, ProgressPage, or
EmptyPage with your own types via initializer lambdasgoNextPage, goPreviousPage, jump). CRUD operations can also mark pages
dirty via the isDirty flagPaginator (read-only navigation, dirty tracking, release) and
MutablePaginator (element-level CRUD, resize, public setState)paginator<T> { … } and mutablePaginator<T> { … } blocks that
collapse PagingCore setup, cache composition, bookmarks, logger and custom PageState
initializers into one configuration sitePaginator (find, count, flatten,
firstOrNull, contains, …) and bulk CRUD on MutablePaginator (prependElement,
moveElement, swapElements, insertBefore/After, removeAll, retainAll, distinctBy,
updateAll/updateWhere)lockJump, lockGoNextPage,
lockGoPreviousPage, lockRestart, lockRefresh)PaginatorPrefetchController monitors scroll position and
automatically loads the next/previous page before the user reaches the edge of contentloadOrGetPageState
PaginatorLogger interface to receive detailed logs about
navigation, state changes, and element-level operations. No logging by default (null)kotlinx.serialization, enabling seamless recovery after process death on any KMP targettransaction { }. If any
exception occurs (including coroutine cancellation), the entire paginator state is rolled backstartContextPage..endContextPage), which defines the visible snapshotFlow<PaginatorUiState<T>>.interweave(weaver) operator that inserts
meta-rows (date headers, unread dividers, section labels, …) between data items without touching
the paginator core, cache, CRUD, serialization, or DSLIn-depth articles comparing Paginator with Jetpack Paging 3 and demonstrating real-world implementation patterns:
English
Русский
Detailed documentation lives in the docs/ directory:
PageState, Paginator vs MutablePaginator,
context window, bookmarks, LoadResult & metadata, capacity, final page limitgoNextPage, goPreviousPage, jump,
jumpForward /
jumpBack, restart, refresh
transaction { }, lock flagsPageState subclasses, PlaceholderPageState, metadata propagationkotlinx.serialization, surviving process deathPaginatorPrefetchController
PaginatorLogger
PageExt,
iteration, search/aggregation, CRUD, refresh, prefetch) plus a complete ViewModel examplepaginator<T> { … } and
mutablePaginator<T> { … } builder DSLFlow operator that interleaves
meta-rows (date headers, unread dividers, …) between data itemsCursorPaginator /
MutableCursorPaginator for opaque-token feeds (GraphQL connections, chat, activity streams)Maintainer docs:
The MIT License (MIT)
Copyright (c) 2023 Jamal Aliev
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Paginator is a powerful, flexible pagination library for Kotlin Multiplatform (KMP) that goes far beyond simple "load next page" patterns. It provides a full-featured page management system with support for jumping to arbitrary pages, bidirectional navigation, bookmarks, page caching, element-level CRUD, incomplete page handling, capacity management, and reactive state via Kotlin Flows.
The library exposes two flavors that share the same page-state model, caches, CRUD, UI state and snapshot flows:
Paginator / MutablePaginator — offset/page-number addressing (MutableList-like).CursorPaginator / MutableCursorPaginator — cursor-based, prev/self/next linked
navigation (LinkedList-like). See
Cursor-Based Pagination.Built entirely with pure Kotlin and without platform-specific dependencies, Paginator can be seamlessly used across all layers of an application — from data to domain to presentation — while preserving Clean Architecture principles and proper layer separation.
Supported targets: Android · JVM · iosX64 · iosArm64 · iosSimulatorArm64
The library is published to Maven Central. No additional repository configuration needed.
Add the dependency to commonMain in your module's build.gradle.kts:
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.github.jamal-wia:paginator:8.3.1")
}
}
}Gradle automatically resolves the correct platform artifact (android, jvm, iosArm64, etc.)
from the KMP metadata.
dependencies {
implementation("io.github.jamal-wia:paginator:8.3.1")
}dependencies {
implementation("io.github.jamal-wia:paginator-jvm:8.3.1")
}The simplest way to create a MutablePaginator is via the DSL builder:
import com.jamal_aliev.paginator.dsl.mutablePaginator
import com.jamal_aliev.paginator.load.LoadResult
class MyViewModel : ViewModel() {
private val paginator = mutablePaginator<Item> {
load { page -> LoadResult(repository.loadPage(page)) }
}
}The load { } block is the only required call — every other knob (capacity, cache strategy,
logger, bookmarks, custom PageState factories) has sensible defaults. See
DSL Builder for the full configuration surface.
If you only need read-only navigation, use paginator<T> { … } instead — it returns a
Paginator<T>, so element-level mutations are not exposed at the call site.
The load lambda receives an Int page number and should return a LoadResult<T> wrapping
your data list. For the simplest case, just wrap with LoadResult(list). The direct constructor
form (MutablePaginator(load = { … })) is also still available if you prefer it.
Subscribe to paginator.uiState to receive UI updates, then start the paginator by jumping to the
first page:
init {
paginator.uiState
.onEach { state ->
when (state) {
is PaginatorUiState.Content -> showContent(state.items)
is PaginatorUiState.Loading -> showLoading()
is PaginatorUiState.Empty -> showEmpty()
is PaginatorUiState.Error -> showError(state.cause)
is PaginatorUiState.Idle -> Unit
}
}
.flowOn(Dispatchers.Main)
.launchIn(viewModelScope)
viewModelScope.launch {
paginator.jump(bookmark = BookmarkInt(page = 1))
}
}uiState emits Idle / Loading / Empty / Error / Content(items, prependState, appendState)
so your UI does not have to reason about individual PageStates. If you need raw page-level access,
collect paginator.core.snapshot instead. See
State, Transactions & Locks → PaginatorUiState.
// Load next page (triggered by scroll reaching the end)
fun loadMore() {
viewModelScope.launch { paginator.goNextPage() }
}
// Load previous page (triggered by scroll reaching the top)
fun loadPrevious() {
viewModelScope.launch { paginator.goPreviousPage() }
}When the paginator is no longer needed, release its resources:
override fun onCleared() {
paginator.release()
super.onCleared()
}Paginator works perfectly for a simple infinite scroll — and this is a first-class use case, not an afterthought.
Every feature in the library is strictly opt-in. If all you need is "load the next page when the
user scrolls down", the entire setup is what you already saw in Quick Start: one load lambda,
one uiState observer, and goNextPage() on scroll. Nothing else is required.
What you still get for free, with zero extra code:
ProgressPage while the next page loads — no manual loading flag neededErrorPage with the previously cached data intact — a failed request won't clear the screenStart with the simplest setup. Adopt advanced features only if and when your product actually needs them.
If your backend returns opaque continuation tokens instead of numeric page offsets (GraphQL connections, chat feeds, activity streams, Slack/Instagram/Reddit-style APIs), reach for the cursor variant:
import com.jamal_aliev.paginator.bookmark.CursorBookmark
import com.jamal_aliev.paginator.dsl.mutableCursorPaginator
import com.jamal_aliev.paginator.load.CursorLoadResult
val messages = mutableCursorPaginator<Message>(capacity = 50) {
load { cursor ->
val page = api.getMessages(cursor?.self as? String)
CursorLoadResult(
data = page.items,
bookmark = CursorBookmark(
prev = page.prevCursor, // null at the head of the feed
self = page.selfCursor, // required — cache key
next = page.nextCursor, // null at the tail of the feed
),
)
}
}
viewModelScope.launch {
messages.restart() // bootstrap from the first cursor (or initialCursor if set)
messages.goNextPage() // follows endContextCursor.next — throws EndOfCursorFeedException at tail
messages.goPreviousPage() // follows startContextCursor.prev — throws at head
}The cursor paginator shares caches, CRUD, UI state (paginator.uiState), snapshot flow,
transaction { }, prefetch controller, logger, and serialization with the offset variant — it
differs only in how pages are addressed. Read the full guide at
Cursor-Based Pagination.
Paginator (MutableList-like, numeric page
addressing) and cursor-based CursorPaginator (LinkedList-like, prev/self/next tokens)
sharing the same page-state model, caches, CRUD surface, UI state and snapshot flow. See
Cursor-Based Pagination
goNextPage) and backward (goPreviousPage)jump(bookmark)
jumpForward / jumpBack,
with optional recycling (wrap-around)goNextPage, showing cached data with a loading
indicatorfinalPage to enforce a maximum page boundary (typically from backend
metadata), throwing FinalPageExceededException when exceededPagingCore:
LRU, FIFO, TTL, and Sliding Window (context-only). Eviction listener callback for reacting to
page removalsnapshot Flow (visible pages) or asFlow() (
entire cache)paginator.uiState: Flow<PaginatorUiState<T>> collapses the raw
snapshot into Idle / Loading / Empty / Error / Content(items, prependState, appendState)
for screens that only need full-screen indicators and boundary activity markersload returns LoadResult<T>, an open wrapper that carries both
page data and arbitrary metadata from the API response (total count, cursors, etc.). Metadata
flows through initializer lambdas into custom PageState subclassesSuccessPage, ErrorPage, ProgressPage, or
EmptyPage with your own types via initializer lambdasgoNextPage, goPreviousPage, jump). CRUD operations can also mark pages
dirty via the isDirty flagPaginator (read-only navigation, dirty tracking, release) and
MutablePaginator (element-level CRUD, resize, public setState)paginator<T> { … } and mutablePaginator<T> { … } blocks that
collapse PagingCore setup, cache composition, bookmarks, logger and custom PageState
initializers into one configuration sitePaginator (find, count, flatten,
firstOrNull, contains, …) and bulk CRUD on MutablePaginator (prependElement,
moveElement, swapElements, insertBefore/After, removeAll, retainAll, distinctBy,
updateAll/updateWhere)lockJump, lockGoNextPage,
lockGoPreviousPage, lockRestart, lockRefresh)PaginatorPrefetchController monitors scroll position and
automatically loads the next/previous page before the user reaches the edge of contentloadOrGetPageState
PaginatorLogger interface to receive detailed logs about
navigation, state changes, and element-level operations. No logging by default (null)kotlinx.serialization, enabling seamless recovery after process death on any KMP targettransaction { }. If any
exception occurs (including coroutine cancellation), the entire paginator state is rolled backstartContextPage..endContextPage), which defines the visible snapshotFlow<PaginatorUiState<T>>.interweave(weaver) operator that inserts
meta-rows (date headers, unread dividers, section labels, …) between data items without touching
the paginator core, cache, CRUD, serialization, or DSLIn-depth articles comparing Paginator with Jetpack Paging 3 and demonstrating real-world implementation patterns:
English
Русский
Detailed documentation lives in the docs/ directory:
PageState, Paginator vs MutablePaginator,
context window, bookmarks, LoadResult & metadata, capacity, final page limitgoNextPage, goPreviousPage, jump,
jumpForward /
jumpBack, restart, refresh
transaction { }, lock flagsPageState subclasses, PlaceholderPageState, metadata propagationkotlinx.serialization, surviving process deathPaginatorPrefetchController
PaginatorLogger
PageExt,
iteration, search/aggregation, CRUD, refresh, prefetch) plus a complete ViewModel examplepaginator<T> { … } and
mutablePaginator<T> { … } builder DSLFlow operator that interleaves
meta-rows (date headers, unread dividers, …) between data itemsCursorPaginator /
MutableCursorPaginator for opaque-token feeds (GraphQL connections, chat, activity streams)Maintainer docs:
The MIT License (MIT)
Copyright (c) 2023 Jamal Aliev
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.