skip to Main Content

I have a navigation link and I need a different behavior when its label (MyView) is tapped depending on the edit mode (or any other condition):

  1. If we are not in edit mode, I want to trigger the navigation link and show the DetailView with the selected model.
  2. If we are in edit mode, I don’t want to trigger the navigation link and show an EditingView in a modal sheet instead.

Here’s a way to implement this that I came up with:

NavigationLink(tag: model, selection: $displayedItem) {
    DetailView(model: model)
} label: {
    if editMode == .active {
        MyView()
            .onTapGesture {
                editingModel = model
            }
    } else {
        MyView()
    }
}
.sheet(item: $editingModel) { model in
    EditingView(model: model)
}

The problem with this approach is that the views in the if- and the else-branch have not the same type (due to the onTapGesture modifier) and SwiftUI doesn’t recognize them as the same view. Thus, animations cannot be interpolated and don’t work properly. Also, MyView always loses its state each time editMode is toggled.

(Here’s a great explanation from Chris Eidhof on why that happens: https://www.objc.io/blog/2021/08/24/conditional-view-modifiers/)

So I went ahead and moved the if-statement inside the onTapGesture modifier as follows so that I don’t have two different MyViews:

NavigationLink(tag: model, selection: $displayedItem) {
    DetailView(model: model)
} label: {
    MyView()
        .onTapGesture {
            if editMode == .active { // moved
                editingModel = model
            }                        // moved
        }
    }
}
.sheet(item: $editingModel) { model in
    EditingView(model: model)
}

Problem with this is that now requirement #1 doesn’t work anymore: The onTapGesture completely swallow the tap gesture and thus the navigation link is never trigged to show the DetailView. Makes sense.

Now my question is:

How can I get the desired behavior without any of these downsides?

4

Answers


  1. Chosen as BEST ANSWER

    Building on George's suggestion, I ended up with the following custom modifier:

    extension View {
        func onTapGestureIf(_ condition: Bool, closure: @escaping () -> ()) -> some View {
            self.allowsHitTesting(condition)
                .onTapGesture {
                    closure()
                }
        }
    }
    

    Works like a charm and this way, I don't have to specify the same condition editMode == .active twice (single source of truth). Highly recommended. 😉

    MyView()
        .onTapGestureIf(editMode == .active) {
            editingCounter = counter
        }
    

  2. In short, you want to change:

    if editMode == .active {
        MyView()
            .onTapGesture {
                editingModel = model
            }
    } else {
        MyView()
    }
    

    Into:

    MyView()
        .allowsHitTesting(editMode == .active)
        .onTapGesture {
            editingModel = model
        }
    

    This fixes the issue because now the onTapGesture is only triggered when it can actually listen to touches. It can only trigger when editMode == .active, because otherwise the hit testing is disabled.


    Full example:

    struct ContentView: View {
        @State private var displayedItem: String?
        @State private var editingModel: EditingModel?
        @State private var editMode: EditMode = .inactive
    
        var body: some View {
            NavigationView {
                List {
                    Button("Edit mode: (editMode == .active ? "active" : "inactive")") {
                        if editMode == .active {
                            editMode = .inactive
                        } else {
                            editMode = .active
                        }
                    }
    
                    NavigationLink(tag: "model", selection: $displayedItem) {
                        Text("DetailView")
                    } label: {
                        if editMode == .active {
                            MyView()
                                .onTapGesture {
                                    editingModel = EditingModel(tag: "model")
                                }
                        } else {
                            MyView()
                        }
                    }
                    .sheet(item: $editingModel) { model in
                        Text("EditingView: (model.tag)")
                    }
                }
            }
        }
    }
    
    struct MyView: View {
        var body: some View {
            Text("MyView")
                .frame(maxWidth: .infinity, alignment: .leading)
                .contentShape(Rectangle())
        }
    }
    
    struct EditingModel: Identifiable {
        var id: String { tag }
        let tag: String
    }
    

    Changing the inner label bit to:

    MyView()
        .allowsHitTesting(editMode == .active)
        .onTapGesture {
            editingModel = EditingModel(tag: "model")
        }
    
    Login or Signup to reply.
  3. Use Group to wrap onTapGesture. That’s it.

    extension View {
      public func onTapGesture(isOn: Bool, count: Int = 1, perform action: @escaping () -> Void) -> some View {
        Group {
          if isOn {
            self.onTapGesture(count: count, perform: action)
          } else {
            self
          }
        }
      }
    }
    
    Login or Signup to reply.
  4. you can use custom modifier.

    struct ContentView: View {
    @State private var displayedItem: String?
    @State private var editingModel: EditingModel?
    @State private var editMode: EditMode = .inactive
    var body: some View {
        NavigationView {
            List {
                Button("Edit mode: (editMode == .active ? "active" : "inactive")") {
                    if editMode == .active {
                        editMode = .inactive
                    } else {
                        editMode = .active
                    }
                }
                
                NavigationLink(tag: "model", selection: $displayedItem) {
                    Text("DetailView")
                } label: {
                    MyView()
                        .modifier(conditionalTapGesture(editMode: $editMode, editingModel: $editingModel))
                }
                .sheet(item: $editingModel) { model in
                    Text("EditingView: (model.tag)")
                }
            }
        }
    }
    

    }

    struct conditionalTapGesture : ViewModifier {
        @Binding var editMode: EditMode
        @Binding var editingModel: EditingModel?
        @ViewBuilder func body(content: Content) -> some View {
            if editMode == .active {
                content.onTapGesture {
                    editingModel = EditingModel(tag: "model")
                }
            }
        }
    }
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search