swiftswiftui

iOS 18 drag gesture blocks scrollview


I have a basic subview that is nested inside a vertical scrollview, this subview has a drag gesture applied to it that should allow it to drag left and right. This works just fine on iOS 15-17 and does NOT block the scroll view, however on iOS 18 this drag gesture prevents scrolling (I cannot scroll the view by dragging on the subview) I can only move the scrollview outside of the frame of this subview.

I tried a normal .gesture a .simultaneousGesture and a highprioritygesture, all had the same issue. There's a ton of people with the same issue on this Apple Thread. Is there a fix that supports iOS 17 and iOS 18?

import SwiftUI

struct HomeView: View {
    var body: some View {
        FullSwipeNavigationStack {
            NavigationLink {
                MessageView().enableFullSwipePop(true)
            } label: {
                Text("Message view")
            }
        }
    }
}

struct MessageView: View {
    @State private var dragOffset: CGSize = .zero
    
    var body: some View {
        ScrollView {
            VStack(spacing: 20) {
                // Swipable view
                ZStack {
                    Rectangle().fill(Color.blue)
                    HStack {
                        Button { } label: { Text("show Image") }
                        Button { } label: { Text("show Video") }
                    }
                }
                .frame(height: 150).offset(x: dragOffset.width)
                .highPriorityGesture (
                    DragGesture()
                        .onChanged({ value in
                            if value.translation.width > 0 { // Drag the message right to reply to it
                                dragOffset = value.translation
                            }
                        })
                        .onEnded({ value in
                            withAnimation {
                                dragOffset = .zero
                            }
                        })
                )
                .onLongPressGesture(minimumDuration: .infinity) {
                    // Nothing here
                } onPressingChanged: { starting in
                    if starting {
                        // Show custom context menu in 1 second if still holding
                    }
                }
                
                // Other rectangles
                ForEach(1..<10) { index in
                    Rectangle().fill(Color.gray).frame(height: 150)
                        .overlay(Text("Rectangle \(index + 1)").foregroundColor(.white).font(.headline))
                }
            }.padding()
        }
    }
}


// IGNORE everything below this, unless you want to see how full swipe pop works

struct FullSwipeNavigationStack<Content: View>: View {
    @ViewBuilder var content: Content
    @State private var customGesture: UIPanGestureRecognizer = {
        let gesture = UIPanGestureRecognizer()
        gesture.name = UUID().uuidString
        gesture.isEnabled = false
        return gesture
    }()
    var body: some View {
        NavigationStack {
            content
                .background {
                    AttachGestureView(gesture: $customGesture)
                }
        }
        .environment(\.popGestureID, customGesture.name)
        .onReceive(NotificationCenter.default.publisher(for: .init(customGesture.name ?? "")), perform: { info in
            if let userInfo = info.userInfo, let status = userInfo["status"] as? Bool {
                customGesture.isEnabled = status
            }
        })
    }
}

extension View {
    @ViewBuilder
    func enableFullSwipePop(_ isEnabled: Bool) -> some View {
        self
            .modifier(FullSwipeModifier(isEnabled: isEnabled))
    }
}

fileprivate struct PopNotificationID: EnvironmentKey {
    static var defaultValue: String?
}

fileprivate extension EnvironmentValues {
    var popGestureID: String? {
        get {
            self[PopNotificationID.self]
        }
        
        set {
            self[PopNotificationID.self] = newValue
        }
    }
}

fileprivate struct FullSwipeModifier: ViewModifier {
    var isEnabled: Bool
    @Environment(\.popGestureID) private var gestureID
    func body(content: Content) -> some View {
        if #available(iOS 17.0, *){
            content
               .onChange(of: isEnabled, initial: true) { oldValue, newValue in
                   guard let gestureID = gestureID else { return }
                   NotificationCenter.default.post(name: .init(gestureID), object: nil, userInfo: [
                       "status": newValue
                   ])
               }
               .onAppear {
                   guard let gestureID = gestureID else { return }
                   NotificationCenter.default.post(name: .init(gestureID), object: nil, userInfo: [
                       "status": isEnabled
                   ])
               }
               .onDisappear(perform: {
                   guard let gestureID = gestureID else { return }
                   NotificationCenter.default.post(name: .init(gestureID), object: nil, userInfo: [
                       "status": false
                   ])
               })
        } else {
            content
                .onAppear {
                    guard let gestureID = gestureID else { return }
                    NotificationCenter.default.post(name: .init(gestureID), object: nil, userInfo: [
                        "status": isEnabled
                    ])
                }
                .onChange(of: isEnabled) { newValue in
                    guard let gestureID = gestureID else { return }
                    NotificationCenter.default.post(name: .init(gestureID), object: nil, userInfo: [
                        "status": newValue
                    ])
                }
                .onDisappear {
                    guard let gestureID = gestureID else { return }
                    NotificationCenter.default.post(name: .init(gestureID), object: nil, userInfo: [
                        "status": false
                    ])
                }
        }
    }
}

fileprivate struct AttachGestureView: UIViewRepresentable {
    @Binding var gesture: UIPanGestureRecognizer
    
    func makeUIView(context: Context) -> UIView {
        return UIView()
    }
    
    func updateUIView(_ uiView: UIView, context: Context) {
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
            if let parentViewController = uiView.parentViewController {
                if let navigationController = parentViewController.navigationController {
                    if let _ = navigationController.view.gestureRecognizers?.first(where: { $0.name == gesture.name }) {
                        print("Already Attached")
                    } else {
                        navigationController.addFullSwipeGesture(gesture)
                        print("Attached")
                    }
                }
            }
        }
    }
}

fileprivate extension UINavigationController {
    func addFullSwipeGesture(_ gesture: UIPanGestureRecognizer) {
        guard let gestureSelector = interactivePopGestureRecognizer?.value(forKey: "targets") else { return }
        
        gesture.setValue(gestureSelector, forKey: "targets")
        view.addGestureRecognizer(gesture)
    }
}

fileprivate extension UIView {
    var parentViewController: UIViewController? {
        sequence(first: self) {
            $0.next
        }.first(where: { $0 is UIViewController}) as? UIViewController
    }
}

Solution

  • I wrote an extensive answer on this topic, it may be useful to read.

    Although my answer mentioned using a .simultaneousGesture and a .highPriorityGesture, after some more testing, I think using just a .highPriorityGesture may suffice:

    .highPriorityGesture(
        TapGesture() // <- required
            .onEnded {
                //Tap action
                print("tap triggered") // <- regular tap moves here
            }
            .exclusively(before: swipeGesture) // <- required
    )
    

    Here's the full code to try:

    import SwiftUI
    
    struct DragGestureScrollView: View {
        
        @GestureState private var dragOffset: CGSize = .zero
        
        var body: some View {
            
            //Define the drag gesture
            let swipeGesture = DragGesture(minimumDistance: 0)
                .updating($dragOffset) { gesture, offset, transaction in
                    if abs(gesture.translation.width) > abs(gesture.translation.height) {
                        offset = gesture.translation
                    }
                }
            
            ScrollView(.vertical) {
                VStack(spacing: 20) {
                    // First rectangle with drag gesture
                    Rectangle()
                        .fill(Color.blue)
                        .frame(height: 150)
                        .overlay(
                            Text("Draggable rectangle")
                                .foregroundColor(.white)
                                .font(.headline)
                        )
                        .offset(x: dragOffset.width)
                        .highPriorityGesture(
                            TapGesture() // <- required
                                .onEnded {
                                    //Tap action
                                    print("tap triggered") // <- regular tap moves here
                                }
                                .exclusively(before: swipeGesture) // <- required
                        )
                    
                    // Other rectangles
                    ForEach(1..<10) { index in
                        Rectangle()
                            .fill(Color.gray)
                            .frame(height: 150)
                            .overlay(
                                Text("Rectangle \(index + 1)")
                                    .foregroundColor(.white)
                                    .font(.headline)
                            )
                    }
                }
                .padding()
            }
        }
    }
    
    #Preview {
        DragGestureScrollView()
    }
    

    Notes:

    UPDATE 1:

    Here's another example with a slightly different approach, for when the Rectangle may contain buttons and a context menu:

    import SwiftUI
    
    struct DragGestureScrollView: View {
        
        var body: some View {
            
            ScrollView(.vertical) {
                VStack(spacing: 20) {
                    // First rectangle with drag gesture
                    DragRowButtonView()
                    
                    // Other rectangles
                    ForEach(1..<10) { index in
                        Rectangle()
                            .fill(Color.gray)
                            .frame(height: 150)
                            .overlay(
                                Text("Rectangle \(index + 1)")
                                    .foregroundColor(.white)
                                    .font(.headline)
                            )
                    }
                }
                .padding()
            }
        }
    }
    
    struct DragRowButtonView: View {
        
        @GestureState private var dragOffset: CGSize = .zero
        
        var body: some View {
            
            let swipeGesture = DragGesture(minimumDistance: 0)
                .updating($dragOffset) { gesture, offset, transaction in
                    if abs(gesture.translation.width) > abs(gesture.translation.height) {
                        offset = gesture.translation
                    }
                }
            
            ZStack {
                
                Color.blue
                
                HStack {
                    Button {
                        print("Button 1 tapped")
                    } label: {
                        Text("Button 1")
                    }
                    
                    Button {
                        print("Button 2 tapped")
                    } label: {
                        Text("Button 2")
                    }
                }
                .foregroundStyle(.blue)
                .tint(.white)
                .buttonStyle(.borderedProminent)
                
                Text("*Long press for context menu")
                    .font(.caption)
                    .foregroundStyle(.white)
                    .frame(maxHeight: .infinity, alignment: .bottom)
                    .padding()
            }
            .frame(height: 150)
            .contextMenu {
                Button {
                    print("Context button tapped")
                } label: {
                    Text("Context Button")
                }
            }
            .offset(x: dragOffset.width)
            .gesture(
                TapGesture()
                    .exclusively(before: swipeGesture)
            )
            .simultaneousGesture(swipeGesture, including: .none)
        }
    }
    
    #Preview {
        DragGestureScrollView()
    }
    

    enter image description here

    UPDATE 2:

    After much testing, it turns out that maybe simply adding a minimumDistance = 20 parameter to the DragGesture() may be enough (tested in Xcode Previews with iOS 18.1 simulator).

    In my previous tests, I had inconsistent results with this method. But I am curious what others are getting based on the code below.

    This version is based on the OP's code update that includes a more complete use case.

    @Ahmed, note I switched to a simple NavigationStack, which gives you by default a swipe-back gesture (when swiping from the left edge of the screen).

    import SwiftUI
    
    struct SwipeGestureHomeView: View {
        var body: some View {
            NavigationStack {
                NavigationLink {
                    SwipeGestureListView()
                } label: {
                    Text("Go to Messages")
                }
            }
        }
    }
    
    struct SwipeGestureListView: View {
        
        @State private var dragOffset: CGSize = .zero
        @State private var isScrolling = false
        @State private var isSwiping = false
        
        var body: some View {
            
            ScrollView {
                VStack(spacing: 20) {
                    
                    // Swipable view
                    SwipeGestureMessageView()
                        .onLongPressGesture(minimumDuration: 1) {
                            print("Should show context menu")
                        } onPressingChanged: { starting in
                            if starting {
                                // Show custom context menu in 1 second if still holding
                            }
                        }
                    
                    // Other rectangles
                    ForEach(1..<10) { index in
                        Rectangle().fill(Color.gray).frame(height: 150)
                            .overlay(Text("Rectangle \(index + 1)").foregroundColor(.white).font(.headline))
                    }
                }
                .padding()
            }
        }
    }
    
    struct SwipeGestureMessageView: View {
        
        @State private var dragOffset: CGSize = .zero
        
        var body: some View {
            
            //
            let swipeGesture = DragGesture(minimumDistance: 20)
                .onChanged({ value in
                    if value.translation.width > 0 { // Drag the message right to reply to it
                        dragOffset = value.translation
                    }
                })
                .onEnded({ value in
                    withAnimation {
                        dragOffset = .zero
                    }
                })
            
            ZStack {
                Rectangle()
                    .fill(Color.blue)
                
                HStack {
                    Button("Show Image") {
                        print("Show Image action")
                    }
                    
                    Button("Show Video") {
                        print("Show Video action")
                    }
                }
                .foregroundStyle(.blue)
                .tint(.white)
                .buttonStyle(.borderedProminent)
                
                Text("*Long press for context menu (console)")
                    .font(.caption)
                    .foregroundStyle(.white)
                    .frame(maxHeight: .infinity, alignment: .bottom)
                    .padding()
            }
            .offset(x: dragOffset.width)
            .highPriorityGesture(swipeGesture)
            .frame(height: 150)
        }
    }
    
    #Preview {
        SwipeGestureHomeView()
    }