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
}
}
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:
If your gesture always resets the offsets when the gesture ends, you can use .updating
with a @GestureState
which automatically resets itself on gesture end. The code above uses this method for setting/resetting dragOffset
.
The drag gesture is defined separately, for convenience, but it could be part of the .highPriorityGesture
.
Although not shown in your original code, if your Rectangle was the label of a Button
, you'd have to move the button logic/actions inside the TapGesture
action, or it will otherwise be ignored due to the .highPriorityGesture
.
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()
}
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()
}