Skip to content

Repository files navigation

JMStatefulTableViewController

A stateful table view controller for iOS that manages loading states, pull-to-refresh, and infinite scrolling. Now includes SwiftUI support!

Features

  • 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: JMStatefulList component 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

Requirements

  • iOS 15.0+ / macOS 12.0+ / tvOS 15.0+ / watchOS 8.0+
  • Swift 5.9+
  • Xcode 15+

Installation

Swift Package Manager

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.

Usage

UIKit - JMStatefulTableViewController

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
}}

SwiftUI - JMStatefulList

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)}}

Using JMStatefulListStateManager

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)}}}}

States

Both UIKit and SwiftUI implementations support similar states:

UIKit States (JMStatefulState)

StateDescription
idleNormal state, user can scroll and interact
initialLoadingFirst load, shows loadingView
loadingFromPullToRefreshPull-to-refresh in progress
loadingNextPageInfinite scrolling load in progress
emptyNo content, shows emptyView
error(Error?)Error occurred, shows errorView

SwiftUI States (JMStatefulListState)

StateDescription
idleNormal state, content is visible
loadingInitial load in progress
emptyNo content to display
error(Error)Error occurred

Customizing Views

UIKit

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
}}

SwiftUI

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()}}}}

Configuration

Disabling Features (UIKit)

classMyTableViewController:JMStatefulTableViewController{
// Disable pull-to-refresh
overridefunc shouldEnablePullToRefresh()->Bool{false}
// Disable infinite scrolling
overridefunc shouldEnableInfiniteScrolling()->Bool{false}}

State Transition Callbacks (UIKit)

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)")}}

Migration from v1.x

Version 2.0 is a complete rewrite in Swift with modern APIs:

Key Changes

  1. Async/await: All loading methods now use async throws instead of callbacks
  2. Native refresh control: Uses UIRefreshControl instead of SVPullToRefresh
  3. Swift Package Manager: Primary distribution method
  4. SwiftUI support: New JMStatefulList component

Migration Steps

  1. 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()}
  1. Update state checking:
// Before
if(self.statefulState == JMStatefulTableViewControllerStateIdle){...}
// After
if statefulState ==.idle {...}
  1. Replace delegate with override methods (or keep using delegate if preferred)

License

MIT License. See LICENSE for details.

Author

Jake Marsh (@jakemarsh)

About

A subclassable table view controller with empty, loading and error states, also supports infinite scrolling and pull to refresh.

Resources

Stars

103 stars

Watchers

7 watching

Forks

Releases

Packages

Used by

Contributors

Languages