1

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.

  • I would like the user to know and see which row was tapped and I would like it to highlight just as it does when when tapping on the NavigationLink: simply make it look like the row was actually "tapped". I hope it makes sense.

  • I would also like the code to be triggered when the user taps on the whole row and not only the parts where the text is visible. Currently, tapping on an empty field of the row does nothing. It has to have text on it.

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())
AdamSwift
  • 75
  • 6
  • Welcome to StackOverflow! Thank you for taking the tour. While you didn't quite give us a minimal, reproducible example, you have given enough to get started. In a situation like this, I would generally edit the list data in its detail view that you follow with the `NavigationLink`. Done this way, the row does highlight as it is selected. You can then throw your sheet up if you want to edit on the next page with the same kind of navigation bar button that triggers the editing sheet to show. You will just need to pass a binding so the changes come back to you `someList` array. – Yrb Nov 23 '21 at 23:23
  • Thank you for your comment. I have thought of this as well but the issue is that my destination of the **NavigationLink** isn't a detail view but something completely different. Actually my list is a list of servers and tapping on one of them leads to another view displaying list of files, or whatever. The specific row edition does not really fit there. – AdamSwift Nov 23 '21 at 23:32
  • Then I would use a `Button` in your rows. If you are not in edit mode, you can [trigger a `NavigationLink` with it}(https://stackoverflow.com/a/63367285/7129318), or if you are in edit mode, then you can have a sheet appear to do your editing. – Yrb Nov 23 '21 at 23:37
  • This could have been a funny solution but again the issue is that when I'm in edit mode, the button is not tappable so the **action** part is never executed no matter what's inside. – AdamSwift Nov 24 '21 at 00:07
  • Use an `@State isEditing: Bool` instead of `.environment(\.editMode, $editMode)`. – Yrb Nov 24 '21 at 00:52
  • Are you suggesting that I use my own custom edit mode instead of SwiftUI builtin edit mode ? Like, I would make my own Edit button that triggers a **@State** variable. In that case I have the following issue: I am currently using the **.onDelete** and **.onMove** implementations on the List alongside with the SwiftUI edition mode, I would loose this possibility if I make my own custom edition mode, right? – AdamSwift Nov 24 '21 at 07:54
  • You can use both. Trigger them with different buttons and make them mutually exclusive, though your UI will have to be clear as to what happens. – Yrb Nov 24 '21 at 13:52
  • That would make me use two different edit modes, the swift built-in edit mode for deletion and moving and my own edit mode for row edition, which means two Edit buttons which is kind of inconvenient and I've seen apps implementing everything as I wanted so I keep thinking this is possible somehow. I'm not sure that I want to make two edition modes like this but thanks for the idea. – AdamSwift Nov 24 '21 at 20:03
  • Which apps? Most legacy ones are using UIKit with Swift or Obj-C. – Yrb Nov 24 '21 at 20:06
  • Probably yes. I'm not saying they are using SwiftUI, but I'm kind of surprised that this simple thing cannot be done with this framework, maybe this will be improved in future? – AdamSwift Nov 24 '21 at 20:21
  • @Yrb as you mentioned I have added a minimal working example in my question at the end, as a Swift playground. – AdamSwift Nov 24 '21 at 20:40
  • Testing your code, it seems to work. The disclosure chevron brings up the sheet. – Yrb Nov 26 '21 at 15:32
  • Thanks. As I said, it works but there is no animation or highlighting. It doesn't look like the row is actually tapped, and also, only tapping on text area works. Tapping on empty area on this same row doesn't do anything. Those are my two issues. – AdamSwift Nov 26 '21 at 15:37
  • Do you mean while in edit mode? There won't be as that would be confusing to the user. The row is no longer tappable, because each of the individual buttons are. You can't have both. You could force it yourself by changing it in your `.onTapGesture`, but I am not sure that adds anything from a user perspective as the sheet will show immediately off of the chevron. The highlighting shows that navigation is about to take place. Since you aren't navigation in edit mode, no highlight. – Yrb Nov 26 '21 at 15:42
  • There is like half a second between the tap and the completion of the sheet showing up to the user. During this time, the tapped row doesn't change color or doesn't dim or anything like that. There is no feedback that the user tapped on it and it looks kind of disturbing for me. How could I at least dim the row a bit when tapped in edit mode? – AdamSwift Nov 26 '21 at 15:46

1 Answers1

1

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
    }
}
Yrb
  • 8,103
  • 2
  • 14
  • 44