Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to reuse view model instance for destination view in a NavigationLink

Tags:

ios

swift

swiftui

in the code provided below I am having an issue that the DetailViewModel is being recreated. That happens because the ContentView updates, which also recreates all the NavigationLinks and destinations. Because of this the state within the DetailViewModel is reset.

This is some example code:

import SwiftUI
import Combine

struct ContentView: View {

    let items = ["Item A", "Item B", "Item C"]

    @State var contentViewUpdater = 0

    var body: some View {
        NavigationView {
            VStack {
                Button("Update ContentView: \(contentViewUpdater)") {
                    self.contentViewUpdater += 1
                }
                List(items, id: \.self) { item in
                    // How to prevent DetailViewModel from recreating after this ContentView receives an update?
                    NavigationLink(destination: DetailView(model: DetailViewModel(item: item))) {
                        Text(item)
                    }
                }
            }
        }
    }
}

final class DetailViewModel: ObservableObject {
    let item: String
    @Published var counter = 0

    init(item: String) {
        self.item = item
    }
}

struct DetailView: View {
    @ObservedObject var model: DetailViewModel

    var body: some View {
        VStack {
            Text("Counter for \(model.item): \(model.counter)")
            Button("Increase counter") {
                self.model.counter += 1
            }
        }
    }
}

#if DEBUG
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
#endif

Here is a screenrecording of the issue. The DetailViewModel.counter var resets if ContentView updates.

screenrecording

How can you prevent the state in DetailViewModel from resetting when the parent view updates?

like image 224
Thomas Vos Avatar asked Jul 31 '19 10:07

Thomas Vos


2 Answers

Apologies, my code is adapted from yours as I've not updated to the latest beta yet, but this works for me. I've used the concept of "Lifting State Up" from React, and moved the model data into the Master view itself.

From a playground:

import SwiftUI
import PlaygroundSupport


final class ItemViewModel : BindableObject {
    let willChange = PassthroughSubject<Void, Never>()

    var name: String {
        willSet { willChange.send() }
    }
    var counter: Int = 0 {
        willSet { willChange.send() }
    }

    init(name: String) {
        self.name = name
    }
}


struct ContentView : View {
    let items = [
        ItemViewModel(name: "Item A"),
        ItemViewModel(name: "Item B"),
        ItemViewModel(name: "Item C")
    ]

    @State var contentViewUpdater = 0

    var body: some View {
        NavigationView {
            VStack {
                Button("Update ContentView: \(contentViewUpdater)") {
                    self.contentViewUpdater += 1
                }
                List(items) { model in
                    NavigationLink(destination: DetailView(model: model)) {
                        Text(model.name)
                    }
                }
            }
        }
    }
}


struct DetailView : View {
    @ObjectBinding var model: ItemViewModel

    var body: some View {
        let name = model.name
        let counter = model.counter
        return VStack {
            Text("Counter for \(name): \(counter)")
            Button("Increase counter") {
                self.model.counter += 1
            }
        }
    }
}


PlaygroundPage.current.liveView = UIHostingController(rootView: ContentView())
PlaygroundPage.current.needsIndefiniteExecution = true

Screen recording from Playground

like image 63
Drarok Avatar answered Nov 02 '22 15:11

Drarok


Your views should not have to be aware of whether SwiftUI regenerates the view or not. In your case, I think you have to change the way you have laid out your model.

There's two approaches I would take in your case:

  1. Have a single model that contains the data for all your items (preferred).
  2. Or, if you need a different model for each item, make it so that it remains allocated at all times (without having to take into consideration the life-cycle of your views). Maybe you can use the environment object, or have an array of DetailViewModel() models held by a State variable. Remember State variables remain allocated, even when the view regenerates.

I think the first option: having a single model, is better. But to illustrate my second point, here's a possible implementation:

import SwiftUI
import Combine

struct Item: Identifiable {
    let id = UUID()
    let model: DetailViewModel

    init(name: String) {
        self.model = DetailViewModel(item: name)
    }
}

struct ContentView: View {

    @State private var items = [Item(name: "Item A"), Item(name: "Item B"), Item(name: "Item C")]
    @State var contentViewUpdater = 0

    var body: some View {
        NavigationView {
            VStack {
                Button("Update ContentView: \(contentViewUpdater)") {
                    self.contentViewUpdater += 1
                }
                List(items, id: \.id) { item in
                    NavigationLink(destination: DetailView(model: item.model)) {
                        Text(item.model.item)
                    }
                }
            }
        }
    }
}

final class DetailViewModel: ObservableObject {
    let item: String
    @Published var counter = 0

    init(item: String) {
        self.item = item
    }
}

struct DetailView: View {
    @ObservedObject var model: DetailViewModel

    var body: some View {
        VStack {
            Text("Counter for \(model.item): \(model.counter)")
            Button("Increase counter") {
                self.model.counter += 1
            }
        }
    }
}
like image 30
kontiki Avatar answered Nov 02 '22 17:11

kontiki