UPGRADE YOUR SKILLS: Learn advanced Swift and SwiftUI on Hacking with Swift+! >>

SOLVED: Successfully edit a binding to a view model property from within a List

Forums > SwiftUI

Hi all

This is a bit of a head scratcher. I'm running macOS Ventura beta 22A5295h, Xcode beta 3, but I have a feeling my issue with the code below is that I'm fundamentally misunderstanding something.

As background, the wider issue I'm having is one where, after passing a bound string from an observed object to a child view, changes to that string in a SwiftUI TextField have some artifacts: the caret jumps to the end of the string, and keystrokes are lost.

I've come up with a short example below, which doesn't show exactly the same behaviour, but still has the issue that the user cannot successfully edit the bound variable's value. Any ideas what I might be doing wrong? I guess I'm looking for the SwiftUI paradigm that allows editing of bound variable with a list.

//
//  ContentView.swift
//  EditViewTest
//
//  Created by Ian Hocking on 11/07/2022.
//

import SwiftUI

public class ViewModel: ObservableObject, Identifiable {
    @Published var fruitNames: [String] = ["Apple", "Orange"]
}

struct ContentView: View {
    @StateObject var viewModel = ViewModel()

    var body: some View {
        NavigationStack {
            List {
                ForEach($viewModel.fruitNames, id: \.self) { $name in
                    NavigationLink(destination: EditView(text: $name)) {
                        Text(name)
                    }
                }
            }
        }
    }
}

struct EditView: View {
    @Binding var text: String

    var body: some View {
        TextField("Edit this", text: $text)
    }
}

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

Cheers

Ian

2      

Ian has a question about bindings:

Any ideas what I might be doing wrong?
I guess I'm looking for the SwiftUI paradigm that allows editing of bound variable with a list.

@twoStraws gives a pretty good example in his tutorials.

See -> Pretty good binding example

Not sure I could give a better example. What part of @twoStraw's example doesn't work for you? Try turning your fruit strings into identifiable Fruit objects, with identifiers.

2      

Thanks, @Obelix

What part of @twoStraw's example doesn't work for you?

None. The paradigm I'm looking for is editing bound variables in a list where the data come from a view model, which isn't what the example is designed to demonstrate.

Let's adapt the code to give it a basic view model, which is the first step towards my original example:

import SwiftUI

struct User: Identifiable {
    let id = UUID()
    var name: String
    var isContacted = false
}

class ViewModel: ObservableObject {
    @Published public var users = [
        User(name: "Taylor"),
        User(name: "Justin"),
        User(name: "Adele")
    ]
}

struct ContentView: View {
    @StateObject var viewModel = ViewModel()

    var body: some View {
        List($viewModel.users) { $user in
            Text(user.name)
                .onTapGesture {
                    print($viewModel)
                }
            Spacer()
            TextField("name", text: $user.name)
        }
    }
}

The good news is that this works. However, the editing should take place in a chlid view.

Here's my adapted code. I get the correct behaviour (i.e. fruit name can be edited) when the EditView is not enclosed by a NavigationLink; howerver, when placed in NavigationLink, I'm back to the original, faulty behaviour (i.e. fruit name can't be edited).

import SwiftUI

struct Fruit: Identifiable {
    var id = UUID()
    var name: String
}

class ViewModel: ObservableObject {
    @Published var fruit: [Fruit] = [
        Fruit(name: "Apple"),
        Fruit(name: "Orange")
    ]
}

struct ContentView: View {
    @StateObject var viewModel = ViewModel()

    var body: some View {
        NavigationStack {
            List {
                // Note that we pass in a collection of our custom struct
                // and don't change the struct wholesale but rather a
                // property of it.
                ForEach($viewModel.fruit) { $fruit in
                    // In this case, the presence of a navigation link
                    // is causing the issue. Without this, we can edit
                    // as we would expect.
                    NavigationLink {
                        EditView(fruit: $fruit)
                    } label: {
                        Text("Label")
                    }
                }
            }
        }
    }
}

struct EditView: View {
    @Binding var fruit: Fruit

    var body: some View {
        TextField("Edit this fruit name", text: $fruit.name)
    }
}

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

At the moment, it looks as though the NavigationLink itself might be causing the issue.

2      

Some further testing shows that the example works when NavigationStackView is replaced with NavigationView. This must be a bug because NavigationStack is explicitly indicated as a replacement for NavigationView. I'll file a bug with Apple.

Meanwhile, to be clear, this is solved. As a workaround for the above issue, use NavigationView instead of NavigationStackView for the time being.

UPDATE: Looking further down Apple's documentation, there are statements suggesting NavigationStack should have a different approach when used like I am above, so this isn't a bug (I believe). However, this new approach doesn't seem to permit binding; when I create a binding and use it, I'm back to the previous failure mentioned above, where the text cannot be edited. So in the meantime, I'll use the NavigationView instead.

UPDATE: I've got the behaviour I was looking for by having the view model store a temporary copy of the edited item, which is freely updatable without causing any views apart from the editor view itself to reload.

I'll put it here in case it's helpful to anyone.

//
//  ContentView.swift
//  EditViewTest
//
//  Created by Ian Hocking on 11/07/2022.
//

import SwiftUI

struct Fruit: Identifiable {
    var id = UUID()
    var name: String = "A fruit"
}

extension Array where Element: Identifiable {

    /// Replaces an old element with the specified new element, basing the
    /// matching on the `id` property.
    ///
    /// - Parameter newElement: The new, replacement element.
    func replacingOldElement<T: Identifiable>(withNewElement newElement: T) -> [T] {
        var newElements = [T]()

        self.forEach {
            if let item = $0 as? T {
                if item.id == newElement.id {
                    newElements.append(newElement)
                } else {
                    newElements.append(item)
                }
            }
        }

        return newElements
    }
}

class FruitsViewModel: ObservableObject {

    /// Our fruits.
    @Published var fruits: [Fruit] = [
        Fruit(name: "Apple"),
        Fruit(name: "Orange")
    ]

    /// A temporary copy of the fruit the user wishes to edit, which can be
    /// changed without triggering updates to any views.
    public var editingFruit: Fruit = Fruit()

    /// Tells us that the editing view has begun editing.
    ///
    /// We can now set our editing buffer to the item that the user wishes to
    /// edit, which then be bound to the editing view. We emit a change so that
    /// this one-time update to `editingFruit` is noticed by the editor immediately
    ///
    /// - Parameter fruit: The fruit that the user has begun to edit.
    public func editorStartedToEdit(_ fruit: Fruit) {
        editingFruit = fruit
        objectWillChange.send()
    }

    /// Writes the currently edited fruit item to our fruit array, replacing
    /// the older version.
    ///
    /// Here we store the item that is being edited.
    public func saveEditedFruit() {
        fruits = fruits.replacingOldElement(withNewElement: editingFruit)
    }
}

struct ContentView: View {
    @StateObject var viewModel = FruitsViewModel()

    var body: some View {
        NavigationView {
            List(viewModel.fruits) { fruit in

                NavigationLink(destination:
                                EditView(fruitToEdit: fruit,
                                         viewModel: viewModel)) {
                    Text(fruit.name)
                }
            }
        }
    }
}

struct EditView: View {

    let fruitToEdit: Fruit
    @ObservedObject var viewModel: FruitsViewModel

    var body: some View {
        VStack {
            TextField("Name", text: $viewModel.editingFruit.name)
            Spacer()
            Button("Save Edits") {
                viewModel.saveEditedFruit()
            }
        }
        .onAppear {
            viewModel.editorStartedToEdit(fruitToEdit)
        }
    }
}

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

2      

In the original code you were using the new NavigationStack but you were using the old NavigationLink(destination: , label: ) .

These are not designed to work with each other. Both NavigationLink(destination: , label: ) and NavigationView are deprecated. They are designed to work with each other.

If you are using NavigationStack then you should be using the new NavigationLink(value: , label: ) For more info see The SwiftUI cookbook for navigation - WWDC22 - Videos - Apple Developer

3      

In the original code you were using the new NavigationStack but you were using the old NavigationLink(destination: , label: ) .

These are not designed to work with each other. Both NavigationLink(destination: , label: ) and NavigationView are deprecated. They are designed to work with each other.

If you are using NavigationStack then you should be using the new NavigationLink(value: , label: ) For more info see The SwiftUI cookbook for navigation - WWDC22 - Videos - Apple Developer

2      

TAKE YOUR SKILLS TO THE NEXT LEVEL If you like Hacking with Swift, you'll love Hacking with Swift+ – it's my premium service where you can learn advanced Swift and SwiftUI, functional programming, algorithms, and more. Plus it comes with stacks of benefits, including monthly live streams, downloadable projects, a 20% discount on all books, and free gifts!

Find out more

Sponsor Hacking with Swift and reach the world's largest Swift community!

Archived topic

This topic has been closed due to inactivity, so you can't reply. Please create a new topic if you need to.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.