Hand-wavy counterproposal:
Reuse AsyncStream.Continuation?
for subtasks to communicate progress to their callers.
public func myLibraryAPI(
reportingProgressVia progress: AsyncStream<MyLibraryAPIProgress>.Continuation? = nil
) async {
while ... {
progress(MyLibraryAPIProgress(...))
}
}
The stream element type is completely open for the library to report whatever makes sense to it (but it will need to be Sendable
).
Invent a new AsyncMapReduceStream
, acting like a heterogeneous CombineLatest, perhaps something like:
struct AsyncMapReduceStream<Key: Hashable, Intermediate: Sendable, Element>: AsyncSequence {
init(reduce: @escaping @Sendable ([Intermediate]) -> Element) { ... }
mutating func addUpstreamSequence<S: AsyncSequence>(
key: Key,
sequence: S,
map: @escaping (S) -> Intermediate
) { ... }
mutating func removeUpstreamSequence(key: Key) { ... }
}
Each time any upstream emits an element, it's mapped to the Intermediate
, then combined with the latest value from each upstream sequence using reduce
, to emit a new element.
It could offer "QOL" overloads to reduce boilerplate where Intermediate == Element
, where S: Identifiable
, etc.
This can be used to aggregate progress from heterogeneous children.
I've suggested above that it allow elements to be added and removed dynamically, against my better judgement, because that seems like a requirement of the "progress" use-case. If dynamic removal isn't needed, we could get rid of Key
, which'd simplify it.
Invent a generic ObservedAsyncSequence
to meet SwiftUI's needs, perhaps something like:
final class ObservedAsyncSequence<S: AsyncSequence>: Observable
where S.Failure == Never {
var value: S.Element? { get }
}
My earlier example, under this scheme:
// DirectoryTraversal library
struct DirectoryTraversalProgress { ... }
enum DirectoryTraversalDecision { ... }
func traverse(
_ root: FilePath,
glob: String,
progress: AsyncStream<DirectoryTraversalProgress>? = nil,
eachFile: @Sendable (FilePath) -> DirectoryTraversalDecision
) async throws { ... }
// ImageTranscoder library
struct ImageTranscoderProgress { ... }
enum ImageFormat { ... }
func transcode(
_ file: FilePath,
toFormat: ImageFormat,
dest: FilePath,
progress: AsyncStream<ImageTranscoderProgress>? = nil
) async throws { ... }
// Me:
enum MyProgressIntermediate {
case traversal(...)
case image(...)
}
struct MyProgress { ... }
enum MyProgressKey: Hashable {
case traversal
case transcode(FilePath)
}
let (traversalStream, traversalProgress) = AsyncStream<DirectoryTraversalProgress>.makeStream()
let aggregate = AsyncMapReduceSequence<MyProgressKey, MyProgressIntermediate, MyProgress> {
...
for intermediate in $0 {
...
}
return MyProgress(...)
}
aggregate.addUpstream(key: .traversal, traversalStream) {
.traversal($0....)
}
try await traverse(
"path",
glob: "*.png",
progress: traversalProgress
) { path in
let (transcodeStream, transcodeProgress) = AsyncStream<ImageTranscoderProgress>.makeStream()
aggregate.addUpstream(key: .transcode(path), transcodeStream) {
.transcode($0....)
}
try await transcode(
path,
toFormat: .jpeg,
dest: path.replacing(".png", with: ".jpeg"),
progress: transcodeProgress
)
}
// Finally, for SwiftUI:
@State var progress = ObservedAsyncStream(aggregate)
var body: some View {
Progress(progress.value.current, total: progress.value.total)
}
Yes, I had to specify in detail how to merge the heterogeneous progress kinds, and decide for myself how all the data should be merged together. I consider that a good thing — it's clear here that there's no "one size fits all".
If there is a "one size fits most", it might be:
struct DeterministicProgress {
var completed: Int
var total: Int
}
This could also be provided, along with simplifying type aliases for AsyncStream, constructors for AsyncMapReduceSequence
that sum them in the obvious way, constructors for SwiftUI.Progress
that accept them, etc.