├── .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 | 
2 | [](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 | [](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 |
--------------------------------------------------------------------------------