2

I would like to build a simple List in SwiftUI of names that, when tapped, navigate to a detail view that allows modification of those names.

When I use a @State array to supply names to the List, and attempt to pass a binding to an element in the array to a detail view, any modification of the bound object in the detail view causes a redraw of not only the detail view, but also the offscreen view containing the List.

Generally, what seems to be the problem is that it's not possible to mutate data that is in a parent view. This is described in this post on the Apple Developer Forums.

There is a post with a similar title on this subject here at StackOverflow as well as blog posts that attempt to work around the issue, etc., but I have not found a good general-purpose solution or design pattern that deals with something that seems like a fairly common use-case.

Apple's WWDC 2022 video "The SwiftUI Cookbook for Navigation" completely sidesteps the issue of mutating data, and shows only examples of an app with static data (i.e. they don't modify any recipes).

Here is some code I've written to demonstrate the problem:

//
//  ContentView.swift
//

import SwiftUI

struct Item: Hashable, Identifiable {
    let id = UUID()
    var text: String
}

struct ContentView: View {
    @State var items = [Item(text: "foo bar"), Item(text: "biz boz") ]

    var body: some View {
        NavigationStack {
            let _ = { print("render NavigationStack") }()

            List(items) { item in
                let _ = { print("render List item") }()

                NavigationLink(item.text, value: item.id)
            }
            .navigationDestination(for: UUID.self) { id in
                if let index = items.firstIndex(where: {$0.id == id}) {
                    let _ = { print("render TextField") }()

                    VStack {
                        TextField("Value", text: $items[index].text)
                            .multilineTextAlignment(.center)
                        Spacer()
                    }
                }
            }
        }
    }
}

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

and here is a video showing the problem:

Video showing issue

Notice that when a character is typed into the text field the cursor always jumps to the end of the field. This is because the entire detail view with the TextField is being re-rendered because its parent view is being re-rendered when the text is modified.

What I'm specifically trying to accomplish is to separate the redraw of the parent view from data modifications made in the detail view. It would be ideal if the parent view wouldn't redraw at all while offscreen, but I still need the list in the parent view to update its text based on modifications made in the detail view. I'd also like to pass a binding to the detail view if possible, but at this point would be happy with any functional workaround.

2
  • 1
    This is a known bug right now. But Binding isn’t a good idea with navigation destination. A Binding is by definition a two way connection, when the variable changes it redraws the body and navigation destination lives in the body. You can use the NavigationLink with destination as an argument. Commented Feb 24, 2023 at 21:31
  • 1
    I had a workaround as you can see here but as of the latest Xcode update it does not work anymore, Like I said in my first comment, Binding and navigationDestination are not compatible. Commented Mar 4, 2023 at 1:05

2 Answers 2

2

To separate redrawing the parent view while you are modifying its state data in the detail view you should operate with a copy of target data and apply changes manually when you need to.

For instance, you can create ItemEditingView that has its own text state for editing and binds with your Item instance. Then we can implement a custom toolbar button ("Back", "Save" etc.) that allows us to apply changes on press only.

struct ItemEditingView: View {
    @Binding var item: Item
    @State var text: String
    
    @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
    var backButton: some View {
        Button(action: {
            // Apply changes
            item.text = text
            
            // Dismiss
            self.presentationMode.wrappedValue.dismiss()
        }, label: {
            Image(systemName: "chevron.backward")
                .fontWeight(.semibold)
            Text("Back")
        })
    }
    
    var body: some View {
        VStack {
            TextField("Text", text: $text)
                .multilineTextAlignment(.center)
            Spacer()
        }
        .navigationBarItems(leading: backButton)
        .navigationBarBackButtonHidden()
    }
}

struct ContentView: View {
    @State var items = [Item(text: "foo bar"), Item(text: "biz boz")]
    
    var body: some View {
        NavigationStack {
            List(items.indices, id: \.self) { i in
                NavigationLink(destination: ItemEditingView(item: $items[i], text: items[i].text)) {
                    Text(items[i].text)
                }
            }
        }
    }
}
Sign up to request clarification or add additional context in comments.

Comments

0
+100

The answer proposed is absolutely fine, but let me propose a more complete one using the MVVM design pattern.

First of all you will need a model to describe your data, some call this data-class from other languages, but in Swift this is most commonly described as a struct.

//
//  ItemModel.swift
//  Design Pattern
//
//  Created by Allan Garcia on 04/03/23.
//

import Foundation

struct Item: Hashable, Identifiable {
    let id = UUID()
    var text: String
}

Then, in MVVM pattern you have a "middle-man" that is responsible to Store this data, manage it, save it, detect changes and let the View redraw itself via radio-tuning into this "station" called ObservableObject.

This is an example of one for this case:

//
//  ItemStorageViewModel.swift
//  Design Pattern
//
//  Created by Allan Garcia on 04/03/23.
//

import SwiftUI

class ItemStorageViewModel: ObservableObject {
    
    @Published var items: [Item]
    
    init() {
        items = ItemStorageViewModel.addSomeExampleItems()
    }
    
    static func addSomeExampleItems() -> [Item] {
        let item1 = Item(text: "foo")
        let item2 = Item(text: "bar")
        return [item1, item2]
    }
    
    func save(_ item: Item) -> Void {
        if let index = items.firstIndex(where: { $0.id == item.id }) {
            items[index] = item
        } else {
            print("Couldn't find the index.")
        }
    }
}

This class have two responsibilities: first, publish the changes of the Item array to the world, whoever are instances of this view-model, this is accomplished using the decorator @Published, and an intent to save in his storage "var items" when someone want to change it's values.

Look that a more appropriate way to describe this var is using private(set) visibility, but I let this pass.

The static func is just a helper function to play well with previews in Xcode.

Then you want to show the list of this Storage into some view...

Now you don't need a NavigationLink(Item.self, item: item), because this is a Struct suitable when you have several models that you want to show into the same List. You can use the more simpler Struct NavigationLink(destination:label) that is presented here.

For destination you will provide the View that you what to show with all parameters necessary.

In the label only what will be seen in the List.

Like so:

//
//  ItemListView.swift
//  Design Pattern
//
//  Created by Allan Garcia on 04/03/23.
//

import SwiftUI

struct ItemListView: View {
    
    @ObservedObject private var viewModel = ItemStorageViewModel()
    
    var body: some View {
        NavigationStack {
            List(viewModel.items) { item in
                NavigationLink {
                    ItemEditItemView(viewModel: viewModel, editingItem: item)
                } label: {
                    Text(item.text)
                }
            }
            .navigationTitle("Items")
        }
    }
}


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

Last but not least, here is the mock view of the Editing an Item of your model and calling the apropriate Intent into the view-model, this could be customized to look better than this.

//
//  ItemEditItemView.swift
//  Design Pattern
//
//  Created by Allan Garcia on 04/03/23.
//

import SwiftUI

struct ItemEditItemView: View {
        
    @ObservedObject var viewModel: ItemStorageViewModel
    
    @State var editingItem: Item
    
    @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
    
    var body: some View {
        VStack {
            TextField("Edit text:", text: $editingItem.text)
                .padding()
            HStack {
                Button {
                    self.viewModel.save(editingItem)
                    
                    self.presentationMode.wrappedValue.dismiss()
                } label: {
                    Text("Save")
                        .padding()
                        .bold()
                }
                Button {
                    self.presentationMode.wrappedValue.dismiss()
                } label: {
                    Text("Cancel")
                        .padding()
                        .foregroundColor(.red)
                }
            }
            Spacer()
//            Text("Debug EditingText")
//                .foregroundColor(.red)
//                .bold()
//            Text("Editing text is: \(editingItem.text)")
        }
        .navigationTitle("Editing Item")
        .navigationViewStyle(.stack)
    }
}

struct ItemEditItemView_Previews: PreviewProvider {
    static var previews: some View {
        let vc = ItemStorageViewModel()
        
        ItemEditItemView(viewModel: vc, editingItem: vc.items.first!)
    }
}

Hope this add some value to the answer already mentioned here.

1 Comment

I awarded the bounty to this answer because it most closely follows Apple's WWDC Data Essentials in SwiftUI recommendations. iUrii's answer is also excellent. To quote lorem ipsum: "Binding and navigationDestination are not compatible."

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.