Skip to content

Instantly share code, notes, and snippets.

@auramagi
Last active August 25, 2023 07:49
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save auramagi/46a3cce49ddbb9a33a7c6ff114ee0406 to your computer and use it in GitHub Desktop.
Save auramagi/46a3cce49ddbb9a33a7c6ff114ee0406 to your computer and use it in GitHub Desktop.
Swift Concurrency + ViewModel action handling
enum Effect {
/// Action completed synchronously
case none
/// Action produced a task
case task(Task<Void, Never>)
/// Notify about completion in a closure
func onCompletion(_ completion: @escaping () -> Void) {
switch self {
case .none:
completion()
case let .task(task):
Task {
_ = await task.value
completion()
}
}
}
/// Wait until completion
var completion: Void {
get async {
switch self {
case .none:
return
case let .task(task):
await withTaskCancellationHandler {
_ = await task.value
} onCancel: {
task.cancel()
}
}
}
}
}
import SwiftUI
final class ViewModel: ObservableObject {
enum Action {
case buttonTap
}
private let tasks = TaskEffectHandler()
func connect() async {
await tasks.connect()
}
@discardableResult
func handle(_ action: Action) -> Effect {
switch action {
case .buttonTap:
return tasks.add {
do {
try await Task.sleep(for: .seconds(2))
} catch {
print("Task cancelled")
}
}
}
}
}
struct ContentView: View {
@ObservedObject var vm = ViewModel()
@State var flag = true
var body: some View {
VStack {
Toggle("Show", isOn: $flag)
Spacer()
if flag {
AsyncButton {
await vm.handle(.buttonTap).completion
} label: { isLoading in
if isLoading {
ProgressView()
} else {
Text("Tap me")
}
}
.task { await vm.connect() }
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.padding()
}
}
struct AsyncButton<Label: View>: View {
let action: @Sendable () async -> Void
let label: (_ isLoading: Bool) -> Label
@State private var id: UUID?
@State private var isLoading = false
init(
@_inheritActorContext action: @Sendable @escaping () async -> Void,
@ViewBuilder label: @escaping (_ isLoading: Bool) -> Label
) {
self.action = action
self.label = label
}
var body: some View {
Button {
id = .init()
} label: {
label(isLoading)
}
.disabled(isLoading)
.task(id: id) {
guard id != nil else { return }
isLoading = true
await action()
guard !Task.isCancelled else { return }
isLoading = false
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
final class TaskEffectHandler {
private typealias OperationStream = AsyncStream<@Sendable () async -> Void>
private var operationStream: OperationStream?
private var operationStreamContinuation: OperationStream.Continuation?
func connect() async {
operationStreamContinuation?.finish()
let stream = OperationStream.makeStream() // Swift 5.9
(operationStream, operationStreamContinuation) = stream
await withDiscardingTaskGroup { group in // Swift 5.9
for await operation in stream.stream {
group.addTask {
await operation()
}
}
}
(operationStream, operationStreamContinuation) = (nil, nil)
}
func add(_ operation: @escaping @Sendable () async -> Void) -> Effect {
guard let operationStreamContinuation else {
print("⚠️ Warning: adding operations while not connected")
return .task(
Task {
await operation()
}
)
}
let (cancelStream, cancelContinuation) = AsyncStream<Void>.makeStream()
return .task(
Task {
await withTaskCancellationHandler {
await withCheckedContinuation { continuation in
operationStreamContinuation.yield {
let operationTask = Task {
await operation()
cancelContinuation.finish()
}
for await _ in cancelStream {
operationTask.cancel()
}
if Task.isCancelled {
operationTask.cancel()
}
continuation.resume()
}
}
} onCancel: {
cancelContinuation.yield()
}
}
)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment