
Pagination
- 3 installs
- 591 repo stars
- Updated July 24, 2026
- rshankras/claude-code-apple-skills
Generates pagination infrastructure supporting offset and cursor-based APIs with infinite-scroll SwiftUI views, state management, and optional search.
About
Generates pagination infrastructure for offset- and cursor-based APIs with infinite-scroll SwiftUI views, a state machine, and optional search. A developer uses it to add paginated lists or load-more functionality.
- Offset and cursor-based pagination patterns
- Infinite-scroll views with search integration
Pagination by the numbers
- 3 all-time installs (skills.sh)
- +1 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #3,739 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/rshankras/claude-code-apple-skills --skill paginationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3 |
|---|---|
| repo stars | ★ 591 |
| Last updated | July 24, 2026 |
| Repository | rshankras/claude-code-apple-skills ↗ |
What it does
Generates pagination infrastructure supporting offset and cursor-based APIs with infinite-scroll SwiftUI views, state management, and optional search.
Files
Pagination Generator
Generate production pagination infrastructure supporting offset-based and cursor-based APIs, with infinite scroll SwiftUI views, state machine management, and optional search integration.
When This Skill Activates
Use this skill when the user:
- Asks to "add pagination" or "paginate a list"
- Wants "infinite scroll" or "load more" functionality
- Mentions "cursor-based pagination" or "offset pagination"
- Asks about "paginated API" or "loading pages of data"
- Wants "search with pagination"
Pre-Generation Checks
1. Project Context Detection
- [ ] Check Swift version (requires Swift 5.9+)
- [ ] Check deployment target (iOS 17+ / macOS 14+ for @Observable)
- [ ] Search for existing pagination implementations
- [ ] Identify source file locations
2. Networking Layer Detection
Search for existing networking code:
Glob: **/*API*.swift, **/*Client*.swift, **/*Endpoint*.swift
Grep: "APIClient" or "APIEndpoint"If networking-layer generator was used, detect the APIEndpoint protocol and generate data sources that conform to it.
3. Conflict Detection
Search for existing pagination:
Glob: **/*Pagina*.swift, **/*LoadMore*.swift
Grep: "PaginationState" or "loadNextPage" or "hasMorePages"If found, ask user whether to replace or extend.
Configuration Questions
Ask user via AskUserQuestion:
1. Pagination style?
- Offset-based (page number + page size) — most common for REST APIs
- Cursor-based (opaque cursor token) — better for real-time data, social feeds
2. Loading trigger?
- Infinite scroll (auto-load when near bottom) — recommended
- Manual "Load More" button
- Both (infinite scroll with manual fallback on error)
3. Additional features? (multi-select)
- Search with pagination (debounced, resets on query change)
- Pull-to-refresh
- Empty/error/loading state views
4. Data source pattern?
- Generic (works with any Codable model)
- Protocol-based (define per-endpoint data sources)
Generation Process
Step 1: Read Templates
Read pagination-patterns.md for architecture guidance. Read templates.md for production Swift code.
Step 2: Create Core Files
Generate these files: 1. PaginatedResponse.swift — Generic response models for offset and cursor 2. PaginationState.swift — State machine (idle, loading, loaded, error, exhausted) 3. PaginatedDataSource.swift — Protocol endpoints conform to 4. PaginationManager.swift — @Observable manager with state transitions
Step 3: Create Optional Files
Based on configuration:
SearchablePaginationManager.swift— If search selectedViews/PaginatedList.swift— Infinite scroll SwiftUI wrapperViews/LoadMoreButton.swift— Manual load-more buttonViews/PaginationStateView.swift— Empty/loading/error state views
Step 4: Determine File Location
Check project structure:
- If
Sources/exists →Sources/Pagination/ - If
App/exists →App/Pagination/ - Otherwise →
Pagination/
Output Format
After generation, provide:
Files Created
Pagination/
├── PaginatedResponse.swift # Generic response models
├── PaginationState.swift # State machine enum
├── PaginatedDataSource.swift # Data source protocol
├── PaginationManager.swift # @Observable manager
├── SearchablePaginationManager.swift # Optional: search + pagination
└── Views/
├── PaginatedList.swift # Infinite scroll wrapper
├── LoadMoreButton.swift # Manual load-more
└── PaginationStateView.swift # Empty/loading/error statesIntegration Steps
Define a data source:
struct UsersDataSource: PaginatedDataSource {
typealias Item = User
let apiClient: APIClient
func fetch(page: PageRequest) async throws -> PaginatedResponse<User> {
try await apiClient.request(UsersEndpoint(page: page.page, size: page.size))
}
}Use PaginationManager in a view model:
@Observable
final class UsersViewModel {
let pagination: PaginationManager<UsersDataSource>
init(apiClient: APIClient) {
pagination = PaginationManager(
dataSource: UsersDataSource(apiClient: apiClient)
)
}
}With SwiftUI (infinite scroll):
struct UsersListView: View {
@State private var viewModel = UsersViewModel()
var body: some View {
PaginatedList(manager: viewModel.pagination) { user in
UserRow(user: user)
}
.task {
await viewModel.pagination.loadFirstPage()
}
}
}With search:
struct SearchableUsersView: View {
@State private var searchManager = SearchablePaginationManager(
dataSource: UsersDataSource()
)
var body: some View {
PaginatedList(manager: searchManager.pagination) { user in
UserRow(user: user)
}
.searchable(text: $searchManager.query)
}
}Testing
@Test
func loadFirstPagePopulatesItems() async throws {
let mockSource = MockDataSource(items: User.mockList(count: 20))
let manager = PaginationManager(dataSource: mockSource, pageSize: 10)
await manager.loadFirstPage()
#expect(manager.items.count == 10)
#expect(manager.state == .loaded)
#expect(manager.hasMore == true)
}
@Test
func loadAllPagesReachesExhausted() async throws {
let mockSource = MockDataSource(items: User.mockList(count: 15))
let manager = PaginationManager(dataSource: mockSource, pageSize: 10)
await manager.loadFirstPage()
await manager.loadNextPage()
#expect(manager.items.count == 15)
#expect(manager.state == .exhausted)
}References
- pagination-patterns.md — Offset vs cursor comparison, state machine, threshold prefetching
- templates.md — All production Swift templates
- Related:
generators/networking-layer— Base networking layer for data sources - Related:
generators/http-cache— Cache paginated responses
Pagination Patterns and Best Practices
Offset vs Cursor Pagination
Offset-Based
GET /users?page=2&size=20Response:
{
"items": [...],
"totalItems": 150,
"totalPages": 8,
"currentPage": 2,
"pageSize": 20
}| Pros | Cons |
|---|---|
| Simple to implement | Inconsistent on inserts/deletes |
| Jump to any page | Slow on large datasets (SQL OFFSET) |
| Know total count | Duplicate/missing items when data changes |
| Easy "Page X of Y" UI |
Best for: Admin dashboards, search results, stable datasets.
Cursor-Based
GET /users?cursor=eyJpZCI6MTAwfQ&limit=20Response:
{
"items": [...],
"nextCursor": "eyJpZCI6MTIwfQ",
"hasMore": true
}| Pros | Cons |
|---|---|
| Consistent with real-time data | Can't jump to arbitrary page |
| Performant at any depth | No total count (usually) |
| No duplicates/gaps | Opaque cursor (can't inspect) |
| Works with infinite scroll |
Best for: Social feeds, chat, real-time data, large datasets.
Decision Guide
Is data frequently inserted/deleted?
├── Yes → Cursor-based
└── No
├── Need "jump to page N"? → Offset-based
└── Infinite scroll only? → Either works, cursor preferredState Machine
Pagination has well-defined states. Model them explicitly:
┌─────────┐
│ idle │ (initial state)
└────┬────┘
│ loadFirstPage()
┌────▼────┐
┌─────│ loading │─────┐
│ └─────────┘ │
success failure
│ │
┌─────▼─────┐ ┌───────▼──────┐
│ loaded │ │ error │
│ (has more) │ │ (retryable) │
└─────┬─────┘ └───────┬───────┘
│ loadNextPage() │ retry()
┌─────▼─────┐ │
│ loading │◄────────────┘
│ (next) │
└─────┬─────┘
│
┌─────▼──────┐
│ exhausted │ (no more pages)
└────────────┘State Enum
enum PaginationState: Equatable {
case idle
case loading
case loadingMore // Loading subsequent pages (items already visible)
case loaded
case error(String)
case exhausted // All pages loaded, no more data
}Key State Rules
1. idle → loading: Only on first load 2. loaded → loadingMore: On subsequent pages (keeps existing items visible) 3. error → loading/loadingMore: On retry 4. loaded → exhausted: When response has fewer items than page size or hasMore == false 5. Any → idle: On refresh/reset
Why "loadingMore" is Separate from "loading"
loading: Show full-screen loading spinner, no items visibleloadingMore: Show items + small bottom spinner, user can still scroll
Threshold Prefetching
The Problem
If you wait until the user scrolls to the very bottom to load the next page, they see a loading spinner every time. Bad UX.
The Solution: Prefetch Threshold
Load the next page when the user is N items from the bottom:
func onItemAppear(_ item: Item) {
let threshold = 5 // Load when 5 items from bottom
guard let index = items.firstIndex(where: { $0.id == item.id }),
index >= items.count - threshold else {
return
}
Task { await loadNextPage() }
}Choosing a Threshold
| Page Size | Threshold | Why |
|---|---|---|
| 10 | 3 | Small pages load fast |
| 20 | 5 | Standard |
| 50 | 10 | Large pages need more lead time |
Rule of thumb: threshold = pageSize / 4, minimum 3.
Pull-to-Refresh
Reset Everything on Refresh
func refresh() async {
items.removeAll()
currentPage = 0
cursor = nil
state = .idle
await loadFirstPage()
}SwiftUI Integration
List {
// ... items
}
.refreshable {
await manager.refresh()
}Search + Pagination
The Challenge
Search queries change pagination context:
- New query → reset to page 1
- Same query, scroll → load next page
- Empty query → show all (or clear)
Debouncing
Don't fire API request on every keystroke:
@Observable
final class SearchablePaginationManager<Source: PaginatedDataSource> {
var query: String = "" {
didSet {
debounceTask?.cancel()
debounceTask = Task {
try await Task.sleep(for: .milliseconds(300))
await resetAndSearch()
}
}
}
private var debounceTask: Task<Void, Error>?
private func resetAndSearch() async {
pagination.reset()
await pagination.loadFirstPage()
}
}Cancel Previous Requests
When query changes, cancel in-flight requests:
func loadFirstPage() async {
currentTask?.cancel()
currentTask = Task {
try Task.checkCancellation()
let response = try await dataSource.fetch(query: query, page: firstPage)
try Task.checkCancellation() // Check again before updating UI
self.items = response.items
}
try? await currentTask?.value
}Empty States
What to Show
| State | What to Show |
|---|---|
idle | Nothing (or skeleton) |
loading (first page) | Full-screen spinner or skeleton |
loadingMore | Items + bottom spinner |
loaded (0 items) | Empty state illustration |
error (first page) | Full-screen error with retry |
error (next page) | Items + error banner + retry |
exhausted | Items + "No more results" footer |
ContentUnavailableView (iOS 17+)
if manager.items.isEmpty && manager.state == .loaded {
ContentUnavailableView(
"No Results",
systemImage: "magnifyingglass",
description: Text("Try a different search term")
)
}Error Handling
Retryable vs Non-Retryable
enum PaginationError: Error {
case networkError(Error) // Retryable
case decodingError(Error) // Usually not retryable
case cancelled // Not an error, ignore
var isRetryable: Bool {
switch self {
case .networkError: return true
case .decodingError: return false
case .cancelled: return false
}
}
}Don't Lose Items on Error
// ❌ Wrong: clear items on next-page error
func loadNextPage() async {
do {
let response = try await fetch()
items = response.items // Replaces existing!
} catch {
items = [] // User loses everything
}
}
// ✅ Right: keep existing items, show error for retry
func loadNextPage() async {
do {
let response = try await fetch()
items.append(contentsOf: response.items) // Append
} catch {
state = .error(error.localizedDescription) // Items preserved
}
}Performance Considerations
Diffable Data
Use Identifiable items for efficient SwiftUI diffing:
protocol PaginatedDataSource {
associatedtype Item: Identifiable & Sendable
// ...
}Avoid Duplicate Requests
Guard against concurrent page loads:
func loadNextPage() async {
guard state == .loaded else { return } // Not idle, loading, or exhausted
state = .loadingMore
// ... fetch
}Memory Management for Large Lists
For lists with thousands of items, consider:
- LazyVStack (not List) for better memory behavior
- Limit in-memory items and reload from cache
- Use
.onDisappearto release image data
Anti-Patterns to Avoid
Don't Use Array Index as Page Number
// ❌ Fragile — breaks if items are filtered or reordered
let page = items.count / pageSize
// ✅ Track page number explicitly
var currentPage = 0Don't Ignore Task Cancellation
// ❌ Stale response overwrites newer data
let response = try await fetch(page: 2)
self.items = response.items // Page 3 response may have already arrived
// ✅ Check cancellation
let response = try await fetch(page: 2)
try Task.checkCancellation()
self.items.append(contentsOf: response.items)Don't Paginate on the Main Actor
// ❌ Blocks UI during fetch
@MainActor func loadNextPage() async {
let data = try await URLSession.shared.data(from: url) // Blocks UI
}
// ✅ Fetch off main, update on main
func loadNextPage() async {
let response = try await dataSource.fetch(page: nextPage)
await MainActor.run { items.append(contentsOf: response.items) }
}Pagination Code Templates
Production-ready Swift templates for pagination infrastructure. All code targets iOS 17+ / macOS 14+ and uses @Observable, modern Swift concurrency, and protocol-based architecture.
PaginatedResponse.swift
import Foundation
// MARK: - Offset-Based Response
/// Generic response model for offset-based paginated APIs.
struct OffsetPaginatedResponse<Item: Decodable & Sendable>: Decodable, Sendable {
let items: [Item]
let totalItems: Int
let totalPages: Int
let currentPage: Int
let pageSize: Int
var hasMore: Bool {
currentPage < totalPages
}
}
// MARK: - Cursor-Based Response
/// Generic response model for cursor-based paginated APIs.
struct CursorPaginatedResponse<Item: Decodable & Sendable>: Decodable, Sendable {
let items: [Item]
let nextCursor: String?
let hasMore: Bool
enum CodingKeys: String, CodingKey {
case items
case nextCursor = "next_cursor"
case hasMore = "has_more"
}
}
// MARK: - Unified Page Request
/// Represents a request for a page of data.
/// Works for both offset and cursor patterns.
struct PageRequest: Sendable {
let page: Int
let size: Int
let cursor: String?
/// Create an offset-based page request.
static func offset(page: Int, size: Int) -> PageRequest {
PageRequest(page: page, size: size, cursor: nil)
}
/// Create a cursor-based page request.
static func cursor(_ cursor: String?, size: Int) -> PageRequest {
PageRequest(page: 0, size: size, cursor: cursor)
}
/// The first page request.
static func first(size: Int) -> PageRequest {
PageRequest(page: 1, size: size, cursor: nil)
}
}PaginationState.swift
import Foundation
/// Represents the current state of pagination.
///
/// Use this enum to drive UI decisions: show spinners, empty states, error views, etc.
enum PaginationState: Equatable, Sendable {
/// Initial state, no data loaded yet.
case idle
/// Loading the first page. Show full-screen spinner.
case loading
/// Loading a subsequent page. Show items with bottom spinner.
case loadingMore
/// Data loaded successfully. More pages may be available.
case loaded
/// An error occurred. Message is displayable to user.
case error(String)
/// All pages have been loaded. No more data available.
case exhausted
/// Whether a loading operation is in progress.
var isLoading: Bool {
self == .loading || self == .loadingMore
}
/// Whether items should be visible (loaded, loadingMore, error on subsequent page, exhausted).
var showItems: Bool {
switch self {
case .loaded, .loadingMore, .exhausted: return true
case .error: return true // Keep items visible on error
default: return false
}
}
}PaginatedDataSource.swift
import Foundation
/// Protocol for paginated data sources.
///
/// Conform to this protocol for each paginated endpoint in your app.
/// The pagination manager calls `fetch` to load pages.
///
/// Example:
/// ```swift
/// struct UsersDataSource: PaginatedDataSource {
/// typealias Item = User
/// let apiClient: APIClient
///
/// func fetch(request: PageRequest) async throws -> PageResult<User> {
/// let response = try await apiClient.request(
/// UsersEndpoint(page: request.page, size: request.size)
/// )
/// return PageResult(
/// items: response.items,
/// hasMore: response.hasMore,
/// nextCursor: nil
/// )
/// }
/// }
/// ```
protocol PaginatedDataSource: Sendable {
associatedtype Item: Identifiable & Sendable
/// Fetch a page of items.
func fetch(request: PageRequest) async throws -> PageResult<Item>
}
/// Searchable variant that accepts a query string.
protocol SearchablePaginatedDataSource: PaginatedDataSource {
/// Fetch a page of items matching the query.
func fetch(request: PageRequest, query: String) async throws -> PageResult<Item>
}
/// Result of a single page fetch, unified across offset and cursor patterns.
struct PageResult<Item: Sendable>: Sendable {
let items: [Item]
let hasMore: Bool
let nextCursor: String?
let totalItems: Int?
init(items: [Item], hasMore: Bool, nextCursor: String? = nil, totalItems: Int? = nil) {
self.items = items
self.hasMore = hasMore
self.nextCursor = nextCursor
self.totalItems = totalItems
}
}PaginationManager.swift
import Foundation
import SwiftUI
/// Manages paginated data loading with state machine transitions.
///
/// Generic over any `PaginatedDataSource`. Handles:
/// - First page loading
/// - Subsequent page loading (append)
/// - Refresh (reset and reload)
/// - Error handling with retry
/// - Threshold-based prefetching
@Observable
final class PaginationManager<Source: PaginatedDataSource>: @unchecked Sendable {
// MARK: - Public State
/// The accumulated items across all loaded pages.
private(set) var items: [Source.Item] = []
/// Current pagination state.
private(set) var state: PaginationState = .idle
/// Whether more pages are available.
private(set) var hasMore: Bool = true
/// Total item count from server (offset-based only).
private(set) var totalItems: Int?
// MARK: - Configuration
/// Number of items per page.
let pageSize: Int
/// Number of items from the bottom to trigger prefetch.
let prefetchThreshold: Int
// MARK: - Private
private let dataSource: Source
private var currentPage: Int = 0
private var nextCursor: String?
private var currentTask: Task<Void, Never>?
init(dataSource: Source, pageSize: Int = 20, prefetchThreshold: Int = 5) {
self.dataSource = dataSource
self.pageSize = pageSize
self.prefetchThreshold = prefetchThreshold
}
// MARK: - Public API
/// Load the first page of data. Resets any existing state.
func loadFirstPage() async {
guard state != .loading else { return }
currentTask?.cancel()
items.removeAll()
currentPage = 0
nextCursor = nil
hasMore = true
state = .loading
await loadPage()
}
/// Load the next page. No-op if already loading or exhausted.
func loadNextPage() async {
guard state == .loaded, hasMore else { return }
state = .loadingMore
await loadPage()
}
/// Refresh: reset everything and reload from the first page.
func refresh() async {
state = .idle
await loadFirstPage()
}
/// Call from `onAppear` of list items to trigger prefetch.
func onItemAppear(_ item: Source.Item) {
guard state == .loaded, hasMore else { return }
guard let index = items.firstIndex(where: { $0.id == item.id }),
index >= items.count - prefetchThreshold else {
return
}
Task { await loadNextPage() }
}
/// Retry after an error.
func retry() async {
if items.isEmpty {
await loadFirstPage()
} else {
state = .loaded
await loadNextPage()
}
}
/// Reset to idle state with no items.
func reset() {
currentTask?.cancel()
items.removeAll()
currentPage = 0
nextCursor = nil
hasMore = true
state = .idle
}
// MARK: - Private
private func loadPage() async {
currentTask?.cancel()
let task = Task { [currentPage, nextCursor, pageSize] in
do {
let request: PageRequest
if let cursor = nextCursor {
request = .cursor(cursor, size: pageSize)
} else {
request = .offset(page: currentPage + 1, size: pageSize)
}
let result = try await dataSource.fetch(request: request)
try Task.checkCancellation()
items.append(contentsOf: result.items)
self.currentPage += 1
self.nextCursor = result.nextCursor
self.hasMore = result.hasMore
self.totalItems = result.totalItems
self.state = result.hasMore ? .loaded : .exhausted
} catch is CancellationError {
// Ignore cancellation
} catch {
self.state = .error(error.localizedDescription)
}
}
currentTask = task
await task.value
}
}SearchablePaginationManager.swift
import Foundation
/// Adds debounced search to pagination.
///
/// Wraps a `PaginationManager` and resets pagination when the search query changes.
/// Debounces keystrokes to avoid excessive API calls.
@Observable
final class SearchablePaginationManager<Source: SearchablePaginatedDataSource> {
/// The current search query. Setting this debounces and triggers a new search.
var query: String = "" {
didSet {
guard query != oldValue else { return }
debounceAndSearch()
}
}
/// The underlying pagination manager.
let pagination: PaginationManager<SearchDataSourceAdapter<Source>>
/// Debounce interval in milliseconds.
let debounceMilliseconds: Int
private var debounceTask: Task<Void, Never>?
private let adapter: SearchDataSourceAdapter<Source>
init(dataSource: Source, pageSize: Int = 20, debounceMilliseconds: Int = 300) {
self.adapter = SearchDataSourceAdapter(source: dataSource)
self.pagination = PaginationManager(dataSource: adapter, pageSize: pageSize)
self.debounceMilliseconds = debounceMilliseconds
}
/// Clear search and reload without query.
func clearSearch() {
query = ""
adapter.currentQuery = ""
Task { await pagination.refresh() }
}
private func debounceAndSearch() {
debounceTask?.cancel()
debounceTask = Task {
try? await Task.sleep(for: .milliseconds(debounceMilliseconds))
guard !Task.isCancelled else { return }
adapter.currentQuery = query
await pagination.refresh()
}
}
}
/// Adapts a `SearchablePaginatedDataSource` to a regular `PaginatedDataSource`
/// by injecting the current query into fetch calls.
final class SearchDataSourceAdapter<Source: SearchablePaginatedDataSource>: PaginatedDataSource, @unchecked Sendable {
typealias Item = Source.Item
var currentQuery: String = ""
private let source: Source
init(source: Source) {
self.source = source
}
func fetch(request: PageRequest) async throws -> PageResult<Source.Item> {
try await source.fetch(request: request, query: currentQuery)
}
}Views/PaginatedList.swift
import SwiftUI
/// A SwiftUI List with built-in pagination support.
///
/// Automatically loads the next page when the user scrolls near the bottom.
/// Shows appropriate states for loading, empty, error, and exhausted.
///
/// Usage:
/// ```swift
/// PaginatedList(manager: viewModel.pagination) { user in
/// UserRow(user: user)
/// }
/// ```
struct PaginatedList<Source: PaginatedDataSource, RowContent: View>: View {
let manager: PaginationManager<Source>
let rowContent: (Source.Item) -> RowContent
init(
manager: PaginationManager<Source>,
@ViewBuilder rowContent: @escaping (Source.Item) -> RowContent
) {
self.manager = manager
self.rowContent = rowContent
}
var body: some View {
Group {
switch manager.state {
case .idle:
Color.clear
case .loading:
ProgressView("Loading...")
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .error(let message) where manager.items.isEmpty:
PaginationErrorView(message: message) {
Task { await manager.retry() }
}
default:
listContent
}
}
.refreshable {
await manager.refresh()
}
}
@ViewBuilder
private var listContent: some View {
if manager.items.isEmpty && manager.state == .loaded {
ContentUnavailableView(
"No Results",
systemImage: "tray",
description: Text("Nothing to show")
)
} else {
List {
ForEach(manager.items) { item in
rowContent(item)
.onAppear { manager.onItemAppear(item) }
}
footerView
}
}
}
@ViewBuilder
private var footerView: some View {
switch manager.state {
case .loadingMore:
HStack {
Spacer()
ProgressView()
Spacer()
}
.listRowSeparator(.hidden)
case .error(let message):
PaginationErrorBanner(message: message) {
Task { await manager.retry() }
}
.listRowSeparator(.hidden)
case .exhausted:
Text("No more results")
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
.listRowSeparator(.hidden)
default:
EmptyView()
}
}
}Views/LoadMoreButton.swift
import SwiftUI
/// A manual "Load More" button for pagination.
///
/// Use instead of or alongside infinite scroll.
struct LoadMoreButton<Source: PaginatedDataSource>: View {
let manager: PaginationManager<Source>
var body: some View {
if manager.hasMore && manager.state == .loaded {
Button {
Task { await manager.loadNextPage() }
} label: {
Text("Load More")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.controlSize(.regular)
.padding()
} else if manager.state == .loadingMore {
ProgressView()
.frame(maxWidth: .infinity)
.padding()
}
}
}Views/PaginationStateView.swift
import SwiftUI
/// Full-screen error view with retry button.
struct PaginationErrorView: View {
let message: String
let onRetry: () -> Void
var body: some View {
ContentUnavailableView {
Label("Error", systemImage: "exclamationmark.triangle")
} description: {
Text(message)
} actions: {
Button("Retry", action: onRetry)
.buttonStyle(.bordered)
}
}
}
/// Inline error banner for errors on subsequent pages.
/// Shows at the bottom of the list, preserving loaded items.
struct PaginationErrorBanner: View {
let message: String
let onRetry: () -> Void
var body: some View {
VStack(spacing: 8) {
Text(message)
.font(.footnote)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Button("Retry") {
onRetry()
}
.buttonStyle(.bordered)
.controlSize(.small)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
}
}
/// Skeleton loading placeholder for initial page load.
struct PaginationSkeletonView: View {
let rowCount: Int
init(rowCount: Int = 8) {
self.rowCount = rowCount
}
var body: some View {
List {
ForEach(0..<rowCount, id: \.self) { _ in
SkeletonRow()
.redacted(reason: .placeholder)
}
}
}
}
private struct SkeletonRow: View {
var body: some View {
HStack(spacing: 12) {
RoundedRectangle(cornerRadius: 8)
.fill(Color.secondary.opacity(0.2))
.frame(width: 44, height: 44)
VStack(alignment: .leading, spacing: 4) {
RoundedRectangle(cornerRadius: 4)
.fill(Color.secondary.opacity(0.2))
.frame(height: 14)
.frame(maxWidth: 200)
RoundedRectangle(cornerRadius: 4)
.fill(Color.secondary.opacity(0.2))
.frame(height: 12)
.frame(maxWidth: 140)
}
}
.padding(.vertical, 4)
}
}