craft-native 0.0.124 → 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/dist/android/src/index.js +5 -3
- package/dist/android/templates/CraftNativeFlatList.kt.template +39 -2
- package/dist/android/templates/CraftNativeRefreshableFlatList.kt.template +29 -0
- package/dist/android/templates/MainActivityNative.kt.template +269 -26
- package/dist/android/templates/build.gradle.kts.app.template +1 -0
- package/dist/api/mobile.d.ts +43 -0
- package/dist/cli.js +3 -3
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.d.ts +49 -1
- package/dist/ios/src/index.js +150 -4
- package/dist/ios/templates/CraftApp.swift +111 -13
- package/dist/ios/templates/CraftHybrid.swift +1196 -0
- package/dist/ios/templates/CraftNativeActions.swift +213 -1
- package/dist/ios/templates/CraftNativeFlatList.swift +150 -21
- package/dist/ios/templates/CraftNativeScreen.swift +1492 -307
- package/dist/mobile.js +66 -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") }
|
|
@@ -115,6 +127,9 @@ enum CraftNativeActions {
|
|
|
115
127
|
}
|
|
116
128
|
authenticate(requestToken: requestToken, args: args, completion: completion)
|
|
117
129
|
return
|
|
130
|
+
case ("Network", "fetch"):
|
|
131
|
+
fetch(requestToken: requestToken, args: args, completion: completion)
|
|
132
|
+
return
|
|
118
133
|
case ("Lifecycle", "getState"):
|
|
119
134
|
sync = .success(currentAppState())
|
|
120
135
|
case ("DeepLinks", "getInitialURL"):
|
|
@@ -147,8 +162,91 @@ enum CraftNativeActions {
|
|
|
147
162
|
cancellationLock.lock()
|
|
148
163
|
cancelledRequests.insert(requestToken)
|
|
149
164
|
let context = biometricContexts.removeValue(forKey: requestToken)
|
|
165
|
+
let task = fetchTasks.removeValue(forKey: requestToken)
|
|
150
166
|
cancellationLock.unlock()
|
|
151
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() }
|
|
152
250
|
}
|
|
153
251
|
|
|
154
252
|
private static func claimInitialDeepLink() -> URL? {
|
|
@@ -535,6 +633,120 @@ enum CraftNativeActions {
|
|
|
535
633
|
}
|
|
536
634
|
}
|
|
537
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
|
+
|
|
538
750
|
/// The Taptic Engine's generators, held for the life of the process.
|
|
539
751
|
///
|
|
540
752
|
/// Each haptic used to make a fresh generator at the moment of the tap. A new
|
|
@@ -9,10 +9,10 @@ private final class CraftNativeFlatListLayout: UICollectionViewFlowLayout {
|
|
|
9
9
|
}
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
/// A keyed, recycling native list used by
|
|
12
|
+
/// A keyed, recycling native list used by stx's native FlatList primitive.
|
|
13
13
|
/// The screen controller owns rendered row state; this view owns collection
|
|
14
14
|
/// diffs, viewport state, and UICollectionView cell reuse.
|
|
15
|
-
final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowLayout {
|
|
15
|
+
final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowLayout, UIGestureRecognizerDelegate {
|
|
16
16
|
typealias RenderItem = (_ node: [String: Any], _ identity: String, _ previous: UIView?) -> UIView
|
|
17
17
|
|
|
18
18
|
private final class Cell: UICollectionViewCell {
|
|
@@ -21,6 +21,9 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
21
21
|
private(set) var representedIdentity: String?
|
|
22
22
|
private(set) var hostedView: UIView?
|
|
23
23
|
var onRecycle: ((String) -> Void)?
|
|
24
|
+
/// The width (or, sideways, height) the list gives this row, so a row
|
|
25
|
+
/// is measured at the list's width rather than the layout's estimate.
|
|
26
|
+
var fittingExtent: CGFloat?
|
|
24
27
|
|
|
25
28
|
override func prepareForReuse() {
|
|
26
29
|
super.prepareForReuse()
|
|
@@ -63,24 +66,25 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
63
66
|
if let collectionView = ancestor as? UICollectionView,
|
|
64
67
|
let flowLayout = collectionView.collectionViewLayout as? UICollectionViewFlowLayout,
|
|
65
68
|
flowLayout.scrollDirection == .horizontal {
|
|
66
|
-
let
|
|
67
|
-
|
|
68
|
-
|
|
69
|
+
let height = fittingExtent ?? layoutAttributes.size.height
|
|
70
|
+
let measured = CraftNativeFlexLayout.measure(
|
|
71
|
+
hostedView,
|
|
72
|
+
width: nil,
|
|
73
|
+
height: height,
|
|
74
|
+
maxWidth: CraftNativeFlexLayout.unbounded,
|
|
75
|
+
maxHeight: height
|
|
69
76
|
)
|
|
70
|
-
|
|
71
|
-
target,
|
|
72
|
-
withHorizontalFittingPriority: .fittingSizeLevel,
|
|
73
|
-
verticalFittingPriority: .required
|
|
74
|
-
)
|
|
75
|
-
attributes.size.width = max(1, measured.width)
|
|
77
|
+
attributes.size = CGSize(width: max(1, ceil(measured.width)), height: max(1, height))
|
|
76
78
|
} else {
|
|
77
|
-
let
|
|
78
|
-
let measured =
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
let width = fittingExtent ?? layoutAttributes.size.width
|
|
80
|
+
let measured = CraftNativeFlexLayout.measure(
|
|
81
|
+
hostedView,
|
|
82
|
+
width: width,
|
|
83
|
+
height: nil,
|
|
84
|
+
maxWidth: width,
|
|
85
|
+
maxHeight: CraftNativeFlexLayout.unbounded
|
|
82
86
|
)
|
|
83
|
-
attributes.size
|
|
87
|
+
attributes.size = CGSize(width: max(1, width), height: max(1, ceil(measured.height)))
|
|
84
88
|
}
|
|
85
89
|
return attributes
|
|
86
90
|
}
|
|
@@ -119,6 +123,10 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
119
123
|
private var isApplyingSnapshot = false
|
|
120
124
|
private var pendingApply: (() -> Void)?
|
|
121
125
|
private var pendingThemeRefresh = false
|
|
126
|
+
private let nativeRefreshControl = UIRefreshControl()
|
|
127
|
+
private var refreshHandler: (() -> Void)?
|
|
128
|
+
private let keyboardTapGesture = UITapGestureRecognizer()
|
|
129
|
+
private var keyboardShouldPersistTaps = "never"
|
|
122
130
|
|
|
123
131
|
init() {
|
|
124
132
|
let layout = CraftNativeFlatListLayout()
|
|
@@ -130,7 +138,12 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
130
138
|
backgroundColor = .clear
|
|
131
139
|
alwaysBounceVertical = true
|
|
132
140
|
delegate = self
|
|
141
|
+
keyboardTapGesture.cancelsTouchesInView = false
|
|
142
|
+
keyboardTapGesture.delegate = self
|
|
143
|
+
keyboardTapGesture.addTarget(self, action: #selector(keyboardTap))
|
|
144
|
+
addGestureRecognizer(keyboardTapGesture)
|
|
133
145
|
register(Cell.self, forCellWithReuseIdentifier: Cell.reuseIdentifier)
|
|
146
|
+
nativeRefreshControl.addTarget(self, action: #selector(refreshTriggered), for: .valueChanged)
|
|
134
147
|
diffableDataSource = UICollectionViewDiffableDataSource<Int, String>(collectionView: self) {
|
|
135
148
|
[weak self] collectionView, indexPath, identity in
|
|
136
149
|
guard let self = self,
|
|
@@ -142,6 +155,7 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
142
155
|
let renderItem = self.renderItem else { return UICollectionViewCell() }
|
|
143
156
|
let view = renderItem(item.node, identity, cell.hostedView)
|
|
144
157
|
cell.onRecycle = self.recycleItem
|
|
158
|
+
cell.fittingExtent = self.itemExtent(isChrome: item.isChrome)
|
|
145
159
|
cell.host(view, identity: identity)
|
|
146
160
|
return cell
|
|
147
161
|
}
|
|
@@ -149,6 +163,123 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
149
163
|
|
|
150
164
|
required init?(coder: NSCoder) { nil }
|
|
151
165
|
|
|
166
|
+
/// Called when a list that does not scroll changes height, so the
|
|
167
|
+
/// containers around it make room.
|
|
168
|
+
var onFittingHeightChanged: (() -> Void)?
|
|
169
|
+
|
|
170
|
+
override var contentSize: CGSize {
|
|
171
|
+
didSet {
|
|
172
|
+
guard !isScrollEnabled, abs(oldValue.height - contentSize.height) > 0.5 else { return }
|
|
173
|
+
invalidateIntrinsicContentSize()
|
|
174
|
+
onFittingHeightChanged?()
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
override var isScrollEnabled: Bool {
|
|
179
|
+
didSet {
|
|
180
|
+
guard isScrollEnabled != oldValue else { return }
|
|
181
|
+
alwaysBounceVertical = isScrollEnabled && flowLayout.scrollDirection == .vertical
|
|
182
|
+
onFittingHeightChanged?()
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/// The height of every row at `width`: what a list that does not scroll
|
|
187
|
+
/// (`scrollEnabled={false}`) takes inside a scroll view or a column.
|
|
188
|
+
/// Rows not measured yet count at their estimate, and the list asks for
|
|
189
|
+
/// room again as they are.
|
|
190
|
+
func fittingHeight(width: CGFloat) -> CGFloat {
|
|
191
|
+
guard !isScrollEnabled else { return 0 }
|
|
192
|
+
if abs(bounds.width - width) > 0.5 { frame.size.width = width }
|
|
193
|
+
layoutIfNeeded()
|
|
194
|
+
let content = flowLayout.collectionViewContentSize.height
|
|
195
|
+
return max(0, ceil(content + contentInset.top + contentInset.bottom))
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/// The width a row gets: the list's own width inside its content padding,
|
|
199
|
+
/// shared between columns for data rows.
|
|
200
|
+
private func itemExtent(isChrome: Bool) -> CGFloat {
|
|
201
|
+
if flowLayout.scrollDirection == .horizontal {
|
|
202
|
+
return max(1, bounds.height - adjustedContentInset.top - adjustedContentInset.bottom)
|
|
203
|
+
}
|
|
204
|
+
let width = max(1, bounds.width - adjustedContentInset.left - adjustedContentInset.right)
|
|
205
|
+
let count = isChrome ? 1 : columns
|
|
206
|
+
let spacing = flowLayout.minimumInteritemSpacing * CGFloat(count - 1)
|
|
207
|
+
return max(1, (width - spacing) / CGFloat(count))
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
override func layoutSubviews() {
|
|
211
|
+
let estimate = flowLayout.scrollDirection == .horizontal
|
|
212
|
+
? CGSize(width: 44, height: itemExtent(isChrome: true))
|
|
213
|
+
: CGSize(width: itemExtent(isChrome: false), height: 44)
|
|
214
|
+
if flowLayout.estimatedItemSize != estimate, bounds.width > 0 {
|
|
215
|
+
flowLayout.estimatedItemSize = estimate
|
|
216
|
+
}
|
|
217
|
+
super.layoutSubviews()
|
|
218
|
+
craftApplyCornerRadius()
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
func setContentContainerStyle(_ raw: [String: Any]?) {
|
|
222
|
+
func number(_ value: Any?) -> CGFloat? { (value as? NSNumber).map { CGFloat(truncating: $0) } }
|
|
223
|
+
let padding = number(raw?["padding"]) ?? 0
|
|
224
|
+
let horizontal = number(raw?["paddingHorizontal"]) ?? padding
|
|
225
|
+
let vertical = number(raw?["paddingVertical"]) ?? padding
|
|
226
|
+
contentInset = UIEdgeInsets(
|
|
227
|
+
top: number(raw?["paddingTop"]) ?? vertical,
|
|
228
|
+
left: number(raw?["paddingLeft"]) ?? horizontal,
|
|
229
|
+
bottom: number(raw?["paddingBottom"]) ?? vertical,
|
|
230
|
+
right: number(raw?["paddingRight"]) ?? horizontal
|
|
231
|
+
)
|
|
232
|
+
scrollIndicatorInsets = contentInset
|
|
233
|
+
collectionViewLayout.invalidateLayout()
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
func setKeyboardShouldPersistTaps(_ value: Any?) {
|
|
237
|
+
let requested = value as? String
|
|
238
|
+
keyboardShouldPersistTaps = ["always", "handled", "never"].contains(requested) ? requested! : "never"
|
|
239
|
+
keyboardTapGesture.isEnabled = keyboardShouldPersistTaps != "always"
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private var refreshingProp = false
|
|
243
|
+
|
|
244
|
+
/// `onRefresh` and `refreshing`, controlled as in React Native: a pull
|
|
245
|
+
/// the screen does not answer with `refreshing` true ends at once.
|
|
246
|
+
func setRefreshHandler(_ handler: (() -> Void)?, refreshing: Bool) {
|
|
247
|
+
refreshHandler = handler
|
|
248
|
+
refreshingProp = refreshing
|
|
249
|
+
let control = handler == nil ? nil : nativeRefreshControl
|
|
250
|
+
if refreshControl !== control { refreshControl = control }
|
|
251
|
+
if handler == nil || !refreshing {
|
|
252
|
+
if nativeRefreshControl.isRefreshing { nativeRefreshControl.endRefreshing() }
|
|
253
|
+
} else if !nativeRefreshControl.isRefreshing {
|
|
254
|
+
nativeRefreshControl.beginRefreshing()
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
@objc private func refreshTriggered() {
|
|
259
|
+
refreshHandler?()
|
|
260
|
+
DispatchQueue.main.async { [weak self] in
|
|
261
|
+
guard let self, !self.refreshingProp, self.nativeRefreshControl.isRefreshing else { return }
|
|
262
|
+
self.nativeRefreshControl.endRefreshing()
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
@objc private func keyboardTap() {
|
|
267
|
+
guard keyboardShouldPersistTaps != "always" else { return }
|
|
268
|
+
if keyboardShouldPersistTaps == "handled" {
|
|
269
|
+
let point = keyboardTapGesture.location(in: self)
|
|
270
|
+
var hit = hitTest(point, with: nil)
|
|
271
|
+
while let view = hit, view !== self {
|
|
272
|
+
if view is UIControl || !(view.gestureRecognizers ?? []).isEmpty { return }
|
|
273
|
+
hit = view.superview
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
window?.endEditing(true)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
280
|
+
true
|
|
281
|
+
}
|
|
282
|
+
|
|
152
283
|
var visibleItemIdentities: [String] {
|
|
153
284
|
indexPathsForVisibleItems
|
|
154
285
|
.sorted { $0.item < $1.item }
|
|
@@ -320,11 +451,9 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
320
451
|
guard items.indices.contains(indexPath.item) else { return CGSize(width: bounds.width, height: 44) }
|
|
321
452
|
let item = items[indexPath.item]
|
|
322
453
|
if flowLayout.scrollDirection == .horizontal {
|
|
323
|
-
return CGSize(width: 44, height:
|
|
454
|
+
return CGSize(width: 44, height: itemExtent(isChrome: item.isChrome))
|
|
324
455
|
}
|
|
325
|
-
|
|
326
|
-
let spacing = flowLayout.minimumInteritemSpacing * CGFloat(count - 1)
|
|
327
|
-
return CGSize(width: max(1, (bounds.width - spacing) / CGFloat(count)), height: 44)
|
|
456
|
+
return CGSize(width: itemExtent(isChrome: item.isChrome), height: 44)
|
|
328
457
|
}
|
|
329
458
|
|
|
330
459
|
private func evaluateEndReached() {
|