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.
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:
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.
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.
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.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
.PaginationManager
will also notify its publisher that a paging event is happening, so that it can update its UI.PagingRequest
goes through PagingInterceptor
s. One of them, CoreDataPagingInterceptor
check if it has the requested data in the local DB. If yes, it returns it immediately.PagingSource
(representing your remote API), which does all the networking and gets the data from the back end.PaginationManager
updates the state with new data and publishes it to any subscribers.Page
references the request that produced it and contains an array of values.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.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.PaginationManager
has a built-in publisher, allowing you to easily send requests using methods (request
, prepend
or append
).PagingSource
know the initial key (via refreshKey
), and provides the key chain for the given key.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
directly offers the most flexibility and customizations.RequestPublisher
and a Pager
and exposes a simpler interface that should suffice for most apps.
refresh
, prepend
and append
.PaginationManagerOutput
which contains the full pagination state. You can implement your own PaginationManagerOutput
or use the DefaultPaginationManagerOutput
implementation.DispatchQueue.main
.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 Repo
s 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<Repo, Error>
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()
}
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:
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.CoreDataInterceptorDataSource
implementation, so that CoreDataInterceptor
know how to interface with your data - namely, how to:get(request:)
,insert(remoteValues:in:)
,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 forValue
andRemoteValue
.
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](https://raw.github.com/globulus/swift-paging/main/)
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:).
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<GithubDataSourceImpl>>: 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 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:
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](https://raw.github.com/globulus/swift-paging/main/)
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.
Creating an interceptor is easy enough:
PagingInterceptor
.intercept(request:)
. From it, return:.complete(Page)
if your interceptor should respond to the request and terminate furhter request propagation..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.handle(result:)
to observe the Page
generated for the request.link |
Stars: 15 |
Last commit: 2 years ago |
Swiftpack is being maintained by Petr Pavlik | @ptrpavlik | @swiftpackco | API | Analytics