Swiftpack.co -  KazaKago/StoreFlowable.swift as Swift Package
Swiftpack.co is a collection of thousands of indexed Swift packages. Search packages.
KazaKago/StoreFlowable.swift
Repository pattern support library for Swift with Combine framework.
.package(url: "https://github.com/KazaKago/StoreFlowable.swift.git", from: "2.0.0")

StoreFlowable.swift

GitHub tag (latest by date) Test License

Repository pattern support library for Swift with Combine Framework.
Available for iOS or any Mac/tvOS/watchOS projects.

Overview

This library provides remote and local data abstraction and observation with Combine Framework.
Created according to the following 5 policies.

  • Repository pattern
    • Abstract remote and local data acquisition.
  • Single Source of Truth
    • Looks like a single source from the user side.
  • Observer pattern
    • Observing data with Combine Framework.
  • Return value as soon as possible
  • Representing the state of data

The following is the class structure of Repository pattern using this library.

https://user-images.githubusercontent.com/7742104/126891014-ce95b223-5b13-4290-8b58-faf1dcebf49e.jpg

The following is an example of screen display using LoadingState.

https://user-images.githubusercontent.com/7742104/125714730-381eee65-4126-4ee8-991a-7fc64dfb325c.jpg

Requirement

  • iOS 13.0 or later
  • MacOS 10.15 or later
  • tvOS 13.0 or later
  • watchOS 6.0 or later

Install

Install as Swift Package Manager exchanging x.x.x for the latest tag.

dependencies: [
    .package(url: "https://github.com/KazaKago/StoreFlowable.swift.git", from: "x.x.x"),
],

Basic Usage

There are only 5 things you have to implement:

  • Create data state management class
  • Get data from local cache
  • Save data to local cache
  • Get data from remote server
  • Whether the cache is valid

1. Create FlowableDataStateManager class

First, create a class that inherits FlowableDataStateManager<KEY: Hashable>.
Put the type you want to use as a key in <KEY: Hashable>. If you don't need the key, put in the UnitHash.

class UserStateManager: FlowableDataStateManager<UserId> {
    static let shared = UserStateManager()
    private override init() {}
}

FlowableDataStateManager<KEY: Hashable> needs to be used in Singleton pattern.

2. Create StoreFlowableFactory class

Next, create a class that implements StoreFlowableFactory.
Put the type you want to use as a Data in DATA associatedtype.

An example is shown below.

struct UserFlowableFactory : StoreFlowableFactory {

    typealias KEY = UserId
    typealias DATA = UserData

    private let userApi = UserApi()
    private let userCache = UserCache()

    init(userId: UserId) {
        key = userId
    }

    // Set the key for input / output of data. If you don't need the key, put in the UnitHash.
    let key: UserId

    // Create data state management class.
    let flowableDataStateManager: FlowableDataStateManager<UserId> = UserStateManager.shared

    // Get data from local cache.
    func loadDataFromCache() -> AnyPublisher<UserData?, Never> {
        userCache.load(userId: key)
    }

    // Save data to local cache.
    func saveDataToCache(newData: UserData?) -> AnyPublisher<Void, Never> {
        userCache.save(userId: key, data: newData)
    }

    // Get data from remote server.
    func fetchDataFromOrigin() -> AnyPublisher<UserData, Error> {
        userApi.fetch(userId: key)
    }

    // Whether the cache is valid.
    func needRefresh(cachedData: UserData) -> AnyPublisher<Bool, Never> {
        cachedData.isExpired()
    }
}

You need to prepare the API access class and the cache access class.
In this case, UserApi and UserCache classes.

3. Create Repository class

After that, you can get the AnyStoreFlowable<KEY: Hashable, DATA> class from the StoreFlowableFactory.create() method, and use it to build the Repository class.
Be sure to go through the created AnyStoreFlowable<KEY: Hashable, DATA> class when getting / updating data.

struct UserRepository {

    func followUserData(userId: UserId) -> LoadingStatePublisher<UserData> {
        let userFlowable: AnyStoreFlowable<UserId, UserData> = UserFlowableFactory(userId: userId).create()
        return userFlowable.publish()
    }

    func updateUserData(userData: UserData) -> AnyPublisher<Void, Never> {
        let userFlowable: AnyStoreFlowable<UserId, UserData> = UserFlowableFactory(userId: userId).create()
        return userFlowable.update(newData: userData)
    }
}

You can get the data in the form of LoadingStatePublisher<DATA> (Same as AnyPublisher<LoadingState<DATA>, Never>) by using the publish() method.
LoadingState is a enum that holds raw data.

4. Use Repository class

You can observe the data by sink AnyPublisher.
and branch the data state with doAction() method or switch statement.

private func subscribe(userId: UserId) {
    userRepository.followUserData(userId: userId)
        .receive(on: DispatchQueue.main)
        .sink { state in
            state.doAction(
                onLoading: { (content: UserData?) in
                    ...
                },
                onCompleted: { (content: UserData, _, _) in
                    ...
                },
                onError: { (error: Error) in
                    ...
                }
            )
        }
        .store(in: &cancellableSet)
}

Example

Refer to the example project for details. This module works as an iOS app.
See GithubMetaFlowableFactory and GithubUserFlowableFactory.

This example accesses the Github API.

Advanced Usage

Get data without LoadingState enum

If you don't need value flow and LoadingState enum, you can use requireData() or getData().
requireData() throws an Error if there is no valid cache and fails to get new data.
getData() returns nil instead of Error.

public extension StoreFlowable {
    func getData(from: GettingFrom = .both) -> AnyPublisher<DATA?, Never>
    func requireData(from: GettingFrom = .both) -> AnyPublisher<DATA, Error>
}

GettingFrom parameter specifies where to get the data.

public enum GettingFrom {
    // Gets a combination of valid cache and remote. (Default behavior)
    case both
    // Gets only remotely.
    case origin
    // Gets only locally.
    case cache
}

However, use requireData() or getData() only for one-shot data acquisition, and consider using publish() if possible.

Refresh data

If you want to ignore the cache and get new data, add forceRefresh parameter to publish().

public extension StoreFlowable {
    func publish(forceRefresh: Bool = false) -> LoadingStatePublisher<DATA>
}

Or you can use refresh() if you are already observing the Publisher.

public protocol StoreFlowable {
    func refresh() -> AnyPublisher<Void, Never>
}

Validate cache data

Use validate() if you want to verify that the local cache is valid.
If invalid, get new data remotely.

public protocol StoreFlowable {
    func validate() -> AnyPublisher<Void, Never>
}

Update cache data

If you want to update the local cache, use the update() method.
Publisher observers will be notified.

public protocol StoreFlowable {
    func update(newData: DATA?) -> AnyPublisher<Void, Never>
}

Pagination support

This library includes Pagination support.

Inherit PagnationStoreFlowableFactory<KEY: Hashable, DATA> instead of StoreFlowableFactory<KEY: Hashable, DATA>.

An example is shown below.

class UserListStateManager: FlowableDataStateManager<UnitHash> {
    static let shared = UserListStateManager()
    private override init() {}
}
struct UserListFlowableFactory : PaginationStoreFlowableFactory {

    typealias KEY = UnitHash
    typealias DATA = [UserData]

    private let userListApi = UserListApi()
    private let userListCache = UserListCache()

    let key: UnitHash = UnitHash()

    let flowableDataStateManager: FlowableDataStateManager<UnitHash> = UserListStateManager.shared

    func loadDataFromCache() -> AnyPublisher<[UserData]?, Never> {
        userListCache.load()
    }

    func saveDataToCache(newData: [UserData]?) -> AnyPublisher<Void, Never> {
        userListCache.save(data: newData)
    }

    func saveNextDataToCache(cachedData: [UserData], newData: [UserData]) -> AnyPublisher<Void, Never> {
        userListCache.save(data: cachedData + newData)
    }

    func fetchDataFromOrigin() -> AnyPublisher<Fetched<[UserData]>, Error> {
        userListApi.fetch(page: nil).map { data in
            Fetched(data: data, nextKey: data.nextToken)
        }.eraseToAnyPublisher()
    }

    func fetchNextDataFromOrigin(nextKey: String) -> AnyPublisher<Fetched<[UserData]>, Error> {
        userListApi.fetch(page: page).map { data in
            Fetched(data: data, nextKey: data.nextToken)
        }.eraseToAnyPublisher()
    }

    func needRefresh(cachedData: [UserData]) -> AnyPublisher<Bool, Never> {
        cachedData.last.isExpired()
    }
}

You need to additionally implements saveNextDataToCache() and fetchNextDataFromOrigin().
When saving the data, combine the cached data and the new data before saving.

And then, You can get the state of additional loading from the next parameter of onCompleted {}.

let userFlowable = UserFlowableFactory(userId: userId).create()
userFlowable.publish()
    .receive(on: DispatchQueue.main)
    .sink { state in
        state.doAction(
            onLoading: { (content: UserData?) in
                // Whole (Initial) data loading.
            },
            onCompleted: { (content: UserData, next: AdditionalLoadingState, _) in
                // Whole (Initial) data loading completed.
                next.doAction(
                    onFixed: { (canRequestAdditionalData: Bool) in
                        // No additional processing.
                    },
                    onLoading: {
                        // Additional data loading.
                    },
                    onError: { (error: Error) in
                        // Additional loading error.
                    }
                )
            },
            onError: { (error: Error) in
                // Whole (Initial) data loading error.
            }
        )
    }
    .store(in: &cancellableSet)

To display in the UITableView, Please use the difference update function. See also UITableViewDiffableDataSource.

Request additional data

You can request additional data for paginating using the requestNextData() method.

public extension PaginationStoreFlowable {
    func requestNextData(continueWhenError: Bool = true) -> AnyPublisher<Void, Never>
}

Pagination Example

The GithubOrgsFlowableFactory and GithubReposFlowableFactory classes in example project implement pagination.

License

This project is licensed under the Apache-2.0 License - see the LICENSE file for details.

GitHub

link
Stars: 2
Last commit: Yesterday

Ad: Job Offers

iOS Software Engineer @ Perry Street Software
Perry Street Software is Jack’d and SCRUFF. We are two of the world’s largest gay, bi, trans and queer social dating apps on iOS and Android. Our brands reach more than 20 million members worldwide so members can connect, meet and express themselves on a platform that prioritizes privacy and security. We invest heavily into SwiftUI and using Swift Packages to modularize the codebase.

Release Notes

2.0.0
1 week ago

New Feature

  • Two-way pagination.
    • Use TwoWayPaginationStoreFlowable & TwoWayPaginationStoreFlowableFactory.
  • You can now hold the key in this library to use for the next fetch of pagination.
    • Please pass nextKey and prevKey in the return value of fetch method.
    • If you pass null or an empty string, it is considered that there is no additional data.
struct MyFlowableFactory : PaginationStoreFlowableFactory {

    ...

    func fetchNextDataFromOrigin(nextKey: String) -> AnyPublisher<Fetched<[DATA]>, Error> {
        githubApi.getRepos(nextPageToken: nextKey).map { data in
            Fetched(data: data, nextKey: data.nextPageToken)
        }.eraseToAnyPublisher()
    }
}

Breaking Changes

  • Rename LoadingState from State.
  • Rename PaginationStoreFlowable from PaginatingStoreFlowable.
  • Changed the method to be implemented in StoreFlowableFactory.
  • Removed methods that were deprecated in 1.x.

Behavior Changes

  • LoadingState always returns Completed on additional loads.
  • Instead, refer to the Completed parameters next, prev to determine the status of additional loads.
flowable.publish()
    .receive(on: DispatchQueue.main)
    .sink { state in
        state.doAction(
            onLoading: { (contents: [DATA]?) in
                // Whole (Initial) data loading.
            },
            onCompleted: { (contents: [DATA], next: AdditionalLoadingState, _) in
                // Whole (Initial) data loading completed.
                next.doAction(
                    onFixed: { (canRequestAdditionalData: Bool) in
                        // No additional processing.
                    },
                    onLoading: {
                        // Additional data loading.
                    },
                    onError: { (error: Error) in
                        // Additional loading error.
                    }
                )
            },
            onError: { (error: Error) in
                // Whole (Initial) data loading error.
            }
        )
    }

Swiftpack is being maintained by Petr Pavlik | @ptrpavlik | @swiftpackco | API