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:

ezgif-2-1a12622339e1

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:

Untitled

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

Screenshot%202021-06-14%20at%2010.11.28

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:

  1. RepoWrapper represents a single repository, with its name, URL, star count, etc.
  2. 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 Repos 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.

Next Post Previous Post