skip to Main Content

I have a view that looks like this:

import SwiftUI
import CoreImage
import CoreImage.CIFilterBuiltins

public struct ShareView: View {
    
    @ObservedObject var viewModel: ShareViewModel
    
    @Environment(.presentationMode) var presentationMode: Binding<PresentationMode>
    
    @State var showInfoPopup = false
    
    @State var sessionId: String?
    
    let buttonColor = Color(#colorLiteral(red: 0.02179291099, green: 0.05895387381, blue: 0.05662788451, alpha: 1))
    let textColor = Color(#colorLiteral(red: 1, green: 1, blue: 1, alpha: 1))
    let accentColor = Color(#colorLiteral(red: 0.8581416011, green: 0.9824749827, blue: 0.7862659097, alpha: 1))
    let secondaryTextColor = Color(#colorLiteral(red: 0.4152581692, green: 0.4603296518, blue: 0.4508641362, alpha: 1))
    @State var isInitalAppear = true
    
    @State var userName: String?
    @State var userAlias: String?
    
    @State var name: String = ""
    @State var alias: String = ""
    
    @State var items: [ItemViewModel] = []
    @State var totalViewModel: TotalViewModel = TotalViewModel(total: 0.00, subtotal: 0.00, tax: 0.00)
    
    let clearItems: (() -> Void)
    
    init(items: [ItemViewModel], totalViewModel: TotalViewModel, clearClosure: @escaping (() -> Void) ) {
        self.viewModel = ShareViewModel()
        self.clearItems = clearClosure
        self.items = items
        self.totalViewModel = totalViewModel
    }
    

    var retryBack : some View { Button(action: {
        self.presentationMode.wrappedValue.dismiss()
        clearItems()
        }) {
            HStack {
            Text("Start Over")
                .font(.custom("PublicSans-Medium", size: 18))
                .padding(7)
                .padding(.leading, 5)
                .foregroundColor(textColor)
                .padding(.trailing, 5)
        }
        .background(buttonColor)
        .cornerRadius(20.0)
        .padding(.bottom, 15)
        }
    }
    
    public var body: some View {
        VStack {
            Text(viewModel.items.first?.name ?? "")
            Text("Don't worry. nWe'll do the math.")
                .font(.custom("PublicSans-Bold", size: 45))
                .foregroundColor(buttonColor)
                .padding(.horizontal)
                .lineLimit(4)
            if let data = viewModel.generateQR(text: "https://example.com/(self.sessionId ?? "")"),
               let image = UIImage(data:data) {
                Image(uiImage: image)
                    .interpolation(.none)
                    .resizable()
                    .frame(width: 200, height: 200)
                HStack {
                    Button {
                        UIPasteboard.general.string = "https://example.com/(self.sessionId ?? "")"
                    } label: {
                        VStack {
                            Image(systemName: "doc.on.doc")
                                .resizable()
                                .frame(width: 20, height: 20)
                                .foregroundColor(buttonColor)
                                .padding(8)
                                .padding(.leading, 3)
                                .background(accentColor)
                                .cornerRadius(10.0)
                                .padding(.trailing, 5)
                        }
                        .background(buttonColor)
                        .cornerRadius(20.0)
                        .padding(5)
                    }
                    NavigationLink(destination: SplitView(viewModel: viewModel, sessionId: $sessionId, alias: $userAlias, name: $userName), label: {
                        HStack {
                            Text("Split")
                                .font(.custom("PublicSans-Medium", size: 22))
                                .padding(7)
                                .foregroundColor(textColor)
                                .padding(.leading, 4)
                            
                            Image(systemName: "arrow.left.and.right.square")
                                .resizable()
                                .frame(width: 15, height: 15)
                                .foregroundColor(buttonColor)
                                .padding(8)
                                .background(accentColor)
                                .cornerRadius(10.0)
                                .shadow(color: Color.black.opacity(0.3), radius: 2, x: 0, y: 3)
                                .padding(.trailing, 5)
                        }
                        .background(buttonColor)
                        .cornerRadius(20.0)
                        .padding(3)
                    })
                    
                }
                
            }
        }
        .padding()
        .background(
            RoundedRectangle(cornerRadius: 15) 
                .fill(Color.white)
                .shadow(color: Color.black.opacity(0.3), radius: 6, x: 0, y: 3)
        )
        .navigationBarBackButtonHidden(true)
        .onAppear {
            if self.isInitalAppear {
                self.showInfoPopup = true
                self.isInitalAppear = false
                Task {
                    guard self.sessionId == nil else { return }
                    self.sessionId = await self.viewModel.createShareSession()
                }
            }
        }.sheet(isPresented: $showInfoPopup, onDismiss: {
            userAlias = alias
            userName = name
            if let userAlias = self.userAlias {
                Task {
                    await self.viewModel.setVenmo(alias: userAlias)
                }
            }
        }) {
            UserInfoView(isPresented: $showInfoPopup, userName: $name, userAlias: $alias)
        }
        
        VStack (alignment: .center) {
            Divider()
            HStack {
                retryBack
                Button(action: {
                    self.showInfoPopup.toggle()
                    }) {
                        HStack {
                        Text("Edit")
                            .font(.custom("PublicSans-Medium", size: 18))
                            .padding(7)
                            .padding(.leading, 5)
                            .foregroundColor(textColor)
                            .padding(.trailing, 5)
                    }
                    .background(buttonColor)
                    .cornerRadius(20.0)
                    .padding(.bottom, 15)
                    }
            }
            .padding(.top)
        }
        .padding()
        .onAppear {
            self.viewModel.setData(items: self.items, totalViewModel: self.viewModel.totalViewModel)
        }
    }
}

this view has the following observed object var:

@ObservedObject var viewModel: ShareViewModel

Which is defined as such:

import Foundation
import SwiftUI
import FirebaseCore
import FirebaseFirestore

@MainActor
class ShareViewModel: ObservableObject {
    @Published var database = Firestore.firestore()
    @Published var items: [ItemViewModel] = []
    @Published var sessionId: String?
    @Published var isFirstAppear = true
    @Published var totalViewModel: TotalViewModel
    @Published var total: Float
    @Published var splitTip: Float?
    @Published var splitTax: Float
    @Published var people: Int = 1
    
     
    var chosenItems = [UUID: Float]()
    
    public init(items: [ItemViewModel], totalViewModel: TotalViewModel) {
        self.items = items
        self.totalViewModel = totalViewModel
        self.total = 0
        self.splitTip = totalViewModel.tip
        self.splitTax = totalViewModel.tax
    }
    
    public func generateQR(text: String) -> Data? {
        let filter = CIFilter.qrCodeGenerator()
        guard let data = text.data(using: .ascii, allowLossyConversion: false) else { return nil }
        filter.message = data
        guard let ciimage = filter.outputImage else { return nil }
        let transform = CGAffineTransform(scaleX: 10, y: 10)
        let scaledCIImage = ciimage.transformed(by: transform)
        let uiimage = UIImage(ciImage: scaledCIImage)
        return uiimage.pngData()!
    }
    
    public func setVenmo(alias: String) async {
        guard let sessionId = sessionId else { return }
        let sessionRef = database.collection("sessions").document(sessionId)

        do {
            let sessionDoc = try await sessionRef.getDocument()

            guard var sessionData = sessionDoc.data(),
                  var _ = sessionData["items"] as? [[String: Any]] else {
                print("Session or items not found")
                return
            }
            
            sessionData["alias"] = alias
            try await sessionRef.setData(sessionData)

            print("Alias updated successfully")
        } catch {
            print("Error updating alias")
        }
        
        
    }
    
    lazy var totalClosure: (ItemViewModel, Bool) -> Void = { [weak self] item, wasAdded in
        guard let self = self else { return }

        let price = item.price
        let quantity = item.quantity
        let name = item.name
        let currentSessionUsers = item.people
        
        if wasAdded {
            let toAdd = price
            self.chosenItems[item.id] = toAdd
        } else {
            self.chosenItems.removeValue(forKey: item.id)
        }
        self.recomputeTotal(people: currentSessionUsers)
    }
    
    private func recomputeTotal(people: Int) {
        var newtotal: Float = 0
        for (key, value) in self.chosenItems {
            let splitQuantity = self.items.filter {
                $0.id == key
            }.first?.buyers.count ?? 1
            newtotal += (value / Float(splitQuantity))
            print("Key: (key), Value: (value)")
        }
        self.splitTax = self.totalViewModel.tax / Float(people)
        if let tip = self.totalViewModel.tip {
            self.splitTip = tip / Float(people)
        }
        self.total = newtotal + self.splitTax + (self.splitTip ?? 0)
        self.people = people
        self.objectWillChange.send()
    }

    
    @MainActor
    public func listen(sessionID: String) async {
        self.sessionId = sessionID
        let _ = Firestore.firestore().collection("sessions").document(sessionID)
            .addSnapshotListener { documentSnapshot, error in
                Task {
                    guard let document = documentSnapshot else {
                        print("Error fetching document: (error!)")
                        return
                    }
                    guard let data = document.data() else {
                        print("Document data was empty.")
                        return
                    }
                    do {
                        // Convert Firestore document data to JSON data
                        let jsonData = try JSONSerialization.data(withJSONObject: data)
                        
                        // Decode JSON data using JSONDecoder
                        let decoder = JSONDecoder()
                        let sessionData = try decoder.decode(SessionData.self, from: jsonData)
                        
                        self.items = sessionData.items
                        let currentSessionUsers = self.items.first?.people
                        if let currentSessionUsers = currentSessionUsers {
                            self.recomputeTotal(people: currentSessionUsers)
                        }
                        
                    } catch {
                        print("Error decoding data: (error)")
                    }
                    print("Current data: (data)")
                }
            }
    }
    
    @MainActor
    public func createShareSession() async -> String? {
        do {
            var itemsData: [[String: Any]] = []
            
            for item in self.items {
                let itemData: [String: Any] = [
                    "id": item.id.uuidString,
                    "price": item.price,
                    "name": item.name,
                    "quantity": item.quantity,
                    "buyers": [],
                    "people": item.people
                ]
                itemsData.append(itemData)
            }
            
            let sessionData: [String: Any] = [
                "items": itemsData
            ]
            
            let ref = try await self.database.collection("sessions").addDocument(data: sessionData)
            await listen(sessionID: ref.documentID)
            return ref.documentID
        } catch {
            print("Error adding document: (error)")
            return nil
        }
    }
    
    
    
}

No matter what I try, changes to the @Published properties in the viewModel do not reflect in the view. For example, I have Text(viewModel.items.first?.name ?? "") which I would expect to change on the viewModel’s listener fire. I have confirmed that all my FireStore logic works. How can I fix this and what am I doing wrong here? Thank you!

2

Answers


  1. To wrap things up: the common thing is that both @StateObject and @ObservedObject are used to tell the SwiftUI View to update if there are any changes to the observed object. However, there is a distinction between them that you might need to know.

    1. @ObservedObject might be recreated during the SwiftUI View life cycle at any time unless it was injected as a dependency from outside.
    2. @StateObject ensures that the object will retain itself during the SwiftUI View life cycle.

    So, mapping with your example above, you were initiating a new instance of ShareViewModel every single time the ShareView was re-rendered, and this instance was held from nowhere. That’s why the entire data will not work. To fix this you need to do one of these steps.

    • Inject ShareViewModel from the parent view which is using ShareView as a subview.
    @StateObject private var viewModel = ShareViewModel()
    
    var body: some View {
        ParentView {
            ShareView(viewModel, ...)
        }
    }
    

    OR

    • Declare ShareViewModel in ShareView as a @StateObject
    struct ShareView: View {
        @StateObject private var viewModel = ShareViewModel()
        ...
    }
    

    Hope this will help you to understand it clearly.

    Login or Signup to reply.
  2. It seams like you have nested @ObservableObject instances.

    ShareViewModel (ObservableObject) -> items: [ItemViewModel] (ObservableObject)

    Changes to nested ObservableObject instances are not visible to the view. However changes to @Published (that are not @ObservableObject) properties should re-render the view on each change.

    One solution to that problem would be to manually call objectWillChange.send() on each change of items array and other fields which are @ObservableObject.

    Perhaps with a didSet as an example:

    @Published var items: [ItemViewModel] = [] {
        didSet {
            objectWillChange.send()
        }
    }
    

    However I do not recommend it, as it involves manual handling for state refresh.

    Instead I would recommend revisiting the architecture decision and only having struct parameters as @Published and possibly extract ObservableObjects as separate @StateObject in the view.

    It is also possible for the item view to create its ViewModel as a state object where receives an "Item" model that is a struct and @Publihsed.

    Generally, it is advisable for views to create its ViewModels using @StateObject, but in the case you want to manipulate the ViewModel from outside, it is then recommended to create it from outside the view (like this case). But if you don’t need to manipulate it or listen to its changes, it would be better if it creates its own ViewModels. And listening to changes from outside is manual, either through Combine publishers or didSet like above.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search