Reputation: 113
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.
Upvotes: 8
Views: 2545
Reputation: 1557
@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.
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).
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()
}
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.
Upvotes: 6
Reputation: 113
After trying different things, the only way that didn't require major changes on my app was to use DragGesture(minimumDistance: 20.0)
. I tried different values and it seems that values up to minimumDistance: 15
don't work correctly.
The documentation for DragGesture
is as follows:
@MainActor @preconcurrency
init(
minimumDistance: CGFloat = 10,
coordinateSpace: some CoordinateSpaceProtocol = .local
)
So, it is likely that ScrollView
in SwiftUI 6/Xcode 16 has a similar threshold for detecting "scrolling". In previous versions of SwiftUI, it seems that ScrollView
always had a lower threshold than the default value for DragGesture
.
The following snippet of code (with minimumDistance: 20.0
) seems to work for my use case. I also tried minimumDistance: 15.0
but it seemed to randomly choose between scroll or drag.
Edit: Code snippet updated to a reproducible example.
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(
DragGesture(minimumDistance: 20) // Added minimumDistance
.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
}
}
}
}
}
Scroll View
struct ExperimentView: View {
var body: some View {
ScrollView {
LazyVStack {
ForEach(1...100, id: \.self) { i in
RowItem(item: i)
.background(Color.red)
}
}
}
}
}
Note: Using simultaneousGesture
also works but it requires proper handling of TapGesture
. If not done correctly, the Tap
and Drag
are triggered simultaneously.
Upvotes: 3