swiftuiswiftui-state

SwiftUI: @State property not updated without weird workaround


I'm experiencing strange behavior with an @State property that isn't being properly updated in its originating view after being changed in another view. I'm using Xcode 12.3 and iOS 14.

What happens is that an @State "session" value-based item and @State "flow" value-based item are sent as bound parameters to another view. When a button is tapped there, it changes their values, and a fullScreenCover call in the originating view is supposed to get the correct view to display next in the flow from a switch statement. But the "session" item is nil in that switch statement unless I include an onChange modifier that looks for changes in either of the two @State properties. The onChange call doesn't have to have any code in it to have this effect.

I'm still relatively new to SwiftUI (although fairly experienced with iOS and Mac development). But this is confusing the heck out of me. I don't understand why it isn't working as expected, nor why adding an empty onChange handler makes it work.

If you'd like to experience this for yourself, here's code to assemble a simple demo project:

// the model types

struct ObservationSession: Codable {
    public let id: UUID
    public var name: String
    
    public init(name: String) {
        self.name = name
        self.id = UUID()
    }

}

struct SessionListModals {
    enum Flow: Identifiable {
        case configuration
        case observation
        case newSession
        
        var id: Flow { self }
    }
}

// ContentView

struct ContentView: View {
    @State private var mutableSession: ObservationSession?
    @State private var flow: SessionListModals.Flow?
    var body: some View {
        VStack {
            Button("New Session", action: {
                mutableSession = ObservationSession(name: "")
                flow = .newSession
            })
                .padding()
        }
        .fullScreenCover(item: $flow) {
                viewForFlow($0)
        }
        
        // Uncomment either of these 2 onChange blocks to see successful execution of this flow
        // Why does that make a difference?
        
//        .onChange(of: mutableSession?.name, perform: { value in
//            //
//        })
        
//        .onChange(of: flow, perform: { value in
//            //
//        })
    }
    
    @ViewBuilder private func viewForFlow(_ flow: SessionListModals.Flow) -> some View {
        switch flow {
        case .newSession:
            // MARK: - Show New Session View
            NavigationView {
                NewSessionView(session: $mutableSession, flow: $flow)
                    .navigationTitle("Create a session")
                    .navigationBarItems(leading: Button("Cancel", action: {
                        self.flow = nil
                    }))
            }
        case .observation:
            // MARK: - Show RecordingView
            NavigationView {
                let name = mutableSession?.name ?? "Unnamed session"
                RecordingView(sessionName: name)
                    .navigationBarItems(leading: Button("Close", action: {
                        self.flow = nil
                    }))
            }
        default:
            NavigationView {
                EmptyView()
                    .navigationBarItems(leading: Button("Close", action: {
                        self.flow = nil
                    }))
            }
        }
    }

}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

// NewSessionView

struct NewSessionView: View {
    @Binding var session: ObservationSession?
    @Binding var flow: SessionListModals.Flow?
    var body: some View {
        VStack {
            Text("Tap button to create a new session")
            Button("New Session", action: {
                createNewSession()
            })
            .padding()
        }
    }
    
    private func createNewSession() {
        let newSession = ObservationSession(name: "Successfully Created A New Session")
        session = newSession
        flow = .observation
    }
}

struct NewSessionView_Previews: PreviewProvider {
    static let newSession = ObservationSession(name: "Preview")
    static let flow: SessionListModals.Flow = .newSession
    static var previews: some View {
        NewSessionView(session: .constant(newSession), flow: .constant(flow))
    }
}

// RecordingView

struct RecordingView: View {
    var sessionName: String
    var body: some View {
        Text(sessionName)
    }
}

struct RecordingView_Previews: PreviewProvider {
    static var previews: some View {
        RecordingView(sessionName: "Preview")
    }
}



Solution

  • class ObservationSession: //Codable, //implement Codable manually
        ObservableObject {
        public let id: UUID
        //This allows you to observe the individual variable
        @Published public var name: String
        
        public init(name: String) {
            self.name = name
            self.id = UUID()
        }
        
    }
    
    struct SessionListModals {
        enum Flow: Identifiable {
            case configuration
            case observation
            case newSession
            
            var id: Flow { self }
        }
    }
    
    // ContentView
    class ContentViewModel: ObservableObject {
        @Published var mutableSession: ObservationSession?
        
    }
    struct ContentView: View {
        //State stores the entire object and observes it as a whole it does not individually observe its variables that is why .onChange works
        @StateObject var vm: ContentView3Model = ContentView3Model()
        @State private var flow: SessionListModals.Flow?
        var body: some View {
            VStack {
                Button("New Session", action: {
                    //Since you want to change it programatically you have to put them in another object
                    vm.mutableSession = ObservationSession(name: "")
                    flow = .newSession
                })
                .padding()
            }
            .fullScreenCover(item: $flow) {
                viewForFlow($0)
            }
        }
        
        @ViewBuilder private func viewForFlow(_ flow: SessionListModals.Flow) -> some View {
            switch flow {
            case .newSession:
                // MARK: - Show New Session View
                NavigationView {
                    NewSessionView(session: $vm.mutableSession, flow: $flow)
                        .navigationTitle("Create a session")
                        .navigationBarItems(leading: Button("Cancel", action: {
                            self.flow = nil
                        }))
                }
            case .observation:
                // MARK: - Show RecordingView
                NavigationView {
                    let name = vm.mutableSession?.name ?? "Unnamed session"
                    RecordingView(sessionName: name)
                        .navigationBarItems(leading: Button("Close", action: {
                            self.flow = nil
                        }))
                }
            default:
                NavigationView {
                    EmptyView()
                        .navigationBarItems(leading: Button("Close", action: {
                            self.flow = nil
                        }))
                }
            }
        }
    }