├── .gitignore
├── Resources
└── Demo.gif
├── .swiftpm
└── xcode
│ └── package.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ └── IDEWorkspaceChecks.plist
├── Package.swift
├── LICENSE
├── Sources
└── SwiftUIDelayedGesture
│ ├── Internal
│ └── DelayModifier.swift
│ └── API
│ └── DelayedGesture.swift
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | /*.xcodeproj
5 | xcuserdata/
6 |
--------------------------------------------------------------------------------
/Resources/Demo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ciaranrobrien/SwiftUIDelayedGesture/HEAD/Resources/Demo.gif
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 6.2
2 |
3 | import PackageDescription
4 |
5 | let package = Package(
6 | name: "SwiftUIDelayedGesture",
7 | platforms: [
8 | .iOS(.v14),
9 | .watchOS(.v7)
10 | ],
11 | products: [
12 | .library(name: "SwiftUIDelayedGesture", targets: ["SwiftUIDelayedGesture"])
13 | ],
14 | targets: [
15 | .target(name: "SwiftUIDelayedGesture", dependencies: [])
16 | ]
17 | )
18 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Ciaran O'Brien
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Sources/SwiftUIDelayedGesture/Internal/DelayModifier.swift:
--------------------------------------------------------------------------------
1 | /**
2 | * SwiftUIDelayedGesture
3 | * Copyright (c) Ciaran O'Brien 2025
4 | * MIT license, see LICENSE file for details
5 | */
6 |
7 | import SwiftUI
8 |
9 | internal struct DelayModifier: ViewModifier {
10 | @StateObject private var state = DelayState()
11 |
12 | var action: () -> Void
13 | var delay: TimeInterval
14 |
15 | func body(content: Content) -> some View {
16 | Button(action: action) {
17 | content
18 | }
19 | .buttonStyle(DelayButtonStyle(delay: delay))
20 | .accessibilityRemoveTraits(.isButton)
21 | .environmentObject(state)
22 | .disabled(state.disabled)
23 | }
24 | }
25 |
26 |
27 | private struct DelayButtonStyle: ButtonStyle {
28 | @EnvironmentObject private var state: DelayState
29 |
30 | var delay: TimeInterval
31 |
32 | func makeBody(configuration: Configuration) -> some View {
33 | configuration.label
34 | .onChange(of: configuration.isPressed) { isPressed in
35 | state.onIsPressed(isPressed, delay: delay)
36 | }
37 | }
38 | }
39 |
40 |
41 | @MainActor
42 | private final class DelayState: ObservableObject {
43 | @Published private(set) var disabled = false
44 |
45 | func onIsPressed(_ isPressed: Bool, delay: TimeInterval) {
46 | workItem.cancel()
47 |
48 | if isPressed {
49 | workItem = DispatchWorkItem { [weak self] in
50 | guard let self else { return }
51 |
52 | self.objectWillChange.send()
53 | self.disabled = true
54 | }
55 |
56 | DispatchQueue.main.asyncAfter(deadline: .now() + max(delay, 0), execute: workItem)
57 | } else {
58 | disabled = false
59 | }
60 | }
61 |
62 | private var workItem = DispatchWorkItem(block: {})
63 | }
64 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # SwiftUI Delayed Gesture
2 |
3 | SwiftUI view modifiers to use gestures inside `ScrollView` and `List`.
4 |
5 | 
6 |
7 | ## .delayedGesture()
8 | Sequences a gesture with a long press and attaches the result to the view, which results in the gesture only receiving events after the long press succeeds.
9 |
10 | ### Usage
11 | Use this view modifier *instead of* `.gesture()` to delay a gesture:
12 |
13 | ```swift
14 | ScrollView {
15 | FooView()
16 | .delayedGesture(someGesture, delay: 0.2)
17 | }
18 | ```
19 |
20 | ### Parameters
21 | * `gesture`: A gesture to attach to the view.
22 | * `mask`: A value that controls how adding this gesture to the view affects other gestures recognized by the view and its subviews.
23 | * `delay`: A value that controls the duration of the long press that must elapse before the gesture can be recognized by the view.
24 | * `action`: An action to perform if a tap gesture is recognized before the long press can be recognized by the view.
25 |
26 | ## .delayedInput()
27 | Attaches a long press gesture to the view, which results in gestures with a lower precedence only receiving events after the long press succeeds.
28 |
29 | ### Usage
30 | Use this view modifier *before* `.gesture()` to delay a gesture:
31 |
32 | ```swift
33 | ScrollView {
34 | FooView()
35 | .delayedInput(delay: 0.2)
36 | .gesture(someGesture)
37 | }
38 | ```
39 |
40 | ### Parameters
41 | * `delay`: A value that controls the duration of the long press that must elapse before lower precedence gestures can be recognized by the view.
42 | * `action`: An action to perform if a tap gesture is recognized before the long press can be recognized by the view.
43 |
44 | ## Requirements
45 |
46 | * iOS 14.0+, watchOS 7.0+
47 | * Xcode 26.0+
48 |
49 | ## Installation
50 |
51 | * Install with [Swift Package Manager](https://developer.apple.com/documentation/xcode/adding_package_dependencies_to_your_app).
52 | * Import `SwiftUIDelayedGesture` to start using.
53 |
--------------------------------------------------------------------------------
/Sources/SwiftUIDelayedGesture/API/DelayedGesture.swift:
--------------------------------------------------------------------------------
1 | /**
2 | * SwiftUIDelayedGesture
3 | * Copyright (c) Ciaran O'Brien 2025
4 | * MIT license, see LICENSE file for details
5 | */
6 |
7 | import SwiftUI
8 |
9 | public extension View {
10 | /// Sequences a gesture with a long press and attaches the result to the view,
11 | /// which results in the gesture only receiving events after the long press
12 | /// succeeds.
13 | ///
14 | /// Use this view modifier *instead* of `.gesture` to delay a gesture:
15 | ///
16 | /// ScrollView {
17 | /// FooView()
18 | /// .delayedGesture(someGesture, delay: 0.2)
19 | /// }
20 | ///
21 | /// - Parameters:
22 | /// - gesture: A gesture to attach to the view.
23 | /// - mask: A value that controls how adding this gesture to the view
24 | /// affects other gestures recognized by the view and its subviews.
25 | /// - delay: A value that controls the duration of the long press that
26 | /// must elapse before the gesture can be recognized by the view.
27 | /// - action: An action to perform if a tap gesture is recognized
28 | /// before the long press can be recognized by the view.
29 | func delayedGesture(
30 | _ gesture: T,
31 | including mask: GestureMask = .all,
32 | delay: TimeInterval = 0.25,
33 | onTapGesture action: @escaping () -> Void = {}
34 | ) -> some View {
35 | self.modifier(DelayModifier(action: action, delay: delay))
36 | .gesture(gesture, including: mask)
37 | }
38 |
39 | /// Attaches a long press gesture to the view, which results in gestures with a
40 | /// lower precedence only receiving events after the long press succeeds.
41 | ///
42 | /// Use this view modifier *before* `.gesture` to delay a gesture:
43 | ///
44 | /// ScrollView {
45 | /// FooView()
46 | /// .delayedInput(delay: 0.2)
47 | /// .gesture(someGesture)
48 | /// }
49 | ///
50 | /// - Parameters:
51 | /// - delay: A value that controls the duration of the long press that
52 | /// must elapse before lower precedence gestures can be recognized by
53 | /// the view.
54 | /// - action: An action to perform if a tap gesture is recognized
55 | /// before the long press can be recognized by the view.
56 | func delayedInput(
57 | delay: TimeInterval = 0.25,
58 | onTapGesture action: @escaping () -> Void = {}
59 | ) -> some View {
60 | modifier(DelayModifier(action: action, delay: delay))
61 | }
62 | }
63 |
--------------------------------------------------------------------------------