diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..ca2d981 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,20 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + # test: + # runs-on: macos-latest + # steps: + # - uses: actions/checkout@v3 + # - run: swift test + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: norio-nomura/action-swiftlint@3.2.1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3b29812 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8c864ac --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Łukasz Rutkowski + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..10754d8 --- /dev/null +++ b/Package.swift @@ -0,0 +1,29 @@ +// swift-tools-version: 5.6 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "LongPressButton", + platforms: [.iOS(.v13)], + products: [ + // Products define the executables and libraries a package produces, and make them visible to other packages. + .library( + name: "LongPressButton", + targets: ["LongPressButton"]) + ], + dependencies: [ + // Dependencies declare other packages that this package depends on. + // .package(url: /* package url */, from: "1.0.0"), + ], + targets: [ + // Targets are the basic building blocks of a package. A target can define a module or a test suite. + // Targets can depend on other targets in this package, and on products in packages this package depends on. + .target( + name: "LongPressButton", + dependencies: []), + .testTarget( + name: "LongPressButtonTests", + dependencies: ["LongPressButton"]) + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..c99142e --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# LongPressButton + +[![CI](https://github.com/Tunous/LongPressButton/actions/workflows/main.yml/badge.svg)](https://github.com/Tunous/LongPressButton/actions/workflows/main.yml) + +A SwiftUI button that initiates action on tap or long press. + +## Usage + +Create `LongPressButton` similar to how you would create a regular `Button` passing it a title and action to perform on long press. You can also optionally pass an action for regular tap. + +```swift +LongPressButton("Tap or long press me") { + // Long pressed +} action: { + // Tapped +} +``` + +Additionally you can configure minimum long press duration, maximum finger travel distance or provide custom label. + +```swift +LongPressButton(minimumDuration: 0.5, maximumDistance: 10) { + // Long pressed +} action: { + // Tapped +} label: { + Image(systemName: "plus") +} +``` + + +## Installation + +### Swift Package Manager + +Add the following to the dependencies array in your "Package.swift" file: + +```swift +.package(url: "https://github.com/Tunous/LongPressButton.git", .upToNextMajor(from: "1.0.0")) +``` + +Or add [https://github.com/Tunous/LongPressButton.git](https://github.com/Tunous/LongPressButton.git), to the list of Swift packages for any project in Xcode. + +## Credits + +[Supporting Both Tap and Long Press on a Button in SwiftUI](https://steipete.com/posts/supporting-both-tap-and-longpress-on-button-in-swiftui/) by Peter Steinberger - Great article with few potential solution on how to create button with long press action. Unfortunately none of them worked correctly for my use case. diff --git a/Sources/LongPressButton/LongPressButton.swift b/Sources/LongPressButton/LongPressButton.swift new file mode 100644 index 0000000..20939b8 --- /dev/null +++ b/Sources/LongPressButton/LongPressButton.swift @@ -0,0 +1,128 @@ +import SwiftUI + +/// A control that initiates action on tap or long press. +public struct LongPressButton: View { + private let minimumDuration: TimeInterval + private let maximumDistance: CGFloat + private let longPressAction: () -> Void + private let action: (() -> Void)? + private let label: Label + + @State private var didLongPress = false + @State private var longPressTask: Task? + + public var body: some View { + Button(action: performActionIfNeeded) { + label + } + .onLongPressGesture( + maximumDistance: maximumDistance, + perform: {}, + onPressingChanged: handleLongPress(isPressing:) + ) + } + + private func performActionIfNeeded() { + longPressTask?.cancel() + if didLongPress { + didLongPress = false + } else { + action?() + } + } + + private func handleLongPress(isPressing: Bool) { + longPressTask?.cancel() + guard isPressing else { return } + didLongPress = false + longPressTask = Task { + do { + try await Task.sleep(nanoseconds: UInt64(minimumDuration * 1_000_000_000)) + } catch { + return + } + await MainActor.run { + didLongPress = true + longPressAction() + } + } + } +} + +extension LongPressButton { + + /// Creates a long press button that displays a custom label. + /// + /// - Parameters: + /// - minimumDuration: The minimum duration of the long press that must elapse before the gesture succeeds. + /// - maximumDistance: The maximum distance that the fingers or cursor performing the long press can move before + /// the gesture fails. + /// - longPressAction: The action to perform when the user long presses the button. + /// - action: The action to perform when the user taps the button. + /// - label: A view that describes the purpose of the button’s action. + public init( + minimumDuration: TimeInterval = 0.5, + maximumDistance: CGFloat = 10, + longPressAction: @escaping () -> Void, + action: (() -> Void)? = nil, + @ViewBuilder label: () -> Label + ) { + self.minimumDuration = minimumDuration + self.maximumDistance = maximumDistance + self.longPressAction = longPressAction + self.action = action + self.label = label() + } + + /// Creates a long press button that generates its label from a localized string key. + /// + /// - Parameters: + /// - titleKey: The key for the button’s localized title, that describes the purpose of the button’s action. + /// - minimumDuration: The minimum duration of the long press that must elapse before the gesture succeeds. + /// - maximumDistance: The maximum distance that the fingers or cursor performing the long press can move before + /// the gesture fails. + /// - longPressAction: The action to perform when the user long presses the button. + /// - action: The action to perform when the user taps the button. + public init( + _ titleKey: LocalizedStringKey, + minimumDuration: TimeInterval = 0.5, + maximumDistance: CGFloat = 10, + longPressAction: @escaping () -> Void, + action: (() -> Void)? = nil + ) where Label == Text { + self.init( + minimumDuration: minimumDuration, + maximumDistance: maximumDistance, + longPressAction: longPressAction, + action: action + ) { + Text(titleKey) + } + } + + /// Creates a long press button that generates its label from a string. + /// + /// - Parameters: + /// - title: A string that describes the purpose of the button’s action. + /// - minimumDuration: The minimum duration of the long press that must elapse before the gesture succeeds. + /// - maximumDistance: The maximum distance that the fingers or cursor performing the long press can move before + /// the gesture fails. + /// - longPressAction: The action to perform when the user long presses the button. + /// - action: The action to perform when the user taps the button. + public init( + _ title: S, + minimumDuration: TimeInterval = 0.5, + maximumDistance: CGFloat = 10, + longPressAction: @escaping () -> Void, + action: (() -> Void)? = nil + ) where Label == Text { + self.init( + minimumDuration: minimumDuration, + maximumDistance: maximumDistance, + longPressAction: longPressAction, + action: action + ) { + Text(title) + } + } +} diff --git a/Tests/LongPressButtonTests/LongPressButtonTests.swift b/Tests/LongPressButtonTests/LongPressButtonTests.swift new file mode 100644 index 0000000..5d2cda0 --- /dev/null +++ b/Tests/LongPressButtonTests/LongPressButtonTests.swift @@ -0,0 +1,5 @@ +import XCTest +@testable import LongPressButton + +final class LongPressButtonTests: XCTestCase { +}