swiftswiftuiobservableobjectswiftui-state

SwiftUI: Pass an ObservableObject's property into another class's initializer


How do I pass a property from an ObservedObject in a View, to another class's initializer in the same View? I get an error with my ObservedObject:

Cannot use instance member 'project' within property initializer; property initializers run before 'self' is available

The reason I want to do this is I have a class which has properties that depend on a value from the ObservedObject.

For example, I have an ObservedObject called project. I want to use the property, project.totalWordsWritten, to change the session class's property, session.totalWordCountWithSession:

struct SessionView: View {
    @Binding var isPresented: Bool
    @ObservedObject var project: Project
// How to pass in project.totalWordsWritten from ObservedObject project to totalWordCount?
    @StateObject var session:Session = Session(startDate: Date(), sessionWordCount: 300, totalWordCount: 4000)
    
    var body: some View {
        
        NavigationView {
            VStack(alignment: .leading) {
                Form {
                    Section {
                        Text("Count")
                        
                        HStack {
                            Text("Session word count")
                            TextField("", value: $session.sessionWordCount, formatter: NumberFormatter())
                                .textFieldStyle(.roundedBorder)
                        }
                        HStack {
                            // Changing text field here should change the session count above
                            Text("Total word count")
                            TextField("", value: $session.totalWordCountWithSession, formatter: NumberFormatter())
                                .textFieldStyle(.roundedBorder)
                        }
                    }
                }
            }.toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    Button("Save") {
                        // Save this session into the project
                        project.addSession(newSession: session)
                        isPresented = false
                    }
                }
            }
        }
    }
}

struct SessionView_Previews: PreviewProvider {
    static var previews: some View {
        SessionView(isPresented: .constant(true), project: Project(title: "TestProject", startWordCount: 0))
    }
}

Below is the rest of the example:

HomeView SessionView

HomeView.swift

import SwiftUI

struct HomeView: View {
    @State private var showingSessionPopover:Bool = false
    
    @StateObject var projectItem:Project = Project(title: "Test Project", startWordCount: 4000)
    
    var body: some View {
        
        NavigationView {
            VStack(alignment: .leading) {
                Text(projectItem.title).font(Font.custom("OpenSans-Regular", size: 18))
                    .fontWeight(.bold)
                Text("Count today: \(projectItem.wordsWrittenToday)")
                Text("Total: \(projectItem.totalWordsWritten)")
            }
            .toolbar {
                ToolbarItem {
                    Button(action: {
                        showingSessionPopover = true
                        }, label: {
                            Image(systemName: "calendar").imageScale(.large)
                        }
                    )
                }
            }
            
        }.popover(isPresented: $showingSessionPopover) {
            SessionView(isPresented: $showingSessionPopover, project: projectItem)
        }
        
    }
}

Session.swift:

import Foundation
import SwiftUI

class Session: Identifiable, ObservableObject {
    
    init(startDate:Date, sessionWordCount:Int, totalWordCount: Int) {
        self.startDate = startDate
        self.endDate = Calendar.current.date(byAdding: .minute, value: 30, to: startDate) ?? Date()
        self.sessionWordCount = sessionWordCount
        self.totalWordCount = totalWordCount
        self.totalWordCountWithSession = self.totalWordCount + sessionWordCount
    }
    
    var id: UUID = UUID()
    @Published var startDate:Date
    @Published var endDate:Date
    var totalWordCount: Int
    var sessionWordCount:Int
    @Published var totalWordCountWithSession:Int {
        didSet {
            sessionWordCount = totalWordCountWithSession - totalWordCount
        }
    }
    
}

Project.swift

import SwiftUI

class Project: Identifiable, ObservableObject {
    
    var id: UUID = UUID()
    @Published var title:String
    
    var sessions:[Session] = []
    
    @Published var wordsWrittenToday:Int = 0
    @Published var totalWordsWritten:Int = 0
    
    @Published var startWordCount:Int

    init(title:String,startWordCount:Int) {
        self.title = title
        self.startWordCount = startWordCount
        self.calculateDailyAndTotalWritten()
    }
    
    // Create a new session
    func addSession(newSession:Session) {
        sessions.append(newSession)
        calculateDailyAndTotalWritten()
    }
    
    // Re-calculate how many 
    // today and in total for the project
    func calculateDailyAndTotalWritten() {
        wordsWrittenToday = 0
        totalWordsWritten = startWordCount
        
        for session in sessions {
            if (Calendar.current.isDateInToday(session.startDate)) {
                wordsWrittenToday += session.sessionWordCount
            }
            
            totalWordsWritten += session.sessionWordCount
        }
    }
    
}

Solution

  • You can use the StateObject initializer in init:

    struct SessionView: View {
        @Binding var isPresented: Bool
        @ObservedObject var project: Project
        @StateObject var session:Session = Session(startDate: Date(), sessionWordCount: 300, totalWordCount: 4000)
        
        init(isPresented: Binding<Bool>, project: Project, session: Session) {
            _isPresented = isPresented
            _session = StateObject(wrappedValue: Session(startDate: Date(), sessionWordCount: 300, totalWordCount: project.totalWordsWritten))
            self.project = project
        }
        
        var body: some View {
            Text("Hello, world")
        }
    }
    

    Note that the documentation says:

    You don’t call this initializer directly

    But, it has been confirmed by SwiftUI engineers in WWDC labs that this is a legitimate technique. What runs in wrappedValue is an autoclosure and only runs on the first init of StateObject, so you don't have to be concerned that every time your View updates that it will run.

    In general, though, it's a good idea to try to avoid doing things in the View's init. You could consider instead, for example, using something like task or onAppear to set the value and just put a placeholder value in at first.