LinkAndreas / AsyncResourceView

Simplified Resource Loading

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Version: 1.0.0 Swift: 5.5 Platforms: iOS, macOS License: MIT

MotivationExamplesInstallationUsageLicenseIssues

Motivation

Modern apps heavily rely on resources that are received over the network, and hence may be affected by connectivity issues or data loss. If, for example, you travel by train within Germany, you may be surprised how often you will experience radio gaps or interruptions due to weak cellular reception. Hence, we as developers have to design our apps to include feedback when an action takes longer than expected and offer the ability to retry the action in case that it failed. This way, we can make our apps stand out, since they can cope with conditions that are far from optimal.

AsyncResourceView offers a consistent way to deal with loading as well as error states in SwiftUI applications. This way, developers can focus on features rather than writing repetitive error-prone code.

You may also refer to my article on Medium:

Installation

Installation via SwiftPM is supported.

Usage

Using AsyncResourceView within your project involves the following steps:

  1. Add the package to our project and import AsyncResourceView wherever it should be used within our component tree.
import AsyncResourceView
  1. Specify the loader that will provide the requested resource.
private func loader() async throws -> Int {
    try await Task.sleep(nanoseconds: 2_000_000_000)
    return 42
}
  1. Provide custom notRequested, loading- or failure- views as desired. Note that the default notRequested view is not visible and will request the resource from the loader as soon as the view appeared. In addition, the default loading- view wraps SwiftUI's spinner. Finally, the default failure- view comes with a counterclockwise error such that the user can retry the action in case that it failed.
private func notRequestedView(load: @escaping () -> Void) -> AnyView {
    AnyView(
        Button("Load Resource", action: load)
            .buttonStyle(.borderedProminent)
    )
}

private func successView<Resource>(resource: Resource) -> AnyView {
    AnyView(
        Text(String(describing: resource))
    )
}
  1. Instantiate the store given the loader and pass it to the AsyncResourceView.
AsyncResourceView(
    store: AsyncResourceView.ViewStore(loader: loader),
    notRequestedView: notRequestedView(load:),
    successView: successView(resource:)
)

As a result, we obtain the Simple Example where users can request an integer by tapping a button:

import AsyncResourceView
import SwiftUI

@main
struct SimpleExampleApp: App {
    var body: some Scene {
        WindowGroup {
            NavigationView {
                AsyncResourceView(
                    store: AsyncResourceView.ViewStore(loader: loader),
                    notRequestedView: notRequestedView(load:),
                    successView: successView(resource:)
                )
                .navigationTitle("Async Resource Demo")
            }
        }
    }

    private func notRequestedView(load: @escaping () -> Void) -> AnyView {
        AnyView(
            Button("Load Resource", action: load)
                .buttonStyle(.borderedProminent)
        )
    }

    private func successView<Resource>(resource: Resource) -> AnyView {
        AnyView(
            Text(String(describing: resource))
        )
    }
}

extension SimpleExampleApp {
    private func loader() async throws -> Int {
        try await Task.sleep(nanoseconds: 2_000_000_000)
        return 42
    }
}

Gallery Example

In addition to the Simple Example, the package also comes with the Gallery Example where colors are arranged in a three-column grid. Each item features the AsyncResourceView to request its color from the loader that will either return a random color or fail after [0.3, 3.0] seconds. In the latter case, a retry button is shown in case the action failed.

@main
struct AsyncResourceGalleryApp: App {
    @StateObject
    private var store: GalleryStore = .init()

    var body: some Scene {
        WindowGroup {
            GalleryView(
                store: store,
                itemView: { item -> AnyView in
                    let store = AsyncResourceViewStore<Color>(loader: loader(item))
                    return AnyView(GalleryItemView(store: store))
                }
            )
            .onAppear(perform: store.onAppear)
        }
    }
}

extension AsyncResourceGalleryApp {
    private func loader(_ item: GalleryItem) -> (() async throws -> Color) {
        return {
            let duration = UInt64.random(in: 300_000_000 ... 3_000_000_000)
            try await Task.sleep(nanoseconds: duration)
            if Int.random(in: 0...5) == 4 {
                throw NSError(domain: "", code: 42, userInfo: nil)
            } else {
                return item.color
            }
        }
    }
}

Since we do not specify a custom notRequested view, the default view is used that requests the resource as soon as it appeared. By wrapping the items in SwiftUI's LazyVGrid they are only created when needed.

struct GalleryView: View {
    private var store: GalleryStore
    private let columns: [GridItem] = [
        GridItem(.flexible(minimum: 50), spacing: 50),
        GridItem(.flexible(minimum: 50), spacing: 50),
        GridItem(.flexible(minimum: 50), spacing: 50)
    ]
    private let itemView: (GalleryItem) -> AnyView

    init(store: GalleryStore, itemView: @escaping (GalleryItem) -> AnyView) {
        self.store = store
        self.itemView = itemView
    }

    var body: some View {
        ScrollView {
            LazyVGrid(columns: columns, spacing: 50) {
                ForEach(store.items, id: \.self) { item in
                    itemView(item)
                        .frame(width: 100, height: 100)
                }
            }
            .padding()
        }
    }
}

Each of the items is driven by its own store, i.e., AsyncResourceViewStore that transitions between states depending on how long the action takes.

struct GalleryItemView: View {
    private let store: AsyncResourceViewStore<Color>

    init(store: AsyncResourceViewStore<Color>) {
        self.store = store
    }

    var body: some View {
        AsyncResourceView(store: store) { color in
            AnyView(color)
        }
    }
}

Finally, we create a GalleryStore that drives the composition and provides a color for each individual loader.

final class GalleryStore: ObservableObject {
    @Published var items: [GalleryItem] = []

    func onAppear() {
        items = (0 ..< 100)
            .map { _ in Color.random }
            .map { GalleryItem(color: $0 )}
    }
}

struct GalleryItem: Hashable {
    let id: UUID
    let color: Color

    init(id: UUID = .init(), color: Color) {
        self.id = id
        self.color = color
    }
}

Implementation

Please refer to the following article if you are interested in how I built the component:

License

This library is released under the MIT License. See LICENSE for details.

About

Simplified Resource Loading

License:MIT License


Languages

Language:Swift 100.0%