Search code examples
swiftuiswiftui-scrollviewios18xcode16

ScrollView not working when DragGesture is enabled in subview


Ever since Xcode 16, whenever there is a subview with a horizontal DragGesture inside a vertical ScrollView, horizontal drag gesture is detected on the subview but any scrolling up/down is not detected. Here's an example code that will not scroll but will recognize the horizontal gesture.

Both .gesture(DragGesture()) and .highPriorityGesture(DragGesture()) aren't working.

Row Item View

struct RowItem: View {
    let item: Int
    @State var offsetWidth: CGFloat = 0.0
    
    var body: some View {
        HStack {
            Text("Row \(item)")
            Spacer()
        }
        .padding()
        .background(Color.gray)
        .offset(x: offsetWidth)
        .gesture( // Issue starts here
            DragGesture()
                .onChanged { gesture in
                    let width = gesture.translation.width
                    
                    if -100..<0 ~= width {
                        if self.offsetWidth != -100 {
                            self.offsetWidth = width
                        }
                    } else if width < -100 {
                        self.offsetWidth = -100
                    }
                }
                .onEnded { _ in
                    if self.offsetWidth > -50 {
                        self.offsetWidth = .zero
                    } else {
                        self.offsetWidth = -100
                    }
                }
        )
        .onChange(of: offsetWidth) { newVal in
            Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { _ in
                withAnimation {
                    offsetWidth = 0.0
                }
            }
        }
    }
}

ScrollView

struct ExperimentView: View {
    var body: some View {
        ScrollView {
            LazyVStack {
                ForEach(1...100, id: \.self) { i in
                    RowItem(item: i)
                    .background(Color.red)
                }
            }
        }
    }
}

Edit: This is also happening with List.

I tried using .simultaneousGesture(DragGesture()), but it causes the DragGesture to be recognized simultaneously with TapGesture, and it is not an ideal behaviour.

This particular issue is only noticed when the app is built on Xcode 16. It is not affecting an older version built on Xcode 15 that is running on iOS 18.

I found a thread on Apple's developer forum that talks about this but no fixes have been suggested.


Solution

  • @Shri, I think i finally managed to figure out a proper fix for the gesture issues in iOS 18.

    Thank you for taking the time to put together the reproducible example, I used it for testing and as the base for the demo below.

    To recap, the issues were:

    1. Horizontal drag gestures on subviews inside a vertical scrolling view (ScrollView, List, etc.) when using .gesture and .highPriorityGesture modifiers are detected on the subview but any scrolling up/down (vertical) is not detected (if initial contact is within the area of the subview specifically).

    2. Using .simultaneousGesture(DragGesture()) (instead of .gesture or .highPriorityGesture restores vertical scrolling but causes the DragGesture() to be recognized simultaneously with TapGesture() causing both vertical and horizontal scrolling to occur at the same time - which is not ideal behaviour.

    3. Adding a .highPriorityGesture(DragGesture()) after the .simultaneousGesture(DragGesture()) breaks it again as described in point #1 above.

    4. Adding a .highPriorityGesture(TapGesture()) after the .simultaneousGesture(DragGesture()) has the same outcome as described in point #2 above.

    5. Specifiying a minimumDistance parameter for DragGesture() seemed to work in some cases, but inconsistently, making it an unreliable solution.

    6. This particular issue is only noticed when the app is built on Xcode 16. It is not affecting an older version built on Xcode 15 that is running on iOS 18.

    7. Similarly, this particular issue is also noticed when the app is built on Xcode 16 running on iOS 18, but NOT when app is built on Xcode 16 running iOS 17.5.


    I spent a great deal of time trying various parameters like including, excluding for GestureMask for all gestures and their combinations, without success.

    I tried using states and gesture states and applying gestures conditionally or using parameters like isEnabled, but no luck.

    I did end up finding a working solution which required additional bindings to be passed around and some additional logic in the main view that would disable or enable scrolling based on initial point of contact and the direction of the gesture. That wasn't as flexible for my needs and although it was working, I wanted something simpler.

    I had also previously tried a number of combinations with .simultaneousGesture and modifiers like .simultaneously, .sequenced and .exclusively, mostly around using DragGesture, but without the desired outcome.

    That is, until I found one that worked:

    .simultaneousGesture(dragGesture)
    .highPriorityGesture(
        tapGesture
            .exclusively(before: dragGesture) 
    )
    

    I don't know how, given the number of combinations I tried previously, I didn't find this before, but it could be due to how I was using them (configured inline, within .simultaneousGesture, rather than a separate property as shown below).

    So the solution steps are:

    1. Declare and configure the DragGesture individually, as a property (constant or variable depending on what makes sense for you)

    2. If your view also has logic for regular taps (as shown in the code below), do the same for TapGesture (configure it as property with whatever logic is needed).

    3. Add the drag gesture as a .simultaneousGesture modifier.

    4. Add the tap gesture as a .highPriorityGesture modifier using the .exclusively(before:) method so the tap happens exclusively before the drag gesture (some more notes on this below).

    5. Drink a beer to celebrate your app working as it did before.

    Here's the full reproducible example that incorporates the fix:

    
    import SwiftUI
    
    //MARK: - Main content view
    
    struct ExperimentGestureView: View {
        
        //State values
        @State private var sourceItem: Int?
        
        //Computed properties
        var status: String {
            if let sourceItem = sourceItem {
                return String(sourceItem)
            } else {
                return "None"
            }
        }
        
        //Body
        var body: some View {
            
            //Status
            Text("Tapped row: \(status)")
            
            ScrollView {
                LazyVStack {
                    ForEach(1...20, id: \.self) { index in
                        
                        //Constant for varying row color - for beautification
                        let hue = Angle(degrees: Double(index) * 10)
                        
                        //Row view
                        ExperimentGestureRowItem(item: index, sourceItem: $sourceItem)
                            .hueRotation(hue)
                            .background(Color.red)
                            .clipShape(Capsule())
                    }
                }
            }
            .resetOnScroll($sourceItem) //custom modifier for iOS 18+ that resets sourceItem on scroll
            .contentMargins(.horizontal, 40) //side padding to allow testing scrolling outside a row item
            .scrollIndicators(.hidden)
        }
    }
    
    
    //MARK: - Row item view
    
    struct ExperimentGestureRowItem: View {
        
        //Parameters
        let item: Int
        @Binding var sourceItem: Int?
        
        //State values
        @State private var offsetWidth: CGFloat = 0.0
        @State private var itemID: Int?
        
        //Body
        var body: some View {
            
            //Drag gesture that reveals the background (and sets a binding identifying itself as the affected row
            let dragGesture = DragGesture()
                .onChanged { gesture in
                    let width = gesture.translation.width
                    
                    if -100..<0 ~= width {
                        if self.offsetWidth != -100 {
                            self.offsetWidth = width
                        }
                    } else if width < -100 {
                        self.offsetWidth = -100
                    }
                    
                    //Update the binding to indicate the affected row/card/item
                    sourceItem = item
                }
                .onEnded { _ in
                    if self.offsetWidth > -50 {
                        self.offsetWidth = .zero
                    } else {
                        self.offsetWidth = -100
                    }
                }
            
            //Logic for simple tag gesture that resets offset if any row is tapped
            let tapGesture = TapGesture()
                .onEnded{
                    withAnimation {
                        resetOffsetWidth()
                    }
                    sourceItem = item
                }
            
            //Layout
            HStack {
                Text("Row \(item)")
                Spacer()
            }
            .padding()
            .background(Color.teal)
            .foregroundStyle(Color.white)
            .clipShape(Capsule())
            .offset(x: offsetWidth)
            .simultaneousGesture(dragGesture)
            .highPriorityGesture(
                tapGesture
                    .exclusively(before: dragGesture) // <- Here, this is needed to restore desired scrolling behaviour
            )
            .onChange(of: sourceItem) {oldValue, newValue in
                if newValue == nil || newValue != item {
                    withAnimation {
                        resetOffsetWidth()
                    }
                }
            }
        }
        
        //Convenience function for resetting offset
        private func resetOffsetWidth() {
            self.offsetWidth = .zero
        }
    }
    
    
    //MARK: - View extension
    
    extension View {
        
        //Modifier conditionally applied for iOS 18+ that resets the object passed as parameter on scroll
        func resetOnScroll<T>(_ binding: Binding<T?>) -> some View {
            Group {
                if #available(iOS 18.0, *) {
                    self
                        .onScrollPhaseChange({ _, newPhase in
                            binding.wrappedValue = nil
                        })
                }
                else {
                    self
                }
            }
        }
    }
    
    
    //MARK: - Preview
    
    #Preview {
        ExperimentGestureView()
    }
    

    Notes:

    • The solution above is based around the reproducible example you provided, plus some minor bells and whistles.

    • Added a couple of states and parameters to allow for the offset to be reset when clicking the row, clicking any other row or dragging another row.

    • Added some padding on the sides for testing (since vertical scrolling before did work if initial drag started outside the area of the row)

    • Added some color variation for visual gratification

    • Optionally, and to bring it more inline with how horizontal swiping works in system-wide, like swiping in list of conversations in Messages, I used the new .onScrollPhaseChange of iOS 18 to reset the offset as soon as the page is scrolled. This modifier is added as a view extension and applied only if iOS 18 is available, which allows the very same code to also work on iOS 17+ (and maybe older versions, not tested).

    • It's important for gestures to be declared separately, outside of the respective modifiers like .simultaneousGesture and .highPriorityGesture, so they can be referenced and used as shown. The same applies to any regular tap gestures that you may have now added using .onTapGesture - primarily because the .highPriorityGesture, unless used as shown, may break functionality that would otherwise work if defined in a .onTapGesture.

    Below is an example regarding the last point. In the following code, the logic to reset the row offset with a single tap on any row is added using the .onTapGesture modifier:

    .simultaneousGesture(dragGesture)
    .highPriorityGesture(
        TapGesture()
            .exclusively(before: dragGesture) 
    )
    .onTapGesture {
        withAnimation {
            resetOffsetWidth()
        }
        sourceItem = item
    }
    

    This, however, will cause the reset on tap to break, since the high priority gesture will replace the logic defined via .onTapGesture. The scrolling will work as intended but the tap to reset will not.


    That's about it, let me know if this works out for you.

    enter image description here