Easy data pagination with SwiftPaging
Reading time: 8 min
This tutorial shows how to use the SwiftPaging framework to seamlessly integrate a paged data source in your app. You'll learn how to fetch paged data from a remote API and display it in an infinite scrolling list. After that, you'll add a database layer that allows for persistent storage of paged data.
The sample app fetches Swift-related repositories from Github API. The API is pageable - you can fetch data in arbitrary chunks defined by the page index and its size.
The end result will look like this:
SwiftPaging is open source and welcomes feedback and contributions!
Prep
Create a new iOS App project, name it SwiftPagingTest. Set interface to SwiftUI, life cycle to SwiftUI App, and check the Use Core Data checkbox:
Start off by adding SwiftPaging package to your project. It's repo URL is:
https://github.com/globulus/swift-paging
Also add the SwiftUIInfiniteList package, it's repo URL is:
https://github.com/globulus/swiftui-infinite-list
Then, go to Persistence.swift and remove the preview
property:
static var preview: PersistenceController = { // REMOVE
...
}
Adding the PagingSource
The first order of business is to add the model that corresponds to the responses returned by the Github API:
RepoWrapper
represents a single repository, with its name, URL, star count, etc.RepoSearchReponse
corresponds to the response format of the API and contains, amongst other things, a list of data.
RepoWrapper
isn't named Repo
as that'll be the name of our CoreData model later on.
Add a new file, Models.swift, and put this as its content:
import Foundation
public struct RepoWrapper: Codable, Hashable {
public let id: Int64
public let name: String
public let fullName: String
public let description: String?
public let url: String
public let stars: Int
public let forks: Int
public let language: String?
public enum CodingKeys: String, CodingKey {
case id = "id",
name = "name",
fullName = "full_name",
description = "description",
url = "html_url",
stars = "stargazers_count",
forks = "forks_count",
language = "language"
}
}
public struct RepoSearchResponse: Codable {
public let total: Int?
public let items: [RepoWrapper]
public let nextPage: Int?
public enum CodingKeys: String, CodingKey {
case total = "total_count",
items = "items",
nextPage = "nextPage"
}
}
Next up, add the paging source as GithubPagingSource.swift:
import Foundation
import Combine
import SwiftPaging
public class GithubPagingSource: PagingSource {
// Fetches the list of repo wrappers from the remote API.
public func fetch(request: PagingRequest<Int>) -> PagingResultPublisher<Int, RepoWrapper> {
let query = "swiftin:name,description"
let page = request.key // our page corresponds to the request key
let pageSize = request.params.pageSize // the page size is passed as a standard request param
return URLSession.shared
.dataTaskPublisher(for: URL(string: "https://api.github.com/search/repositories?sort=stars&q=\(query)&page=\(page)&per_page=\(pageSize)")!)
.map(\.data)
.decode(type: RepoSearchResponse.self, decoder: JSONDecoder()) // decode the response data
.map(\.items) // get the array of RepoWrappers
.map { Page(request: request, values: $0) } // the response publisher must return a Page of data
.eraseToAnyPublisher()
}
public let refreshKey: Int = 0 // first page is at 0 and refreshing starts from here
// Constructs the key chain, which tells how pages are ordered on the remote source.
// In Github API, every page has a next key, and all but the first have the previous one.
public func keyChain(for key: Int) -> PagingKeyChain<Int> {
PagingKeyChain(key: key,
prevKey: (key == 0) ? nil : (key - 1),
nextKey: key + 1)
}
}
Believe or not, that's almost it! Time to work on the UI and hook everything up.
Hooking up the UI
We'll add a single SwiftUI screen and its view model. Start by adding a new file, ContentViewModel.swift, and this as its content:
import Foundation
import SwiftUI
import SwiftUIPullToRefresh
import SwiftPaging
import Combine
// This is the state that PaginationManager publishes. We can use the default one,
// just specifying that its data are RepoWrappers.
typealias GithubPagingState = DefaultPaginationManagerOutput<RepoWrapper>
private let pageSize = 15 // fetch 15 repos at the time
class ContentViewModel: ObservableObject {
@Published var repos = [RepoWrapper]() // the contents of the infinite list
@Published var isAppending = false // shows the spinner at the bottom of the list
// This is the bread and butter of the entire view model. PaginationManager is a
// utility that binds all the paging components together.
private var paginationManager = PaginationManager<Int, RepoWrapper, GithubPagingSource, GithubPagingState>(
source: GithubPagingSource(),
pageSize: pageSize,
interceptors: [LoggingInterceptor()]
)
private var subs = Set<AnyCancellable>()
// Store the refresh callback so that we can update the UI after refreshed data
// comes through.
private var refreshComplete: RefreshComplete?
init() {
// subscribe to the paginationManager's publisher to receive state updates and
// know when the app is refreshing, appending, prepending, etc, and what its
// total values are
paginationManager.publisher
.replaceError(with: GithubPagingState.initial) // no error handling for now
.sink { [self] state in // receive a paging state update
if !state.isRefreshing {
refreshComplete?()
refreshComplete = nil
}
repos = state.values // update the list of repos
isAppending = state.isAppending // update the spinner state
}.store(in: &subs)
}
func loadMore() {
paginationManager.append()
}
func refresh(refreshComplete: RefreshComplete? = nil) {
self.refreshComplete = refreshComplete
paginationManager.refresh()
}
}
Then, replace the contents of ContentView.swift with this:
import SwiftUI
import SwiftUIInfiniteList
import SwiftUIPullToRefresh
struct ContentView: View {
@ObservedObject var viewModel: ContentViewModel
var body: some View {
InfiniteList(data: $viewModel.repos,
isLoading: $viewModel.isAppending,
loadingView: ProgressView(),
loadMore: viewModel.loadMore,
onRefresh: viewModel.refresh(refreshComplete:)) { repo in
VStack(alignment: .leading) {
Text("\(repo.name ?? "") \(repo.stars) \(repo.forks)")
.font(.system(size: 14))
Text(repo.url ?? "")
}.padding()
}
}
}
Lastly, update the body
in SwiftPagingTestApp.swift to this:
var body: some Scene {
WindowGroup {
ContentView(viewModel: ContentViewModel())
}
}
And that's it! Build and run the app and you'll see the infinite scroll of Swift-related Github repos!
Consider how little actual code you had to write here - all that was necessary was to specify the PagingSource
and to bind to the UI. (Plus, InfiniteList
and SwiftUIPullToRefresh
to a lot of heavy lifting as well.)
Adding CoreData support
SwiftPaging makes it super easy to introduce a database layer to your app. The database will serve as a persistent store for your data, so that it isn't loaded from server with each requrest.
Start off by replacing SwiftPagingTest.xcdatamodeld in your project with this one (unzip first):
SwiftPagingTest.xcdatamodeld.zip
Add the corresponding model class to Models.swift:
import CoreData
@objc(Repo)
public class Repo: NSManagedObject {
// Maps a RepoWrapper to Repo
public func fromWrapper(_ wrapper: RepoWrapper) {
id = wrapper.id
name = wrapper.name
fullName = wrapper.fullName
desc = wrapper.description
url = wrapper.url
stars = Int32(wrapper.stars)
forks = Int32(wrapper.forks)
language = wrapper.language
}
}
public extension Repo {
static let entityName = "Repo"
@nonobjc class func fetchRequest() -> NSFetchRequest<Repo> {
return NSFetchRequest<Repo>(entityName: entityName)
}
@NSManaged var id: Int64
@NSManaged var name: String?
@NSManaged var fullName: String?
@NSManaged var desc: String?
@NSManaged var url: String?
@NSManaged var stars: Int32
@NSManaged var forks: Int32
@NSManaged var language: String?
}
extension Repo : Identifiable {
}
Now it's time to add the data source, which will serve as an interface between SwiftPaging and your database. Add a new file GithubDataSource.swift and put this as its content:
import CoreData
import SwiftPaging
public class GithubDataSource: CoreDataInterceptorDataSource {
private let persistentStoreCoordinator: NSPersistentStoreCoordinator
public init(persistentStoreCoordinator: NSPersistentStoreCoordinator) {
self.persistentStoreCoordinator = persistentStoreCoordinator
}
// Fetches data from the DB for the given PagingRequest, taking in the key (as fetchOffset)
// and page size (as fetchLimit).
public func get(request: PagingRequest<Int>) throws -> [Repo] {
let moc = request.moc!
let fetchRequest = Repo.fetchRequest() as NSFetchRequest<Repo>
// emulate sorting of API
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Repo.stars, ascending: false),
NSSortDescriptor(keyPath: \Repo.name, ascending: true)
]
let pageSize = request.params.pageSize
fetchRequest.fetchOffset = request.key * pageSize
fetchRequest.fetchLimit = pageSize
return try moc.fetch(fetchRequest)
}
// Inserts data retrieved from the remote API into the DB.
public func insert(remoteValues: [RepoWrapper], in moc: NSManagedObjectContext) throws -> [Repo] {
let entity = NSEntityDescription.entity(forEntityName: Repo.entityName, in: moc)!
let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: Repo.entityName)
var repos = [Repo]()
for wrapper in remoteValues {
fetchRequest.predicate = NSPredicate(format: "id == %d", wrapper.id)
try persistentStoreCoordinator.execute(NSBatchDeleteRequest(fetchRequest: fetchRequest), with: moc)
let repo = Repo(entity: entity, insertInto: moc)
repo.fromWrapper(wrapper)
repos.append(repo)
}
try moc.save()
return repos
}
// Clears all data in case of a hard refresh.
public func deleteAll(in moc: NSManagedObjectContext) throws {
let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: Repo.entityName)
let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)
try persistentStoreCoordinator.execute(deleteRequest, with: moc)
}
}
Then, go to GithubPagingSource.swift and update it to work with Repo
instead of RepoWrapper
. This also requres you to use the data source to immediately store new Repo
s in the DB.
public class GithubPagingSource: PagingSource {
private let dataSource: GithubDataSource
public init(dataSource: GithubDataSource) {
self.dataSource = dataSource
}
public func fetch(request: PagingRequest<Int>) -> PagingResultPublisher<Int, Repo> {
guard let moc = request.moc
else {
return Fail(outputType: Page<Int, Repo>.self, failure: URLError(.badURL))
.eraseToAnyPublisher()
}
let query = "swiftin:name,description"
let page = request.key
let pageSize = request.params.pageSize
return URLSession.shared
.dataTaskPublisher(for: URL(string: "https://api.github.com/search/repositories?sort=stars&q=\(query)&page=\(page)&per_page=\(pageSize)")!)
.map(\.data)
.decode(type: RepoSearchResponse.self, decoder: JSONDecoder()) // decode the response data
.map(\.items) // get the array of RepoWrappers
.tryMap { [self] wrappers in
let repos = try dataSource.insert(remoteValues: wrappers, in: moc)
return Page(request: request, values: repos)
}.eraseToAnyPublisher()
}
// the rest remains the same
}
Lastly, go to ContentViewModel.swift and update it to work with Repo
and use the CoreDataInterceptor
:
...
typealias GithubPagingState = DefaultPaginationManagerOutput<Repo> // change to 'Repo'
...
class ContentViewModel: ObservableObject {
@Published var repos = [Repo]() // change to 'Repo'
private var dataSource: GithubDataSource! // add this line
// PaginationManager is now initialized later on
private var paginationManager: PaginationManager<Int, Repo, GithubPagingSource>!
init() {
dataSource = GithubDataSource(persistentStoreCoordinator: PersistenceController.shared.container.persistentStoreCoordinator)
paginationManager = PaginationManager(source: GithubPagingSource(dataSource: dataSource),
pageSize: pageSize,
interceptors: [LoggingInterceptor(),
CoreDataInterceptor(dataSource: dataSource) // note CoreDataInterceptor
])
// keep everything else in 'init' as is
}
// adds NSManagedObjectContext to each pagination request so that it's available to
// the data source and CoreDataInterceptor
private var userInfo: PagingRequestParamsUserInfo {
[CoreDataInterceptorUserInfoParams.moc: PersistenceController.shared.container.viewContext]
}
func loadMore() {
paginationManager.append(userInfo: userInfo) // update with userInfo
}
func refresh(refreshComplete: RefreshComplete? = nil) {
self.refreshComplete = refreshComplete
paginationManager.refresh(userInfo: userInfo) // update with userInfo
}
}
And that's it! Build and run, and the app will now use CoreData as an intermediary between your UI and the back end. All the data that's available in the DB will be served from there!
You can download the final version of the project here. If you want to learn more about SwiftPaging and its inner workings, be sure to check out the docs.