Swift Paging is a framework that helps you load and display pages of data from a larger dataset from local storage or over network.

Overview

SwiftPaging

Swift Paging is a framework that helps you load and display pages of data from a larger dataset from local storage or over network. This approach allows your app to use both network bandwidth and system resources more efficiently. It's built on top of Combine, allowing you to harness its full power, handle errors easily, etc.

Features

  • A server-client architecture for requesting and receiving data.
  • Requests and responses go through Combine.
  • Support for interceptors that allow for custom logic - logging, caching, CoreData interop, etc.
  • Built-in deduplication of requests, conditional retries and error handling.
  • Automatic state management - know what paging operation your apps is currently doing and which data is available to it.

Installation

This framework is distributed as a Swift Package. Simply add the URL of this Git to your dependencies list and it'll work.

SwiftPaging is written in pure Swift and contains no platform dependencies. It relies on Combine, which means that it can be used on:

  • iOS 13 and above
  • MacOS 10.15 and above
  • tvOS 13 and above
  • WatchOS 6 and above

Demo apps

If you want to jump straight to the action, there are two demo apps you can try - implemented in UIKit or SwiftUI. They both do the same thing - represent an infinite scroll of Github repositories that contain the word swift. The lists are refreshable and the apps use CoreData for local storage. The gist of the code lives in the shared Swift Package. Overall, it represents a good use case for the framework.

Core Concepts

SwiftPaging tries to make complex things simple, but it still may seem like there're a lot of concepts to swallow. However, all you need to do to get going is to implement a PagingSource. If you want to use CoreDataPagingInterceptor, you'll need to implement a CoreDataInterceptorDataSource as well. Beyond that, PaginationManager will provide you with the state publisher and interface methods.

TL;DR

  1. PagingSource is your remote API. CoreDataInterceptorDataSource is your DAO. Both know how to get values (a Page) based on parameters from PagingRequest - its key, page size, etc.
  2. When you want data, tell your PaginationManager to refresh, prepend or append data, depending on what you want . It'll send a request that uniquely identifies the data that should come back via its key and params.
  3. Your PaginationManager will also notify its publisher that a paging event is happening, so that it can update its UI.
  4. PagingRequest goes through PagingInterceptors. One of them, CoreDataPagingInterceptor check if it has the requested data in the local DB. If yes, it returns it immediately.
  5. If the data isn't locally present, the request goes to your PagingSource (representing your remote API), which does all the networking and gets the data from the back end.
  6. PaginationManager updates the state with new data and publishes it to any subscribers.

In depth

  • Your data is organized in Pages. Each Page references the request that produced it and contains an array of values.
  • A PagingRequest is an event that prompts the framework to return a Page. The request contains the KeyChain, as well as other parameters (which are customizable). There are 3 types of requests - refresh, prepend and append. You can tweak their exact meaning in your code, but the default PaginationManager takes refresh as the one that updates all the data, append the one that adds data to the end, and prepend as the one that adds data to the start.
  • Each Page is uniquely identified by its key. The PagingRequest contains a KeyChain, which is the current key, plus its predecessor and successor (if there are any). This allows paging requests to be chained and for the system to keep track on which page to load next.
  • A RequestPublisher is a Combine publisher that produces requests on-demand. Each PaginationManager has a built-in publisher, allowing you to easily send requests using methods (request, prepend or append).
  • PagingSource responds to PagingRequests and provides Pages. Normally, it represents your remote data source, such as the API your app is consuming to fetch data. Besides this, a PagingSource know the initial key (via refreshKey), and provides the key chain for the given key.
  • You can place PagingInterceptors between your RequestPublisher and PagingSource. Interceptors can inspect the request, modify it, or even return data. Examples are:
    • LoggingInterceptor - simply inspects requests and logs what goes on.
    • CacheInterceptor - caches data locally and returns if available.
    • CoreDataInterceptor - stores data in a local DB and returns if available.
  • Pager is the glue that binds all these components together, mapping requests from the publisher, passing through interceptor and finally to the paging source. It publishes PagingStates that allow your app to respond to paging events and update the UI. Working with a Pager directly offers the most flexibility and customizations.
  • PaginationManager is a util class that wraps together a RequestPublisher and a Pager and exposes a simpler interface that should suffice for most apps.
    • You can trigger requests using methods - refresh, prepend and append.
    • Keeps track of request order, and makes sure that refresh updates all the data, while prepend and append add the data to beginning and end, respectively.
    • It publishes a PaginationManagerOutputwhich contains the full pagination state. You can implement your own PaginationManagerOutput or use the DefaultPaginationManagerOutput implementation.
    • Makes sure all pagination is done on a background thread, while the state is published on DispatchQueue.main.

Implementing a PagingSource

A PagingSource responds to PagingRequests and returns Pages. It also knows where does refreshing start and what are its boundaries (first and last page). PagingSource normally represents your remote API, but can represent any paginated data source.

In the demo apps, the PagingSource fetches Repos from Github's API. Its pages are identified by numbers, so its Key is Int. It has three overrides:

  • refreshKey tells the origin point of pagination, the key of the first page:
public let refreshKey: Int = 0
  • keyChain(for:) tells how are keys linked together, i.e for a given key, what is its previous key, and what is the next one:
public func keyChain(for key: Int) -> PagingKeyChain<Int> {
    PagingKeyChain(key: key,
                   prevKey: (key == 0) ? nil : (key - 1),
                   nextKey: key + 1)
}
  • fetch(request:) produces a Publisher for the given request. Note how requests can hold custom data in their params.userInfo:
public func fetch(request: PagingRequest<Int>) -> PagingResultPublisher<Int, Repo> {
    guard let moc = request.moc,
          let query = request.query
    else {
        return Fail(outputType: Page<Int, Repo>.self, failure: URLError(.badURL))
            .eraseToAnyPublisher()
    }
    return service.getRepos(query: query, page: request.key, pageSize: request.params.pageSize)
        .tryMap { [self] wrappers in
            print("paging source returned \(wrappers.count) items for request: \(request)")
            let repos = try dataSource.insert(remoteValues: wrappers, in: moc)
            return Page(request: request, values: repos)
        }.eraseToAnyPublisher()
}

Storing pages in DB

A common use case is to have a permanent client-side storage in the form of CoreData. SwiftPaging makes placing the DB between your app and its remote source dead easy via CoreDataInterceptor. (This is an interceptor - read more on interceptors here).

To use CoreDataInterceptor, you must do two things:

  1. Pass a NSManagedObjectContext in PagingRequestParams.userInfo. You should use CoreDataInterceptorUserInfoParams.moc as the key (check out sample for how it's done). Also, if you want the refresh request to clear your DB, add CoreDataInterceptorUserInfoParams.hardRefresh: true to the userInfo dictionary as well.
  2. Define a CoreDataInterceptorDataSource implementation, so that CoreDataInterceptor know how to interface with your data - namely, how to:
  • read data from the DB for the given request via get(request:),
  • write a batch of remote data via insert(remoteValues:in:),
  • clear the DB via deleteAll(in:).

Here's the implementation from the demo apps:

Note that CoreDataInterceptorDataSource makes a distinction between your DB model (Value) and its remote variant (RemoteValue), allowing you to work with different models. If the model coming back from your remote API is exactly the same as your CoreDataModel, use the same value for Value and RemoteValue.

[Repo] { let entity = NSEntityDescription.entity(forEntityName: Repo.entityName, in: moc)! let fetchRequest = NSFetchRequest(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 } public func deleteAll(in moc: NSManagedObjectContext) throws { let fetchRequest = NSFetchRequest(entityName: Repo.entityName) let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) try persistentStoreCoordinator.execute(deleteRequest, with: moc) } } ">
public protocol GithubDataSource: CoreDataInterceptorDataSource where Key == Int, Value == Repo, RemoteValue == RepoWrapper {

}

public class GithubDataSourceImpl: GithubDataSource {
    private let persistentStoreCoordinator: NSPersistentStoreCoordinator
    
    public init(persistentStoreCoordinator: NSPersistentStoreCoordinator) {
        self.persistentStoreCoordinator = persistentStoreCoordinator
    }
    
    public func get(request: PagingRequest<Int>) throws -> [Repo] {
        let moc = request.moc!
        let query = request.query!
        let fetchRequest = Repo.fetchRequest() as NSFetchRequest<Repo>
        fetchRequest.predicate = NSPredicate(format: "(%K CONTAINS[cd] %@) OR (%K CONTAINS[cd] %@)", #keyPath(Repo.name), query, #keyPath(Repo.desc), query)
        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)
    }
    
    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
    }
    
    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, just add CoreDataInterceptor to your interceptors array:

interceptors: [..., CoreDataInterceptor(dataSource: dataSource), ...])

The specific nature of CoreData will most likely force you to use your data source in your paging source, since Values are created via Value(entity:insertInto:).

Using PaginationManager

PaginationManager is a util class that makes it dead easy to tie your PagingSource and Interceptors and provide your app with a state publisher. PaginationMangager works out of the box, but you'll usually want to set it up with parameters specific to your app:

public class GithubPaginationManager<PagingSource: GithubPagingSource>: PaginationManager<Int, Repo, PagingSource, GithubPagingState> { }

Then, instantiate it with your source and interceptors:

dataSource = GithubDataSourceImpl(persistentStoreCoordinator: PersistenceController.shared.container.persistentStoreCoordinator)
paginationManager = GithubPaginationManager(source: GithubPagingSource(service: GithubServiceImpl(), dataSource: dataSource),
                                            pageSize: 15,
                                            interceptors: [LoggingInterceptor<Int, Repo>(), CoreDataInterceptor(dataSource: dataSource)])

Then, subscribe to its state publisher wherever necessary. Here's the example from the SwiftUI demo:

paginationManager.publisher
    .replaceError(with: GithubPagingState.initial)
    .sink { [self] state in
        if !state.isRefreshing {
            refreshComplete?()
            refreshComplete = nil
        }
        repos = state.values
        isAppending = state.isAppending
    }.store(in: &subs)

When you need paginaton to happen, just trigger its methods - refresh, prepend or append. It's as simple as that!

Interceptors

Interceptors are a powerful mechanism that can monitor and rewrite requests, or even complete them on the spot. After a Page is returned, all interceptors are notified of it, and can use it to modify their internal state. You can chain any number of interceptors in your Pager.

The built-in LoggingInterceptor is an example of a passive interceptor that analyzes request and response and prints it to a log:

) { log("Received page: \(page)") // once the page is retuned, print it } } ">
public class LoggingInterceptor<Key: Equatable, Value>: PagingInterceptor<Key, Value> {
    private let log: (String) -> Void // allows for custom logging

    public init(log: ((String) -> Void)? = nil) {
        self.log = log ?? { print($0) }
    }

    public override func intercept(request: PagingRequest<Key>) throws -> PagingInterceptResult<Key, Value> {
        log("Sending pagination request: \(request)") // log the request
        return .proceed(request, handleAfterwards: true) // proceed with the request, without changing it
    }

    public override func handle(result page: Page<Key, Value>) {
        log("Received page: \(page)") // once the page is retuned, print it
    }
}

On the other hand, CacheInterceptor checks if it has the page available locally, and terminates the request chain if so:

public let cacheInterceptorDefaultExpirationInterval = TimeInterval(10 * 60) // 10 min

public class CacheInterceptor<Key: Hashable, Value>: PagingInterceptor<Key, Value> {
    private let expirationInterval: TimeInterval
    private var cache = [Key: CacheEntry]()
    
    public init(expirationInterval: TimeInterval = cacheInterceptorDefaultExpirationInterval) {
        self.expirationInterval = expirationInterval
    }
    
    public override func intercept(request: PagingRequest<Key>) throws -> PagingInterceptResult<Key, Value> {
        pruneCache() // remove expired items
        if let cached = cache[request.key] {
            return .complete(cached.page) // complete the request with the cached page
        } else {
            return .proceed(request, handleAfterwards: true) // don't have data, proceed...
        }
    }
    
    public override func handle(result page: Page<Key, Value>) {
        cache[page.key] = CacheEntry(page: page) // store result in cache
    }
    
    private func pruneCache() {
        let now = Date().timeIntervalSince1970
        let keysToRemove = cache.keys.filter { now - (cache[$0]?.timestamp ?? 0) > expirationInterval }
        for key in keysToRemove {
            cache.removeValue(forKey: key)
        }
    }
    
    private struct CacheEntry {
        let page: Page<Key, Value>
        let timestamp: TimeInterval = Date().timeIntervalSince1970
    }
}

CoreDataInterceptor works in a similar fashion.

Writing your own interceptor

Creating an interceptor is easy enough:

  1. Subclass PagingInterceptor.
  2. Override intercept(request:). From it, return:
  3. .complete(Page) if your interceptor should respond to the request and terminate furhter request propagation.
  4. .proceed(PagingRequest, handleAfterwards: Bool) if the request should go forward. You can modify the original request in any way you want, or keep it as is. Set handleAfterwards: parameter to true if you want handle(result:) to be invoked for this interceptor once the response Page comes back.
  5. Override handle(result:) to observe the Page generated for the request.
You might also like...
A custom wrapper over AFNetworking library that we use inside RC extensively

AFNetworkingHelper A very simple wrapper over the most amazing networking library for objective C, AFNetworking. We extensively use it inside RC and i

NSURLSession network abstraction layer, using Codable and Decodable for response and Encodable for request. ⚙️🚀
NSURLSession network abstraction layer, using Codable and Decodable for response and Encodable for request. ⚙️🚀

SONetworking NSURLSession network abstraction layer, using Codable and Decodable for response and Encodable for request. Project Folder and File Struc

A new, clean and lean network interface reachability library written in Swift.

Reachability A new, clean and lean network interface reachability library written in Swift. Remarks Network reachability changes can be monitored usin

SwiftyReachability is a simple and lightweight network interface manager written in Swift.
SwiftyReachability is a simple and lightweight network interface manager written in Swift.

SwiftyReachability is a simple and lightweight network interface manager written in Swift. Freely inspired by https://github.com/tonymillion/Reachabil

A lightweight but powerful network library with simplified and expressive syntax based on AFNetworking.
A lightweight but powerful network library with simplified and expressive syntax based on AFNetworking.

XMNetworking English Document XMNetworking 是一个轻量的、简单易用但功能强大的网络库,基于 AFNetworking 3.0+ 封装。 其中,XM 前缀是我们团队 Xcode-Men 的缩写。 简介 如上图所示,XMNetworking 采用中心化的设计思想

DBNetworkStack is a network abstraction for fetching request and mapping them to model objects

DBNetworkStack Main Features 🛡 Typed network resources 🏠 Value oriented architecture 🔀 Exchangeable implementations 🚄 Extendable API 🎹 Composable

Write clean, concise and declarative network code relying on URLSession, with the power of RxSwift. Inspired by Retrofit.

ReactiveAPI Reactive API library allows you to write clean, concise and declarative network code, with the power of RxSwift and URLSession! iOS Demo A

Network abstraction layer written in Swift.
Network abstraction layer written in Swift.

Moya 14.0.0 A Chinese version of this document can be found here. You're a smart developer. You probably use Alamofire to abstract away access to URLS

Elegant network abstraction layer in Swift.
Elegant network abstraction layer in Swift.

Elegant network abstraction layer in Swift. 中文 Design Features Requirements Communication Installation Usage Base Usage - Target - Request - Download

Owner
Gordan Glavaš
Gordan Glavaš
An iOS app for communicating with your clightning node over the lightning network

An iOS app for communicating with your clightning node over the lightning network

William Casarin 18 Dec 14, 2022
Another network wrapper for URLSession. Built to be simple, small and easy to create tests at the network layer of your application.

Another network wrapper for URLSession. Built to be simple, small and easy to create tests at the network layer of your application. Install Carthage

Ronan Rodrigo Nunes 89 Dec 26, 2022
Easy and lightweight network layer for creating different set of network requests like GET, POST, PUT, DELETE customizable with coders conforming to TopLevelDecoder, TopLevelEncoder

Easy and lightweight network layer for creating different set of network requests like GET, POST, PUT, DELETE customizable with coders conforming to TopLevelDecoder, TopLevelEncoder

Igor 2 Sep 16, 2022
A network extension app to block a user input URI. Meant as a network extension filter proof of concept.

URIBlockNE A network extension app to block a user input URI. Meant as a network extension filter proof of concept. This is just a research effort to

Charles Edge 5 Oct 17, 2022
Say goodbye to the Fat ugly singleton Network Manager with this Network Layer

MHNetwork Protocol Oriented Network Layer Aim to avoid having bloated singleton NetworkManager Philosophy the main philosophy behind MHNetwork is to h

Mohamed Emad Hegab 19 Nov 19, 2022
iOS 15, MVVM, Async Await, Core Data, Abstract Network Layer, Repository & DAO design patterns, SwiftUI and Combine

iOS 15, MVVM, Async Await, Core Data, Abstract Network Layer, Repository & DAO design patterns, SwiftUI and Combine

Conrado Mateu Gisbert 18 Dec 23, 2022
MonkeyKing helps you to post messages to Chinese Social Networks.

MonkeyKing MonkeyKing helps you post SNS messages to Chinese Social Networks, without their buggy SDKs. MonkeyKing uses the same analysis process of o

null 2.7k Dec 29, 2022
VFNetwork is a protocol-oriented network layer that will help you assemble your requests in just a few steps.

Simple, Fast and Easy. Introduction VFNetwork is a protocol-oriented network layer that will help you assemble your requests in just a few steps. How

Victor Freitas 4 Aug 22, 2022
iOS Network monitor/interceptor framework written in Swift

NetShears NetShears is a Network interceptor framework written in Swift. NetShears adds a Request interceptor mechanisms to be able to modify the HTTP

Divar 119 Dec 21, 2022
A toolkit for Network Extension Framework

NEKit NEKit is deprecated. It should still work but I'm not intent on maintaining it anymore. It has many flaws and needs a revamp to be a high-qualit

zhuhaow 2.8k Jan 2, 2023