iosswiftforeachswiftuieditmode

SwiftUI make ForEach List row properly clickable for edition in EditMode


Description

I have this following code:

NavigationView {
    List {
        ForEach(someList) { someElement in
            NavigationLink(destination: someView()) {
                someRowDisplayView()
            } //: NavigationLink
        } //: ForEach
    } //: List
} //: NavigationView

Basically, it displays a dynamic list of class objects previously filled by user input. I have an "addToList()" function that allows users to add some elements (let's say name, email, whatever) and once the user confirms it adds the element to this list. Then I have a view that displays the list through a ForEach and then makes a NavigationLink of each element as indicated in the code example above.

Once clicked on an element of this list, the user should be properly redirected to a destination view as the NavigationLink implies. All this is working fine.

What I want

Here's where I face an issue: I want the user to be able to edit the content of a row of this list without having to delete the row and re-add another one.

I decided to use the EditMode() feature of SwiftUI. So this is what I came up with:

@State private var editMode = EditMode.inactive
[...]
NavigationView {
    List {
        ForEach(someList) { someElement in
            NavigationLink(destination: someView()) {
                someRowDisplayView()
            } //: NavigationLink
        } //: ForEach
    } //: List
    .toolbar {
        ToolbarItem(placement: .navigationBarLeading) {
            EditButton()
        }
    }
    .environment(\.editMode, $editMode)
} //: NavigationView

The EditMode is properly triggered when I click on the Edit button. But I noticed that the list row is not clickable in edit mode, which is fine because I do not want it to follow the NavigationLink while in edit mode.

Though what I want is that the user is either redirected to a view that allows editing of the tapped row, or better, that an edition sheet is presented to the user.

What I have tried

Since I couldn't tap the row in edition mode, I have tried several tricks but none of them concluded as I wanted. Here are my tries.

.simultaneousGesture

I tried to add a .simultaneousGesture modified to my NavigationLink and toggle a @State variable in order to display the edition sheet.

@State private var isShowingEdit: Bool = false
[...]
.simultaneousGesture(TapGesture().onEnded{
    isShowingEdit = true
})
.sheet(isPresented: $isShowingEdit) {
    EditionView()
}

This simply does not work. It seems that this .simultaneousGesture somehow breaks the NavigationLink, the tap succeeds like once out of five times.

It doesn't work even by adding a condition on the edit mode, like:

if (editMode == .active) {
    isShowingEdit = true
}

In non-edition mode the NavigationLink is still bugged. But I have noticed that once in edition mode, it kind of does what I wanted.

Modifier condition extension on View

After the previous failure my first thought was that I needed the tap gesture to be triggered only in edit mode, so that in non-edit mode the view doesn't even know that it has a tap gesture.

I decided to add an extension to the View structure in order to define conditions to modifiers (I found this code sample somewhere on Stackoverflow):

extension View {
    @ViewBuilder func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
        if condition {
            transform(self)
        } else {
            self
        }
    }
}

Then in my main code, instead of the previously tried .simultaneousGesture modifier, I added this to NavigationLink:

.if(editMode == .active) { view in
    view.onTapGesture {
        isShowingEdit = true
    }
}

And it worked, I was able to display this edition view once a user taps on a row while in edition mode and the normal non-edition mode still worked as it properly triggered the NavigationLink.

Remaining issues

But something bothered me, it didn't feel like a natural or native behavior. Once tapped, the row didn't show any feedback like it shows in non-edition mode when following the NavigationLink: it doesn't highlight even for a very short time.
The tap gesture modifier simply executes the code I have asked without animation, feedback or anything.

An even better solution would be something that prevents me from applying such conditional modifiers and extensions to the View structure as I surely prefer a more natural and better method if this is possible.

I'm new to Swift so maybe there is a lot easier or better solution and I'm willing to follow the best practices.

How could I manage to accomplish what I want in this situation?
Thank you for your help.

Additional information

I am currently using the .onDelete and .onMove implementations on the List alongside with the SwiftUI edition mode and I want to keep using them.
I am developing the app for minimum iOS 14, using Swift language and SwiftUI framework.
If you need more code samples or better explanations please feel free to ask and I will edit my question.

Minimal working example

Asked by Yrb.

import SwiftUI
import PlaygroundSupport

extension View {
    @ViewBuilder func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
        if condition {
            transform(self)
        } else {
            self
        }
    }
}

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

let someList: [SomeList] = [
    SomeList(name: "row1"),
    SomeList(name: "row2"),
    SomeList(name: "row3")
]

struct ContentView: View {
    @State private var editMode = EditMode.inactive
    @State private var isShowingEditionSheet: Bool = false
    
    var body: some View {
        NavigationView {
            List {
                ForEach(someList) { someElement in
                    NavigationLink(destination: EmptyView()) {
                        Text(someElement.name)
                    } //: NavigationLink
                    .if(editMode == .active) { view in
                        view.onTapGesture {
                            isShowingEditionSheet = true
                        }
                    }
                } //: ForEach
                .onDelete { (indexSet) in }
                .onMove { (source, destination) in }
            } //: List
            .toolbar {
                ToolbarItem(placement: .navigationBarLeading) {
                    EditButton()
                }
            }
            .environment(\.editMode, $editMode)
            .sheet(isPresented: $isShowingEditionSheet) {
                Text("Edition sheet")
            }
        } //: NavigationView
    }
}

PlaygroundPage.current.setLiveView(ContentView())

Solution

  • As we discussed in the comments, your code does everything in your list except highlight the row when the disclosure chevron is tapped. To change the background color of a row, you use .listRowBackground(). To make just one row highlight, you need to make the row identifiable off of the id in your array in the ForEach. You then have an optional @State variable to hold the value of the row id, and set the row id in your .onTapGesture. Lastly, you use a DispatchQueue.main.asyncAfter() to reset the variable to nil after a certain time. This gives you a momentary highlight.

    However, once you go this route, you have to manage the background highlighting for your NavigationLink as well. This brings its own complexity. You need to use the NavigationLink(destination:,isActive:,label:) initializer, create a binding with a setter and getter, and in the getter, run your highlight code as well.

    struct ContentView: View {
        @State private var editMode = EditMode.inactive
        @State private var isShowingEditionSheet: Bool = false
        @State private var isTapped = false
        @State private var highlight: UUID?
    
        var body: some View {
            NavigationView {
                List {
                    ForEach(someList) { someElement in
                        // You use the NavigationLink(destination:,isActive:,label:) initializer
                        // Then create your own binding for it
                        NavigationLink(destination: EmptyView(), isActive: Binding<Bool>(
                            get: { isTapped },
                            // in the set, you can run other code
                            set: {
                                isTapped = $0
                                // Set highlight to the row you just tapped
                                highlight = someElement.id
                                // Reset the row id to nil
                                DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
                                    highlight = nil
                                }
                            }
                        )) {
                            Text(someElement.name)
                        } //: NavigationLink
                        // identify your row
                        .id(someElement.id)
                        .if(editMode == .active) { view in
                            view.onTapGesture {
                                // Set highlight to the row you just tapped
                                highlight = someElement.id
                                // Reset the row id to nil
                                DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
                                    highlight = nil
                                }
                                isShowingEditionSheet = true
                            }
                        }
                        .listRowBackground(highlight == someElement.id ? Color(UIColor.systemGray5) : Color(UIColor.systemBackground))
                    } //: ForEach
                    .onDelete { (indexSet) in }
                    .onMove { (source, destination) in }
                } //: List
    
                .toolbar {
                    ToolbarItem(placement: .navigationBarLeading) {
                        EditButton()
                    }
                }
                .environment(\.editMode, $editMode)
                .sheet(isPresented: $isShowingEditionSheet) {
                    Text("Edition sheet")
                }
            } //: NavigationView
        }
    }