swiftswiftui

Update row in list view from sheet in SwiftUI


Goal

Given a List view (parent) and a Row view within that List (child), and given that a sheet modal is on the List view, I want to allow the user to swipe and tap an 'edit' button on a Row within the List, and within the displayed sheet, use a TextField to update the contents of that chosen Row.

Problem and Question

When the sheet opens, it does indeed contain the content of the row that was selected thanks to the use of @Binding. However, updating this text field does not update the contents of the Row once the sheet is closed.

I think I have a fundamental misunderstanding of @Binding - I'm completely new to SwiftUI. Firstly, is @Binding the correct tool that I should be looking at to achieve this? I have seen reference to Environment, Observable and Publisher - am I 'doing it wrong' in attempting to use @Binding here? Or is there just some small silly mistake that I'm making and just can't see it?

Secondly, it feels weird to me that I have to set the selected row, within a row itself, just to bind it so that it's available in the parent List. This just feels a bit wrong. What is the better alternative?

Video in action: https://streamable.com/805fsp

Code

Please note, all of this can be placed within one single SwiftUI file to see it failing for yourself. I've separated them with titles for ease of understanding the SoC and distilled my problem down to the simplest example I could think of.

Firstly, the small simple struct which contains a name and description.

/// Simple entity containing data to be displayed in a row view.
struct Row {
    var name: String
    var description: String
}

Next, the view which will represent the Row struct on the page:

/// A single row to be displayed within a List with a swipe actoun.
struct RowView: View {
    @Binding var isEditing: Bool
    @Binding var rowBeingEdited: Row
    
    // Every RowView is provided a Row entity to draw it's data from.
    @State var row: Row

    var body: some View {
        HStack {
            Text(self.row.name)
            Text(self.row.description)
        }
        // Edit Button.
        .swipeActions(edge: .leading, allowsFullSwipe: false) {
            Button(action: {
                self.rowBeingEdited = self.row
                self.isEditing = true
            }) {
                Label("Edit", systemImage: "rectangle.and.pencil.and.ellipsis")
            }
        }
    }
}

Next the List View to display multiple Rows:

/// The list view containing multiple RowViews.
struct ListView: View {
    // ListView will be told when a row is selected to be edited.
    @State var isEditing: Bool

    // ListView needs to know which row is being edited to pass to the modal sheet
    @State var rowBeingEdited: Row
    
    var body: some View {
        List {
            RowView(
                isEditing: $isEditing,
                rowBeingEdited: $rowBeingEdited,
                row: Row(name: "Name1", description: "Description1")
            )
            RowView(
                isEditing: $isEditing,
                rowBeingEdited: $rowBeingEdited,
                row: Row(name: "Name2", description: "Description2")
            )
        }
        .sheet(isPresented: $isEditing) {
            EditSheet(rowBeingEdited: $rowBeingEdited)
        }
    }
}

Finally the sheet modal to be displayed once a row is selected:

/// The sheet view modal used to edit the row.
struct EditSheet: View {
    @Binding var rowBeingEdited: Row
    
    // My expectation: Changing value of text field updates row here, so in list view, so in row view. Why isn't it doing this?
    var body: some View {
        VStack {
            TextField("Name", text: $rowBeingEdited.name)
            TextField("Description", text: $rowBeingEdited.description)
        }
    }
}

Some code to display previews in Xcode. Maybe there's a better way of doing this btw?

struct RowTest_Previews: PreviewProvider {
    static var previews: some View {
        let testPreview = RowTestRow_Preview(rowBeingEdited: Row(name: "N", description: "D"))

        ListView(
            isEditing: false,
            rowBeingEdited: testPreview.rowBeingEdited
        )
    }
}

struct RowTestRow_Preview: View {
    @State var rowBeingEdited: Row

    var body: some View {
        Text("")
    }
}


Solution

  • It is actually simpler conceptually to start with the data array in this case. Also, I made your Row struct identifiable as we need to keep track of it. When working with arrays of data in SwiftUI, you should instinctively conform to Identifiable. Things get infinitely easier.

    You were on the right track with thinking you needed an individualized solution for each row, but not having your data in an array, led you into putting that code into your RowView which actually complicates things. It is much easier to deal with in a ForEach as everything you need to identify a particular row is there.

    The most complicated part of this NOT having an @State var to keep track of presenting a sheet. Once you pull the sheet code out of the row, this will cause all of the sheets to try and fire. I replaced the need for that with a computed Binding in .sheet(isPresented:) that compares an @State var rowID to the current row id. If they match, then this is the row we want and the sheet fires. Otherwise, nothing happens. The rowID is simply set in the .swipeAction() button.

    struct ListView: View {
        @State var rows: [Row] = [Row(name: "Name1", description: "Description1"),
                               Row(name: "Name2", description: "Description2"),
                               Row(name: "Name3", description: "Description3"),
                               Row(name: "Name4", description: "Description4"),
                               Row(name: "Name5", description: "Description5"),
                               Row(name: "Name6", description: "Description6")]
        @State var rowID: UUID?
        
        var body: some View {
            List {
                ForEach($rows) { $row in
                    RowView(row: row)
                        .swipeActions(edge: .leading, allowsFullSwipe: false) {
                            Button(action: {
                                rowID = row.id
                            }) {
                                Label("Edit", systemImage: "rectangle.and.pencil.and.ellipsis")
                            }
                        }
                        .sheet(isPresented: Binding<Bool>(
                            get: { row.id == rowID },
                            set: { _ in
                                rowID = nil
                            })
                        ) {
                            EditSheet(rowBeingEdited: $row)
                        }
                }
            }
        }
    }
    

    RowView now has no logic in it, so I could pull our all of the @State and @Binding and turn it into a simple display view:

    /// A single row to be displayed within a List with a swipe actoun.
    struct RowView: View {
        // Every RowView is provided a Row entity to draw it's data from.
        let row: Row
    
        var body: some View {
            HStack {
                Text(row.name)
                Text(row.description)
            }
        }
    }
    
    /// Simple entity containing data to be displayed in a row view.
    struct Row: Identifiable {
        let id = UUID()
        var name: String
        var description: String
    }
    

    Lastly, you asked for some preview hints. I simplified the display of the data by putting it directly in to the ListView state, but some times you need to have a @State to supply a binding to preview a view because it would normally get it's data from somewhere else (like your original RowView. That is actually stupidly difficult to inject from a preview provider where it works in a live preview. In a case like that, I will make what I call an Intermediary View" which is a simple View` struct where I make whatever state I need to run the previewed view. That struct then calls my preview view and supplies the data. I then call the "Intermediary View" from the preview provider. It is simple and works well.

    edit:

    Example "Intermediary View"

    Let's say your ListView calls for a @Binding instead of being @State:

    struct ListViewFromSheet: View {
        @Binding var rows: [Row]
        ...
    }
    

    You would then write your "Intermediary View" like this:

    struct IntermediaryListView: View {
    
        @State var rows: [Row] = [Row(name: "Name1", description: "Description1"),
                               Row(name: "Name2", description: "Description2"),
                               Row(name: "Name3", description: "Description3"),
                               Row(name: "Name4", description: "Description4"),
                               Row(name: "Name5", description: "Description5"),
                               Row(name: "Name6", description: "Description6")]
    
        var body: some View {
            ListView(rows: $rows)
        }
    }
    

    Then your preview provider simply calls the intermediary view:

    struct ListViewFromSheet_Previews: PreviewProvider {
        static var previews: some View {
            IntermediaryListView()
        }
    }
    

    That example is a bit contrived simply because if you needed data for all of those views, you would probably supply it where all of the views could get it, but it works equally well where you might have a @State var in the hierarchy that acted as a flag for state in the view and was a @Binding. Say your view had a button that changed the flag and you needed to test the results of changing that flag in your view. In setting that up in a preview provider, you need to have a @State static var to set that up. While that is not terribly, things get more complicated from there, and it is easier to embed some code in a regular View struct, and that code can generally be copied directly from the calling View.