Home > Mobile >  Form reverts and reloads data on unwind
Form reverts and reloads data on unwind

Time:09-05

So I have a tough SwiftUI form situation which I'm trying to solve.

I'm trying to reuse a Form in both the creation and the editing of data depending on whether I pass a Model into it or not.

What I have at the moment is a working version, but if the user changes some of the TextFields before selecting some items in the next Navigation screens, then on the unwind it undoes all the users edits.

However, if I remove the .onAppear then the data doesn't load into the edit mode.

If I don't add in the if !UserDefaults.standard.bool(forKey: "formItems") {} block, then whenever the data is segued to the list, and back it resets to the original model.

Is there a way that I can have both the data load if there is a model passed in, and updated if edited on the unwind - but not reset it.

The only way I can think - but I don't think it is a good or best way - is to store the formItems array into a UserDefault, then delete it on full pop to root.

I have also tried using an init() { vm.loadData(model: model) } on the ContentView but I get the purple error:

Accessing StateObject's object without being installed on a View. This will create a new instance each time.

I am on iOS 14 so I am not using any of the new APIs.

With the UserDefault block
With the UserDefault block
Without the UserDefault block
Without the UserDefault block

Test code

Entrance

@main
struct testApp: App {
  var body: some Scene {
    WindowGroup {
      NavigationView {
        Section {
          VStack {
            List(items) { item in
              NavigationLink("Go to form - \(item.name)") {
                ContentView(model: item)
              }
            }
            NavigationLink("Add new") {
              ContentView(model: nil)
            }
          }
        }
        .navigationTitle("Main screen")
      }
    }
  }
}

Model

struct Model {
  let id = UUID().uuidString
  let name: String
  let numbers: [Int]
}

let items = [
  Model(name: "Michael", numbers: [1, 5, 7]),
  Model(name: "Jan", numbers: [1]),
  Model(name: "Liam", numbers: [3, 6]),
  Model(name: "Rav", numbers: [7, 8]),
  Model(name: "Paul", numbers: [2, 4])
]

Form view

struct ContentView: View {
  @StateObject private var vm = ViewModel()
  var model: Model? = Model(name: "Michael", numbers: [1, 5, 7])
  var body: some View {
  Form {
    TextField("Name", text: $vm.formName)
      Section {
        NavigationLink(vm.formItems.isEmpty ? "Choose items" : vm.formItemsName) {
          ListItemsView()
            .environmentObject(vm)
        }
      } footer: { Text("\(vm.formItems.description)") }
    }
    .navigationTitle("Add new")
    .onAppear { vm.loadData(model: model) }
  }
}

Form sub-view

struct ListItemsView: View {
  @EnvironmentObject private var vm: ViewModel
  var body: some View {
  Form {
    List(0..<10) { index in
      Text("\(index)")
        .onTapGesture {
          vm.formItems.append(index)
        }
    }
  }
  }
}

View Model

class ViewModel: ObservableObject {
  @Published var formName: String = ""
  @Published var formItems: [Int] = []
  @Published var formItemsName: String = ""
  func loadData(model: Model?) {
    if let model = model {
      formName = model.name
      if !UserDefaults.standard.bool(forKey: "formItems") {
        formItems = model.numbers
        UserDefaults.standard.set(true, forKey: "formItems")
      }
    }
    formItemsName = getNames(from: formItems)
  }
  func getNames(from items: [Int]) -> String {
    var output: [String] = []
    for item in items {
      output.append("\(item)")
    }
    return output.joined(separator: ", ")
  }
}

CodePudding user response:

As I´ve allready mentioned in the comments you are using multiple sources of truth. This is in general a bad idea as you would need to syncronize this changes all over your app.

You mentioned:

I have trimmed down my actual code to replicate the my actual environment, but may have made this example complicated but in the actual app is needed.

So please take this as a more general example:

If you want to reuse your ContentView pass the data into it. Capsulate this data in the Viewmodel and pass that into the Environment.

The View shouldn´t care if the data given is new or should be edited. It has a certain job: presenting what it has been given.

struct ContentView: View {

    @EnvironmentObject private var vm: ViewModel
    
    var body: some View {
        Form {
            TextField("Name", text: $vm.model.name)
            Section {
                NavigationLink(vm.model.numbers.isEmpty ? "Choose items" : vm.getNames()) {
                    ListItemsView()
                        .environmentObject(vm)
                }
            } footer: { Text("\(vm.model.numbers.description)") }
        }
        .navigationTitle("Add new")
    }
}

struct ListItemsView: View {
    @EnvironmentObject private var vm: ViewModel
    var body: some View {
        Form {
            List(0..<10) { index in
                Text("\(index)")
                    .frame(maxWidth: .infinity)
                    .contentShape(Rectangle())
                    .onTapGesture {
                        vm.model.numbers.append(index)
                    }
            }
        }
    }
}

class ViewModel: ObservableObject {

    @Published var model: Model = Model(name: "", numbers: [])

    func getNames() -> String{
        getNames(from: model.numbers)
    }
    
    func getNames(from items: [Int]) -> String {
        var output: [String] = []
        for item in items {
            output.append("\(item)")
        }
        return output.joined(separator: ", ")
    }
}

struct Model {
    let id = UUID().uuidString
    //use vars here
    var name: String
    var numbers: [Int]
}

Edit:

This is still possible. With the now changed design I would recommend the following solution:

NavigationView {
    Section {
        VStack {
            List($viewmodel.models) { $model in
                NavigationLink("Go to form - \(model.name.isEmpty ? "new user" : model.name)") {
                    ContentView(model: $model, title: "edit")
                }
            }
            Button("Add new"){
                // use this button to add a new model an trigger navigation
                viewmodel.models.append(Model(name: "", numbers: []))
                showNew = true
            }
            // create this invisible navigation link to shwo the newly added model
            NavigationLink("", isActive: $showNew){
                ContentView(model: $viewmodel.models.last!, title: "add new")
            }
        }
    }
    .navigationTitle("Main screen")
}

your Model would need to be Identifiable and changes those let´s to var´s:

struct Model: Identifiable {
    let id = UUID().uuidString
    var name: String
    var numbers: [Int]
}

class ViewModel: ObservableObject {

    @Published var models: [Model]
    
    init(){
        models = [
            Model(name: "Michael", numbers: [1, 5, 7]),
            Model(name: "Jan", numbers: [1]),
            Model(name: "Liam", numbers: [3, 6]),
            Model(name: "Rav", numbers: [7, 8]),
            Model(name: "Paul", numbers: [2, 4])
          ]

    }
}

struct ContentView: View {
    @Binding var model: Model
    let title: String
    var body: some View {
        Form {
            TextField("Name", text: $model.name)
            Section {
                NavigationLink(model.numbers.isEmpty ? "Choose items" : getNames()) {
                    ListItemsView(model: $model)
                        
                }
            } footer: { Text("\(model.numbers.description)") }
        }
        .navigationTitle(title)
    }
    
    func getNames() -> String{
        var output: [String] = []
        for item in model.numbers {
            output.append("\(item)")
        }
        return output.joined(separator: ", ")
    }
}

struct ListItemsView: View {
    @Binding var model: Model
    var body: some View {
        Form {
            List(0..<10) { index in
                Text("\(index)")
                    .frame(maxWidth: .infinity)
                    .contentShape(Rectangle())
                    .onTapGesture {
                        model.numbers.append(index)
                    }
            }
        }
    }
}
  • Related