
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.
Supported targets: Android ยท JVM ยท iosX64 ยท iosArm64 ยท iosSimulatorArm64
Telegram Community | YouTube Tutorial (RU)
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 exceededsnapshot Flow (visible pages) or asFlow() (
entire cache)SuccessPage, 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)lockJump, lockGoNextPage,
lockGoPreviousPage, lockRestart, lockRefresh)loadOrGetPageState
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 targetstartContextPage..endContextPage), which defines the visible snapshotThe 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:7.2.1")
}
}
}Gradle automatically resolves the correct platform artifact (android, jvm, iosArm64, etc.)
from the KMP metadata.
dependencies {
implementation("io.github.jamal-wia:paginator:7.2.1")
}dependencies {
implementation("io.github.jamal-wia:paginator-jvm:7.2.1")
}Create a MutablePaginator in your ViewModel or Presenter, providing a data source lambda:
class MyViewModel : ViewModel() {
private val paginator = MutablePaginator<Item>(source = { page ->
repository.loadPage(page)
})
}The source lambda receives an Int page number and should return a List<T>.
Subscribe to the snapshot Flow to receive UI updates, then start the paginator by jumping to the
first page:
init {
paginator.snapshot
.filter { it.isNotEmpty() }
.onEach { pages -> updateUI(pages) }
.flowOn(Dispatchers.Main)
.launchIn(viewModelScope)
viewModelScope.launch {
paginator.jump(bookmark = BookmarkInt(page = 1))
}
}// 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()
}PageState<E> is a sealed class representing the state of a single page. Every page has a
page: Int number, data: List<E> items, and a unique id: Long.
| Type | Description |
|---|---|
SuccessPage<T> |
Successfully loaded page with non-empty data |
EmptyPage<T> |
Successfully loaded page with no data (extends SuccessPage) |
ProgressPage<T> |
Page currently being loaded. May contain cached data from a previous load |
ErrorPage<T> |
Page that failed to load. Carries the exception and may contain previously cached data |
All PageState subclasses are open, so you can create your own custom types:
class MyCustomProgress<T>(
page: Int,
data: List<T>,
val progressPercent: Int = 0
) : PageState.ProgressPage<T>(page, data)The library provides two classes with different levels of access:
Paginator<T> |
MutablePaginator<T> |
|
|---|---|---|
| Role | Read-only base class | Full-featured mutable extension |
| Navigation |
jump, goNextPage, goPreviousPage, restart, refresh
|
Inherits all from Paginator
|
| State access |
getStateOf, getElement, scan, snapshot
|
setState (public), removeState
|
| CRUD | -- |
setElement, removeElement, addAllElements, replaceAllElements
|
| Capacity | Read-only capacity
|
core.resize() |
| Dirty pages |
markDirty, clearDirty, isDirty
|
Inherits + isDirty param on CRUD ops |
| Lifecycle | release() |
Inherits |
When to use Paginator: Use it when you only need to navigate and observe pages (e.g., a
read-only list screen). It keeps setState protected, preventing accidental cache mutations from
outside the class hierarchy.
When to use MutablePaginator: Use it when you need element-level CRUD, capacity resizing, or
direct state manipulation. Most use cases will use this class.
// Most common: full-featured paginator
val paginator = MutablePaginator<String>(source = { page ->
api.fetchItems(page)
})
// Read-only: only navigation, no element mutations
val readOnlyPaginator = Paginator<String>(source = { page ->
api.fetchItems(page)
})The constructor takes a single source lambda -- a suspending function that loads data for a given
page. The receiver is the paginator itself, giving you access to its properties during loading.
The paginator maintains a context window defined by startContextPage and endContextPage.
This represents the contiguous range of successfully loaded pages visible to the user. Thesnapshot
Flow emits only pages within (and adjacent to) this window.
When you call goNextPage, the window expands forward. When you call goPreviousPage, it expands
backward. When you jump, the window resets to the target page and expands outward.
Bookmarks are predefined page targets for quick navigation:
paginator.bookmarks.addAll(
listOf(
BookmarkInt(5),
BookmarkInt(10),
BookmarkInt(15),
)
)
paginator.recyclingBookmark = true // Wrap around when reaching the endNavigate through bookmarks with:
jumpForward() -- moves to the next bookmarkjumpBack() -- moves to the previous bookmarkYou can also implement the Bookmark interface for custom bookmark types.
capacity defines the expected number of items per page (default: 20). This is critical for the
paginator to determine whether a page is filled (complete) or incomplete.
paginator.core.resize(capacity = 10, resize = false, silently = true)When a page returns fewer items than capacity, the paginator considers it incomplete. On the
next goNextPage call, instead of advancing to a new page, the paginator re-requests the same page.
During this re-request, it creates a ProgressPage containing the previously cached data, so the UI
can show the existing items alongside a loading indicator.
This is useful when a backend occasionally returns partial results.
Set capacity to UNLIMITED_CAPACITY (0) to disable capacity checks entirely.
Set finalPage to enforce an upper boundary on pagination:
paginator.finalPage = 20 // Typically from backend metadataAny attempt to navigate beyond this page (via goNextPage, jump, etc.) throws
FinalPageExceededException:
try {
paginator.goNextPage()
} catch (e: FinalPageExceededException) {
showMessage("Reached page ${e.finalPage}, no more data")
}Loads the page after the current endContextPage. If the paginator hasn't started, it automatically
jumps to page 1.
suspend fun goNextPage(): PageState<T>Behavior:
endContextPage to find the true end of contiguous success pagesfinalPage, throws FinalPageExceededException
ProgressPage, returns immediately (deduplication)ProgressPage, loads from source, and updates the cacheLoads the page before the current startContextPage. Requires the paginator to be started.
suspend fun goPreviousPage(): PageState<T>Behavior mirrors goNextPage but in the backward direction. Shows a loading indicator at the top.
Jumps directly to any page by bookmark:
suspend fun jump(bookmark: Bookmark): Pair<Bookmark, PageState<T>>If the target page is already cached as a filled success page, returns immediately without reloading. Otherwise, resets the context window to the target page and loads it.
Navigate through the bookmark list:
suspend fun jumpForward(): Pair<Bookmark, PageState<T>>? // null if no more bookmarks
suspend fun jumpBack(): Pair<Bookmark, PageState<T>>? // null if no more bookmarksWhen recyclingBookmark = true, the iterator wraps around.
Clears all cached pages except page 1's structure, resets the context to page 1, and reloads it:
suspend fun restart()Ideal for swipe-to-refresh.
Reloads specific pages in parallel without clearing the cache:
suspend fun refresh(pages: List<Int>)Sets all specified pages to ProgressPage (preserving cached data), then reloads them concurrently.
Use the extension refreshAll() to refresh every cached page.
Pages can be marked as dirty to indicate that their data is stale and needs to be refreshed.
When a navigation function (jump, goNextPage, goPreviousPage) completes, it automatically
checks for dirty pages within the current context window (startContextPage..endContextPage) and
launches a fire-and-forget refresh for them in parallel.
// Mark a single page
paginator.core.markDirty(3)
// Mark multiple pages
paginator.core.markDirty(listOf(1, 2, 3))
// CRUD operations can also mark the affected page as dirty
paginator.setElement(updatedItem, page = 3, index = 0, isDirty = true)
paginator.removeElement(page = 3, index = 2, isDirty = true)
paginator.addAllElements(listOf(newItem), targetPage = 3, index = 0, isDirty = true)// Clear a single page
paginator.core.clearDirty(3)
// Clear multiple pages
paginator.core.clearDirty(listOf(1, 2))
// Clear all dirty flags
paginator.core.clearAllDirty()Dirty flags are also automatically cleared:
refresh() completes for the refreshed pagesrelease() resets the paginatorpaginator.core.isDirty(3) // true if page 3 is dirty
paginator.core.dirtyPages // Set<Int> snapshot of all dirty page numbersWhen navigation completes (e.g., goNextPage loads page 5 successfully):
dirtyPages for pages in startContextPage..endContextPage
refresh(pages = dirtyInContext) is launched in a separate coroutine (fire-and-forget)This ensures the user sees the navigation result instantly while stale pages are silently refreshed in the background.
The primary way to observe the paginator's visible state:
val snapshot: Flow<List<PageState<T>>>Emits a list of PageState objects within the context window whenever a navigation action
completes. This is what your UI should collect.
paginator.snapshot
.onEach { pages -> adapter.submitList(pages) }
.launchIn(scope)For advanced use cases, observe the entire cache:
val cacheFlow: Flow<List<PageState<T>>> = paginator.core.asFlow()This emits the complete cache list (all pages, including those outside the context window).
The paginator supports CRUD operations on individual items within pages:
// Get an element
val item: T? = paginator.core.getElement(page = 3, index = 0)
// Set/replace an element
paginator.setElement(element = updatedItem, page = 3, index = 0)
// Remove an element (auto-rebalances pages)
val removed: T = paginator.removeElement(page = 3, index = 2)
// Add elements (overflows cascade to next pages)
paginator.addAllElements(
elements = listOf(newItem),
targetPage = 3,
index = 0
)
// Replace all matching elements across all pages
paginator.replaceAllElements(
providerElement = { current, _, _ -> current.copy(read = true) },
predicate = { current, _, _ -> current.id == targetId }
)When removing elements causes a page to drop below capacity, items are pulled from the next page
to fill the gap. When adding elements causes overflow beyond capacity, excess items cascade to
subsequent pages.
All mutating operations (setElement, removeElement, addAllElements) accept an optional
isDirty: Boolean = false parameter. When set to true, the affected page is marked as dirty and
will be automatically refreshed on the next navigation (see Dirty Pages).
You can create custom PageState subclasses and use them via the initializer lambdas:
// Custom progress page with additional metadata
class DetailedProgress<T>(
page: Int,
data: List<T>,
val source: String = "network"
) : PageState.ProgressPage<T>(page, data)
// Register the custom initializer
paginator.initializerProgressPage = { page, data ->
DetailedProgress(page = page, data = data, source = "api")
}Available initializer properties:
initializerProgressPage: (page: Int, data: List<T>) -> ProgressPage<T>initializerSuccessPage: (page: Int, data: List<T>) -> SuccessPage<T>initializerEmptyPage: (page: Int, data: List<T>) -> EmptyPage<T>initializerErrorPage: (exception: Exception, page: Int, data: List<T>) -> ErrorPage<T>Use isRealProgressState(MyCustomProgress::class) and similar extension functions to check for
specific subclasses with smart-casting.
The paginator supports saving and restoring its full state via kotlinx.serialization. This is
essential for surviving Android/iOS process death -- when the system kills your app, you can save the
paginator state to SavedStateHandle or a file and restore it when the user returns.
There are two levels of serialization:
finalPage, bookmarks, bookmark position, lock flagsYour element type T must be annotated with @Serializable:
@Serializable
data class Article(val id: Long, val title: String, val body: String)Use saveStateToJson to serialize the entire Paginator state into a JSON string. This is a
suspend function that acquires the navigation mutex for thread-safety:
val json: String = paginator.saveStateToJson(Article.serializer())To save only pages within the current context window (reduces snapshot size):
val json: String = paginator.saveStateToJson(Article.serializer(), contextOnly = true)Persist it using whatever mechanism fits your platform:
// Android โ SavedStateHandle (survives process death)
savedStateHandle["paginator_state"] = json
// Any KMP target โ file storage
// (use expect/actual or a KMP file I/O library)Use restoreStateFromJson to rebuild the full state from a previously saved JSON string.
The snapshot is validated before restoration โ invalid data throws IllegalArgumentException:
val json: String? = savedStateHandle["paginator_state"]
if (json != null) {
paginator.restoreStateFromJson(json, Article.serializer())
}After restoration, the paginator is ready to use immediately โ the snapshot flow emits the
restored pages, and navigation (goNextPage, goPreviousPage, jump) works as normal.
| Included | Not included (re-initialize in code) |
|---|---|
| All cached page data |
source lambda |
| Context window boundaries | Logger |
| Capacity | |
| Dirty page flags | |
finalPage |
|
| Bookmarks & bookmark position | |
| Lock flags | |
| Error messages from ErrorPages |
| Included | Not included (re-initialize in code) |
|---|---|
| All cached page data |
source lambda |
| Context window boundaries | finalPage |
| Capacity | Bookmarks |
| Dirty page flags | Lock flags |
| Error messages from ErrorPages |
ErrorPage and ProgressPage cannot be serialized as-is (Exception is not serializable, and
in-flight loads are meaningless after process death). During save, these pages are converted:
exception.message) is preserved in the errorMessage field of
PageEntry, so the UI can display the error reason after restorationSuccessPage (or EmptyPage if data was empty)When restoring from a snapshot, the following validations are performed:
capacity must be > 0startContextPage and endContextPage must be >= 0startContextPage must be <= endContextPage (unless both are 0)Invalid snapshots throw IllegalArgumentException.
For advanced use cases, you can work with PagingCore directly. These methods are not
suspend and do not acquire the mutex โ ensure proper synchronization if calling
concurrently:
// Save to a snapshot object
val snapshot: PagingCoreSnapshot<Article> = paginator.core.saveState()
// Save only context window pages
val snapshot: PagingCoreSnapshot<Article> = paginator.core.saveState(contextOnly = true)
// Restore from a snapshot object
paginator.core.restoreState(snapshot)
// JSON round-trip
val json: String = paginator.core.saveStateToJson(Article.serializer())
paginator.core.restoreStateFromJson(json, Article.serializer())For custom serialization formats or non-JSON persistence:
// Save to a snapshot object (suspend, thread-safe)
val snapshot: PaginatorSnapshot<Article> = paginator.saveState()
// Restore from a snapshot object (suspend, thread-safe)
paginator.restoreState(snapshot)Prevent specific operations at runtime:
| Flag | Blocks | Exception |
|---|---|---|
lockJump |
jump, jumpForward, jumpBack
|
JumpWasLockedException |
lockGoNextPage |
goNextPage |
GoNextPageWasLockedException |
lockGoPreviousPage |
goPreviousPage |
GoPreviousPageWasLockedException |
lockRestart |
restart |
RestartWasLockedException |
lockRefresh |
refresh, refreshAll
|
RefreshWasLockedException |
paginator.lockGoNextPage = true // Temporarily prevent forward paginationAll locks are reset to false on release().
The paginator supports pluggable logging via the PaginatorLogger interface. By default, no logging is
performed (logger is null). Implement the interface and assign it to paginator.logger to receive
logs about navigation, state changes, and element-level operations.
interface PaginatorLogger {
fun log(tag: String, message: String)
}import com.jamal_aliev.paginator.logger.PaginatorLogger
// Platform-agnostic (works on all KMP targets)
object ConsoleLogger : PaginatorLogger {
override fun log(tag: String, message: String) {
println("[$tag] $message")
}
}
// Android-specific
object AndroidLogger : PaginatorLogger {
override fun log(tag: String, message: String) {
android.util.Log.d(tag, message)
}
}
val paginator = MutablePaginator<String>(source = { page ->
api.fetchItems(page)
}).apply {
logger = ConsoleLogger // or AndroidLogger on Android
}| Operation | Example message |
|---|---|
jump |
jump: page=5 |
jumpForward |
jumpForward: recycling=true |
jumpBack |
jumpBack: recycling=false |
goNextPage |
goNextPage: page=3 result=SuccessPage |
goPreviousPage |
goPreviousPage: page=1 result=SuccessPage |
restart |
restart |
refresh |
refresh: pages=[1, 2, 3] |
setState |
setState: page=2 |
setElement |
setElement: page=1 index=0 isDirty=false |
removeElement |
removeElement: page=2 index=3 isDirty=false |
addAllElements |
addAllElements: targetPage=1 index=0 count=5 isDirty=false |
removeState |
removeState: page=3 |
resize |
resize: capacity=10 resize=true |
release |
release |
markDirty |
markDirty: page=3 |
clearDirty |
clearDirty: page=3 |
clearAllDirty |
clearAllDirty |
refreshDirtyPagesInContext |
refreshDirtyPagesInContext: pages=[2, 3] |
Type-checking with Kotlin contracts for smart-casting:
pageState.isProgressState() // true if ProgressPage
pageState.isSuccessState() // true if SuccessPage (but NOT EmptyPage)
pageState.isEmptyState() // true if EmptyPage
pageState.isErrorState() // true if ErrorPage
// Check for specific custom subclasses
pageState.isRealProgressState(MyCustomProgress::class)Distance calculations:
pageA near pageB // true if pages are 0 or 1 apart
pageA far pageB // true if pages are more than 1 apart
pageA gap pageB // Int distance between page numbers// Search for elements
paginator.indexOfFirst { it.id == targetId } // Returns Pair<Int, Int>? (page, index)
paginator.indexOfLast { it.name == "test" } // Search in reverse
paginator.getElement { it.id == targetId } // Get first matching element
// Modify elements
paginator.setElement(updatedItem) { it.id == targetId }
paginator.removeElement { it.id == targetId }
paginator.addElement(newItem) // Append to last page
// Iteration
paginator.forEach { pageState -> /* ... */ }
paginator.smartForEach { states, index, currentState -> /* continue? */ true }
// Traversal
paginator.walkForwardWhile(startState) { it.isSuccessState() }
paginator.walkBackwardWhile(endState) { it.isSuccessState() }
// Refresh all cached pages
paginator.refreshAll()A complete ViewModel demonstrating all major features:
class PaginatorViewModel : ViewModel() {
private val _uiState = MutableStateFlow<List<PageState<String>>>(emptyList())
val uiState = _uiState.asStateFlow()
private val paginator = MutablePaginator<String>(source = { page ->
repository.loadPage(page)
}).apply {
core.resize(capacity = 5, resize = false, silently = true)
finalPage = 20
bookmarks.addAll(listOf(BookmarkInt(5), BookmarkInt(10), BookmarkInt(15)))
recyclingBookmark = true
logger = object : PaginatorLogger {
override fun log(tag: String, message: String) {
println("[$tag] $message")
}
}
}
init {
paginator.snapshot
.filter { it.isNotEmpty() }
.onEach { _uiState.value = it }
.flowOn(Dispatchers.Main)
.launchIn(viewModelScope)
viewModelScope.launch {
paginator.jump(BookmarkInt(1))
}
}
// 1. Forward pagination (loading indicator at bottom)
fun loadNextPage() = viewModelScope.launch {
try {
paginator.goNextPage()
} catch (e: FinalPageExceededException) {
showError("No more pages")
}
}
// 2. Backward pagination (loading indicator at top)
fun loadPreviousPage() = viewModelScope.launch {
paginator.goPreviousPage()
}
// 3. Jump to a user-specified page
fun jumpToPage(page: Int) = viewModelScope.launch {
try {
paginator.jump(BookmarkInt(page))
} catch (e: FinalPageExceededException) {
showError("Page exceeds limit")
}
}
// 4. Navigate bookmarks
fun nextBookmark() = viewModelScope.launch { paginator.jumpForward() }
fun prevBookmark() = viewModelScope.launch { paginator.jumpBack() }
// 5. Swipe-to-refresh
fun restart() = viewModelScope.launch { paginator.restart() }
// 6. Retry on error
fun retryPage(page: Int) = viewModelScope.launch {
paginator.refresh(pages = listOf(page))
}
override fun onCleared() {
paginator.release()
super.onCleared()
}
}Rendering in Compose:
@Composable
fun PaginatedList(pages: List<PageState<String>>) {
LazyColumn {
pages.forEach { pageState ->
when {
pageState.isSuccessState() -> {
items(pageState.data.size) { index ->
Text(pageState.data[index])
}
}
pageState.isProgressState() -> {
// Show cached data (if any) + loading indicator
items(pageState.data.size) { index ->
Text(pageState.data[index], color = Color.Gray)
}
item { CircularProgressIndicator() }
}
pageState.isErrorState() -> {
item {
ErrorCard(
message = pageState.exception.message,
onRetry = { retryPage(pageState.page) }
)
}
}
pageState.isEmptyState() -> {
item { Text("No data on page ${pageState.page}") }
}
}
}
}
}| Property | Type | Description |
|---|---|---|
source |
suspend Paginator<T>.(Int) -> List<T> |
Data source lambda |
logger |
PaginatorLogger? |
Logging interface (null by default) |
capacity |
Int (read-only) |
Expected items per page |
isCapacityUnlimited |
Boolean |
true if capacity == 0
|
pages |
List<Int> |
All cached page numbers (sorted) |
pageStates |
List<PageState<T>> |
All cached page states (sorted) |
size |
Int |
Number of cached pages |
dirtyPages |
Set<Int> |
Snapshot of all dirty page numbers |
startContextPage |
Int |
Left boundary of visible context |
endContextPage |
Int |
Right boundary of visible context |
isStarted |
Boolean |
true if context pages are set |
finalPage |
Int |
Maximum allowed page (default Int.MAX_VALUE) |
bookmarks |
MutableList<Bookmark> |
Bookmark list (default: page 1) |
recyclingBookmark |
Boolean |
Wrap-around bookmark iteration |
snapshot |
Flow<List<PageState<T>>> |
Visible page states flow |
lockJump |
Boolean |
Lock jump operations |
lockGoNextPage |
Boolean |
Lock forward navigation |
lockGoPreviousPage |
Boolean |
Lock backward navigation |
lockRestart |
Boolean |
Lock restart |
lockRefresh |
Boolean |
Lock refresh |
| Method | Returns | Description |
|---|---|---|
jump(bookmark) |
Pair<Bookmark, PageState<T>> |
Jump to a page |
jumpForward() |
Pair<Bookmark, PageState<T>>? |
Next bookmark |
jumpBack() |
Pair<Bookmark, PageState<T>>? |
Previous bookmark |
goNextPage() |
PageState<T> |
Load next page |
goPreviousPage() |
PageState<T> |
Load previous page |
restart() |
Unit |
Reset and reload page 1 |
refresh(pages) |
Unit |
Reload specific pages |
loadOrGetPageState(page, forceLoading) |
PageState<T> |
Load or get cached page |
getStateOf(page) |
PageState<T>? |
Get cached page state |
getElement(page, index) |
T? |
Get element by position |
markDirty(page) / markDirty(pages)
|
Unit |
Mark page(s) as dirty |
clearDirty(page) / clearDirty(pages)
|
Unit |
Remove dirty flag from page(s) |
clearAllDirty() |
Unit |
Remove all dirty flags |
isDirty(page) |
Boolean |
Check if page is dirty |
isFilledSuccessState(state) |
Boolean |
Check if page is complete |
snapshot(pageRange?) |
Unit |
Emit snapshot |
scan(pagesRange) |
List<PageState<T>> |
Get pages in range |
walkWhile(pivot, next, predicate) |
PageState<T>? |
Traverse pages |
findNearContextPage(start, end) |
Unit |
Find nearest context |
asFlow() |
Flow<List<PageState<T>>> |
Full cache flow |
resize(capacity, resize, silently) |
Unit |
Change capacity (via core) |
release(capacity, silently) |
Unit |
Full reset |
saveState(contextOnly) |
PaginatorSnapshot<T> |
Full state snapshot (suspend) |
restoreState(snapshot, silently) |
Unit |
Restore full state (suspend) |
saveStateToJson(serializer, json) |
String |
Save full state as JSON (suspend) |
restoreStateFromJson(str, serializer) |
Unit |
Restore full state from JSON (suspend) |
| Method | Returns | Description |
|---|---|---|
setState(state, silently) |
Unit |
Set a page state (public) |
removeState(page, silently) |
PageState<T>? |
Remove a page |
setElement(element, page, index, isDirty) |
Unit |
Replace element (optionally mark dirty) |
removeElement(page, index, isDirty) |
T |
Remove element (optionally mark dirty) |
addAllElements(elements, page, index, isDirty) |
Unit |
Insert elements (optionally mark dirty) |
replaceAllElements(provider, predicate) |
Unit |
Bulk replace |
| Operator | Class | Description |
|---|---|---|
paginator[page] |
Paginator |
Get page state |
paginator[page, index] |
Paginator |
Get element |
paginator += pageState |
MutablePaginator |
Set page state |
paginator -= page |
MutablePaginator |
Remove page |
page in paginator |
Paginator |
Check if page exists |
Step-by-step guide for publishing a new release of the library to Maven Central.
Make sure the following GitHub Secrets are configured in the repository
(Settings โ Secrets and variables โ Actions โ Repository secrets):
| Secret | Description |
|---|---|
MAVEN_CENTRAL_USERNAME |
Sonatype Central Portal token username |
MAVEN_CENTRAL_PASSWORD |
Sonatype Central Portal token password |
GPG_KEY_ID |
Last 8 characters of your GPG key ID |
GPG_KEY_PASSWORD |
Passphrase for the GPG key |
GPG_KEY |
Base64-encoded GPG private key (gpg --export-secret-keys <KEY_ID> | base64) |
In paginator/build.gradle.kts, change the version property:
version = "7.2.1" // โ new versionUpdate the version in all implementation(...) snippets in this README
(sections KMP, Android-only, and JVM) to match the new version.
git add -A
git commit -m "Bump version to 7.2.1"
git push origin master7.2.1), then select "Create new tag on publish"
7.2.1)This triggers the Publish to Maven Central GitHub Actions workflow automatically.
io.github.jamal-wia:paginator:<version>
If you need to publish from your local machine instead of CI:
Add credentials to ~/.gradle/gradle.properties (NOT the project's gradle.properties):
mavenCentralUsername=<your-sonatype-token-username>
mavenCentralPassword=<your-sonatype-token-password>
signing.keyId=<last-8-chars-of-gpg-key>
signing.password=<gpg-key-passphrase>
signing.secretKeyRingFile=<path-to-secring.gpg>Run:
./gradlew :paginator:publishAndReleaseToMavenCentral --no-configuration-cacheThe automaticRelease = true flag in build.gradle.kts ensures the deployment is
automatically released after validation. No manual "Release" button click is needed.
| Problem | Solution |
|---|---|
| Workflow fails at "Import GPG key" | Re-export and update the GPG_KEY secret: gpg --export-secret-keys <KEY_ID> | base64
|
| Deployment stuck at VALIDATED |
automaticRelease = true was not set. Either click "Release" manually in the Central Portal, or re-run with the flag enabled |
| Deployment FAILED | Check the Central Portal for validation errors (missing POM fields, signature issues, etc.) |
| Artifact not resolvable after PUBLISHED | Wait up to 30 minutes. Maven Central syncing can be slow |
| Signing error locally | Ensure signing.secretKeyRingFile points to a valid .gpg file and passphrase is correct |
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.
Supported targets: Android ยท JVM ยท iosX64 ยท iosArm64 ยท iosSimulatorArm64
Telegram Community | YouTube Tutorial (RU)
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 exceededsnapshot Flow (visible pages) or asFlow() (
entire cache)SuccessPage, 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)lockJump, lockGoNextPage,
lockGoPreviousPage, lockRestart, lockRefresh)loadOrGetPageState
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 targetstartContextPage..endContextPage), which defines the visible snapshotThe 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:7.2.1")
}
}
}Gradle automatically resolves the correct platform artifact (android, jvm, iosArm64, etc.)
from the KMP metadata.
dependencies {
implementation("io.github.jamal-wia:paginator:7.2.1")
}dependencies {
implementation("io.github.jamal-wia:paginator-jvm:7.2.1")
}Create a MutablePaginator in your ViewModel or Presenter, providing a data source lambda:
class MyViewModel : ViewModel() {
private val paginator = MutablePaginator<Item>(source = { page ->
repository.loadPage(page)
})
}The source lambda receives an Int page number and should return a List<T>.
Subscribe to the snapshot Flow to receive UI updates, then start the paginator by jumping to the
first page:
init {
paginator.snapshot
.filter { it.isNotEmpty() }
.onEach { pages -> updateUI(pages) }
.flowOn(Dispatchers.Main)
.launchIn(viewModelScope)
viewModelScope.launch {
paginator.jump(bookmark = BookmarkInt(page = 1))
}
}// 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()
}PageState<E> is a sealed class representing the state of a single page. Every page has a
page: Int number, data: List<E> items, and a unique id: Long.
| Type | Description |
|---|---|
SuccessPage<T> |
Successfully loaded page with non-empty data |
EmptyPage<T> |
Successfully loaded page with no data (extends SuccessPage) |
ProgressPage<T> |
Page currently being loaded. May contain cached data from a previous load |
ErrorPage<T> |
Page that failed to load. Carries the exception and may contain previously cached data |
All PageState subclasses are open, so you can create your own custom types:
class MyCustomProgress<T>(
page: Int,
data: List<T>,
val progressPercent: Int = 0
) : PageState.ProgressPage<T>(page, data)The library provides two classes with different levels of access:
Paginator<T> |
MutablePaginator<T> |
|
|---|---|---|
| Role | Read-only base class | Full-featured mutable extension |
| Navigation |
jump, goNextPage, goPreviousPage, restart, refresh
|
Inherits all from Paginator
|
| State access |
getStateOf, getElement, scan, snapshot
|
setState (public), removeState
|
| CRUD | -- |
setElement, removeElement, addAllElements, replaceAllElements
|
| Capacity | Read-only capacity
|
core.resize() |
| Dirty pages |
markDirty, clearDirty, isDirty
|
Inherits + isDirty param on CRUD ops |
| Lifecycle | release() |
Inherits |
When to use Paginator: Use it when you only need to navigate and observe pages (e.g., a
read-only list screen). It keeps setState protected, preventing accidental cache mutations from
outside the class hierarchy.
When to use MutablePaginator: Use it when you need element-level CRUD, capacity resizing, or
direct state manipulation. Most use cases will use this class.
// Most common: full-featured paginator
val paginator = MutablePaginator<String>(source = { page ->
api.fetchItems(page)
})
// Read-only: only navigation, no element mutations
val readOnlyPaginator = Paginator<String>(source = { page ->
api.fetchItems(page)
})The constructor takes a single source lambda -- a suspending function that loads data for a given
page. The receiver is the paginator itself, giving you access to its properties during loading.
The paginator maintains a context window defined by startContextPage and endContextPage.
This represents the contiguous range of successfully loaded pages visible to the user. Thesnapshot
Flow emits only pages within (and adjacent to) this window.
When you call goNextPage, the window expands forward. When you call goPreviousPage, it expands
backward. When you jump, the window resets to the target page and expands outward.
Bookmarks are predefined page targets for quick navigation:
paginator.bookmarks.addAll(
listOf(
BookmarkInt(5),
BookmarkInt(10),
BookmarkInt(15),
)
)
paginator.recyclingBookmark = true // Wrap around when reaching the endNavigate through bookmarks with:
jumpForward() -- moves to the next bookmarkjumpBack() -- moves to the previous bookmarkYou can also implement the Bookmark interface for custom bookmark types.
capacity defines the expected number of items per page (default: 20). This is critical for the
paginator to determine whether a page is filled (complete) or incomplete.
paginator.core.resize(capacity = 10, resize = false, silently = true)When a page returns fewer items than capacity, the paginator considers it incomplete. On the
next goNextPage call, instead of advancing to a new page, the paginator re-requests the same page.
During this re-request, it creates a ProgressPage containing the previously cached data, so the UI
can show the existing items alongside a loading indicator.
This is useful when a backend occasionally returns partial results.
Set capacity to UNLIMITED_CAPACITY (0) to disable capacity checks entirely.
Set finalPage to enforce an upper boundary on pagination:
paginator.finalPage = 20 // Typically from backend metadataAny attempt to navigate beyond this page (via goNextPage, jump, etc.) throws
FinalPageExceededException:
try {
paginator.goNextPage()
} catch (e: FinalPageExceededException) {
showMessage("Reached page ${e.finalPage}, no more data")
}Loads the page after the current endContextPage. If the paginator hasn't started, it automatically
jumps to page 1.
suspend fun goNextPage(): PageState<T>Behavior:
endContextPage to find the true end of contiguous success pagesfinalPage, throws FinalPageExceededException
ProgressPage, returns immediately (deduplication)ProgressPage, loads from source, and updates the cacheLoads the page before the current startContextPage. Requires the paginator to be started.
suspend fun goPreviousPage(): PageState<T>Behavior mirrors goNextPage but in the backward direction. Shows a loading indicator at the top.
Jumps directly to any page by bookmark:
suspend fun jump(bookmark: Bookmark): Pair<Bookmark, PageState<T>>If the target page is already cached as a filled success page, returns immediately without reloading. Otherwise, resets the context window to the target page and loads it.
Navigate through the bookmark list:
suspend fun jumpForward(): Pair<Bookmark, PageState<T>>? // null if no more bookmarks
suspend fun jumpBack(): Pair<Bookmark, PageState<T>>? // null if no more bookmarksWhen recyclingBookmark = true, the iterator wraps around.
Clears all cached pages except page 1's structure, resets the context to page 1, and reloads it:
suspend fun restart()Ideal for swipe-to-refresh.
Reloads specific pages in parallel without clearing the cache:
suspend fun refresh(pages: List<Int>)Sets all specified pages to ProgressPage (preserving cached data), then reloads them concurrently.
Use the extension refreshAll() to refresh every cached page.
Pages can be marked as dirty to indicate that their data is stale and needs to be refreshed.
When a navigation function (jump, goNextPage, goPreviousPage) completes, it automatically
checks for dirty pages within the current context window (startContextPage..endContextPage) and
launches a fire-and-forget refresh for them in parallel.
// Mark a single page
paginator.core.markDirty(3)
// Mark multiple pages
paginator.core.markDirty(listOf(1, 2, 3))
// CRUD operations can also mark the affected page as dirty
paginator.setElement(updatedItem, page = 3, index = 0, isDirty = true)
paginator.removeElement(page = 3, index = 2, isDirty = true)
paginator.addAllElements(listOf(newItem), targetPage = 3, index = 0, isDirty = true)// Clear a single page
paginator.core.clearDirty(3)
// Clear multiple pages
paginator.core.clearDirty(listOf(1, 2))
// Clear all dirty flags
paginator.core.clearAllDirty()Dirty flags are also automatically cleared:
refresh() completes for the refreshed pagesrelease() resets the paginatorpaginator.core.isDirty(3) // true if page 3 is dirty
paginator.core.dirtyPages // Set<Int> snapshot of all dirty page numbersWhen navigation completes (e.g., goNextPage loads page 5 successfully):
dirtyPages for pages in startContextPage..endContextPage
refresh(pages = dirtyInContext) is launched in a separate coroutine (fire-and-forget)This ensures the user sees the navigation result instantly while stale pages are silently refreshed in the background.
The primary way to observe the paginator's visible state:
val snapshot: Flow<List<PageState<T>>>Emits a list of PageState objects within the context window whenever a navigation action
completes. This is what your UI should collect.
paginator.snapshot
.onEach { pages -> adapter.submitList(pages) }
.launchIn(scope)For advanced use cases, observe the entire cache:
val cacheFlow: Flow<List<PageState<T>>> = paginator.core.asFlow()This emits the complete cache list (all pages, including those outside the context window).
The paginator supports CRUD operations on individual items within pages:
// Get an element
val item: T? = paginator.core.getElement(page = 3, index = 0)
// Set/replace an element
paginator.setElement(element = updatedItem, page = 3, index = 0)
// Remove an element (auto-rebalances pages)
val removed: T = paginator.removeElement(page = 3, index = 2)
// Add elements (overflows cascade to next pages)
paginator.addAllElements(
elements = listOf(newItem),
targetPage = 3,
index = 0
)
// Replace all matching elements across all pages
paginator.replaceAllElements(
providerElement = { current, _, _ -> current.copy(read = true) },
predicate = { current, _, _ -> current.id == targetId }
)When removing elements causes a page to drop below capacity, items are pulled from the next page
to fill the gap. When adding elements causes overflow beyond capacity, excess items cascade to
subsequent pages.
All mutating operations (setElement, removeElement, addAllElements) accept an optional
isDirty: Boolean = false parameter. When set to true, the affected page is marked as dirty and
will be automatically refreshed on the next navigation (see Dirty Pages).
You can create custom PageState subclasses and use them via the initializer lambdas:
// Custom progress page with additional metadata
class DetailedProgress<T>(
page: Int,
data: List<T>,
val source: String = "network"
) : PageState.ProgressPage<T>(page, data)
// Register the custom initializer
paginator.initializerProgressPage = { page, data ->
DetailedProgress(page = page, data = data, source = "api")
}Available initializer properties:
initializerProgressPage: (page: Int, data: List<T>) -> ProgressPage<T>initializerSuccessPage: (page: Int, data: List<T>) -> SuccessPage<T>initializerEmptyPage: (page: Int, data: List<T>) -> EmptyPage<T>initializerErrorPage: (exception: Exception, page: Int, data: List<T>) -> ErrorPage<T>Use isRealProgressState(MyCustomProgress::class) and similar extension functions to check for
specific subclasses with smart-casting.
The paginator supports saving and restoring its full state via kotlinx.serialization. This is
essential for surviving Android/iOS process death -- when the system kills your app, you can save the
paginator state to SavedStateHandle or a file and restore it when the user returns.
There are two levels of serialization:
finalPage, bookmarks, bookmark position, lock flagsYour element type T must be annotated with @Serializable:
@Serializable
data class Article(val id: Long, val title: String, val body: String)Use saveStateToJson to serialize the entire Paginator state into a JSON string. This is a
suspend function that acquires the navigation mutex for thread-safety:
val json: String = paginator.saveStateToJson(Article.serializer())To save only pages within the current context window (reduces snapshot size):
val json: String = paginator.saveStateToJson(Article.serializer(), contextOnly = true)Persist it using whatever mechanism fits your platform:
// Android โ SavedStateHandle (survives process death)
savedStateHandle["paginator_state"] = json
// Any KMP target โ file storage
// (use expect/actual or a KMP file I/O library)Use restoreStateFromJson to rebuild the full state from a previously saved JSON string.
The snapshot is validated before restoration โ invalid data throws IllegalArgumentException:
val json: String? = savedStateHandle["paginator_state"]
if (json != null) {
paginator.restoreStateFromJson(json, Article.serializer())
}After restoration, the paginator is ready to use immediately โ the snapshot flow emits the
restored pages, and navigation (goNextPage, goPreviousPage, jump) works as normal.
| Included | Not included (re-initialize in code) |
|---|---|
| All cached page data |
source lambda |
| Context window boundaries | Logger |
| Capacity | |
| Dirty page flags | |
finalPage |
|
| Bookmarks & bookmark position | |
| Lock flags | |
| Error messages from ErrorPages |
| Included | Not included (re-initialize in code) |
|---|---|
| All cached page data |
source lambda |
| Context window boundaries | finalPage |
| Capacity | Bookmarks |
| Dirty page flags | Lock flags |
| Error messages from ErrorPages |
ErrorPage and ProgressPage cannot be serialized as-is (Exception is not serializable, and
in-flight loads are meaningless after process death). During save, these pages are converted:
exception.message) is preserved in the errorMessage field of
PageEntry, so the UI can display the error reason after restorationSuccessPage (or EmptyPage if data was empty)When restoring from a snapshot, the following validations are performed:
capacity must be > 0startContextPage and endContextPage must be >= 0startContextPage must be <= endContextPage (unless both are 0)Invalid snapshots throw IllegalArgumentException.
For advanced use cases, you can work with PagingCore directly. These methods are not
suspend and do not acquire the mutex โ ensure proper synchronization if calling
concurrently:
// Save to a snapshot object
val snapshot: PagingCoreSnapshot<Article> = paginator.core.saveState()
// Save only context window pages
val snapshot: PagingCoreSnapshot<Article> = paginator.core.saveState(contextOnly = true)
// Restore from a snapshot object
paginator.core.restoreState(snapshot)
// JSON round-trip
val json: String = paginator.core.saveStateToJson(Article.serializer())
paginator.core.restoreStateFromJson(json, Article.serializer())For custom serialization formats or non-JSON persistence:
// Save to a snapshot object (suspend, thread-safe)
val snapshot: PaginatorSnapshot<Article> = paginator.saveState()
// Restore from a snapshot object (suspend, thread-safe)
paginator.restoreState(snapshot)Prevent specific operations at runtime:
| Flag | Blocks | Exception |
|---|---|---|
lockJump |
jump, jumpForward, jumpBack
|
JumpWasLockedException |
lockGoNextPage |
goNextPage |
GoNextPageWasLockedException |
lockGoPreviousPage |
goPreviousPage |
GoPreviousPageWasLockedException |
lockRestart |
restart |
RestartWasLockedException |
lockRefresh |
refresh, refreshAll
|
RefreshWasLockedException |
paginator.lockGoNextPage = true // Temporarily prevent forward paginationAll locks are reset to false on release().
The paginator supports pluggable logging via the PaginatorLogger interface. By default, no logging is
performed (logger is null). Implement the interface and assign it to paginator.logger to receive
logs about navigation, state changes, and element-level operations.
interface PaginatorLogger {
fun log(tag: String, message: String)
}import com.jamal_aliev.paginator.logger.PaginatorLogger
// Platform-agnostic (works on all KMP targets)
object ConsoleLogger : PaginatorLogger {
override fun log(tag: String, message: String) {
println("[$tag] $message")
}
}
// Android-specific
object AndroidLogger : PaginatorLogger {
override fun log(tag: String, message: String) {
android.util.Log.d(tag, message)
}
}
val paginator = MutablePaginator<String>(source = { page ->
api.fetchItems(page)
}).apply {
logger = ConsoleLogger // or AndroidLogger on Android
}| Operation | Example message |
|---|---|
jump |
jump: page=5 |
jumpForward |
jumpForward: recycling=true |
jumpBack |
jumpBack: recycling=false |
goNextPage |
goNextPage: page=3 result=SuccessPage |
goPreviousPage |
goPreviousPage: page=1 result=SuccessPage |
restart |
restart |
refresh |
refresh: pages=[1, 2, 3] |
setState |
setState: page=2 |
setElement |
setElement: page=1 index=0 isDirty=false |
removeElement |
removeElement: page=2 index=3 isDirty=false |
addAllElements |
addAllElements: targetPage=1 index=0 count=5 isDirty=false |
removeState |
removeState: page=3 |
resize |
resize: capacity=10 resize=true |
release |
release |
markDirty |
markDirty: page=3 |
clearDirty |
clearDirty: page=3 |
clearAllDirty |
clearAllDirty |
refreshDirtyPagesInContext |
refreshDirtyPagesInContext: pages=[2, 3] |
Type-checking with Kotlin contracts for smart-casting:
pageState.isProgressState() // true if ProgressPage
pageState.isSuccessState() // true if SuccessPage (but NOT EmptyPage)
pageState.isEmptyState() // true if EmptyPage
pageState.isErrorState() // true if ErrorPage
// Check for specific custom subclasses
pageState.isRealProgressState(MyCustomProgress::class)Distance calculations:
pageA near pageB // true if pages are 0 or 1 apart
pageA far pageB // true if pages are more than 1 apart
pageA gap pageB // Int distance between page numbers// Search for elements
paginator.indexOfFirst { it.id == targetId } // Returns Pair<Int, Int>? (page, index)
paginator.indexOfLast { it.name == "test" } // Search in reverse
paginator.getElement { it.id == targetId } // Get first matching element
// Modify elements
paginator.setElement(updatedItem) { it.id == targetId }
paginator.removeElement { it.id == targetId }
paginator.addElement(newItem) // Append to last page
// Iteration
paginator.forEach { pageState -> /* ... */ }
paginator.smartForEach { states, index, currentState -> /* continue? */ true }
// Traversal
paginator.walkForwardWhile(startState) { it.isSuccessState() }
paginator.walkBackwardWhile(endState) { it.isSuccessState() }
// Refresh all cached pages
paginator.refreshAll()A complete ViewModel demonstrating all major features:
class PaginatorViewModel : ViewModel() {
private val _uiState = MutableStateFlow<List<PageState<String>>>(emptyList())
val uiState = _uiState.asStateFlow()
private val paginator = MutablePaginator<String>(source = { page ->
repository.loadPage(page)
}).apply {
core.resize(capacity = 5, resize = false, silently = true)
finalPage = 20
bookmarks.addAll(listOf(BookmarkInt(5), BookmarkInt(10), BookmarkInt(15)))
recyclingBookmark = true
logger = object : PaginatorLogger {
override fun log(tag: String, message: String) {
println("[$tag] $message")
}
}
}
init {
paginator.snapshot
.filter { it.isNotEmpty() }
.onEach { _uiState.value = it }
.flowOn(Dispatchers.Main)
.launchIn(viewModelScope)
viewModelScope.launch {
paginator.jump(BookmarkInt(1))
}
}
// 1. Forward pagination (loading indicator at bottom)
fun loadNextPage() = viewModelScope.launch {
try {
paginator.goNextPage()
} catch (e: FinalPageExceededException) {
showError("No more pages")
}
}
// 2. Backward pagination (loading indicator at top)
fun loadPreviousPage() = viewModelScope.launch {
paginator.goPreviousPage()
}
// 3. Jump to a user-specified page
fun jumpToPage(page: Int) = viewModelScope.launch {
try {
paginator.jump(BookmarkInt(page))
} catch (e: FinalPageExceededException) {
showError("Page exceeds limit")
}
}
// 4. Navigate bookmarks
fun nextBookmark() = viewModelScope.launch { paginator.jumpForward() }
fun prevBookmark() = viewModelScope.launch { paginator.jumpBack() }
// 5. Swipe-to-refresh
fun restart() = viewModelScope.launch { paginator.restart() }
// 6. Retry on error
fun retryPage(page: Int) = viewModelScope.launch {
paginator.refresh(pages = listOf(page))
}
override fun onCleared() {
paginator.release()
super.onCleared()
}
}Rendering in Compose:
@Composable
fun PaginatedList(pages: List<PageState<String>>) {
LazyColumn {
pages.forEach { pageState ->
when {
pageState.isSuccessState() -> {
items(pageState.data.size) { index ->
Text(pageState.data[index])
}
}
pageState.isProgressState() -> {
// Show cached data (if any) + loading indicator
items(pageState.data.size) { index ->
Text(pageState.data[index], color = Color.Gray)
}
item { CircularProgressIndicator() }
}
pageState.isErrorState() -> {
item {
ErrorCard(
message = pageState.exception.message,
onRetry = { retryPage(pageState.page) }
)
}
}
pageState.isEmptyState() -> {
item { Text("No data on page ${pageState.page}") }
}
}
}
}
}| Property | Type | Description |
|---|---|---|
source |
suspend Paginator<T>.(Int) -> List<T> |
Data source lambda |
logger |
PaginatorLogger? |
Logging interface (null by default) |
capacity |
Int (read-only) |
Expected items per page |
isCapacityUnlimited |
Boolean |
true if capacity == 0
|
pages |
List<Int> |
All cached page numbers (sorted) |
pageStates |
List<PageState<T>> |
All cached page states (sorted) |
size |
Int |
Number of cached pages |
dirtyPages |
Set<Int> |
Snapshot of all dirty page numbers |
startContextPage |
Int |
Left boundary of visible context |
endContextPage |
Int |
Right boundary of visible context |
isStarted |
Boolean |
true if context pages are set |
finalPage |
Int |
Maximum allowed page (default Int.MAX_VALUE) |
bookmarks |
MutableList<Bookmark> |
Bookmark list (default: page 1) |
recyclingBookmark |
Boolean |
Wrap-around bookmark iteration |
snapshot |
Flow<List<PageState<T>>> |
Visible page states flow |
lockJump |
Boolean |
Lock jump operations |
lockGoNextPage |
Boolean |
Lock forward navigation |
lockGoPreviousPage |
Boolean |
Lock backward navigation |
lockRestart |
Boolean |
Lock restart |
lockRefresh |
Boolean |
Lock refresh |
| Method | Returns | Description |
|---|---|---|
jump(bookmark) |
Pair<Bookmark, PageState<T>> |
Jump to a page |
jumpForward() |
Pair<Bookmark, PageState<T>>? |
Next bookmark |
jumpBack() |
Pair<Bookmark, PageState<T>>? |
Previous bookmark |
goNextPage() |
PageState<T> |
Load next page |
goPreviousPage() |
PageState<T> |
Load previous page |
restart() |
Unit |
Reset and reload page 1 |
refresh(pages) |
Unit |
Reload specific pages |
loadOrGetPageState(page, forceLoading) |
PageState<T> |
Load or get cached page |
getStateOf(page) |
PageState<T>? |
Get cached page state |
getElement(page, index) |
T? |
Get element by position |
markDirty(page) / markDirty(pages)
|
Unit |
Mark page(s) as dirty |
clearDirty(page) / clearDirty(pages)
|
Unit |
Remove dirty flag from page(s) |
clearAllDirty() |
Unit |
Remove all dirty flags |
isDirty(page) |
Boolean |
Check if page is dirty |
isFilledSuccessState(state) |
Boolean |
Check if page is complete |
snapshot(pageRange?) |
Unit |
Emit snapshot |
scan(pagesRange) |
List<PageState<T>> |
Get pages in range |
walkWhile(pivot, next, predicate) |
PageState<T>? |
Traverse pages |
findNearContextPage(start, end) |
Unit |
Find nearest context |
asFlow() |
Flow<List<PageState<T>>> |
Full cache flow |
resize(capacity, resize, silently) |
Unit |
Change capacity (via core) |
release(capacity, silently) |
Unit |
Full reset |
saveState(contextOnly) |
PaginatorSnapshot<T> |
Full state snapshot (suspend) |
restoreState(snapshot, silently) |
Unit |
Restore full state (suspend) |
saveStateToJson(serializer, json) |
String |
Save full state as JSON (suspend) |
restoreStateFromJson(str, serializer) |
Unit |
Restore full state from JSON (suspend) |
| Method | Returns | Description |
|---|---|---|
setState(state, silently) |
Unit |
Set a page state (public) |
removeState(page, silently) |
PageState<T>? |
Remove a page |
setElement(element, page, index, isDirty) |
Unit |
Replace element (optionally mark dirty) |
removeElement(page, index, isDirty) |
T |
Remove element (optionally mark dirty) |
addAllElements(elements, page, index, isDirty) |
Unit |
Insert elements (optionally mark dirty) |
replaceAllElements(provider, predicate) |
Unit |
Bulk replace |
| Operator | Class | Description |
|---|---|---|
paginator[page] |
Paginator |
Get page state |
paginator[page, index] |
Paginator |
Get element |
paginator += pageState |
MutablePaginator |
Set page state |
paginator -= page |
MutablePaginator |
Remove page |
page in paginator |
Paginator |
Check if page exists |
Step-by-step guide for publishing a new release of the library to Maven Central.
Make sure the following GitHub Secrets are configured in the repository
(Settings โ Secrets and variables โ Actions โ Repository secrets):
| Secret | Description |
|---|---|
MAVEN_CENTRAL_USERNAME |
Sonatype Central Portal token username |
MAVEN_CENTRAL_PASSWORD |
Sonatype Central Portal token password |
GPG_KEY_ID |
Last 8 characters of your GPG key ID |
GPG_KEY_PASSWORD |
Passphrase for the GPG key |
GPG_KEY |
Base64-encoded GPG private key (gpg --export-secret-keys <KEY_ID> | base64) |
In paginator/build.gradle.kts, change the version property:
version = "7.2.1" // โ new versionUpdate the version in all implementation(...) snippets in this README
(sections KMP, Android-only, and JVM) to match the new version.
git add -A
git commit -m "Bump version to 7.2.1"
git push origin master7.2.1), then select "Create new tag on publish"
7.2.1)This triggers the Publish to Maven Central GitHub Actions workflow automatically.
io.github.jamal-wia:paginator:<version>
If you need to publish from your local machine instead of CI:
Add credentials to ~/.gradle/gradle.properties (NOT the project's gradle.properties):
mavenCentralUsername=<your-sonatype-token-username>
mavenCentralPassword=<your-sonatype-token-password>
signing.keyId=<last-8-chars-of-gpg-key>
signing.password=<gpg-key-passphrase>
signing.secretKeyRingFile=<path-to-secring.gpg>Run:
./gradlew :paginator:publishAndReleaseToMavenCentral --no-configuration-cacheThe automaticRelease = true flag in build.gradle.kts ensures the deployment is
automatically released after validation. No manual "Release" button click is needed.
| Problem | Solution |
|---|---|
| Workflow fails at "Import GPG key" | Re-export and update the GPG_KEY secret: gpg --export-secret-keys <KEY_ID> | base64
|
| Deployment stuck at VALIDATED |
automaticRelease = true was not set. Either click "Release" manually in the Central Portal, or re-run with the flag enabled |
| Deployment FAILED | Check the Central Portal for validation errors (missing POM fields, signature issues, etc.) |
| Artifact not resolvable after PUBLISHED | Wait up to 30 minutes. Maven Central syncing can be slow |
| Signing error locally | Ensure signing.secretKeyRingFile points to a valid .gpg file and passphrase is correct |
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.