A simple calculator using @Observable, withObservationTracking and AsyncStream

Published by malhal on

Following on from my previous post on this topic I found a forum post with someone struggling to build a calculator using @Observable and thought maybe my technique of wrapping withObservationTracking in AsyncStream might help and came up with the code below which appears to work. Obviously this code comes with a health warning that using a func calculate(), computed var sum or use didSet on the inputs would be a much more sensible implementation for a simple calculator that isn’t doing any long running or intensive calculations.

import SwiftUI
import AsyncAlgorithms

@MainActor
struct Calculator {
    
    @Observable
    class Model {
        var a: Int?
        var b: Int?
        var sum: Int = 0
    }
    
    let model = Model()
    static var shared = Calculator()
    
    init() {
        Task { [model] in
            let aDidChange = AsyncStream {
                await withCheckedContinuation { continuation in
                    let _ = withObservationTracking {
                        model.a
                    } onChange: {
                        continuation.resume()
                    }
                }
                return model.a
            }
            .compacted().removeDuplicates()
            
            let bDidChange = AsyncStream {
                await withCheckedContinuation { continuation in
                    let _ = withObservationTracking {
                        model.b
                    } onChange: {
                        continuation.resume()
                    }
                }
                return model.b
            }
            .compacted().removeDuplicates()
            
            for await x in combineLatest(aDidChange, bDidChange).map(+) {
                model.sum = x
            }
        }
    }
}

struct ContentView: View {
    let calculator = Calculator.shared
    
    var body: some View {
        Form {
            Section("ObservedObject") {
                let bindable = Bindable(calculator.model)
                TextField("a", value: bindable.a, format: .number)
                TextField("b", value: bindable.b, format: .number)
                
                Text(calculator.model.sum, format: .number)
            }
        }
    }
}

The idea to use .map(+) was taken from the original forum post in their Combine pipeline however here it errors with `Converting non-sendable function value to ‘@Sendable(…` so am not sure yet how to fix that but will look into it.

In case you are wondering why I used Task in a singleton instead of .task it’s just because I’ve been working on app level async services lately and there currently is no similar modifier for those. If you would like the calculation to be cancelled when the UI disappears then .task is probably a better option.