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:
classMyTableViewController:JMStatefulTableViewController{varitems:[Item]=[]varhasMorePages=true
// MARK: - Data Source
overridefunc tableView(_ tableView:UITableView, numberOfRowsInSection section:Int)->Int{
items.count
}overridefunc tableView(_ tableView:UITableView, cellForRowAt indexPath:IndexPath)->UITableViewCell{letcell= tableView.dequeueReusableCell(withIdentifier:"Cell", for: indexPath)
cell.textLabel?.text =items[indexPath.row].title
return cell
}
// MARK: - Loading Methods
overridefunc loadInitialContent()asyncthrows{
items =tryawait api.fetchItems()
tableView.reloadData()}overridefunc loadFromPullToRefresh()asyncthrows->JMPullToRefreshResult{letnewItems=tryawait api.fetchNewerItems(than: items.first)
items.insert(contentsOf: newItems, at:0)
// Return inserted index paths for smooth animation
letindexPaths=(0..<newItems.count).map{IndexPath(row: $0, section:0)}returnJMPullToRefreshResult(insertedIndexPaths: indexPaths)}overridefunc loadNextPage()asyncthrows{letmoreItems=tryawait api.fetchOlderItems(than: items.last)
items.append(contentsOf: moreItems)
hasMorePages = !moreItems.isEmpty
tableView.reloadData()}overridefunc canLoadNextPage()->Bool{
hasMorePages
}}Use JMStatefulList for SwiftUI projects:
structContentView:View{@StateObjectprivatevarviewModel=ItemsViewModel()varbody:someView{JMStatefulList(
state: viewModel.state,
loadInitial:{tryawait viewModel.loadInitial()},
loadMore: viewModel.hasMore ?{tryawait viewModel.loadMore()}:nil,
refresh:{tryawait viewModel.refresh()}){ForEach(viewModel.items){ item inItemRow(item: item)}}}}@MainActorclassItemsViewModel:ObservableObject{@Publishedvaritems:[Item]=[]@Publishedvarstate:JMStatefulListState=.loading
varhasMore=truefunc loadInitial()asyncthrows{
items =tryawait api.fetchItems()
state = items.isEmpty ?.empty :.idle
}func loadMore()asyncthrows{letmoreItems=tryawait api.fetchOlderItems(than: items.last)
items.append(contentsOf: moreItems)
hasMore = !moreItems.isEmpty
}func refresh()asyncthrows{letnewItems=tryawait api.fetchNewerItems(than: items.first)
items.insert(contentsOf: newItems, at:0)}}For more convenient state management:
@MainActorclassItemsViewModel:ObservableObject{@Publishedvaritems:[Item]=[]letstateManager=JMStatefulListStateManager()varhasMore=truefunc loadInitial()asyncthrows{do{
items =tryawait api.fetchItems()if items.isEmpty {
stateManager.setEmpty()}else{
stateManager.setIdle()}}catch{
stateManager.setError(error)}}}structContentView:View{@StateObjectprivatevarviewModel=ItemsViewModel()varbody:someView{JMStatefulList(
state: viewModel.stateManager.state,
loadInitial:{tryawait viewModel.loadInitial()},
loadMore: viewModel.hasMore ?{tryawait viewModel.loadMore()}:nil){ForEach(viewModel.items){ item inItemRow(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 |
classMyTableViewController:JMStatefulTableViewController{overridefunc viewDidLoad(){
super.viewDidLoad()
// Custom loading view
letloadingView=UIView()letspinner=UIActivityIndicatorView(style:.large)
// ... configure spinner
loadingView.addSubview(spinner)self.loadingView = loadingView
// Custom empty view
letemptyView=UIView()letlabel=UILabel()
label.text ="No items yet"
emptyView.addSubview(label)self.emptyView = emptyView
// Custom error view
leterrorView=UIView()
// ... configure error view with retry button
self.errorView = errorView
}}JMStatefulList(
state: viewModel.state,
loadInitial:{tryawait viewModel.loadInitial()}){ForEach(viewModel.items){ item inItemRow(item: item)}}.loadingView{VStack{ProgressView()Text("Loading...")}}.emptyView{VStack{Image(systemName:"tray").font(.largeTitle)Text("No items yet")}}.errorView{ error inVStack{Text("Error: \(error.localizedDescription)")Button("Retry"){Task{tryawait viewModel.loadInitial()}}}}classMyTableViewController:JMStatefulTableViewController{
// Disable pull-to-refresh
overridefunc shouldEnablePullToRefresh()->Bool{false}
// Disable infinite scrolling
overridefunc shouldEnableInfiniteScrolling()->Bool{false}}classMyTableViewController:JMStatefulTableViewController{overridefunc willTransition(from oldState:JMStatefulState, to newState:JMStatefulState){print("Transitioning from \(oldState) to \(newState)")}overridefunc 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)
overridefunc loadInitialContent()asyncthrows{
items =tryawait 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)