A stateful table view controller for iOS that manages loading states, pull-to-refresh, and infinite scrolling. Now includes SwiftUI support!
- Loading States: Automatic management of idle, loading, empty, and error states
- Pull-to-Refresh: Built-in support using native
UIRefreshControl - Infinite Scrolling: Automatic pagination when scrolling near the bottom
- SwiftUI Support:
JMStatefulListcomponent with the same functionality - Modern Swift: Async/await API, @MainActor support, Sendable conformance
- Customizable Views: Easily replace loading, empty, and error views
- State Callbacks: Delegate methods for state transitions
- iOS 15.0+ / macOS 12.0+ / tvOS 15.0+ / watchOS 8.0+
- Swift 5.9+
- Xcode 15+
Add the following to your Package.swift:
dependencies: [
.package(url: "https://github.com/jakemarsh/JMStatefulTableViewController.git", from: "2.0.0")
]Or in Xcode: File → Add Package Dependencies → Enter the repository URL.
Subclass JMStatefulTableViewController and implement the required loading methods:
class MyTableViewController: JMStatefulTableViewController {
var items: [Item] = []
var hasMorePages = true
// MARK: - Data Source
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
items.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
cell.textLabel?.text = items[indexPath.row].title
return cell
}
// MARK: - Loading Methods
override func loadInitialContent() async throws {
items = try await api.fetchItems()
tableView.reloadData()
}
override func loadFromPullToRefresh() async throws -> JMPullToRefreshResult {
let newItems = try await api.fetchNewerItems(than: items.first)
items.insert(contentsOf: newItems, at: 0)
// Return inserted index paths for smooth animation
let indexPaths = (0..<newItems.count).map { IndexPath(row: $0, section: 0) }
return JMPullToRefreshResult(insertedIndexPaths: indexPaths)
}
override func loadNextPage() async throws {
let moreItems = try await api.fetchOlderItems(than: items.last)
items.append(contentsOf: moreItems)
hasMorePages = !moreItems.isEmpty
tableView.reloadData()
}
override func canLoadNextPage() -> Bool {
hasMorePages
}
}Use JMStatefulList for SwiftUI projects:
struct ContentView: View {
@StateObject private var viewModel = ItemsViewModel()
var body: some View {
JMStatefulList(
state: viewModel.state,
loadInitial: { try await viewModel.loadInitial() },
loadMore: viewModel.hasMore ? { try await viewModel.loadMore() } : nil,
refresh: { try await viewModel.refresh() }
) {
ForEach(viewModel.items) { item in
ItemRow(item: item)
}
}
}
}
@MainActor
class ItemsViewModel: ObservableObject {
@Published var items: [Item] = []
@Published var state: JMStatefulListState = .loading
var hasMore = true
func loadInitial() async throws {
items = try await api.fetchItems()
state = items.isEmpty ? .empty : .idle
}
func loadMore() async throws {
let moreItems = try await api.fetchOlderItems(than: items.last)
items.append(contentsOf: moreItems)
hasMore = !moreItems.isEmpty
}
func refresh() async throws {
let newItems = try await api.fetchNewerItems(than: items.first)
items.insert(contentsOf: newItems, at: 0)
}
}For more convenient state management:
@MainActor
class ItemsViewModel: ObservableObject {
@Published var items: [Item] = []
let stateManager = JMStatefulListStateManager()
var hasMore = true
func loadInitial() async throws {
do {
items = try await api.fetchItems()
if items.isEmpty {
stateManager.setEmpty()
} else {
stateManager.setIdle()
}
} catch {
stateManager.setError(error)
}
}
}
struct ContentView: View {
@StateObject private var viewModel = ItemsViewModel()
var body: some View {
JMStatefulList(
state: viewModel.stateManager.state,
loadInitial: { try await viewModel.loadInitial() },
loadMore: viewModel.hasMore ? { try await viewModel.loadMore() } : nil
) {
ForEach(viewModel.items) { item in
ItemRow(item: item)
}
}
}
}Both UIKit and SwiftUI implementations support similar states:
| State | Description |
|---|---|
idle |
Normal state, user can scroll and interact |
initialLoading |
First load, shows loadingView |
loadingFromPullToRefresh |
Pull-to-refresh in progress |
loadingNextPage |
Infinite scrolling load in progress |
empty |
No content, shows emptyView |
error(Error?) |
Error occurred, shows errorView |
| State | Description |
|---|---|
idle |
Normal state, content is visible |
loading |
Initial load in progress |
empty |
No content to display |
error(Error) |
Error occurred |
class MyTableViewController: JMStatefulTableViewController {
override func viewDidLoad() {
super.viewDidLoad()
// Custom loading view
let loadingView = UIView()
let spinner = UIActivityIndicatorView(style: .large)
// ... configure spinner
loadingView.addSubview(spinner)
self.loadingView = loadingView
// Custom empty view
let emptyView = UIView()
let label = UILabel()
label.text = "No items yet"
emptyView.addSubview(label)
self.emptyView = emptyView
// Custom error view
let errorView = UIView()
// ... configure error view with retry button
self.errorView = errorView
}
}JMStatefulList(
state: viewModel.state,
loadInitial: { try await viewModel.loadInitial() }
) {
ForEach(viewModel.items) { item in
ItemRow(item: item)
}
}
.loadingView {
VStack {
ProgressView()
Text("Loading...")
}
}
.emptyView {
VStack {
Image(systemName: "tray")
.font(.largeTitle)
Text("No items yet")
}
}
.errorView { error in
VStack {
Text("Error: \(error.localizedDescription)")
Button("Retry") {
Task { try await viewModel.loadInitial() }
}
}
}class MyTableViewController: JMStatefulTableViewController {
// Disable pull-to-refresh
override func shouldEnablePullToRefresh() -> Bool {
false
}
// Disable infinite scrolling
override func shouldEnableInfiniteScrolling() -> Bool {
false
}
}class MyTableViewController: JMStatefulTableViewController {
override func willTransition(from oldState: JMStatefulState, to newState: JMStatefulState) {
print("Transitioning from \(oldState) to \(newState)")
}
override func didTransition(to state: JMStatefulState) {
print("Now in state: \(state)")
}
}Version 2.0 is a complete rewrite in Swift with modern APIs:
- Async/await: All loading methods now use
async throwsinstead of callbacks - Native refresh control: Uses
UIRefreshControlinstead of SVPullToRefresh - Swift Package Manager: Primary distribution method
- SwiftUI support: New
JMStatefulListcomponent
- Replace callback-based loading with async methods:
// Before (v1.x)
- (void)loadInitialContentWithCompletion:(void(^)(NSError *))completion {
[self.api fetchItemsWithCompletion:^(NSArray *items, NSError *error) {
self.items = items;
completion(error);
}];
}
// After (v2.0)
override func loadInitialContent() async throws {
items = try await api.fetchItems()
tableView.reloadData()
}- Update state checking:
// Before
if (self.statefulState == JMStatefulTableViewControllerStateIdle) { ... }
// After
if statefulState == .idle { ... }- Replace delegate with override methods (or keep using delegate if preferred)
MIT License. See LICENSE for details.
Jake Marsh (@jakemarsh)