Search code examples
iosswiftuigestureswiftui-listios17

SwiftUI: SwipeActions not working when tap gesture is used elsewhere, FocusState unreliable


In my iOS app, I have a VStack that consists of a view with a List and a view with a TextInput:

    var body: some View {
        NavigationView {
            ScrollViewReader { scrollProxy in
                VStack() {
                    NoteListView(notes: notes)
                        .onTapGesture { hideKeyboard() }
                    NoteInputView(scrollProxy: scrollProxy)
                }
            }
            .navigationBarTitle(Text("Notes"))
        }
    }
    func hideKeyboard() {
        UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
    }

On my NoteListView, I have attached a .swipeAction to delete Notes:

        List(notes) { note in
            NotePreview(note: note)
                .swipeActions(edge: .leading, allowsFullSwipe: true) {
                    Button(role: .destructive) {
                        delete(note)
                    } label: {
                        Label("Delete", systemImage: "trash")
                    }
                    .tint(.red)
                }
                
        }

While this hides the keyboard successfully, it also prevents the tap gesture to be recognized by the delete button on the swipe action. Using .simultaneousGesture does not fix this issue, the only way to get the delete button to work when tapping is to remove any tap gestures attached to parent views—adding .highPriorityGesture to the button does not fire, either. This seems like a SwiftUI bug to me.

Attempted Workaround—works unpredictably?

Instead of using a VStack, I decided to move to a ZStack that fills the entire screen whenever the keyboard is showing. When the keyboard is showing, a Spacer captures tap events. When the keyboard is not showing, not taps should be captured:

    var body: some View {
        NavigationView {
            ScrollViewReader { scrollProxy in
                ZStack() {
                    NoteListView(notes: notes)
                    NoteInputView(scrollProxy: scrollProxy)
                }
            }
            .navigationBarTitle(Text("Notes"))
        }
    }

In my NoteInputView I now have @FocusState to track whether the TextInput has focus:

struct NoteInputView: View {
    ...
    
    @FocusState var focusInputField: Bool
    var body: some View {
        ZStack(alignment: .bottom) {
            if(focusInputField) {
                Spacer()
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                    .contentShape(Rectangle())
                    .simultaneousGesture(TapGesture().onEnded({ _ in
                        focusInputField = false
                        print("I'm still standing yeah yeah yeah")
                        print(focusInputField)
                    })).onTap
            } else {
                Spacer()
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
            }

            
            HStack() {
                if(focusInputField) {
                    Button("", systemImage: "keyboard.chevron.compact.down") {
                        focusInputField = false
                    }
                }
                TextField("Enter a quick note...", text: $newNoteContent, axis: .vertical)
                    .lineLimit(1...5)
            }
        }
    }
}

However, this works unreliably—sometimes, the Spacer capturing the tap will still print I'm still standing yeah yeah yeah despite focusInputField being false. So far, I have not been able to reliably reproduce when the Spacer remains or when it disappears.

Would be glad to hear other workarounds, or feedback on why this might be working unreliably.


Solution

  • While I still believe the original problem (tap not getting through to the SwipeAction button) to be an Apple bug, at least I was able to fix the workaround by removing the else-conditional spacer.

    In the ContentView, ensure that the input sits at the bottom:

    var body: some View {
        NavigationView {
            ScrollViewReader { scrollProxy in
                ZStack() {
                    NoteListView(notes: notes)
                    NoteInputView(scrollProxy: scrollProxy)
                        .frame(maxHeight: .infinity, alignment: .bottom)
                }
            }
            .navigationBarTitle(Text("Notes"))
        }
    }
    

    Then, in NoteInputView, remove the else-condition:

    struct NoteInputView: View {
        ...
        
        @FocusState var focusInputField: Bool
        var body: some View {
            ZStack(alignment: .bottom) {
                if(focusInputField) {
                    Spacer()
                        .frame(maxWidth: .infinity, maxHeight: .infinity)
                        .contentShape(Rectangle())
                        .simultaneousGesture(TapGesture().onEnded({ _ in
                            focusInputField = false
                            print("I'm still standing yeah yeah yeah")
                            print(focusInputField)
                        })).onTap
                } 
                
                HStack() {
                    if(focusInputField) {
                        Button("", systemImage: "keyboard.chevron.compact.down") {
                            focusInputField = false
                        }
                    }
                    TextField("Enter a quick note...", text: $newNoteContent, axis: .vertical)
                        .lineLimit(1...5)
                }
            }
        }
    }
    

    The Spacer with the tap gesture will now correctly show only when the TextInput has focus. As to why it remains in the view with my original workaround is anyone's guess, but this does the trick.