Lazy-Loading Data with SwiftUI and Combine

Jan 2020, updated May 2020

Apple clearly wants us to pair SwiftUI with Combine for asynchronous network operations, but using the two frameworks together is surprisingly awkward. The @ObservedObject property wrapper makes it easy for SwiftUI views to auto-update in response to changing data, but there’s no obvious way to start fetching data.

Kicking off network requests in initializers is tempting, but it’s common for views to be initialized and never presented: for example, NavigationLink eagerly constructs the destination view. If you’re building a list of navigation links, making network requests in init can easily load thousands of unused resources.

Instead of making network requests in init, we could push them into each view’s onAppear hook. This avoids fetching unused data, but it scatters imperative networking code in hooks throughout your code. It also takes some extra bookkeeping to avoid unnecessarily refetching resources shared between views.

Ideally, we’d have the best of both worlds: resources automatically fetched on demand, but without constant use of the onAppear hook. In Swift Talk 160, Florian Kugler and Chris Eidhof dove into the beta releases of Combine and SwiftUI and discovered a way to defer fetching data until a subscriber is waiting. With the changes made before Combine’s GA release, their approach can be made even simpler.

import Combine
import Foundation

public final class Lazy<A>: ObservableObject {
    // We want to lazy-load data automatically; that is, we should
    // defer network requests until the data is required to render
    // an on-screen view. We do this by wrapping the actual publisher
    // in an event handler that fetches data on subscription.
    private let changes = ObservableObjectPublisher()
    private var subscribers = 0
    public var objectWillChange = ObservableObjectPublisher().handleEvents()

    // Intentionally oversimplified loading - use an abstraction from
    // your networking layer here.
    private let load: ()->A
    public var value: A? {
        willSet {
            DispatchQueue.main.async {
                self.changes.send()
            }
        }
    }

    public init(load: @escaping ()->A) {
        self.load = load
        self.objectWillChange = self.changes.handleEvents(
            receiveSubscription: { _ in
                let isFirst = self.subscribers == 0
                self.subscribers += 1
                if isFirst {
                    self.reload()
                }
            },
            receiveCancel: {
                self.subscribers -= 1
            }
        )
    }

    public func reload() {
        value = load()
    }
}

Using the Lazy class is short and declarative, and data is loaded only when the observing view is presented.

import SwiftUI

struct ExpensiveView: View {
    @ObservedObject var n = Lazy<Int>() {
        print("Simulating network latency...")
        sleep(2)
        return Int.random(in: 1...100)
    }

    var body: some View {
        if n.value != nil {
            return Text("\(n.value!)")
        } else {
            return Text("Loading...")
        }
    }
}

This implementation doesn’t include any synchronization, but it seems likely that value and subscribers need to be protected by a DispatchSemaphore. I’m new to Apple’s developer ecosystem, but I’ve been unable to confirm this suspicion.