From everything-claude-code-mobile
Implements Android Paging 3 pagination patterns for mobile: PagingSource for offset-based and cursor-based strategies, Pager config, RemoteMediator, LazyPagingItems.
npx claudepluginhub ahmed3elshaer/everything-claude-code-mobile --plugin everything-claude-code-mobileThis skill uses the workspace's default tool permissions.
```kotlin
Implements Relay's cursor-based GraphQL pagination in React apps using usePaginationFragment for infinite scroll, load more, and automatic cache updates.
Implements offset, cursor, and keyset pagination strategies for APIs handling large datasets. Use for paginated endpoints, infinite scroll, or optimizing database collection queries.
Implements offset/limit, cursor-based, and keyset pagination for APIs with large datasets. Use for returning collections, search results, infinite scroll, and query optimization.
Share bugs, ideas, or general feedback.
dependencies {
val pagingVersion = "3.3.5"
implementation("androidx.paging:paging-runtime-ktx:$pagingVersion")
implementation("androidx.paging:paging-compose:$pagingVersion")
testImplementation("androidx.paging:paging-testing:$pagingVersion")
}
class ArticlePagingSource(
private val api: ArticleApi,
private val query: String
) : PagingSource<Int, Article>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Article> {
val page = params.key ?: 1
return try {
val response = api.searchArticles(
query = query,
page = page,
pageSize = params.loadSize
)
LoadResult.Page(
data = response.articles,
prevKey = if (page == 1) null else page - 1,
nextKey = if (response.articles.isEmpty()) null else page + 1
)
} catch (e: IOException) {
LoadResult.Error(e)
} catch (e: HttpException) {
LoadResult.Error(e)
}
}
override fun getRefreshKey(state: PagingState<Int, Article>): Int? {
return state.anchorPosition?.let { anchor ->
state.closestPageToPosition(anchor)?.prevKey?.plus(1)
?: state.closestPageToPosition(anchor)?.nextKey?.minus(1)
}
}
}
class CursorArticlePagingSource(
private val api: ArticleApi
) : PagingSource<String, Article>() {
override suspend fun load(params: LoadParams<String>): LoadResult<String, Article> {
return try {
val response = api.getArticles(
cursor = params.key,
limit = params.loadSize
)
LoadResult.Page(
data = response.articles,
prevKey = null, // cursor-based usually does not support backward
nextKey = response.nextCursor
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
override fun getRefreshKey(state: PagingState<String, Article>): String? = null
}
class ArticleRepository(private val api: ArticleApi) {
fun getArticlesPager(query: String): Flow<PagingData<Article>> {
return Pager(
config = PagingConfig(
pageSize = 20,
prefetchDistance = 5,
enablePlaceholders = false,
initialLoadSize = 40, // first load is usually 2x pageSize
maxSize = 200 // cap cached pages
),
pagingSourceFactory = { ArticlePagingSource(api, query) }
).flow
}
}
class ArticleListViewModel(
private val repository: ArticleRepository
) : ViewModel() {
private val _query = MutableStateFlow("")
val articles: Flow<PagingData<Article>> = _query
.debounce(300)
.distinctUntilChanged()
.flatMapLatest { query ->
repository.getArticlesPager(query)
}
.cachedIn(viewModelScope)
fun search(query: String) {
_query.value = query
}
}
@Composable
fun ArticleListScreen(viewModel: ArticleListViewModel = koinViewModel()) {
val articles = viewModel.articles.collectAsLazyPagingItems()
LazyColumn {
items(
count = articles.itemCount,
key = articles.itemKey { it.id }
) { index ->
val article = articles[index]
if (article != null) {
ArticleCard(article = article)
} else {
ArticlePlaceholder()
}
}
// Append loading indicator
when (articles.loadState.append) {
is LoadState.Loading -> {
item { LoadingIndicator() }
}
is LoadState.Error -> {
item {
RetryButton(onClick = { articles.retry() })
}
}
else -> {}
}
}
}
@Composable
fun PaginatedList(articles: LazyPagingItems<Article>) {
Box(modifier = Modifier.fillMaxSize()) {
// Initial loading state
when (articles.loadState.refresh) {
is LoadState.Loading -> {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
is LoadState.Error -> {
val error = (articles.loadState.refresh as LoadState.Error).error
ErrorScreen(
message = error.localizedMessage ?: "Unknown error",
onRetry = { articles.refresh() }
)
}
is LoadState.NotLoading -> {
if (articles.itemCount == 0) {
EmptyState(message = "No articles found")
} else {
ArticleLazyColumn(articles = articles)
}
}
}
// Pull to refresh
PullToRefreshBox(
isRefreshing = articles.loadState.refresh is LoadState.Loading,
onRefresh = { articles.refresh() }
) {
ArticleLazyColumn(articles = articles)
}
}
}
@OptIn(ExperimentalPagingApi::class)
class ArticleRemoteMediator(
private val api: ArticleApi,
private val database: AppDatabase
) : RemoteMediator<Int, ArticleEntity>() {
private val articleDao = database.articleDao()
private val remoteKeyDao = database.remoteKeyDao()
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, ArticleEntity>
): MediatorResult {
val page = when (loadType) {
LoadType.REFRESH -> 1
LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
LoadType.APPEND -> {
val remoteKey = remoteKeyDao.getRemoteKey("articles")
remoteKey?.nextPage ?: return MediatorResult.Success(
endOfPaginationReached = true
)
}
}
return try {
val response = api.getArticles(page = page, pageSize = state.config.pageSize)
database.withTransaction {
if (loadType == LoadType.REFRESH) {
articleDao.deleteAll()
remoteKeyDao.deleteByKey("articles")
}
articleDao.insertAll(response.articles.map { it.toEntity() })
remoteKeyDao.insert(
RemoteKey(
key = "articles",
nextPage = if (response.articles.isEmpty()) null else page + 1
)
)
}
MediatorResult.Success(endOfPaginationReached = response.articles.isEmpty())
} catch (e: Exception) {
MediatorResult.Error(e)
}
}
}
// Usage in repository
@OptIn(ExperimentalPagingApi::class)
fun getOfflineArticles(): Flow<PagingData<ArticleEntity>> {
return Pager(
config = PagingConfig(pageSize = 20),
remoteMediator = ArticleRemoteMediator(api, database),
pagingSourceFactory = { database.articleDao().pagingSource() }
).flow
}
@Entity(tableName = "remote_keys")
data class RemoteKey(
@PrimaryKey val key: String,
val nextPage: Int?
)
@Dao
interface RemoteKeyDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(key: RemoteKey)
@Query("SELECT * FROM remote_keys WHERE `key` = :key")
suspend fun getRemoteKey(key: String): RemoteKey?
@Query("DELETE FROM remote_keys WHERE `key` = :key")
suspend fun deleteByKey(key: String)
}
@Observable
class ArticleListViewModel {
var articles: [Article] = []
var isLoading = false
var hasMore = true
private var currentPage = 1
func loadNextPage() async {
guard !isLoading, hasMore else { return }
isLoading = true
defer { isLoading = false }
do {
let response = try await api.getArticles(page: currentPage, pageSize: 20)
articles.append(contentsOf: response.articles)
hasMore = !response.articles.isEmpty
currentPage += 1
} catch {
// handle error
}
}
}
struct ArticleListView: View {
@State private var viewModel = ArticleListViewModel()
var body: some View {
List(viewModel.articles) { article in
ArticleRow(article: article)
.onAppear {
if article == viewModel.articles.last {
Task { await viewModel.loadNextPage() }
}
}
}
.overlay {
if viewModel.isLoading && viewModel.articles.isEmpty {
ProgressView()
}
}
.task {
await viewModel.loadNextPage()
}
}
}
{
"data": [...],
"pagination": {
"next_cursor": "eyJpZCI6MTAwfQ==",
"has_more": true
}
}
{
"data": [...],
"pagination": {
"page": 2,
"page_size": 20,
"total_count": 156,
"total_pages": 8
}
}
@Serializable
data class PaginatedResponse<T>(
val data: List<T>,
val pagination: Pagination
)
@Serializable
data class Pagination(
val nextCursor: String? = null,
val hasMore: Boolean = false,
val page: Int? = null,
val totalCount: Int? = null
)
prefetchDistance to 3-5 items so loading starts before the user reaches the end.RemoteMediator for offline-capable paginated lists backed by a local database.refresh, append, and prepend.PagingData in viewModelScope with .cachedIn() to survive configuration changes.articles.refresh() on LazyPagingItems.