swiftswiftui

SwiftUI state update


I thought I had a very good understanding of how SwiftUI works. But sometimes something still confuses me especially when state is involved. Here are some basic codes.

Firstly, I have a model.

struct Item : Equatable {
    let id = UUID()
    let name: String
}

And a ViewModel

@Observable
final class ContentViewModel {
    
    var items = [
        Item(name: "test1"),
        Item(name: "test2"),
        Item(name: "test3")
    ]
    
    func mark(item: Item, wrong: Bool) {
        //deal with right or wrong answer
        items.removeAll {
            $0 == item
        }
    }
    
}

And some views

struct ContentView: View {
    
    let viewModel = ContentViewModel()
    
    var body: some View {
        if let item = viewModel.items.first {
            BackwardableView(item) {
                QuizView(item, fallback: $0)
            }
                .environment(viewModel)
        }
    }
}

struct QuizView : View {
    
    private let item: Item
    @Binding private var fallback: Bool
    @Environment(ContentViewModel.self) private var viewModel
    
    init(_ item: Item, fallback: Binding<Bool>) {
        self.item = item
        self._fallback = fallback
    }
    
    var body: some View {
        VStack {
            Text("Quiz \(item.name)")
            Button("Wrong Answer") {
                fallback = true
            }
            Button("Correct Answer") {
                viewModel.mark(item: item, wrong: false)
            }
        }
        
    }
}

struct FallbackView : View {
    
    private let item: Item
    @Environment(ContentViewModel.self) private var viewModel
    
    init(_ item: Item) {
        self.item = item
    }
    
    var body: some View {
        Text("Fallback \(item.name)")
            .toolbar {
                Button("Next") {
                    viewModel.mark(item: item, wrong: true)
                }
            }
    }
}

struct BackwardableView<Content: View> : View {
    
    private let item: Item
    private let content: (Binding<Bool>) -> Content
    
    @Environment(ContentViewModel.self) private var viewModel
    @State private var backward = false
    
    init(_ item: Item, content: @escaping (Binding<Bool>) -> Content) {
        self.item = item
        self.content = content
    }
    
    var body: some View {
        Group {
            if backward {
                FallbackView(item)
            } else {
                content($backward)
            }
        }
              .id(item.id)
//            .onChange(of: item) { oldValue, newValue in
//                if oldValue == newValue {
//                    return
//                }
//                backward = false
//            }
    }
}

The problem occurs in the last view. Whenever a fallback is triggered by QuizView, it should switch to the fallback view and perform some fallback actions. In this mock code, it simply asks the viewModel to remove the first item and jump to the next one. However, the BackwardableView seems to retain the "backward state" for the next item, which is illogical.

I tried adding an explicit id to differentiate this view as completely new. This trick often works, as I assume it allows SwiftUI to recognize it as a distinct view. However, it's not effective in this case. Adding the onChange modifier resolves the issue. While I understand this approach works, I'm still confused about how the view, which is supposedly a struct with immutable properties (including the item), can apparently change.


Solution

  • You never set backward to false, except its initial value.

    If you want to use the .id(item.id) approach, you should understand that this works by giving a view a new ID, hence resetting all its @State. In this case, you want the backward state to be reset, so you should put .id on BackwardableView (which contains backward), not the Group that wraps the if statement.

    struct ContentView: View {
        
        @State var viewModel = ContentViewModel()
        
        var body: some View {
            if let item = viewModel.items.first {
                BackwardableView(item) {
                    QuizView(item, fallback: $0)
                }
                .environment(viewModel)
                .id(item.id) // put it here!
            }
        }
    }
    

    In my opinion though, adding an extra Binding to FallbackView is better, because this doesn't involve recreating the entire view.

    struct FallbackView : View {
        
        private let item: Item
        @Environment(ContentViewModel.self) private var viewModel
        
        @Binding var backward: Bool
        
        init(_ item: Item, _ backward: Binding<Bool>) {
            self.item = item
            self._backward = backward
        }
        
        var body: some View {
            Text("Fallback \(item.name)")
                .toolbar {
                    Button("Next") {
                        viewModel.mark(item: item, wrong: true)
                        backward = false // reset backward here!
                    }
                }
        }
    }