├── .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 | ![Demo](./Resources/Demo.gif "Demo") 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 | --------------------------------------------------------------------------------