craft-native 0.0.123 → 0.0.125
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/dist/android/src/index.d.ts +3 -0
- package/dist/android/src/index.js +22 -3
- package/dist/android/templates/AndroidManifest.xml.template +1 -1
- package/dist/android/templates/CraftNativeFlatList.kt.template +151 -7
- package/dist/android/templates/CraftNativeRefreshableFlatList.kt.template +29 -0
- package/dist/android/templates/MainActivity.kt.template +17 -0
- package/dist/android/templates/MainActivityNative.kt.template +1085 -84
- package/dist/android/templates/build.gradle.kts.app.template +1 -0
- package/dist/api/mobile.d.ts +250 -2
- package/dist/cli.js +3 -3
- package/dist/index.cjs +22 -1
- package/dist/index.js +22 -1
- package/dist/ios/src/index.d.ts +125 -3
- package/dist/ios/src/index.js +284 -47
- package/dist/ios/templates/CraftApp.swift +1186 -69
- package/dist/ios/templates/CraftHybrid.swift +1196 -0
- package/dist/ios/templates/CraftNativeActions.swift +310 -10
- package/dist/ios/templates/CraftNativeFlatList.swift +226 -17
- package/dist/ios/templates/CraftNativeScreen.swift +2217 -220
- package/dist/ios/templates/CraftNativeUI.swift +562 -0
- package/dist/ios/templates/Info.plist.template +2 -1
- package/dist/mobile.js +204 -0
- package/package.json +1 -1
|
@@ -6,6 +6,11 @@ import UIKit
|
|
|
6
6
|
import UserNotifications
|
|
7
7
|
|
|
8
8
|
let craftNativeCapabilityProtocolVersion = 1
|
|
9
|
+
/// URLSession's own request timeout, matching the bridge's 30-second deadline.
|
|
10
|
+
let craftNativeFetchTimeoutSeconds: TimeInterval = 30
|
|
11
|
+
/// A response body larger than this rejects with RESPONSE_TOO_LARGE rather
|
|
12
|
+
/// than being copied into JavaScriptCore as one string.
|
|
13
|
+
let craftNativeFetchMaxResponseBytes = 8 * 1024 * 1024
|
|
9
14
|
|
|
10
15
|
struct CraftNativeActionError: Error {
|
|
11
16
|
let code: String
|
|
@@ -21,12 +26,19 @@ enum CraftNativeActions {
|
|
|
21
26
|
private static let cancellationLock = NSLock()
|
|
22
27
|
private static var cancelledRequests = Set<String>()
|
|
23
28
|
private static var biometricContexts = [String: LAContext]()
|
|
29
|
+
private static var fetchTasks = [String: URLSessionDataTask]()
|
|
30
|
+
private static let fetchSession: URLSession = {
|
|
31
|
+
let configuration = URLSessionConfiguration.default
|
|
32
|
+
configuration.timeoutIntervalForRequest = craftNativeFetchTimeoutSeconds
|
|
33
|
+
configuration.timeoutIntervalForResource = craftNativeFetchTimeoutSeconds
|
|
34
|
+
return URLSession(configuration: configuration)
|
|
35
|
+
}()
|
|
24
36
|
private static var initialDeepLinkClaimed = false
|
|
25
37
|
private static var database: OpaquePointer?
|
|
26
38
|
private static let sqliteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
|
|
27
39
|
|
|
28
40
|
static func capabilities(config: CraftConfig) -> [String] {
|
|
29
|
-
var values = ["device", "clipboard", "haptics", "storage", "lifecycle"]
|
|
41
|
+
var values = ["device", "clipboard", "haptics", "storage", "lifecycle", "fetch"]
|
|
30
42
|
if config.enableBiometric { values.append("biometric") }
|
|
31
43
|
if config.enableSecureStorage { values.append("secureStorage") }
|
|
32
44
|
if config.enableLocalDatabase { values.append("database") }
|
|
@@ -44,6 +56,10 @@ enum CraftNativeActions {
|
|
|
44
56
|
guard config.enableHaptics else { return failure("CAPABILITY_DISABLED", "Haptics is disabled") }
|
|
45
57
|
triggerHaptic(style: body["style"] as? String ?? "medium")
|
|
46
58
|
return .success(true)
|
|
59
|
+
case "hapticPrepare":
|
|
60
|
+
guard config.enableHaptics else { return failure("CAPABILITY_DISABLED", "Haptics is disabled") }
|
|
61
|
+
CraftHaptics.prepare(body["kind"] as? String)
|
|
62
|
+
return .success(true)
|
|
47
63
|
case "clipboardWrite":
|
|
48
64
|
guard config.enableClipboard else { return failure("CAPABILITY_DISABLED", "Clipboard is disabled") }
|
|
49
65
|
guard let text = body["text"] as? String else {
|
|
@@ -82,6 +98,12 @@ enum CraftNativeActions {
|
|
|
82
98
|
sync = perform(action: "getDeviceInfo", body: [:], config: config)
|
|
83
99
|
case ("Haptics", "impact"):
|
|
84
100
|
sync = perform(action: "haptic", body: ["style": args.first ?? NSNull()], config: config)
|
|
101
|
+
case ("Haptics", "notification"):
|
|
102
|
+
sync = perform(action: "haptic", body: ["style": (args.first as? String) ?? "success"], config: config)
|
|
103
|
+
case ("Haptics", "selection"):
|
|
104
|
+
sync = perform(action: "haptic", body: ["style": "selection"], config: config)
|
|
105
|
+
case ("Haptics", "prepare"):
|
|
106
|
+
sync = perform(action: "hapticPrepare", body: ["kind": args.first ?? NSNull()], config: config)
|
|
85
107
|
case ("Clipboard", "write"):
|
|
86
108
|
sync = perform(action: "clipboardWrite", body: ["text": args.first ?? NSNull()], config: config)
|
|
87
109
|
case ("Clipboard", "read"):
|
|
@@ -105,6 +127,9 @@ enum CraftNativeActions {
|
|
|
105
127
|
}
|
|
106
128
|
authenticate(requestToken: requestToken, args: args, completion: completion)
|
|
107
129
|
return
|
|
130
|
+
case ("Network", "fetch"):
|
|
131
|
+
fetch(requestToken: requestToken, args: args, completion: completion)
|
|
132
|
+
return
|
|
108
133
|
case ("Lifecycle", "getState"):
|
|
109
134
|
sync = .success(currentAppState())
|
|
110
135
|
case ("DeepLinks", "getInitialURL"):
|
|
@@ -137,8 +162,91 @@ enum CraftNativeActions {
|
|
|
137
162
|
cancellationLock.lock()
|
|
138
163
|
cancelledRequests.insert(requestToken)
|
|
139
164
|
let context = biometricContexts.removeValue(forKey: requestToken)
|
|
165
|
+
let task = fetchTasks.removeValue(forKey: requestToken)
|
|
140
166
|
cancellationLock.unlock()
|
|
141
167
|
context?.invalidate()
|
|
168
|
+
task?.cancel()
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/// `fetch` for native screens: one URLSession data task per request, keyed
|
|
172
|
+
/// by the request token so the bridge deadline, `API_CANCEL` and route
|
|
173
|
+
/// teardown all cancel the transfer. Bodies are UTF-8 text both ways.
|
|
174
|
+
private static func fetch(
|
|
175
|
+
requestToken: String,
|
|
176
|
+
args: [Any],
|
|
177
|
+
completion: @escaping (Result<Any, CraftNativeActionError>) -> Void
|
|
178
|
+
) {
|
|
179
|
+
guard let options = args.first as? [String: Any],
|
|
180
|
+
let address = options["url"] as? String,
|
|
181
|
+
let url = URL(string: address),
|
|
182
|
+
let scheme = url.scheme?.lowercased(), scheme == "https" || scheme == "http",
|
|
183
|
+
url.host?.isEmpty == false else {
|
|
184
|
+
complete(requestToken, failure("INVALID_ARGUMENT", "fetch needs an absolute http or https URL"), completion)
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
let method = ((options["method"] as? String) ?? "GET").uppercased()
|
|
188
|
+
guard !method.isEmpty, method.allSatisfy({ $0.isLetter && $0.isASCII }) else {
|
|
189
|
+
complete(requestToken, failure("INVALID_ARGUMENT", "fetch method must be an HTTP token"), completion)
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
var request = URLRequest(url: url, timeoutInterval: craftNativeFetchTimeoutSeconds)
|
|
193
|
+
request.httpMethod = method
|
|
194
|
+
if let headers = options["headers"] as? [String: Any] {
|
|
195
|
+
for (name, value) in headers {
|
|
196
|
+
guard let value = value as? String else {
|
|
197
|
+
complete(requestToken, failure("INVALID_ARGUMENT", "fetch header values must be strings"), completion)
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
request.setValue(value, forHTTPHeaderField: name)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if let body = options["body"] as? String {
|
|
204
|
+
guard method != "GET", method != "HEAD" else {
|
|
205
|
+
complete(requestToken, failure("INVALID_ARGUMENT", "A GET or HEAD request cannot have a body"), completion)
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
request.httpBody = Data(body.utf8)
|
|
209
|
+
} else if let body = options["body"], !(body is NSNull) {
|
|
210
|
+
complete(requestToken, failure("INVALID_ARGUMENT", "fetch body must be a string"), completion)
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
let task = fetchSession.dataTask(with: request) { data, response, error in
|
|
215
|
+
cancellationLock.lock()
|
|
216
|
+
fetchTasks.removeValue(forKey: requestToken)
|
|
217
|
+
cancellationLock.unlock()
|
|
218
|
+
if let error = error {
|
|
219
|
+
let code = (error as? URLError)?.code == .timedOut ? "TIMEOUT" : "NETWORK_ERROR"
|
|
220
|
+
complete(requestToken, failure(code, error.localizedDescription), completion)
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
guard let http = response as? HTTPURLResponse else {
|
|
224
|
+
complete(requestToken, failure("NETWORK_ERROR", "fetch received a response that is not HTTP"), completion)
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
let body = data ?? Data()
|
|
228
|
+
guard body.count <= craftNativeFetchMaxResponseBytes else {
|
|
229
|
+
complete(requestToken, failure("RESPONSE_TOO_LARGE", "fetch responses are limited to \(craftNativeFetchMaxResponseBytes) bytes"), completion)
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
var headers: [String: String] = [:]
|
|
233
|
+
for (name, value) in http.allHeaderFields {
|
|
234
|
+
headers[String(describing: name).lowercased()] = String(describing: value)
|
|
235
|
+
}
|
|
236
|
+
complete(requestToken, .success([
|
|
237
|
+
"url": http.url?.absoluteString ?? address,
|
|
238
|
+
"status": http.statusCode,
|
|
239
|
+
"statusText": HTTPURLResponse.localizedString(forStatusCode: http.statusCode),
|
|
240
|
+
"headers": headers,
|
|
241
|
+
"redirected": http.url.map { $0 != url } ?? false,
|
|
242
|
+
"body": String(decoding: body, as: UTF8.self),
|
|
243
|
+
]), completion)
|
|
244
|
+
}
|
|
245
|
+
cancellationLock.lock()
|
|
246
|
+
let alreadyCancelled = cancelledRequests.contains(requestToken)
|
|
247
|
+
if !alreadyCancelled { fetchTasks[requestToken] = task }
|
|
248
|
+
cancellationLock.unlock()
|
|
249
|
+
if !alreadyCancelled { task.resume() }
|
|
142
250
|
}
|
|
143
251
|
|
|
144
252
|
private static func claimInitialDeepLink() -> URL? {
|
|
@@ -460,15 +568,7 @@ enum CraftNativeActions {
|
|
|
460
568
|
}
|
|
461
569
|
|
|
462
570
|
static func triggerHaptic(style: String) {
|
|
463
|
-
|
|
464
|
-
case "light": UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
|
465
|
-
case "heavy": UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
|
|
466
|
-
case "success": UINotificationFeedbackGenerator().notificationOccurred(.success)
|
|
467
|
-
case "warning": UINotificationFeedbackGenerator().notificationOccurred(.warning)
|
|
468
|
-
case "error": UINotificationFeedbackGenerator().notificationOccurred(.error)
|
|
469
|
-
case "selection": UISelectionFeedbackGenerator().selectionChanged()
|
|
470
|
-
default: UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
|
471
|
-
}
|
|
571
|
+
CraftHaptics.play(style)
|
|
472
572
|
}
|
|
473
573
|
|
|
474
574
|
static func currentAppState() -> String {
|
|
@@ -532,3 +632,203 @@ enum CraftNativeActions {
|
|
|
532
632
|
]
|
|
533
633
|
}
|
|
534
634
|
}
|
|
635
|
+
|
|
636
|
+
// MARK: - Synchronous reads for a screen's first frame
|
|
637
|
+
|
|
638
|
+
extension CraftNativeActions {
|
|
639
|
+
/// The JSON text `craft.storage` keeps under `key`, or nil. The same store
|
|
640
|
+
/// as the asynchronous Storage module, read without a bridge round trip.
|
|
641
|
+
static func storageJSON(forKey key: String) -> String? {
|
|
642
|
+
guard let key = validKey(key), let data = UserDefaults.standard.data(forKey: storagePrefix + key) else { return nil }
|
|
643
|
+
return String(decoding: data, as: UTF8.self)
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/// Stores JSON text under `key` (nil removes it). False for an invalid key
|
|
647
|
+
/// or text that is not JSON, which the asynchronous `get` could not read.
|
|
648
|
+
@discardableResult
|
|
649
|
+
static func setStorageJSON(_ json: String?, forKey key: String) -> Bool {
|
|
650
|
+
guard let key = validKey(key) else { return false }
|
|
651
|
+
guard let json else {
|
|
652
|
+
UserDefaults.standard.removeObject(forKey: storagePrefix + key)
|
|
653
|
+
return true
|
|
654
|
+
}
|
|
655
|
+
let data = Data(json.utf8)
|
|
656
|
+
guard (try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed])) != nil else { return false }
|
|
657
|
+
UserDefaults.standard.set(data, forKey: storagePrefix + key)
|
|
658
|
+
return true
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/// The Keychain item the web page's `craft.secureStorage` wrote under
|
|
662
|
+
/// `key`: a generic password whose account is the key, as CraftApp's
|
|
663
|
+
/// `secureStore` writes it. Nil when there is none.
|
|
664
|
+
static func webSecureValue(forKey key: String) -> String? {
|
|
665
|
+
guard let key = validKey(key) else { return nil }
|
|
666
|
+
let query: [String: Any] = [
|
|
667
|
+
kSecClass as String: kSecClassGenericPassword,
|
|
668
|
+
kSecAttrAccount as String: key,
|
|
669
|
+
kSecReturnData as String: true,
|
|
670
|
+
kSecMatchLimit as String: kSecMatchLimitOne,
|
|
671
|
+
]
|
|
672
|
+
var result: AnyObject?
|
|
673
|
+
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, let data = result as? Data else { return nil }
|
|
674
|
+
return String(data: data, encoding: .utf8)
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/// Last-known data for a screen's first frame: one JSON file per name in
|
|
679
|
+
/// `Application Support/craft-snapshots/<name>.json`. A web page writes them
|
|
680
|
+
/// (`snapshots.set` in the mobile API) and a native screen reads them
|
|
681
|
+
/// synchronously before its first render, or writes its own.
|
|
682
|
+
enum CraftSnapshots {
|
|
683
|
+
private static let queue = DispatchQueue(label: "dev.craft.snapshots", qos: .utility)
|
|
684
|
+
private static let lock = NSLock()
|
|
685
|
+
/// Writes not on disk yet, so a read right after a write sees it.
|
|
686
|
+
private static var pending: [String: String?] = [:]
|
|
687
|
+
|
|
688
|
+
static var directory: URL? {
|
|
689
|
+
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first?
|
|
690
|
+
.appendingPathComponent("craft-snapshots", isDirectory: true)
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/// Letters, digits, `-`, `_` and `.`, not starting with a dot: a file
|
|
694
|
+
/// name, never a path.
|
|
695
|
+
static func isValidName(_ name: String) -> Bool {
|
|
696
|
+
!name.isEmpty && name.count <= 128 && !name.hasPrefix(".")
|
|
697
|
+
&& name.unicodeScalars.allSatisfy { $0.isASCII && (CharacterSet.alphanumerics.contains($0) || "-_.".unicodeScalars.contains($0)) }
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
static func url(for name: String) -> URL? {
|
|
701
|
+
guard isValidName(name) else { return nil }
|
|
702
|
+
return directory?.appendingPathComponent("\(name).json", isDirectory: false)
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/// The snapshot's JSON text, or nil.
|
|
706
|
+
static func read(_ name: String) -> String? {
|
|
707
|
+
guard let url = url(for: name) else { return nil }
|
|
708
|
+
lock.lock()
|
|
709
|
+
let waiting = pending[name]
|
|
710
|
+
lock.unlock()
|
|
711
|
+
if let waiting { return waiting }
|
|
712
|
+
guard let data = try? Data(contentsOf: url) else { return nil }
|
|
713
|
+
return String(decoding: data, as: UTF8.self)
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/// Replaces the snapshot with JSON text (nil deletes it) off the main
|
|
717
|
+
/// thread, atomically. `completion` runs on the main queue.
|
|
718
|
+
static func write(_ name: String, json: String?, completion: ((Bool) -> Void)? = nil) {
|
|
719
|
+
guard let url = url(for: name), let directory else {
|
|
720
|
+
DispatchQueue.main.async { completion?(false) }
|
|
721
|
+
return
|
|
722
|
+
}
|
|
723
|
+
if let json, (try? JSONSerialization.jsonObject(with: Data(json.utf8), options: [.fragmentsAllowed])) == nil {
|
|
724
|
+
DispatchQueue.main.async { completion?(false) }
|
|
725
|
+
return
|
|
726
|
+
}
|
|
727
|
+
lock.lock()
|
|
728
|
+
pending[name] = .some(json)
|
|
729
|
+
lock.unlock()
|
|
730
|
+
queue.async {
|
|
731
|
+
var ok = true
|
|
732
|
+
do {
|
|
733
|
+
if let json {
|
|
734
|
+
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
735
|
+
try Data(json.utf8).write(to: url, options: [.atomic])
|
|
736
|
+
} else if FileManager.default.fileExists(atPath: url.path) {
|
|
737
|
+
try FileManager.default.removeItem(at: url)
|
|
738
|
+
}
|
|
739
|
+
} catch {
|
|
740
|
+
ok = false
|
|
741
|
+
}
|
|
742
|
+
lock.lock()
|
|
743
|
+
if let current = pending[name], current == json { pending.removeValue(forKey: name) }
|
|
744
|
+
lock.unlock()
|
|
745
|
+
DispatchQueue.main.async { completion?(ok) }
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/// The Taptic Engine's generators, held for the life of the process.
|
|
751
|
+
///
|
|
752
|
+
/// Each haptic used to make a fresh generator at the moment of the tap. A new
|
|
753
|
+
/// generator has to wake the engine before it can play, which is the tens of
|
|
754
|
+
/// milliseconds between a finger landing and the feel that makes web haptics
|
|
755
|
+
/// read as late. These are made once, prepared again after every use, and
|
|
756
|
+
/// `prepare` lets a page warm one before a gesture it knows is coming (the
|
|
757
|
+
/// start of a drag, a long press about to complete).
|
|
758
|
+
///
|
|
759
|
+
/// The three kinds are the three UIKit has, and they are not interchangeable:
|
|
760
|
+
/// a notification is the success, warning or error pattern, a selection is the
|
|
761
|
+
/// detent a picker plays, and an impact is a single tap at one of five weights.
|
|
762
|
+
/// The page used to get impacts for all of them.
|
|
763
|
+
///
|
|
764
|
+
/// Main thread only, like every UIFeedbackGenerator. Every caller is.
|
|
765
|
+
enum CraftHaptics {
|
|
766
|
+
private static var impacts: [UIImpactFeedbackGenerator.FeedbackStyle: UIImpactFeedbackGenerator] = [:]
|
|
767
|
+
private static var notifier: UINotificationFeedbackGenerator?
|
|
768
|
+
private static var selector: UISelectionFeedbackGenerator?
|
|
769
|
+
|
|
770
|
+
/// An impact weight by name, or nil for a name that is not one.
|
|
771
|
+
static func impactStyle(_ name: String) -> UIImpactFeedbackGenerator.FeedbackStyle? {
|
|
772
|
+
switch name {
|
|
773
|
+
case "light": return .light
|
|
774
|
+
case "medium": return .medium
|
|
775
|
+
case "heavy": return .heavy
|
|
776
|
+
case "soft": return .soft
|
|
777
|
+
case "rigid": return .rigid
|
|
778
|
+
default: return nil
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/// Play one haptic: an impact weight, a notification type, or `selection`.
|
|
783
|
+
/// Anything else plays a medium impact, as an unknown style always has.
|
|
784
|
+
static func play(_ style: String) {
|
|
785
|
+
switch style {
|
|
786
|
+
case "success": notification().notificationOccurred(.success); notification().prepare()
|
|
787
|
+
case "warning": notification().notificationOccurred(.warning); notification().prepare()
|
|
788
|
+
case "error": notification().notificationOccurred(.error); notification().prepare()
|
|
789
|
+
case "selection": selection().selectionChanged(); selection().prepare()
|
|
790
|
+
default:
|
|
791
|
+
let generator = impact(impactStyle(style) ?? .medium)
|
|
792
|
+
generator.impactOccurred()
|
|
793
|
+
generator.prepare()
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/// Warm a generator so the next haptic plays without the engine's wake-up
|
|
798
|
+
/// delay. `impact`, `notification` or `selection` for one kind, an impact
|
|
799
|
+
/// weight or notification type for that one, nothing for all of them.
|
|
800
|
+
/// UIKit keeps a prepared engine awake for a few seconds only.
|
|
801
|
+
static func prepare(_ kind: String?) {
|
|
802
|
+
switch kind {
|
|
803
|
+
case "impact": impact(.medium).prepare()
|
|
804
|
+
case "notification", "success", "warning", "error": notification().prepare()
|
|
805
|
+
case "selection": selection().prepare()
|
|
806
|
+
case let name? where impactStyle(name) != nil: impact(impactStyle(name)!).prepare()
|
|
807
|
+
default:
|
|
808
|
+
impact(.medium).prepare()
|
|
809
|
+
notification().prepare()
|
|
810
|
+
selection().prepare()
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
private static func impact(_ style: UIImpactFeedbackGenerator.FeedbackStyle) -> UIImpactFeedbackGenerator {
|
|
815
|
+
if let generator = impacts[style] { return generator }
|
|
816
|
+
let generator = UIImpactFeedbackGenerator(style: style)
|
|
817
|
+
impacts[style] = generator
|
|
818
|
+
return generator
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
private static func notification() -> UINotificationFeedbackGenerator {
|
|
822
|
+
if let notifier { return notifier }
|
|
823
|
+
let generator = UINotificationFeedbackGenerator()
|
|
824
|
+
notifier = generator
|
|
825
|
+
return generator
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
private static func selection() -> UISelectionFeedbackGenerator {
|
|
829
|
+
if let selector { return selector }
|
|
830
|
+
let generator = UISelectionFeedbackGenerator()
|
|
831
|
+
selector = generator
|
|
832
|
+
return generator
|
|
833
|
+
}
|
|
834
|
+
}
|