Home > Software design >  SwiftUI: Publishing changes from async model
SwiftUI: Publishing changes from async model

Time:10-22

I'm attempting to figure out how to display a message to my users when some asynchronous code takes some time to run. So far I've used a sample I found online to create a popup banner and tied the message together using an ObservedObject of the async method on my view and then Publish the values from my async method.

My sample code project is on a public GitHub repository here and I'll post the code at the bottom.

Right now I have an issue when setting the variables from the async method: Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates. Solutions online seem to fix this issue by updating the value on the @mainActor thread but I want these methods to run asynchronously AND update the user on what's happening. What's the best way to update my variables from this location?

CODE

in the main app:

var body: some Scene {
    WindowGroup {
        ContentView(asyncmethod: myAsyncViewModel())
    }
}

ContentView:

import SwiftUI

struct ContentView: View {
    @State private var isLoaderPresented = false
    @State private var isTopMessagePresented = false

    @ObservedObject var asyncmethod: myAsyncViewModel

    var body: some View {
        VStack {
            Spacer()
            Button( action: {
                    Task {
                        isTopMessagePresented = true
                        let response = await asyncmethod.thisMethodTakesTime()
                        // Want to return a string or object so I know what happens.
                        print("Response Loader: \(response ?? "no response")")
                        isTopMessagePresented = false
                    }
                },
                label: { Text("Run Top Banner Code") }
            )
            Spacer()
        }
        .foregroundColor(.black)
        .popup(isPresented: isTopMessagePresented, alignment: .top, direction: .top, content: {
            Snackbar(showForm: $isTopMessagePresented, asyncmethod: asyncmethod)
        })
    }
}

struct Snackbar: View {
    @Binding var showForm: Bool
    
    @ObservedObject var asyncmethod: myAsyncViewModel

    var body: some View {
        VStack {
            HStack() {
                Image(systemName: asyncmethod.imageName)
                    .resizable()
                    .aspectRatio(contentMode: ContentMode.fill)
                    .frame(width: 40, height: 40)
                Spacer()
                VStack(alignment: .center, spacing: 4) {
                    Text(asyncmethod.title)
                        .foregroundColor(.black)
                        .font(.headline)
                    
                    Text(asyncmethod.subTitle)
                        .font(.body)
                        .foregroundColor(.black)
                        .frame(maxWidth: .infinity)
                }
            }
            .frame(minWidth: 200)
        }
        .padding(15)
        .frame(maxWidth: .infinity, idealHeight: 100)
        .background(Color.black.opacity(0.1))
    }

}

My async sample method:

import Foundation

class myAsyncViewModel:  ObservableObject  {
    @Published var imageName: String = "questionmark"
    @Published var title: String = "title"
    @Published var subTitle: String = "subtitle"
    
    func thisMethodTakesTime() async -> String? {
        print("In method: \(imageName), \(title), \(subTitle)")
        title = "MY METHOD"
        subTitle = "Starting out!"
        print("In method. Starting \(title)")
        subTitle = "This is the message"
        print("Sleeping")
        try? await Task.sleep(nanoseconds: 1_000_000_000)
        subTitle = "Between"
        try? await Task.sleep(nanoseconds: 1_000_000_000)
        print("After sleep. Ending")
        subTitle = "About to return. Success!"
        print("In method: \(imageName), \(title), \(subTitle)")
        return "RETURN RESULT"
    }
    
}

And the supporting file for the popup:

import SwiftUI

struct Popup<T: View>: ViewModifier {
    let popup: T
    let isPresented: Bool
    let alignment: Alignment
    let direction: Direction

    // 1.
    init(isPresented: Bool, alignment: Alignment, direction: Direction, @ViewBuilder content: () -> T) {
        self.isPresented = isPresented
        self.alignment = alignment
        self.direction = direction
        popup = content()
    }

    // 2.
    func body(content: Content) -> some View {
        content
            .overlay(popupContent())
    }

    // 3.
    @ViewBuilder private func popupContent() -> some View {
        GeometryReader { geometry in
            if isPresented {
                withAnimation {
                    popup
                        .transition(.offset(x: 0, y: direction.offset(popupFrame: geometry.frame(in: .global))))
                        .frame(width: geometry.size.width, height: geometry.size.height, alignment: alignment)
                }
            }
        }
    }
}

extension Popup {
    enum Direction {
        case top, bottom

        func offset(popupFrame: CGRect) -> CGFloat {
            switch self {
            case .top:
                let aboveScreenEdge = -popupFrame.maxY
                return aboveScreenEdge
            case .bottom:
                let belowScreenEdge = UIScreen.main.bounds.height - popupFrame.minY
                return belowScreenEdge
            }
        }
    }
}

private extension GeometryProxy {
    var belowScreenEdge: CGFloat {
        UIScreen.main.bounds.height - frame(in: .global).minY
    }
}

extension View {
    func popup<T: View>(
        isPresented: Bool,
        alignment: Alignment = .center,
        direction: Popup<T>.Direction = .bottom,
        @ViewBuilder content: () -> T
    ) -> some View {
        return modifier(Popup(isPresented: isPresented, alignment: alignment, direction: direction, content: content))
    }
}

Again all this can be found in my GitHub page here.

CodePudding user response:

You can annotate the observable class or just the function with ‘@MainActor’ or use DispatchQueue.main.async when you assign to the published variables.

  • Related