@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:
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).
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.
Adding a .highPriorityGesture(DragGesture())
after the .simultaneousGesture(DragGesture())
breaks it again as described in point #1 above.
Adding a .highPriorityGesture(TapGesture())
after the .simultaneousGesture(DragGesture())
has the same outcome as described in point #2 above.
Specifiying a minimumDistance
parameter for DragGesture()
seemed to work in some cases, but inconsistently, making it an unreliable solution.
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.
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:
Declare and configure the DragGesture
individually, as a property (constant or variable depending on what makes sense for you)
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).
Add the drag gesture as a .simultaneousGesture
modifier.
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).
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.
.highPriorityGesture
compared to iOS 17 that would explain this breaking behaviour. I look forward to developments on this. I will update here if I find anything myself. – TsunamiScrollView
takes aminimumDistance of 10.0
before it detects scrolling. Prior to Xcode 16, it seems the value was lower than the default value of10.0
inDragGesture()
. So, I tried usingDragGesture(minimumDistance: 20.0)
and it seems to work without affecting any functionality on my end. But it might not work for someone looking for a more sensitive threshold. – General