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 |
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)
}
}
}
}
}