├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── LICENSE.md ├── Package.swift ├── README.md └── Sources └── Feedback ├── Audio ├── Audio.swift ├── AudioFeedback.swift └── AudioPlayer.swift ├── Feedback.swift ├── Feedback ├── AnyFeedback.swift └── Feedback+Binding.swift ├── Flash └── Flash.swift ├── Haptic ├── PatternHaptic.swift └── SystemHaptic.swift ├── Miscellaneous └── DelayedFeedback.swift └── Support ├── Backport.swift └── OnChange.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Shaps Benkau 4 | 5 | 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: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | 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. -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Feedback", 8 | platforms: [ 9 | .iOS(.v13), 10 | .macOS(.v10_15) 11 | ], 12 | products: [ 13 | .library( 14 | name: "Feedback", 15 | targets: ["Feedback"] 16 | ), 17 | ], 18 | targets: [ 19 | .target(name: "Feedback"), 20 | ] 21 | ) 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![ios](https://img.shields.io/badge/iOS-0C62C7) 2 | [![swift](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fshaps80%2FFeedback%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/shaps80/Feedback) 3 | 4 | # Feedback 5 | 6 | A SwiftUI library for conveniently adding haptic, audio and other feedback to your view's and state changes. 7 | 8 | ## Sponsor 9 | 10 | Building useful libraries like these, takes time away from my family. I build these tools in my spare time because I feel its important to give back to the community. Please consider [Sponsoring](https://github.com/sponsors/shaps80) me as it helps keep me working on useful libraries like these 😬 11 | 12 | You can also give me a follow and a 'thanks' anytime. 13 | 14 | [![Twitter](https://img.shields.io/badge/Twitter-@shaps-4AC71B)](http://twitter.com/shaps) 15 | 16 | ## Features 17 | 18 | - Familiar API (follow transition and animation API styles) 19 | - Haptics 20 | - Audio 21 | - Screen flash 22 | 23 | ## Usage 24 | 25 | **Imperative feedback** 26 | 27 | ```swift 28 | struct ContentView: View { 29 | var body: some View { 30 | Button { 31 | withFeedback( 32 | .haptic(.selection) 33 | .combined( 34 | .audio(.keyboardPress) 35 | ) 36 | ) { 37 | // state change 38 | } 39 | } label: { 40 | Text("Submit") 41 | } 42 | } 43 | } 44 | ``` 45 | 46 | **State observation** 47 | 48 | ```swift 49 | struct ContentView: View { 50 | @State private var toggle: Bool = false 51 | 52 | var body: some View { 53 | Toggle("Toggle", isOn: $toggle.feedback(.haptic(.selection))) 54 | } 55 | } 56 | ``` 57 | 58 | ## Installation 59 | 60 | You can install manually (by copying the files in the `Sources` directory) or using Swift Package Manager (**preferred**) 61 | 62 | To install using Swift Package Manager, add this to the `dependencies` section of your `Package.swift` file: 63 | 64 | `.package(url: "https://github.com/shaps80/Feedback.git", .upToNextMinor(from: "1.0.0"))` 65 | -------------------------------------------------------------------------------- /Sources/Feedback/Audio/Audio.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// Represents an audio URL 4 | public struct Audio: Identifiable, Hashable { 5 | public var id: String { url.path } 6 | public let url: URL 7 | 8 | init(url: URL) { 9 | self.url = url 10 | } 11 | 12 | init?(name: String, bundle: Bundle) { 13 | guard let url = bundle.url(forResource: name, withExtension: nil) else { return nil } 14 | self.url = url 15 | } 16 | 17 | /// Returns a new instance from the specified URL 18 | /// - Parameter url: The URL of the audio file 19 | public static func custom(url: URL) -> Self { 20 | .init(url: url) 21 | } 22 | 23 | /// Returns a new instance from a resource in the specified bundle 24 | /// - Parameters: 25 | /// - name: The name of the resource 26 | /// - bundle: The bundle where the resource is located 27 | public static func custom(named name: String, in bundle: Bundle = .main) -> Self? { 28 | .init(name: name, bundle: bundle) 29 | } 30 | } 31 | 32 | public extension Audio { 33 | static let busyToneANSI = Self( 34 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/busy_tone_ansi.caf") 35 | ) 36 | 37 | static let busyToneCEPT = Self( 38 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/busy_tone_cept.caf") 39 | ) 40 | 41 | static let callWaitingToneANSI = Self( 42 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/call_waiting_tone_ansi.caf") 43 | ) 44 | 45 | static let callWaitingToneCEPT = Self( 46 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/call_waiting_tone_cept.caf") 47 | ) 48 | 49 | static let ctCallWaiting = Self( 50 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/ct-call-waiting.caf") 51 | ) 52 | 53 | static let dtmf0 = Self( 54 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/dtmf-0.caf") 55 | ) 56 | 57 | static let dtmf1 = Self( 58 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/dtmf-1.caf") 59 | ) 60 | 61 | static let dtmf2 = Self( 62 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/dtmf-2.caf") 63 | ) 64 | 65 | static let dtmf3 = Self( 66 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/dtmf-3.caf") 67 | ) 68 | 69 | static let dtmf4 = Self( 70 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/dtmf-4.caf") 71 | ) 72 | 73 | static let dtmf5 = Self( 74 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/dtmf-5.caf") 75 | ) 76 | 77 | static let dtmf6 = Self( 78 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/dtmf-6.caf") 79 | ) 80 | 81 | static let dtmf7 = Self( 82 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/dtmf-7.caf") 83 | ) 84 | 85 | static let dtmf8 = Self( 86 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/dtmf-8.caf") 87 | ) 88 | 89 | static let dtmf9 = Self( 90 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/dtmf-9.caf") 91 | ) 92 | 93 | static let dtmfPound = Self( 94 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/dtmf-pound.caf") 95 | ) 96 | 97 | static let dtmfStar = Self( 98 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/dtmf-star.caf") 99 | ) 100 | 101 | static let endCallToneCEPT = Self( 102 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/end_call_tone_cept.caf") 103 | ) 104 | 105 | static let headphoneAudioExposureLimitExceeded = Self( 106 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/HeadphoneAudioExposureLimitExceeded.caf") 107 | ) 108 | 109 | static let healthNotificationUrgent = Self( 110 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/HealthNotificationUrgent.caf") 111 | ) 112 | 113 | static let mediaHandoff = Self( 114 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/MediaHandoff.caf") 115 | ) 116 | 117 | static let mediaPaused = Self( 118 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/MediaPaused.caf") 119 | ) 120 | 121 | static let micMute = Self( 122 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/MicMute.caf") 123 | ) 124 | 125 | static let micUnmute = Self( 126 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/MicUnmute.caf") 127 | ) 128 | 129 | static let micUnmuteFail = Self( 130 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/MicUnmuteFail.caf") 131 | ) 132 | 133 | static let multiwayJoin = Self( 134 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/MultiwayJoin.caf") 135 | ) 136 | 137 | static let multiwayLeave = Self( 138 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/MultiwayLeave.caf") 139 | ) 140 | 141 | static let pushToTalkJoined = Self( 142 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/PushToTalkJoined.caf") 143 | ) 144 | 145 | static let pushToTalkLeft = Self( 146 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/PushToTalkLeft.caf") 147 | ) 148 | 149 | static let pushToTalkMute = Self( 150 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/PushToTalkMute.caf") 151 | ) 152 | 153 | static let pushToTalkUnmute = Self( 154 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/PushToTalkUnmute.caf") 155 | ) 156 | 157 | static let pushToTalkUnmuteFail = Self( 158 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/PushToTalkUnmuteFail.caf") 159 | ) 160 | 161 | static let ringbackToneANSI = Self( 162 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/ringback_tone_ansi.caf") 163 | ) 164 | 165 | static let ringbackToneAUS = Self( 166 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/ringback_tone_aus.caf") 167 | ) 168 | 169 | static let ringbackToneCEPT = Self( 170 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/ringback_tone_cept.caf") 171 | ) 172 | 173 | static let ringbackToneHK = Self( 174 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/ringback_tone_hk.caf") 175 | ) 176 | 177 | static let ringbackToneUK = Self( 178 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/ringback_tone_uk.caf") 179 | ) 180 | 181 | static let screenCapture = Self( 182 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/ScreenCapture.caf") 183 | ) 184 | 185 | static let screenSharingStarted = Self( 186 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/ScreenSharingStarted.caf") 187 | ) 188 | 189 | static let vcEnded = Self( 190 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/vc~ended.caf") 191 | ) 192 | 193 | static let vcInvitationAccepted = Self( 194 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/vc~invitation-accepted.caf") 195 | ) 196 | 197 | static let vcRinging = Self( 198 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/vc~ringing.caf") 199 | ) 200 | 201 | static let vcRingingWatch = Self( 202 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/vc~ringing_watch.caf") 203 | ) 204 | 205 | static let workoutCompleteAutodetect = Self( 206 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/WorkoutCompleteAutodetect.caf") 207 | ) 208 | 209 | static let workoutPaceAbove = Self( 210 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/WorkoutPaceAbove.caf") 211 | ) 212 | 213 | static let workoutPaceBelow = Self( 214 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/WorkoutPaceBelow.caf") 215 | ) 216 | 217 | static let workoutPausedAutoDetect = Self( 218 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/WorkoutPausedAutoDetect.caf") 219 | ) 220 | 221 | static let workoutResumedAutoDetect = Self( 222 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/WorkoutResumedAutoDetect.caf") 223 | ) 224 | 225 | static let workoutStartAutodetect = Self( 226 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nano/WorkoutStartAutodetect.caf") 227 | ) 228 | 229 | static let critical = Self( 230 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/3rd_party_critical.caf") 231 | ) 232 | 233 | static let accessScanComplete = Self( 234 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/access_scan_complete.caf") 235 | ) 236 | 237 | static let acknowledgmentReceived = Self( 238 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/acknowledgment_received.caf") 239 | ) 240 | 241 | static let acknowledgmentSent = Self( 242 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/acknowledgment_sent.caf") 243 | ) 244 | 245 | static let alarm = Self( 246 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/alarm.caf") 247 | ) 248 | 249 | static let beginRecord = Self( 250 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/begin_record.caf") 251 | ) 252 | 253 | static let cameraTimerCountdown = Self( 254 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/camera_timer_countdown.caf") 255 | ) 256 | 257 | static let cameraTimerFinalSecond = Self( 258 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/camera_timer_final_second.caf") 259 | ) 260 | 261 | static let connectPower = Self( 262 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/connect_power.caf") 263 | ) 264 | 265 | static let ctBusy = Self( 266 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/ct-busy.caf") 267 | ) 268 | 269 | static let ctCongestion = Self( 270 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/ct-congestion.caf") 271 | ) 272 | 273 | static let ctError = Self( 274 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/ct-error.caf") 275 | ) 276 | 277 | static let ctKeytone2 = Self( 278 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/ct-keytone2.caf") 279 | ) 280 | 281 | static let ctPathACK = Self( 282 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/ct-path-ack.caf") 283 | ) 284 | 285 | static let deviceShutdown = Self( 286 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/DeviceShutdown.caf") 287 | ) 288 | 289 | static let doorbell = Self( 290 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/Doorbell.caf") 291 | ) 292 | 293 | static let endRecord = Self( 294 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/end_record.caf") 295 | ) 296 | 297 | static let focusChangeAppIcon = Self( 298 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/focus_change_app_icon.caf") 299 | ) 300 | 301 | static let focusChangeKeyboard = Self( 302 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/focus_change_keyboard.caf") 303 | ) 304 | 305 | static let focusChangeLarge = Self( 306 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/focus_change_large.caf") 307 | ) 308 | 309 | static let focusChangeSmall = Self( 310 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/focus_change_small.caf") 311 | ) 312 | 313 | static let gotoSleepAlert = Self( 314 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/go_to_sleep_alert.caf") 315 | ) 316 | 317 | static let healthNotification = Self( 318 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/health_notification.caf") 319 | ) 320 | 321 | static let jblAmbiguous = Self( 322 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/jbl_ambiguous.caf") 323 | ) 324 | 325 | static let jblBegin = Self( 326 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/jbl_begin.caf") 327 | ) 328 | 329 | static let jblBeginShort = Self( 330 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/jbl_begin_short.caf") 331 | ) 332 | 333 | static let jblBeginShortCarplay = Self( 334 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/jbl_begin_short_carplay.caf") 335 | ) 336 | 337 | static let jblCancel = Self( 338 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/jbl_cancel.caf") 339 | ) 340 | 341 | static let jblConfirm = Self( 342 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/jbl_confirm.caf") 343 | ) 344 | 345 | static let jblNoMatch = Self( 346 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/jbl_no_match.caf") 347 | ) 348 | 349 | static let keyPressClick = Self( 350 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/key_press_click.caf") 351 | ) 352 | 353 | static let keyPressDelete = Self( 354 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/key_press_delete.caf") 355 | ) 356 | 357 | static let keyPressModifier = Self( 358 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/key_press_modifier.caf") 359 | ) 360 | 361 | static let keyboardPressClear = Self( 362 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/keyboard_press_clear.caf") 363 | ) 364 | 365 | static let keyboardPressDelete = Self( 366 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/keyboard_press_delete.caf") 367 | ) 368 | 369 | static let keyboardPressNormal = Self( 370 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/keyboard_press_normal.caf") 371 | ) 372 | 373 | static let lock = Self( 374 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/lock.caf") 375 | ) 376 | 377 | static let longLowShortHigh = Self( 378 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/long_low_short_high.caf") 379 | ) 380 | 381 | static let lowPower = Self( 382 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/low_power.caf") 383 | ) 384 | 385 | static let mailSent = Self( 386 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/mail-sent.caf") 387 | ) 388 | 389 | static let middle9ShortDoubleLow = Self( 390 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/middle_9_short_double_low.caf") 391 | ) 392 | 393 | static let multiwayInvitation = Self( 394 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/multiway_invitation.caf") 395 | ) 396 | 397 | static let navigationPop = Self( 398 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/navigation_pop.caf") 399 | ) 400 | 401 | static let navigationPush = Self( 402 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/navigation_push.caf") 403 | ) 404 | 405 | static let navigationGenericManeuver = Self( 406 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/NavigationGenericManeuver.caf") 407 | ) 408 | 409 | static let newMail = Self( 410 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/new-mail.caf") 411 | ) 412 | 413 | static let nfcScanComplete = Self( 414 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nfc_scan_complete.caf") 415 | ) 416 | 417 | static let nfcScanFailure = Self( 418 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/nfc_scan_failure.caf") 419 | ) 420 | 421 | static let paymentFailure = Self( 422 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/payment_failure.caf") 423 | ) 424 | 425 | static let paymentSuccess = Self( 426 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/payment_success.caf") 427 | ) 428 | 429 | static let paymentReceived = Self( 430 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/PaymentReceived.caf") 431 | ) 432 | 433 | static let paymentReceivedFailure = Self( 434 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/PaymentReceivedFailure.caf") 435 | ) 436 | 437 | static let photoShutter = Self( 438 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/photoShutter.caf") 439 | ) 440 | 441 | static let pinDelete = Self( 442 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/PINDelete.caf") 443 | ) 444 | 445 | static let pinDeleteAX = Self( 446 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/PINDelete_AX.caf") 447 | ) 448 | 449 | static let pinEnterDigit = Self( 450 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/PINEnterDigit.caf") 451 | ) 452 | 453 | static let pinEnterDigitAX = Self( 454 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/PINEnterDigit_AX.caf") 455 | ) 456 | 457 | static let pinSubmitAX = Self( 458 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/PINSubmit_AX.caf") 459 | ) 460 | 461 | static let pinUnexpected = Self( 462 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/PINUnexpected.caf") 463 | ) 464 | 465 | static let receivedMessage = Self( 466 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/ReceivedMessage.caf") 467 | ) 468 | 469 | static let ringerChanged = Self( 470 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/RingerChanged.caf") 471 | ) 472 | 473 | static let sentMessage = Self( 474 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/SentMessage.caf") 475 | ) 476 | 477 | static let shake = Self( 478 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/shake.caf") 479 | ) 480 | 481 | static let shortDoubleHigh = Self( 482 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/short_double_high.caf") 483 | ) 484 | 485 | static let shortDoubleLow = Self( 486 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/short_double_low.caf") 487 | ) 488 | 489 | static let shortLowHigh = Self( 490 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/short_low_high.caf") 491 | ) 492 | 493 | static let simToolkitCallDropped = Self( 494 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/SIMToolkitCallDropped.caf") 495 | ) 496 | 497 | static let simToolkitGeneralBeep = Self( 498 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/SIMToolkitGeneralBeep.caf") 499 | ) 500 | 501 | static let simToolkitNegativeACK = Self( 502 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/SIMToolkitNegativeACK.caf") 503 | ) 504 | 505 | static let simToolkitPositiveACK = Self( 506 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/SIMToolkitPositiveACK.caf") 507 | ) 508 | 509 | static let simToolkitSMS = Self( 510 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/SIMToolkitSMS.caf") 511 | ) 512 | 513 | static let smsReceived1 = Self( 514 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/sms-received1.caf") 515 | ) 516 | 517 | static let smsReceived2 = Self( 518 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/sms-received2.caf") 519 | ) 520 | 521 | static let smsReceived3 = Self( 522 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/sms-received3.caf") 523 | ) 524 | 525 | static let smsReceived4 = Self( 526 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/sms-received4.caf") 527 | ) 528 | 529 | static let smsReceived5 = Self( 530 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/sms-received5.caf") 531 | ) 532 | 533 | static let smsReceived6 = Self( 534 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/sms-received6.caf") 535 | ) 536 | 537 | static let swish = Self( 538 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/Swish.caf") 539 | ) 540 | 541 | static let tink = Self( 542 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/Tink.caf") 543 | ) 544 | 545 | static let tock = Self( 546 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/Tock.caf") 547 | ) 548 | 549 | static let tweetSent = Self( 550 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/tweet_sent.caf") 551 | ) 552 | 553 | static let ussd = Self( 554 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/ussd.caf") 555 | ) 556 | 557 | static let warsaw = Self( 558 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/warsaw.caf") 559 | ) 560 | 561 | static let webcamStart = Self( 562 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/WebcamStart.caf") 563 | ) 564 | 565 | static let wheelsOfTime = Self( 566 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/wheels_of_time.caf") 567 | ) 568 | 569 | static let anticipate = Self( 570 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/New/Anticipate.caf") 571 | ) 572 | 573 | static let bloom = Self( 574 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/New/Bloom.caf") 575 | ) 576 | 577 | static let calypso = Self( 578 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/New/Calypso.caf") 579 | ) 580 | 581 | static let chooChoo = Self( 582 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/New/Choo_Choo.caf") 583 | ) 584 | 585 | static let descent = Self( 586 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/New/Descent.caf") 587 | ) 588 | 589 | static let fanfare = Self( 590 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/New/Fanfare.caf") 591 | ) 592 | 593 | static let ladder = Self( 594 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/New/Ladder.caf") 595 | ) 596 | 597 | static let minuet = Self( 598 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/New/Minuet.caf") 599 | ) 600 | 601 | static let newsFlash = Self( 602 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/New/News_Flash.caf") 603 | ) 604 | 605 | static let noir = Self( 606 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/New/Noir.caf") 607 | ) 608 | 609 | static let sherwoodForest = Self( 610 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/New/Sherwood_Forest.caf") 611 | ) 612 | 613 | static let spell = Self( 614 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/New/Spell.caf") 615 | ) 616 | 617 | static let suspense = Self( 618 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/New/Suspense.caf") 619 | ) 620 | 621 | static let telegraph = Self( 622 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/New/Telegraph.caf") 623 | ) 624 | 625 | static let tiptoes = Self( 626 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/New/Tiptoes.caf") 627 | ) 628 | 629 | static let typewriters = Self( 630 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/New/Typewriters.caf") 631 | ) 632 | 633 | static let update = Self( 634 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/New/Update.caf") 635 | ) 636 | 637 | static let cameraShutterBurst = Self( 638 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/Modern/camera_shutter_burst.caf") 639 | ) 640 | 641 | static let cameraShutterBurstBegin = Self( 642 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/Modern/camera_shutter_burst_begin.caf") 643 | ) 644 | 645 | static let cameraShutterBurstEnd = Self( 646 | url: URL(fileURLWithPath: "/System/Library/Audio/UISounds/Modern/camera_shutter_burst_end.caf") 647 | ) 648 | } 649 | -------------------------------------------------------------------------------- /Sources/Feedback/Audio/AudioFeedback.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public extension AnyFeedback { 4 | /// Specifies feedback that plays an audio file 5 | /// - Parameter audio: The audio to play when this feedback is triggered 6 | static func audio(_ audio: Audio) -> Self { 7 | .init(AudioFeedback(audio: audio)) 8 | } 9 | } 10 | 11 | private struct AudioPlayerEnvironmentKey: EnvironmentKey { 12 | static var defaultValue: AudioPlayer = .init() 13 | } 14 | 15 | private extension EnvironmentValues { 16 | var audioPlayer: AudioPlayer { 17 | get { self[AudioPlayerEnvironmentKey.self] } 18 | set { self[AudioPlayerEnvironmentKey.self] = newValue } 19 | } 20 | } 21 | 22 | private struct AudioFeedback: Feedback, ViewModifier { 23 | @Environment(\.audioPlayer) private var player 24 | typealias Body = Never 25 | 26 | var audio: Audio 27 | 28 | init(audio: Audio) { 29 | self.audio = audio 30 | } 31 | 32 | func perform() async { 33 | do { 34 | try await player.play(audio: audio) 35 | } catch { 36 | print(error) 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/Feedback/Audio/AudioPlayer.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import AVFoundation 3 | 4 | private enum PlayerError: LocalizedError { 5 | case badUrl(Audio) 6 | var errorDescription: String? { 7 | switch self { 8 | case let .badUrl(audio): 9 | return "Couldn't play sound: \(audio.url.lastPathComponent), the URL was invalid." 10 | } 11 | } 12 | } 13 | 14 | internal final class AudioPlayer: NSObject, ObservableObject, AVAudioPlayerDelegate { 15 | private var player: AVAudioPlayer? 16 | 17 | @MainActor 18 | func play(audio: Audio) async throws { 19 | #if os(iOS) 20 | await stop() 21 | 22 | try AVAudioSession.sharedInstance().setCategory(.ambient) 23 | try AVAudioSession.sharedInstance().setActive(true) 24 | 25 | player = try AVAudioPlayer(contentsOf: audio.url) 26 | player?.delegate = self 27 | player?.play() 28 | #else 29 | #warning("macOS audio not implemented") 30 | #endif 31 | } 32 | 33 | @MainActor 34 | func stop() async { 35 | player?.stop() 36 | player = nil 37 | } 38 | 39 | #if os(iOS) 40 | @MainActor 41 | func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { 42 | Task { await stop() } 43 | } 44 | #endif 45 | } 46 | -------------------------------------------------------------------------------- /Sources/Feedback/Feedback.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import CoreHaptics 3 | 4 | /// Represents a feedback type 5 | public protocol Feedback { 6 | func perform() async 7 | } 8 | 9 | #if os(iOS) 10 | /// Returns the result of recomputing the view's body with the provided animation. 11 | /// - Parameters: 12 | /// - feedback: The feedback to perform when the body is called 13 | /// - body: The content of this value will be called alongside the feedback 14 | public func withFeedback(_ feedback: AnyFeedback = .haptic(.selection), _ body: () throws -> Result) rethrows -> Result { 15 | Task { await feedback.perform() } 16 | return try body() 17 | } 18 | #else 19 | /// Returns the result of recomputing the view's body with the provided animation. 20 | /// - Parameters: 21 | /// - feedback: The feedback to perform when the body is called 22 | /// - body: The content of this value will be called alongside the feedback 23 | public func withFeedback(_ feedback: AnyFeedback = .haptic(.haptic(intensity: 1, sharpness: 1)), _ body: () throws -> Result) rethrows -> Result { 24 | Task { await feedback.perform() } 25 | return try body() 26 | } 27 | #endif 28 | 29 | public extension View { 30 | /// Attaches some feedback to this view when the specified value changes 31 | /// - Parameters: 32 | /// - feedback: The feedback to perform when the value changes 33 | /// - value: The value to observe for changes 34 | func feedback(_ feedback: AnyFeedback, value: V) -> some View where V: Equatable { 35 | modifier(FeedbackModifier(feedback: feedback, value: value)) 36 | } 37 | } 38 | 39 | extension ModifiedContent: Feedback where Content: Feedback, Modifier: Feedback { 40 | /// Performs the specified feedback and any associated feedback (via combined) 41 | public func perform() async { 42 | async let c: Void = content.perform() 43 | async let m: Void = modifier.perform() 44 | _ = await (c, m) 45 | } 46 | } 47 | 48 | public extension Feedback { 49 | /// Combines this feedback with another 50 | /// - Parameter feedback: The feedback to combine with this feedback 51 | /// - Returns: The combined feedback 52 | func combined(with feedback: AnyFeedback) -> AnyFeedback { 53 | AnyFeedback(ModifiedContent(content: self, modifier: feedback)) 54 | } 55 | } 56 | 57 | internal struct FeedbackModifier: ViewModifier { 58 | let feedback: any Feedback 59 | let value: V 60 | 61 | func body(content: Content) -> some View { 62 | content 63 | .backport.onChange(of: value) { value in 64 | Task { await feedback.perform() } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Sources/Feedback/Feedback/AnyFeedback.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A type-erased Feedback 4 | public struct AnyFeedback: Feedback { 5 | private var haptic: Feedback 6 | 7 | /// The feedback to type-erase 8 | public init(_ haptic: Feedback) { 9 | self.haptic = haptic 10 | } 11 | 12 | /// Asks the type-erased feedback to perform 13 | public func perform() async { 14 | await haptic.perform() 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/Feedback/Feedback/Feedback+Binding.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public extension Binding { 4 | /// Specifies the feedback to perform when the binding value changes. 5 | /// - Parameter feedback: Feedback performed when the binding value changes. 6 | /// 7 | /// - Returns: A new binding. 8 | func feedback(_ feedback: AnyFeedback) -> Self { 9 | Binding( 10 | get: { wrappedValue }, 11 | set: { newValue in 12 | withFeedback(feedback) { wrappedValue = newValue } 13 | } 14 | ) 15 | } 16 | } 17 | 18 | public extension Binding where Value: BinaryFloatingPoint { 19 | /// Specifies the feedback to perform when the binding value changes. 20 | /// - Parameter feedback: Feedback performed when the binding value changes. 21 | /// - Parameter step: The step required to trigger a binding change 22 | /// 23 | /// - Returns: A new binding. 24 | func feedback(_ feedback: AnyFeedback, step: Value) -> Self { 25 | Binding( 26 | get: { wrappedValue }, 27 | set: { newValue in 28 | let oldValue = round(wrappedValue / step) * step 29 | let stepValue = round(newValue / step) * step 30 | 31 | if oldValue != stepValue { 32 | withFeedback(feedback) { wrappedValue = newValue } 33 | } else { 34 | wrappedValue = newValue 35 | } 36 | } 37 | ) 38 | } 39 | } 40 | 41 | public extension Binding where Value: BinaryInteger { 42 | /// Specifies the feedback to perform when the binding value changes. 43 | /// - Parameter feedback: Feedback performed when the binding value changes. 44 | /// - Parameter step: The step required to trigger a binding change 45 | /// 46 | /// - Returns: A new binding. 47 | func feedback(_ feedback: AnyFeedback, step: Value) -> Self { 48 | Binding( 49 | get: { wrappedValue }, 50 | set: { newValue in 51 | let oldValue = wrappedValue / step * step 52 | let stepValue = newValue / step * step 53 | 54 | if oldValue != stepValue { 55 | withFeedback(feedback) { wrappedValue = newValue } 56 | } else { 57 | wrappedValue = newValue 58 | } 59 | } 60 | ) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Sources/Feedback/Flash/Flash.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @available(iOS 14, *) 4 | public extension AnyFeedback { 5 | /// Specifies feedback that flashes the screen 6 | static var flash: Self { 7 | .init(Flash(color: .accentColor, duration: 0.15)) 8 | } 9 | 10 | /// Specifies feedback that flashes the screen 11 | /// - Parameters: 12 | /// - color: The color to use for the flash 13 | /// - duration: The animation duration for the flash 14 | static func flash(_ color: Color, duration: Double = 0.15) -> Self { 15 | .init(Flash(color: color, duration: duration)) 16 | } 17 | } 18 | 19 | @available(iOS 14, *) 20 | private struct Flash: Feedback { 21 | var color: Color 22 | var duration: Double 23 | 24 | @MainActor 25 | func perform() async { 26 | #if os(iOS) 27 | guard 28 | let scene = UIApplication.shared.connectedScenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene, 29 | let window = scene.windows.first else { return } 30 | 31 | let view = UIView() 32 | view.backgroundColor = UIColor(color) 33 | window.addSubview(view) 34 | view.frame = window.bounds 35 | view.alpha = 0 36 | view.layer.compositingFilter = filters[7] 37 | 38 | UIView.animate(withDuration: duration, delay: 0, options: [.curveEaseInOut, .allowUserInteraction]) { 39 | view.alpha = 0.85 40 | } completion: { _ in 41 | UIView.animate(withDuration: duration, delay: 0, options: [.curveEaseInOut, .allowUserInteraction, .allowAnimatedContent]) { 42 | view.alpha = 0 43 | } completion: { _ in 44 | view.removeFromSuperview() 45 | } 46 | } 47 | #else 48 | #warning("macOS flash not implemented") 49 | #endif 50 | } 51 | 52 | let filters = [ 53 | "addition", 54 | "maximum", 55 | "minimum", 56 | "multiply", 57 | "sourceAtop", 58 | "sourceIn", 59 | "sourceOut", 60 | "sourceOver" // 7 61 | ] 62 | 63 | let blendModes = [ 64 | "colorBlendMode", 65 | "colorBurnBlendMode", 66 | "colorDodgeBlendMode", 67 | "darkenBlendMode", // 3 68 | "differenceBlendMode", 69 | "divideBlendMode", 70 | "exclusionBlendMode", 71 | "hardLightBlendMode", 72 | "hueBlendMode", 73 | "lightenBlendMode", 74 | "linearBurnBlendMode", // 10 75 | "linearDodgeBlendMode", 76 | "linearLightBlendMode", 77 | "luminosityBlendMode", 78 | "multiplyBlendMode", // 14 79 | "overlayBlendMode", 80 | "pinLightBlendMode", 81 | "saturationBlendMode", 82 | "screenBlendMode", 83 | "softLightBlendMode", 84 | "subtractBlendMode", 85 | "vividLightBlendMode" // 21 86 | ] 87 | } 88 | -------------------------------------------------------------------------------- /Sources/Feedback/Haptic/PatternHaptic.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import CoreHaptics 3 | 4 | public extension AnyFeedback { 5 | /// Specifies haptic feedback 6 | /// - Parameters: 7 | /// - intensity: The intensity of the feedback 8 | /// - sharpness: The sharpness of the feedback 9 | /// - relativeTime: The relative time for the feedback, useful when combining haptic feedback 10 | /// - duration: The duration of the feedback 11 | static func haptic(intensity: Double, sharpness: Double, relativeTime: TimeInterval = 0, duration: TimeInterval = 0) -> Self { 12 | let event = HapticEvent.haptic(intensity: intensity, sharpness: sharpness, relativeTime: relativeTime, duration: duration) 13 | return .init(PatternHaptic(style: .events([event]))) 14 | } 15 | 16 | /// Specifies haptic feedback, defined by the specified events 17 | /// - Parameter events: The events representing the haptic feedback 18 | static func haptic(_ events: HapticEvent...) -> Self { 19 | .init(PatternHaptic(style: .events(events))) 20 | } 21 | 22 | /// Specifies haptic feedback, defined by the specified events 23 | /// - Parameter events: The events representing the haptic feedback 24 | static func haptic(_ events: [HapticEvent]) -> Self { 25 | .init(PatternHaptic(style: .events(events))) 26 | } 27 | 28 | /// Specifies haptic feedback, defined by the specified pattern 29 | /// - Parameter pattern: The pattern representing the haptic feedback 30 | static func haptic(_ pattern: CHHapticPattern) -> Self { 31 | .init(PatternHaptic(style: .pattern(pattern))) 32 | } 33 | 34 | /// Specifies haptic feedback, loaded from a file 35 | /// - Parameter url: The url representing the haptic feedback pattern 36 | @available(iOS 16, *) 37 | static func haptic(url: URL) -> Self { 38 | .init(PatternHaptic(style: .url(url))) 39 | } 40 | } 41 | 42 | private struct PatternHaptic: Feedback { 43 | enum Style: CustomStringConvertible { 44 | case events([HapticEvent]) 45 | case url(URL) 46 | case pattern(CHHapticPattern) 47 | 48 | var description: String { 49 | switch self { 50 | case let .events(events): 51 | return events.map { 52 | "- \($0.description)" 53 | }.joined(separator: "\n") 54 | case let .pattern(pattern): 55 | return pattern.description 56 | case let .url(url): 57 | return url.absoluteString 58 | } 59 | } 60 | } 61 | 62 | let style: Style 63 | 64 | public func perform() async { 65 | do { 66 | let engine = try CHHapticEngine() 67 | let pattern: CHHapticPattern 68 | 69 | engine.playsHapticsOnly = true 70 | 71 | switch style { 72 | case let .events(events): 73 | pattern = try CHHapticPattern(events: events.map { $0.event }, parameters: []) 74 | case let .url(url): 75 | if #available(iOS 16, macOS 13, *) { 76 | pattern = try CHHapticPattern(contentsOf: url) 77 | } else { 78 | fatalError("This should never occur since the API will be limited to iOS 16+") 79 | } 80 | case let .pattern(pat): 81 | pattern = pat 82 | } 83 | 84 | try await engine.start() 85 | 86 | let player = try engine.makePlayer(with: pattern) 87 | try player.start(atTime: CHHapticTimeImmediate) 88 | 89 | // this ensures we stop when we're done but its also 90 | // IMPORTANT since it 'captures' the engine temporarily 91 | // ensuring haptics play for the entire duration 92 | DispatchQueue.main.asyncAfter(deadline: .now() + pattern.duration) { 93 | engine.stop() 94 | } 95 | } catch { 96 | print("Haptic pattern could not be played") 97 | } 98 | } 99 | } 100 | 101 | public enum HapticEvent: CustomStringConvertible { 102 | case haptic(intensity: Double, sharpness: Double, relativeTime: TimeInterval = 0, duration: TimeInterval = 0.01) 103 | 104 | public var description: String { 105 | switch self { 106 | case let .haptic(intensity, sharpness, relativeTime, duration): 107 | return "Haptic (intensity: \(intensity), sharpness: \(sharpness), time: \(relativeTime), duration: \(duration))" 108 | } 109 | } 110 | 111 | internal var event: CHHapticEvent { 112 | switch self { 113 | case let .haptic(intensity, sharpness, relativeTime, duration): 114 | return .init( 115 | eventType: .hapticContinuous, 116 | parameters: [ 117 | CHHapticEventParameter(parameterID: .hapticIntensity, value: Float(intensity)), 118 | CHHapticEventParameter(parameterID: .hapticSharpness, value: Float(sharpness)), 119 | ], 120 | relativeTime: relativeTime, 121 | duration: duration 122 | ) 123 | } 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /Sources/Feedback/Haptic/SystemHaptic.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | #if os(iOS) 4 | public extension AnyFeedback { 5 | /// Specifies haptic feedback provided by the system 6 | /// - Parameter style: The style of the haptic feedback 7 | static func haptic(_ style: HapticStyle) -> Self { 8 | .init(SystemHaptic(style: style)) 9 | } 10 | } 11 | 12 | public extension HapticStyle { 13 | /// Used to give user feedback when a selection changes 14 | static var selection: Self { .init(style: .selection) } 15 | 16 | /// Used to give user feedback when a notification is displayed 17 | static var notification: Self { .init(style: .notification(.success)) } 18 | 19 | /// Used to give user feedback when an impact between UI elements occurs 20 | static var impact: Self { .init(style: .impact(.medium, intensity: 1)) } 21 | 22 | static func notification(_ style: NotificationStyle) -> Self { 23 | .init(style: .notification(style)) 24 | } 25 | 26 | static func impact(_ style: ImpactStyle, intensity: Double = 1) -> Self { 27 | .init(style: .impact(style, intensity: intensity)) 28 | } 29 | } 30 | 31 | private struct SystemHaptic: Feedback { 32 | var style: HapticStyle 33 | 34 | public func perform() async { 35 | switch style.style { 36 | case.selection: 37 | await UISelectionFeedbackGenerator() 38 | .selectionChanged() 39 | case let .notification(style): 40 | await UINotificationFeedbackGenerator() 41 | .notificationOccurred(style.type) 42 | case let .impact(style, intensity): 43 | await UIImpactFeedbackGenerator(style: style.style) 44 | .impactOccurred(intensity: max(0, min(1, intensity))) 45 | } 46 | } 47 | } 48 | 49 | /// The type of notification that a notification feedback generator object generates 50 | public enum NotificationStyle: String { 51 | /// A notification feedback type that indicates a task has completed successfully. 52 | case success 53 | /// A notification feedback type that indicates a task has produced a warning. 54 | case warning 55 | /// A notification feedback type that indicates a task has failed. 56 | case error 57 | 58 | #if os(iOS) 59 | var type: UINotificationFeedbackGenerator.FeedbackType { 60 | switch self { 61 | case .success: return .success 62 | case .warning: return .warning 63 | case .error: return .error 64 | } 65 | } 66 | #endif 67 | } 68 | 69 | public enum ImpactStyle: String { 70 | /// A collision between small, light user interface elements. 71 | case light 72 | /// A collision between user interface elements that are soft, exhibiting a large amount of compression or elasticity. 73 | case soft 74 | /// A collision between moderately sized user interface elements. 75 | case medium 76 | /// A collision between large, heavy user interface elements. 77 | case heavy 78 | /// A collision between user interface elements that are rigid, exhibiting a small amount of compression or elasticity. 79 | case rigid 80 | 81 | #if os(iOS) 82 | var style: UIImpactFeedbackGenerator.FeedbackStyle { 83 | switch self { 84 | case .light: return .light 85 | case .soft: return .soft 86 | case .medium: return .medium 87 | case .heavy: return .heavy 88 | case .rigid: return .rigid 89 | } 90 | } 91 | #endif 92 | } 93 | 94 | /// The style of the system haptic 95 | public struct HapticStyle: CustomStringConvertible { 96 | let style: Style 97 | } 98 | 99 | extension HapticStyle { 100 | public var description: String { 101 | style.description 102 | } 103 | 104 | enum Style: CustomStringConvertible { 105 | case selection 106 | case notification(_ style: NotificationStyle) 107 | case impact(_ style: ImpactStyle, intensity: Double = 1) 108 | 109 | var description: String { 110 | switch self { 111 | case let .impact(style, intensity): return "\(style) (\(intensity))" 112 | case let .notification(style): return "\(style)" 113 | case .selection: return "selection" 114 | } 115 | } 116 | } 117 | } 118 | #endif 119 | -------------------------------------------------------------------------------- /Sources/Feedback/Miscellaneous/DelayedFeedback.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public extension AnyFeedback { 4 | /// Defines a delay before the next feedback is performed 5 | /// - Parameter delay: The duration of the delay 6 | func delay(_ delay: Double) -> Self { 7 | .init(DelayedFeedback(duration: delay, haptic: self)) 8 | } 9 | } 10 | 11 | private struct DelayedFeedback: Feedback { 12 | let duration: Double 13 | let haptic: any Feedback 14 | 15 | public func perform() async { 16 | do { 17 | try await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000)) 18 | await haptic.perform() 19 | } catch { } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/Feedback/Support/Backport.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | internal struct Backport { 4 | let content: Wrapped 5 | init(_ content: Wrapped) { 6 | self.content = content 7 | } 8 | } 9 | 10 | extension Backport where Wrapped == Any { 11 | init(_ content: Wrapped) { 12 | self.content = content 13 | } 14 | } 15 | 16 | extension View { 17 | var backport: Backport { .init(self) } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/Feedback/Support/OnChange.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Combine 3 | 4 | internal extension Backport where Wrapped: View { 5 | @ViewBuilder 6 | func onChange(of value: Value, perform action: @escaping (Value) -> Void) -> some View { 7 | if #available(iOS 14, macOS 11, *) { 8 | content.onChange(of: value, perform: action) 9 | } else { 10 | content.modifier(ChangeModifier(value: value, action: action)) 11 | } 12 | } 13 | 14 | } 15 | 16 | private struct ChangeModifier: ViewModifier { 17 | let value: Value 18 | let action: (Value) -> Void 19 | 20 | @State var oldValue: Value? 21 | 22 | init(value: Value, action: @escaping (Value) -> Void) { 23 | self.value = value 24 | self.action = action 25 | _oldValue = .init(initialValue: value) 26 | } 27 | 28 | func body(content: Content) -> some View { 29 | content 30 | .onReceive(Just(value)) { newValue in 31 | guard newValue != oldValue else { return } 32 | action(newValue) 33 | oldValue = newValue 34 | } 35 | } 36 | } 37 | --------------------------------------------------------------------------------