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
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.
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.ShareViewModel
from the parent view which is using ShareView as a subview.OR
ShareViewModel
in ShareView as a@StateObject
Hope this will help you to understand it clearly.
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:
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.