@craft-native/ios 0.0.187 → 0.0.189
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 +12 -0
- package/package.json +1 -1
- package/templates/CraftHybrid.swift +36 -4
- package/templates/CraftNativeActions.swift +16 -4
- package/templates/CraftNativeFiles.swift +131 -25
- package/templates/CraftNativeScreen.swift +454 -3
package/README.md
CHANGED
|
@@ -174,12 +174,24 @@ reversing each line's main-axis children, matching Android's native host.
|
|
|
174
174
|
|
|
175
175
|
For multiple native screens, configure named `.stx` routes and an initial screen in `native.config.json` (see [`fixtures/native-navigation`](./fixtures/native-navigation)). Compile without a positional file: `stx native compile --format bundle --output ./screen.js`. A screen can call `craft.navigation.push('details', { id: 7 })`, `.replace('home')`, or `.back()`; the destination reads `craft.route.params`. Craft uses a native navigation stack, so the back button and edge-swipe reveal the existing controller and retain its JavaScript and input state. `bun packages/ios/scripts/test-native-navigation.ts` compiles the fixture and runs its simulator test; the default WebView renderer is unchanged.
|
|
176
176
|
|
|
177
|
+
Hybrid web-to-web pushes use the same shared WebView across stack entries. The maintained [`fixtures/hybrid-navigation`](./fixtures/hybrid-navigation) fixture samples the outgoing page during the UIKit transition and is run by CI with:
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
bun packages/ios/scripts/test-hybrid-navigation.ts
|
|
181
|
+
```
|
|
182
|
+
|
|
177
183
|
Hybrid apps also get the shell's completed left/right tab swipe over the
|
|
178
184
|
WebView. Disable that page-wide gesture with
|
|
179
185
|
`craft.tabBar.set(tabs, { swipeEnabled: false })`; a native screen can still
|
|
180
186
|
use `craft.navigation.setOptions({ tabSwipeEnabled: false })` for its own
|
|
181
187
|
content.
|
|
182
188
|
|
|
189
|
+
Native screens can opt into an opaque navigation-bar fill that follows their
|
|
190
|
+
screen background (including light/dark trait changes) with
|
|
191
|
+
`craft.navigation.setOptions({ navigationBarSolid: true })`. The default stays
|
|
192
|
+
UIKit's translucent material; set it back to `false` when a screen no longer
|
|
193
|
+
needs a solid bar.
|
|
194
|
+
|
|
183
195
|
The optional Liquid Glass tab-bar styling is compiler-gated for Xcode 26/Swift 6.2 and later. Older SDKs compile the same tab bar with the material fallback, so generated projects remain buildable on Xcode 16.
|
|
184
196
|
|
|
185
197
|
The iOS host overlays `craft.config.json` on its defaults. Missing or `null` capability flags stay disabled; explicitly set a flag to `true` to enable it. Both the Swift host and Zig bridge reject malformed values and fall back to all capabilities disabled.
|
package/package.json
CHANGED
|
@@ -992,7 +992,10 @@ final class CraftHybridWebSlot: UIViewController {
|
|
|
992
992
|
|
|
993
993
|
override func viewWillAppear(_ animated: Bool) {
|
|
994
994
|
super.viewWillAppear(animated)
|
|
995
|
-
|
|
995
|
+
if let controller = parent?.parent as? CraftHybridController {
|
|
996
|
+
controller.prepareWebTransition(to: self)
|
|
997
|
+
controller.adoptWeb(into: self)
|
|
998
|
+
}
|
|
996
999
|
}
|
|
997
1000
|
}
|
|
998
1001
|
|
|
@@ -1011,11 +1014,18 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1011
1014
|
private var shownStacks: [ObjectIdentifier: [UIViewController]] = [:]
|
|
1012
1015
|
/// A tab whose page has not caught up yet; shown when it has, or soon.
|
|
1013
1016
|
private var pendingTab: (id: String, deadline: DispatchWorkItem)?
|
|
1017
|
+
/// The live WebView moves to the destination before UIKit animates a web
|
|
1018
|
+
/// push. Keep the outgoing page visible until that transition completes.
|
|
1019
|
+
private var webTransitionSnapshots: [UIView] = []
|
|
1014
1020
|
private var openingWeb = false
|
|
1015
1021
|
private var firstFrameLogged = false
|
|
1016
1022
|
private var tabBarHeight: CGFloat = 0
|
|
1017
1023
|
private var tabSwipeRecognizers: [UIGestureRecognizer] = []
|
|
1018
1024
|
private var activeTabSwipeTarget: String?
|
|
1025
|
+
/// The edge the incoming tab started from for the current gesture. Keep
|
|
1026
|
+
/// this separate from the live frame: deriving it after the drag begins
|
|
1027
|
+
/// makes the target jump when the translation crosses the midpoint.
|
|
1028
|
+
private var tabSwipeFrom: CGFloat = 0
|
|
1019
1029
|
|
|
1020
1030
|
init(config: CraftConfig) {
|
|
1021
1031
|
self.config = config
|
|
@@ -1134,7 +1144,8 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1134
1144
|
}
|
|
1135
1145
|
target.view.isHidden = false
|
|
1136
1146
|
let width = view.bounds.width
|
|
1137
|
-
|
|
1147
|
+
tabSwipeFrom = -direction * width
|
|
1148
|
+
target.view.frame = view.bounds.offsetBy(dx: tabSwipeFrom, dy: 0)
|
|
1138
1149
|
view.bringSubviewToFront(target.view)
|
|
1139
1150
|
CraftChrome.shared.beginTabSwipe(to: id)
|
|
1140
1151
|
return true
|
|
@@ -1144,9 +1155,8 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1144
1155
|
guard let targetId = activeTabSwipeTarget, let current = visibleStack,
|
|
1145
1156
|
let target = stacks[targetId] else { return }
|
|
1146
1157
|
let width = max(1, view.bounds.width)
|
|
1147
|
-
let start = target.view.frame.minX >= width * 0.5 ? width : -width
|
|
1148
1158
|
let offset = min(width, max(-width, translation))
|
|
1149
|
-
target.view.frame.origin.x =
|
|
1159
|
+
target.view.frame.origin.x = tabSwipeFrom + offset
|
|
1150
1160
|
current.view.frame.origin.x = offset
|
|
1151
1161
|
}
|
|
1152
1162
|
|
|
@@ -1244,6 +1254,26 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1244
1254
|
])
|
|
1245
1255
|
}
|
|
1246
1256
|
|
|
1257
|
+
/// UIKit calls the destination's viewWillAppear before it starts a push.
|
|
1258
|
+
/// Since the hybrid shell has one WebView shared by all web entries, moving
|
|
1259
|
+
/// it at that point would leave the outgoing slot blank for the whole
|
|
1260
|
+
/// transition. A snapshot gives that slot the page it was showing while
|
|
1261
|
+
/// the live WebView is adopted by the destination.
|
|
1262
|
+
func prepareWebTransition(to slot: CraftHybridWebSlot) {
|
|
1263
|
+
guard let nav = slot.navigationController,
|
|
1264
|
+
nav === visibleStack,
|
|
1265
|
+
let from = nav.transitionCoordinator?.viewController(forKey: .from),
|
|
1266
|
+
let source = from as? CraftHybridWebSlot,
|
|
1267
|
+
source !== slot,
|
|
1268
|
+
let webHost,
|
|
1269
|
+
webHost.view.superview === source.view,
|
|
1270
|
+
let snapshot = webHost.view.snapshotView(afterScreenUpdates: false) else { return }
|
|
1271
|
+
snapshot.frame = source.view.bounds
|
|
1272
|
+
snapshot.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
1273
|
+
source.view.addSubview(snapshot)
|
|
1274
|
+
webTransitionSnapshots.append(snapshot)
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1247
1277
|
private var visibleStack: UINavigationController? { stacks[currentTab] }
|
|
1248
1278
|
|
|
1249
1279
|
var webIsVisible: Bool {
|
|
@@ -1593,6 +1623,8 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1593
1623
|
if let depth = removed.compactMap({ ($0 as? CraftHybridWebSlot)?.depth }).min() {
|
|
1594
1624
|
hybrid.pageBack(to: depth - 1)
|
|
1595
1625
|
}
|
|
1626
|
+
for snapshot in webTransitionSnapshots { snapshot.removeFromSuperview() }
|
|
1627
|
+
webTransitionSnapshots.removeAll()
|
|
1596
1628
|
// Back at a tab's native root while the page, with no history left
|
|
1597
1629
|
// to go back through (it reloaded on the screen just popped), still
|
|
1598
1630
|
// shows that screen: it moves to the root's path. Left there, the
|
|
@@ -31,7 +31,7 @@ enum CraftNativeActions {
|
|
|
31
31
|
private static let fetchSession: URLSession = {
|
|
32
32
|
let configuration = URLSessionConfiguration.default
|
|
33
33
|
configuration.timeoutIntervalForRequest = craftNativeFetchTimeoutSeconds
|
|
34
|
-
configuration.timeoutIntervalForResource =
|
|
34
|
+
configuration.timeoutIntervalForResource = 600
|
|
35
35
|
return URLSession(configuration: configuration)
|
|
36
36
|
}()
|
|
37
37
|
private static var initialDeepLinkClaimed = false
|
|
@@ -219,6 +219,12 @@ enum CraftNativeActions {
|
|
|
219
219
|
CraftNativeFiles.pickImage(options, cameraEnabled: config.enableCamera) { complete(requestToken, $0, completion) }
|
|
220
220
|
}
|
|
221
221
|
return
|
|
222
|
+
case ("Files", "pickVideo"):
|
|
223
|
+
let options = args.first as? [String: Any] ?? [:]
|
|
224
|
+
DispatchQueue.main.async {
|
|
225
|
+
CraftNativeFiles.pickVideo(options) { complete(requestToken, $0, completion) }
|
|
226
|
+
}
|
|
227
|
+
return
|
|
222
228
|
case ("Files", "download"):
|
|
223
229
|
download(requestToken: requestToken, args: args, completion: completion)
|
|
224
230
|
return
|
|
@@ -594,6 +600,7 @@ enum CraftNativeActions {
|
|
|
594
600
|
request.setValue(value, forHTTPHeaderField: name)
|
|
595
601
|
}
|
|
596
602
|
}
|
|
603
|
+
var uploadFile: URL?
|
|
597
604
|
if let form = options["form"] as? [Any] {
|
|
598
605
|
// A screen's FormData: text fields, and files craft.files made.
|
|
599
606
|
guard method != "GET", method != "HEAD" else {
|
|
@@ -604,8 +611,9 @@ enum CraftNativeActions {
|
|
|
604
611
|
case .failure(let error):
|
|
605
612
|
complete(requestToken, .failure(error), completion)
|
|
606
613
|
return
|
|
607
|
-
case .success(let (
|
|
608
|
-
|
|
614
|
+
case .success(let (file, boundary)):
|
|
615
|
+
uploadFile = file
|
|
616
|
+
request.timeoutInterval = 600
|
|
609
617
|
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
|
610
618
|
}
|
|
611
619
|
} else if let body = options["body"] as? String {
|
|
@@ -619,7 +627,8 @@ enum CraftNativeActions {
|
|
|
619
627
|
return
|
|
620
628
|
}
|
|
621
629
|
|
|
622
|
-
let
|
|
630
|
+
let responseHandler: (Data?, URLResponse?, Error?) -> Void = { data, response, error in
|
|
631
|
+
if let uploadFile { CraftNativeFiles.remove(uploadFile.path) }
|
|
623
632
|
cancellationLock.lock()
|
|
624
633
|
fetchTasks.removeValue(forKey: requestToken)
|
|
625
634
|
cancellationLock.unlock()
|
|
@@ -656,11 +665,14 @@ enum CraftNativeActions {
|
|
|
656
665
|
"body": String(decoding: body, as: UTF8.self),
|
|
657
666
|
]), completion)
|
|
658
667
|
}
|
|
668
|
+
let task: URLSessionTask = uploadFile.map { fetchSession.uploadTask(with: request, fromFile: $0, completionHandler: responseHandler) }
|
|
669
|
+
?? fetchSession.dataTask(with: request, completionHandler: responseHandler)
|
|
659
670
|
cancellationLock.lock()
|
|
660
671
|
let alreadyCancelled = cancelledRequests.contains(requestToken)
|
|
661
672
|
if !alreadyCancelled { fetchTasks[requestToken] = task }
|
|
662
673
|
cancellationLock.unlock()
|
|
663
674
|
if !alreadyCancelled { task.resume() }
|
|
675
|
+
else if let uploadFile { CraftNativeFiles.remove(uploadFile.path) }
|
|
664
676
|
}
|
|
665
677
|
|
|
666
678
|
/// `craft.files.download`: a GET straight to a file made here (for the
|
|
@@ -274,41 +274,147 @@ enum CraftNativeFiles {
|
|
|
274
274
|
}
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
+
// MARK: Picking a video
|
|
278
|
+
|
|
279
|
+
/// System pickers grant access to just the selected video. Copy it while
|
|
280
|
+
/// the provider's URL is valid; no movie bytes cross JavaScriptCore.
|
|
281
|
+
static func pickVideo(_ options: [String: Any], done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
|
|
282
|
+
guard activePicker == nil else {
|
|
283
|
+
done(.failure(CraftNativeActionError(code: "BUSY", message: "A picker is already open")))
|
|
284
|
+
return
|
|
285
|
+
}
|
|
286
|
+
let limit = min(512 * 1024 * 1024, max(1, (options["maxBytes"] as? NSNumber)?.intValue ?? 512 * 1024 * 1024))
|
|
287
|
+
let finish: (Result<Any, CraftNativeActionError>) -> Void = { result in
|
|
288
|
+
DispatchQueue.main.async { activePicker = nil; done(result) }
|
|
289
|
+
}
|
|
290
|
+
if options["source"] as? String == "files" {
|
|
291
|
+
let delegate = VideoDocumentDelegate(limit: limit, done: finish)
|
|
292
|
+
var types: [UTType] = [.movie]
|
|
293
|
+
if let webm = UTType(filenameExtension: "webm") { types.append(webm) }
|
|
294
|
+
let picker = UIDocumentPickerViewController(forOpeningContentTypes: types, asCopy: true)
|
|
295
|
+
picker.allowsMultipleSelection = false
|
|
296
|
+
picker.delegate = delegate
|
|
297
|
+
activePicker = delegate
|
|
298
|
+
if !CraftPresenter.present(picker, from: nil) {
|
|
299
|
+
activePicker = nil
|
|
300
|
+
done(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the video picker")))
|
|
301
|
+
}
|
|
302
|
+
} else {
|
|
303
|
+
var configuration = PHPickerConfiguration()
|
|
304
|
+
configuration.filter = .videos
|
|
305
|
+
configuration.selectionLimit = 1
|
|
306
|
+
configuration.preferredAssetRepresentationMode = .current
|
|
307
|
+
let delegate = VideoLibraryDelegate(limit: limit, done: finish)
|
|
308
|
+
let picker = PHPickerViewController(configuration: configuration)
|
|
309
|
+
picker.delegate = delegate
|
|
310
|
+
activePicker = delegate
|
|
311
|
+
if !CraftPresenter.present(picker, from: nil) {
|
|
312
|
+
activePicker = nil
|
|
313
|
+
done(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the video picker")))
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
static func copyVideo(_ source: URL, limit: Int) -> Result<Any, CraftNativeActionError> {
|
|
319
|
+
let ext = source.pathExtension.lowercased()
|
|
320
|
+
let types = ["mp4": "video/mp4", "m4v": "video/x-m4v", "mov": "video/quicktime", "webm": "video/webm"]
|
|
321
|
+
guard let type = types[ext] else {
|
|
322
|
+
return .failure(CraftNativeActionError(code: "INVALID_VIDEO", message: "Choose an MP4, MOV or WebM video."))
|
|
323
|
+
}
|
|
324
|
+
do {
|
|
325
|
+
let size = ((try FileManager.default.attributesOfItem(atPath: source.path))[.size] as? NSNumber)?.intValue ?? 0
|
|
326
|
+
guard size > 0, size <= limit else {
|
|
327
|
+
return .failure(CraftNativeActionError(code: "FILE_TOO_LARGE", message: "Choose a nonempty video under \(limit / 1024 / 1024)MB."))
|
|
328
|
+
}
|
|
329
|
+
let destination = try newFile(named: source.lastPathComponent)
|
|
330
|
+
do { try FileManager.default.copyItem(at: source, to: destination) }
|
|
331
|
+
catch { try? FileManager.default.removeItem(at: destination.deletingLastPathComponent()); throw error }
|
|
332
|
+
return .success(describe(destination, type: type))
|
|
333
|
+
} catch {
|
|
334
|
+
return .failure(CraftNativeActionError(code: "READ_ERROR", message: error.localizedDescription))
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private final class VideoLibraryDelegate: NSObject, PHPickerViewControllerDelegate {
|
|
339
|
+
let limit: Int
|
|
340
|
+
let done: (Result<Any, CraftNativeActionError>) -> Void
|
|
341
|
+
init(limit: Int, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) { self.limit = limit; self.done = done }
|
|
342
|
+
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
|
343
|
+
picker.dismiss(animated: true)
|
|
344
|
+
guard let provider = results.first?.itemProvider else { done(.success(NSNull())); return }
|
|
345
|
+
let (limit, done) = (self.limit, self.done)
|
|
346
|
+
provider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { url, error in
|
|
347
|
+
guard let url else {
|
|
348
|
+
done(.failure(CraftNativeActionError(code: "READ_ERROR", message: error?.localizedDescription ?? "The video could not be read")))
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
done(CraftNativeFiles.copyVideo(url, limit: limit))
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
private final class VideoDocumentDelegate: NSObject, UIDocumentPickerDelegate {
|
|
357
|
+
let limit: Int
|
|
358
|
+
let done: (Result<Any, CraftNativeActionError>) -> Void
|
|
359
|
+
init(limit: Int, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) { self.limit = limit; self.done = done }
|
|
360
|
+
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { done(.success(NSNull())) }
|
|
361
|
+
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
|
362
|
+
guard let url = urls.first else { done(.success(NSNull())); return }
|
|
363
|
+
let (limit, done) = (self.limit, self.done)
|
|
364
|
+
DispatchQueue.global(qos: .userInitiated).async {
|
|
365
|
+
let granted = url.startAccessingSecurityScopedResource()
|
|
366
|
+
defer { if granted { url.stopAccessingSecurityScopedResource() } }
|
|
367
|
+
done(CraftNativeFiles.copyVideo(url, limit: limit))
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
277
372
|
// MARK: Sending files
|
|
278
373
|
|
|
279
374
|
/// A multipart/form-data body from a screen's `FormData`: parts are
|
|
280
375
|
/// `{ name, value }` or `{ name, path, filename?, type? }`, a file made
|
|
281
376
|
/// here. Answers the body and its boundary.
|
|
282
|
-
static func multipart(_ parts: [Any]) -> Result<(
|
|
377
|
+
static func multipart(_ parts: [Any]) -> Result<(URL, String), CraftNativeActionError> {
|
|
283
378
|
let boundary = "craft-\(UUID().uuidString)"
|
|
284
|
-
var
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
379
|
+
var temporary: URL?
|
|
380
|
+
do {
|
|
381
|
+
let destination = try newFile(named: "upload.multipart")
|
|
382
|
+
temporary = destination
|
|
383
|
+
FileManager.default.createFile(atPath: destination.path, contents: nil)
|
|
384
|
+
let output = try FileHandle(forWritingTo: destination)
|
|
385
|
+
defer { try? output.close() }
|
|
386
|
+
func append(_ text: String) throws { try output.write(contentsOf: Data(text.utf8)) }
|
|
387
|
+
for item in parts {
|
|
388
|
+
guard let part = item as? [String: Any], let name = part["name"] as? String else {
|
|
389
|
+
throw CraftNativeActionError(code: "INVALID_ARGUMENT", message: "A form part needs a name")
|
|
294
390
|
}
|
|
295
|
-
|
|
296
|
-
|
|
391
|
+
try append("--\(boundary)\r\n")
|
|
392
|
+
if let path = part["path"] as? String {
|
|
393
|
+
guard let url = owned(path) else {
|
|
394
|
+
throw CraftNativeActionError(code: "INVALID_ARGUMENT", message: "A form file must be one craft.files made, and still there")
|
|
395
|
+
}
|
|
396
|
+
let filename = (part["filename"] as? String).map(safeName) ?? url.lastPathComponent
|
|
397
|
+
let type = (part["type"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? "application/octet-stream"
|
|
398
|
+
try append("Content-Disposition: form-data; name=\"\(quoted(name))\"; filename=\"\(quoted(filename))\"\r\n")
|
|
399
|
+
try append("Content-Type: \(type.replacingOccurrences(of: "\r", with: "").replacingOccurrences(of: "\n", with: ""))\r\n\r\n")
|
|
400
|
+
let input = try FileHandle(forReadingFrom: url)
|
|
401
|
+
defer { try? input.close() }
|
|
402
|
+
while let chunk = try input.read(upToCount: 64 * 1024), !chunk.isEmpty {
|
|
403
|
+
try output.write(contentsOf: chunk)
|
|
404
|
+
}
|
|
405
|
+
try append("\r\n")
|
|
406
|
+
} else {
|
|
407
|
+
try append("Content-Disposition: form-data; name=\"\(quoted(name))\"\r\n\r\n")
|
|
408
|
+
try append(part["value"] as? String ?? "")
|
|
409
|
+
try append("\r\n")
|
|
297
410
|
}
|
|
298
|
-
let filename = (part["filename"] as? String).map(safeName) ?? url.lastPathComponent
|
|
299
|
-
let type = (part["type"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? "application/octet-stream"
|
|
300
|
-
append("Content-Disposition: form-data; name=\"\(quoted(name))\"; filename=\"\(quoted(filename))\"\r\n")
|
|
301
|
-
append("Content-Type: \(type.replacingOccurrences(of: "\r", with: "").replacingOccurrences(of: "\n", with: ""))\r\n\r\n")
|
|
302
|
-
body.append(data)
|
|
303
|
-
append("\r\n")
|
|
304
|
-
} else {
|
|
305
|
-
append("Content-Disposition: form-data; name=\"\(quoted(name))\"\r\n\r\n")
|
|
306
|
-
append(part["value"] as? String ?? "")
|
|
307
|
-
append("\r\n")
|
|
308
411
|
}
|
|
412
|
+
try append("--\(boundary)--\r\n")
|
|
413
|
+
return .success((destination, boundary))
|
|
414
|
+
} catch {
|
|
415
|
+
if let temporary { remove(temporary.path) }
|
|
416
|
+
return .failure((error as? CraftNativeActionError) ?? CraftNativeActionError(code: "WRITE_ERROR", message: error.localizedDescription))
|
|
309
417
|
}
|
|
310
|
-
append("--\(boundary)--\r\n")
|
|
311
|
-
return .success((body, boundary))
|
|
312
418
|
}
|
|
313
419
|
|
|
314
420
|
/// A name inside a quoted header value, escaped as browsers send it.
|
|
@@ -135,22 +135,105 @@ private enum CraftNativeTreeLayoutBridge {
|
|
|
135
135
|
var childCount: UInt32
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
private struct
|
|
138
|
+
private struct StyleInputV2 {
|
|
139
|
+
var direction: UInt32 = 0
|
|
140
|
+
var width: Float = .nan
|
|
141
|
+
var height: Float = .nan
|
|
142
|
+
var intrinsicWidth: Float = .nan
|
|
143
|
+
var intrinsicHeight: Float = .nan
|
|
144
|
+
var baseline: Float = .nan
|
|
145
|
+
var aspectRatio: Float = .nan
|
|
146
|
+
var flexBasis: Float = .nan
|
|
147
|
+
var flexGrow: Float = 0
|
|
148
|
+
var flexShrink: Float = 0
|
|
149
|
+
var minWidth: Float = .nan
|
|
150
|
+
var maxWidth: Float = .nan
|
|
151
|
+
var minHeight: Float = .nan
|
|
152
|
+
var maxHeight: Float = .nan
|
|
153
|
+
var gap: Float = 0
|
|
154
|
+
var justifyContent: UInt32 = 0
|
|
155
|
+
var alignItems: UInt32 = 3
|
|
156
|
+
var alignSelf: UInt32 = 0
|
|
157
|
+
var flexWrap: UInt32 = 0
|
|
158
|
+
var alignContent: UInt32 = 6
|
|
159
|
+
var marginTop: Float = 0
|
|
160
|
+
var marginRight: Float = 0
|
|
161
|
+
var marginBottom: Float = 0
|
|
162
|
+
var marginLeft: Float = 0
|
|
163
|
+
var paddingTop: Float = 0
|
|
164
|
+
var paddingRight: Float = 0
|
|
165
|
+
var paddingBottom: Float = 0
|
|
166
|
+
var paddingLeft: Float = 0
|
|
167
|
+
var grid: UInt32 = 0
|
|
168
|
+
var gridColumns: UInt32 = 1
|
|
169
|
+
var rowGap: Float = 0
|
|
170
|
+
var columnGap: Float = 0
|
|
171
|
+
var reverse: UInt32 = 0
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private struct NodeInputV2 {
|
|
175
|
+
var style: StyleInputV2
|
|
176
|
+
var firstChild: UInt32
|
|
177
|
+
var childCount: UInt32
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
fileprivate struct Frame {
|
|
139
181
|
var x: Float
|
|
140
182
|
var y: Float
|
|
141
183
|
var width: Float
|
|
142
184
|
var height: Float
|
|
143
185
|
}
|
|
144
186
|
|
|
187
|
+
fileprivate struct TreeNode {
|
|
188
|
+
let view: UIView?
|
|
189
|
+
let style: CraftNativeLayoutStyle
|
|
190
|
+
let intrinsic: CGSize
|
|
191
|
+
let baseline: CGFloat
|
|
192
|
+
let direction: NSLayoutConstraint.Axis
|
|
193
|
+
let gap: CGFloat
|
|
194
|
+
let rowGap: CGFloat
|
|
195
|
+
let columnGap: CGFloat
|
|
196
|
+
let wrap: Bool
|
|
197
|
+
let justifyContent: String
|
|
198
|
+
let alignItems: String
|
|
199
|
+
let alignContent: String
|
|
200
|
+
let padding: UIEdgeInsets
|
|
201
|
+
let grid: Bool
|
|
202
|
+
let gridColumns: Int
|
|
203
|
+
let reverse: Bool
|
|
204
|
+
let wrapReverse: Bool
|
|
205
|
+
let children: [TreeNode]
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
fileprivate struct TreeResult {
|
|
209
|
+
let nodes: [TreeNode]
|
|
210
|
+
let parents: [Int]
|
|
211
|
+
let frames: [Frame]
|
|
212
|
+
}
|
|
213
|
+
|
|
145
214
|
private typealias Function = @convention(c) (
|
|
146
215
|
Float, Float, UnsafeRawPointer, Int, UnsafeMutableRawPointer, Int
|
|
147
216
|
) -> Int32
|
|
148
217
|
|
|
218
|
+
private typealias FunctionV2 = @convention(c) (
|
|
219
|
+
Float, Float, UnsafeRawPointer, Int, UnsafeMutableRawPointer, Int
|
|
220
|
+
) -> Int32
|
|
221
|
+
|
|
149
222
|
private static let function: Function? = {
|
|
150
223
|
guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "craft_native_layout_tree") else { return nil }
|
|
151
224
|
return unsafeBitCast(symbol, to: Function.self)
|
|
152
225
|
}()
|
|
153
226
|
|
|
227
|
+
private static let functionV2: FunctionV2? = {
|
|
228
|
+
guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "craft_native_layout_tree_v2") else { return nil }
|
|
229
|
+
return unsafeBitCast(symbol, to: FunctionV2.self)
|
|
230
|
+
}()
|
|
231
|
+
|
|
232
|
+
private static let functionV3: FunctionV2? = {
|
|
233
|
+
guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "craft_native_layout_tree_v3") else { return nil }
|
|
234
|
+
return unsafeBitCast(symbol, to: FunctionV2.self)
|
|
235
|
+
}()
|
|
236
|
+
|
|
154
237
|
private static func finite(_ value: CGFloat?) -> Float {
|
|
155
238
|
guard let value, value.isFinite else { return .nan }
|
|
156
239
|
return Float(value)
|
|
@@ -260,6 +343,199 @@ private enum CraftNativeTreeLayoutBridge {
|
|
|
260
343
|
(child.0, CGRect(x: CGFloat(frame.x), y: CGFloat(frame.y), width: CGFloat(frame.width), height: CGFloat(frame.height)))
|
|
261
344
|
}
|
|
262
345
|
}
|
|
346
|
+
|
|
347
|
+
fileprivate static func treeFrames(root: TreeNode, width: CGFloat, height: CGFloat) -> TreeResult? {
|
|
348
|
+
guard let function, width.isFinite, height.isFinite, width >= 0, height >= 0, !root.children.isEmpty else { return nil }
|
|
349
|
+
if containsWrapReverse(root) {
|
|
350
|
+
return treeFramesV2(root: root, width: width, height: height, function: functionV3)
|
|
351
|
+
}
|
|
352
|
+
if containsReverse(root) {
|
|
353
|
+
return treeFramesV2(root: root, width: width, height: height, function: functionV2)
|
|
354
|
+
}
|
|
355
|
+
var nodes: [TreeNode] = []
|
|
356
|
+
var parents: [Int] = []
|
|
357
|
+
func append(_ node: TreeNode, parent: Int) {
|
|
358
|
+
let index = nodes.count
|
|
359
|
+
nodes.append(node)
|
|
360
|
+
parents.append(parent)
|
|
361
|
+
node.children.forEach { append($0, parent: index) }
|
|
362
|
+
}
|
|
363
|
+
append(root, parent: -1)
|
|
364
|
+
|
|
365
|
+
func put(_ input: inout StyleInput, _ node: TreeNode, parentWidth: CGFloat, parentHeight: CGFloat, rootNode: Bool) {
|
|
366
|
+
let contentWidth = max(0, (rootNode ? width : node.intrinsic.width) - node.padding.left - node.padding.right)
|
|
367
|
+
let contentHeight = max(0, (rootNode ? height : node.intrinsic.height) - node.padding.top - node.padding.bottom)
|
|
368
|
+
let mainSize = node.direction == .horizontal ? contentWidth : contentHeight
|
|
369
|
+
input.direction = node.direction == .horizontal ? 0 : 1
|
|
370
|
+
input.width = rootNode ? Float(width) : length(node.style.width, base: parentWidth)
|
|
371
|
+
input.height = rootNode ? Float(height) : length(node.style.height, base: parentHeight)
|
|
372
|
+
input.intrinsicWidth = rootNode ? .nan : Float(max(0, node.intrinsic.width))
|
|
373
|
+
input.intrinsicHeight = rootNode ? .nan : Float(max(0, node.intrinsic.height))
|
|
374
|
+
input.baseline = rootNode ? .nan : Float(max(0, node.baseline))
|
|
375
|
+
input.aspectRatio = finite(node.style.aspectRatio)
|
|
376
|
+
input.flexBasis = length(node.style.flexBasis, base: mainSize)
|
|
377
|
+
input.flexGrow = rootNode ? 0 : Float(max(0, node.style.flexGrow))
|
|
378
|
+
let defaultShrink: CGFloat = node.view is UIScrollView || (node.view is UILabel && node.direction == .horizontal) ? 1 : 0
|
|
379
|
+
input.flexShrink = rootNode ? 0 : Float(max(0, node.style.flexShrink ?? defaultShrink))
|
|
380
|
+
input.minWidth = length(node.style.minWidth, base: contentWidth)
|
|
381
|
+
input.maxWidth = length(node.style.maxWidth, base: contentWidth)
|
|
382
|
+
input.minHeight = length(node.style.minHeight, base: contentHeight)
|
|
383
|
+
input.maxHeight = length(node.style.maxHeight, base: contentHeight)
|
|
384
|
+
input.gap = Float(max(0, node.gap))
|
|
385
|
+
input.justifyContent = justify(node.justifyContent)
|
|
386
|
+
input.alignItems = alignment(node.alignItems)
|
|
387
|
+
input.alignSelf = rootNode ? 0 : alignSelf(node.style.alignSelf)
|
|
388
|
+
input.flexWrap = node.wrap ? 1 : 0
|
|
389
|
+
input.alignContent = craftNativeAlignContentCode(node.alignContent)
|
|
390
|
+
input.marginTop = Float(max(0, node.style.marginTop))
|
|
391
|
+
input.marginRight = Float(max(0, node.style.marginRight))
|
|
392
|
+
input.marginBottom = Float(max(0, node.style.marginBottom))
|
|
393
|
+
input.marginLeft = Float(max(0, node.style.marginLeft))
|
|
394
|
+
input.paddingTop = Float(max(0, node.padding.top))
|
|
395
|
+
input.paddingRight = Float(max(0, node.padding.right))
|
|
396
|
+
input.paddingBottom = Float(max(0, node.padding.bottom))
|
|
397
|
+
input.paddingLeft = Float(max(0, node.padding.left))
|
|
398
|
+
input.grid = node.grid ? 1 : 0
|
|
399
|
+
input.gridColumns = UInt32(max(1, node.gridColumns))
|
|
400
|
+
input.rowGap = Float(max(0, node.rowGap))
|
|
401
|
+
input.columnGap = Float(max(0, node.columnGap))
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
var styles = [StyleInput](repeating: StyleInput(), count: nodes.count)
|
|
405
|
+
var metadata = [Float](repeating: 0, count: nodes.count * 2)
|
|
406
|
+
var cursor = 0
|
|
407
|
+
func write(_ node: TreeNode, parentWidth: CGFloat, parentHeight: CGFloat, rootNode: Bool) {
|
|
408
|
+
let index = cursor
|
|
409
|
+
cursor += 1
|
|
410
|
+
put(&styles[index], node, parentWidth: parentWidth, parentHeight: parentHeight, rootNode: rootNode)
|
|
411
|
+
let contentWidth = max(0, (rootNode ? width : node.intrinsic.width) - node.padding.left - node.padding.right)
|
|
412
|
+
let contentHeight = max(0, (rootNode ? height : node.intrinsic.height) - node.padding.top - node.padding.bottom)
|
|
413
|
+
let firstChild = cursor
|
|
414
|
+
node.children.forEach { write($0, parentWidth: contentWidth, parentHeight: contentHeight, rootNode: false) }
|
|
415
|
+
metadata[index * 2] = Float(firstChild)
|
|
416
|
+
metadata[index * 2 + 1] = Float(cursor - firstChild)
|
|
417
|
+
}
|
|
418
|
+
write(root, parentWidth: width, parentHeight: height, rootNode: true)
|
|
419
|
+
let inputs = zip(styles, stride(from: 0, to: metadata.count, by: 2)).map { style, offset in
|
|
420
|
+
NodeInput(style: style, firstChild: UInt32(metadata[offset]), childCount: UInt32(metadata[offset + 1]))
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
var output = [Frame](repeating: Frame(x: 0, y: 0, width: 0, height: 0), count: nodes.count)
|
|
424
|
+
let frameCount = output.count
|
|
425
|
+
let status = inputs.withUnsafeBufferPointer { inputBuffer in
|
|
426
|
+
metadata.withUnsafeBufferPointer { metadataBuffer in
|
|
427
|
+
output.withUnsafeMutableBufferPointer { outputBuffer in
|
|
428
|
+
function(
|
|
429
|
+
Float(width), Float(height), UnsafeRawPointer(inputBuffer.baseAddress!), inputs.count,
|
|
430
|
+
UnsafeMutableRawPointer(outputBuffer.baseAddress!), frameCount
|
|
431
|
+
)
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
guard status == 0, output.allSatisfy({ frame in
|
|
436
|
+
frame.x.isFinite && frame.y.isFinite && frame.width.isFinite && frame.height.isFinite &&
|
|
437
|
+
frame.width >= 0 && frame.height >= 0
|
|
438
|
+
}) else { return nil }
|
|
439
|
+
return TreeResult(nodes: nodes, parents: parents, frames: output)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
private static func containsReverse(_ node: TreeNode) -> Bool {
|
|
443
|
+
node.reverse || node.children.contains(where: containsReverse)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
private static func containsWrapReverse(_ node: TreeNode) -> Bool {
|
|
447
|
+
node.wrapReverse || node.children.contains(where: containsWrapReverse)
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
private static func treeFramesV2(root: TreeNode, width: CGFloat, height: CGFloat, function: FunctionV2?) -> TreeResult? {
|
|
451
|
+
guard let function, width.isFinite, height.isFinite, width >= 0, height >= 0, !root.children.isEmpty else { return nil }
|
|
452
|
+
var nodes: [TreeNode] = []
|
|
453
|
+
var parents: [Int] = []
|
|
454
|
+
func append(_ node: TreeNode, parent: Int) {
|
|
455
|
+
let index = nodes.count
|
|
456
|
+
nodes.append(node)
|
|
457
|
+
parents.append(parent)
|
|
458
|
+
node.children.forEach { append($0, parent: index) }
|
|
459
|
+
}
|
|
460
|
+
append(root, parent: -1)
|
|
461
|
+
|
|
462
|
+
func put(_ input: inout StyleInputV2, _ node: TreeNode, parentWidth: CGFloat, parentHeight: CGFloat, rootNode: Bool) {
|
|
463
|
+
let contentWidth = max(0, (rootNode ? width : node.intrinsic.width) - node.padding.left - node.padding.right)
|
|
464
|
+
let contentHeight = max(0, (rootNode ? height : node.intrinsic.height) - node.padding.top - node.padding.bottom)
|
|
465
|
+
let mainSize = node.direction == .horizontal ? contentWidth : contentHeight
|
|
466
|
+
input.direction = node.direction == .horizontal ? 0 : 1
|
|
467
|
+
input.width = rootNode ? Float(width) : length(node.style.width, base: parentWidth)
|
|
468
|
+
input.height = rootNode ? Float(height) : length(node.style.height, base: parentHeight)
|
|
469
|
+
input.intrinsicWidth = rootNode ? .nan : Float(max(0, node.intrinsic.width))
|
|
470
|
+
input.intrinsicHeight = rootNode ? .nan : Float(max(0, node.intrinsic.height))
|
|
471
|
+
input.baseline = rootNode ? .nan : Float(max(0, node.baseline))
|
|
472
|
+
input.aspectRatio = finite(node.style.aspectRatio)
|
|
473
|
+
input.flexBasis = length(node.style.flexBasis, base: mainSize)
|
|
474
|
+
input.flexGrow = rootNode ? 0 : Float(max(0, node.style.flexGrow))
|
|
475
|
+
let defaultShrink: CGFloat = node.view is UIScrollView || (node.view is UILabel && node.direction == .horizontal) ? 1 : 0
|
|
476
|
+
input.flexShrink = rootNode ? 0 : Float(max(0, node.style.flexShrink ?? defaultShrink))
|
|
477
|
+
input.minWidth = length(node.style.minWidth, base: contentWidth)
|
|
478
|
+
input.maxWidth = length(node.style.maxWidth, base: contentWidth)
|
|
479
|
+
input.minHeight = length(node.style.minHeight, base: contentHeight)
|
|
480
|
+
input.maxHeight = length(node.style.maxHeight, base: contentHeight)
|
|
481
|
+
input.gap = Float(max(0, node.gap))
|
|
482
|
+
input.justifyContent = justify(node.justifyContent)
|
|
483
|
+
input.alignItems = alignment(node.alignItems)
|
|
484
|
+
input.alignSelf = rootNode ? 0 : alignSelf(node.style.alignSelf)
|
|
485
|
+
input.flexWrap = node.wrap ? 1 : 0
|
|
486
|
+
input.alignContent = craftNativeAlignContentCode(node.alignContent)
|
|
487
|
+
input.marginTop = Float(max(0, node.style.marginTop))
|
|
488
|
+
input.marginRight = Float(max(0, node.style.marginRight))
|
|
489
|
+
input.marginBottom = Float(max(0, node.style.marginBottom))
|
|
490
|
+
input.marginLeft = Float(max(0, node.style.marginLeft))
|
|
491
|
+
input.paddingTop = Float(max(0, node.padding.top))
|
|
492
|
+
input.paddingRight = Float(max(0, node.padding.right))
|
|
493
|
+
input.paddingBottom = Float(max(0, node.padding.bottom))
|
|
494
|
+
input.paddingLeft = Float(max(0, node.padding.left))
|
|
495
|
+
input.grid = node.grid ? 1 : 0
|
|
496
|
+
input.gridColumns = UInt32(max(1, node.gridColumns))
|
|
497
|
+
input.rowGap = Float(max(0, node.rowGap))
|
|
498
|
+
input.columnGap = Float(max(0, node.columnGap))
|
|
499
|
+
input.reverse = (node.reverse ? 1 : 0) | (node.wrapReverse ? 2 : 0)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
var styles = [StyleInputV2](repeating: StyleInputV2(), count: nodes.count)
|
|
503
|
+
var metadata = [Float](repeating: 0, count: nodes.count * 2)
|
|
504
|
+
var cursor = 0
|
|
505
|
+
func write(_ node: TreeNode, parentWidth: CGFloat, parentHeight: CGFloat, rootNode: Bool) {
|
|
506
|
+
let index = cursor
|
|
507
|
+
cursor += 1
|
|
508
|
+
put(&styles[index], node, parentWidth: parentWidth, parentHeight: parentHeight, rootNode: rootNode)
|
|
509
|
+
let contentWidth = max(0, (rootNode ? width : node.intrinsic.width) - node.padding.left - node.padding.right)
|
|
510
|
+
let contentHeight = max(0, (rootNode ? height : node.intrinsic.height) - node.padding.top - node.padding.bottom)
|
|
511
|
+
let firstChild = cursor
|
|
512
|
+
node.children.forEach { write($0, parentWidth: contentWidth, parentHeight: contentHeight, rootNode: false) }
|
|
513
|
+
metadata[index * 2] = Float(firstChild)
|
|
514
|
+
metadata[index * 2 + 1] = Float(cursor - firstChild)
|
|
515
|
+
}
|
|
516
|
+
write(root, parentWidth: width, parentHeight: height, rootNode: true)
|
|
517
|
+
let inputs = zip(styles, stride(from: 0, to: metadata.count, by: 2)).map { style, offset in
|
|
518
|
+
NodeInputV2(style: style, firstChild: UInt32(metadata[offset]), childCount: UInt32(metadata[offset + 1]))
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
var output = [Frame](repeating: Frame(x: 0, y: 0, width: 0, height: 0), count: nodes.count)
|
|
522
|
+
let frameCount = output.count
|
|
523
|
+
let status = inputs.withUnsafeBufferPointer { inputBuffer in
|
|
524
|
+
metadata.withUnsafeBufferPointer { metadataBuffer in
|
|
525
|
+
output.withUnsafeMutableBufferPointer { outputBuffer in
|
|
526
|
+
function(
|
|
527
|
+
Float(width), Float(height), UnsafeRawPointer(inputBuffer.baseAddress!), inputs.count,
|
|
528
|
+
UnsafeMutableRawPointer(outputBuffer.baseAddress!), frameCount
|
|
529
|
+
)
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
guard status == 0, output.allSatisfy({ frame in
|
|
534
|
+
frame.x.isFinite && frame.y.isFinite && frame.width.isFinite && frame.height.isFinite &&
|
|
535
|
+
frame.width >= 0 && frame.height >= 0
|
|
536
|
+
}) else { return nil }
|
|
537
|
+
return TreeResult(nodes: nodes, parents: parents, frames: output)
|
|
538
|
+
}
|
|
263
539
|
}
|
|
264
540
|
|
|
265
541
|
private enum CraftNativeFlexLineBridge {
|
|
@@ -1548,6 +1824,137 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
1548
1824
|
flowChildren.filter { $0.superview === self && !$0.isHidden && !($0 is CraftNativeModalView) }
|
|
1549
1825
|
}
|
|
1550
1826
|
|
|
1827
|
+
/// Builds the eligible portion of this container for the host-neutral
|
|
1828
|
+
/// tree ABI. The local algorithm remains authoritative for
|
|
1829
|
+
/// grid-auto-rows, absolute children, and any subtree whose measurement
|
|
1830
|
+
/// cannot be represented by the shared contract.
|
|
1831
|
+
fileprivate func sharedTreeNode(parentWidth: CGFloat, parentHeight: CGFloat) -> CraftNativeTreeLayoutBridge.TreeNode? {
|
|
1832
|
+
guard gridAutoRows == nil else { return nil }
|
|
1833
|
+
let children = visibleChildren
|
|
1834
|
+
let flow = children.filter { $0.craftLayoutStyle.position != "absolute" }
|
|
1835
|
+
guard flow.count == children.count else { return nil }
|
|
1836
|
+
let style = craftLayoutStyle
|
|
1837
|
+
let width = style.width?.resolve(parentWidth)
|
|
1838
|
+
let height = style.height?.resolve(parentHeight)
|
|
1839
|
+
let intrinsic = CraftNativeFlexLayout.measure(
|
|
1840
|
+
self, width: width, height: height,
|
|
1841
|
+
maxWidth: max(0, parentWidth), maxHeight: max(0, parentHeight)
|
|
1842
|
+
)
|
|
1843
|
+
let measuredWidth = width ?? intrinsic.width
|
|
1844
|
+
let measuredHeight = height ?? intrinsic.height
|
|
1845
|
+
let contentWidth = max(0, measuredWidth - effectivePadding.left - effectivePadding.right)
|
|
1846
|
+
let contentHeight = max(0, measuredHeight - effectivePadding.top - effectivePadding.bottom)
|
|
1847
|
+
let nodes = flow.map { child -> CraftNativeTreeLayoutBridge.TreeNode? in
|
|
1848
|
+
let childStyle = child.craftLayoutStyle
|
|
1849
|
+
let childWidth = childStyle.width?.resolve(contentWidth)
|
|
1850
|
+
let childHeight = childStyle.height?.resolve(contentHeight)
|
|
1851
|
+
let childIntrinsic = CraftNativeFlexLayout.measure(
|
|
1852
|
+
child, width: childWidth, height: childHeight,
|
|
1853
|
+
maxWidth: contentWidth, maxHeight: contentHeight
|
|
1854
|
+
)
|
|
1855
|
+
let nested = child as? CraftNativeFlowView
|
|
1856
|
+
let nestedNode = nested?.sharedTreeNode(
|
|
1857
|
+
parentWidth: contentWidth, parentHeight: contentHeight
|
|
1858
|
+
)
|
|
1859
|
+
if nested != nil && nestedNode == nil { return nil }
|
|
1860
|
+
return CraftNativeTreeLayoutBridge.TreeNode(
|
|
1861
|
+
view: child,
|
|
1862
|
+
style: childStyle,
|
|
1863
|
+
intrinsic: childIntrinsic,
|
|
1864
|
+
baseline: Self.firstBaseline(child, cross: childIntrinsic.height),
|
|
1865
|
+
direction: nested?.axis ?? .horizontal,
|
|
1866
|
+
gap: nested.map { $0.axis == .horizontal ? ($0.columnGap ?? $0.gap) : ($0.rowGap ?? $0.gap) } ?? 0,
|
|
1867
|
+
rowGap: nested?.rowGap ?? 0,
|
|
1868
|
+
columnGap: nested?.columnGap ?? 0,
|
|
1869
|
+
wrap: nested?.wrap ?? false,
|
|
1870
|
+
justifyContent: nested?.justifyContent ?? "flex-start",
|
|
1871
|
+
alignItems: nested?.alignItems ?? "stretch",
|
|
1872
|
+
alignContent: nested?.alignContent ?? "flex-start",
|
|
1873
|
+
padding: nested?.effectivePadding ?? .zero,
|
|
1874
|
+
grid: nested?.grid ?? false,
|
|
1875
|
+
gridColumns: nested?.gridColumns ?? 1,
|
|
1876
|
+
reverse: nested?.reverse ?? false,
|
|
1877
|
+
wrapReverse: nested?.wrapReverse ?? false,
|
|
1878
|
+
children: nestedNode?.children ?? []
|
|
1879
|
+
)
|
|
1880
|
+
}
|
|
1881
|
+
guard nodes.allSatisfy({ $0 != nil }) else { return nil }
|
|
1882
|
+
return CraftNativeTreeLayoutBridge.TreeNode(
|
|
1883
|
+
view: self,
|
|
1884
|
+
style: style,
|
|
1885
|
+
intrinsic: intrinsic,
|
|
1886
|
+
baseline: 0,
|
|
1887
|
+
direction: axis,
|
|
1888
|
+
gap: axis == .horizontal ? (columnGap ?? gap) : (rowGap ?? gap),
|
|
1889
|
+
rowGap: rowGap ?? 0,
|
|
1890
|
+
columnGap: columnGap ?? 0,
|
|
1891
|
+
wrap: wrap,
|
|
1892
|
+
justifyContent: justifyContent,
|
|
1893
|
+
alignItems: alignItems,
|
|
1894
|
+
alignContent: alignContent,
|
|
1895
|
+
padding: effectivePadding,
|
|
1896
|
+
grid: grid,
|
|
1897
|
+
gridColumns: gridColumns,
|
|
1898
|
+
reverse: reverse,
|
|
1899
|
+
wrapReverse: wrapReverse,
|
|
1900
|
+
children: nodes.compactMap { $0 }
|
|
1901
|
+
)
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
private func sharedTreeFrames(_ children: [UIView], width: CGFloat?, height: CGFloat?) -> CraftNativeTreeLayoutBridge.TreeResult? {
|
|
1905
|
+
guard let width, let height, gridAutoRows == nil,
|
|
1906
|
+
width.isFinite, height.isFinite, width >= 0, height >= 0 else { return nil }
|
|
1907
|
+
let nodes = children.map { child -> CraftNativeTreeLayoutBridge.TreeNode? in
|
|
1908
|
+
let style = child.craftLayoutStyle
|
|
1909
|
+
let intrinsic = CraftNativeFlexLayout.measure(child, width: style.width?.resolve(width), height: style.height?.resolve(height), maxWidth: width, maxHeight: height)
|
|
1910
|
+
let nested = child as? CraftNativeFlowView
|
|
1911
|
+
let nestedNode = nested?.sharedTreeNode(parentWidth: width, parentHeight: height)
|
|
1912
|
+
if nested != nil && nestedNode == nil { return nil }
|
|
1913
|
+
return CraftNativeTreeLayoutBridge.TreeNode(
|
|
1914
|
+
view: child,
|
|
1915
|
+
style: style,
|
|
1916
|
+
intrinsic: intrinsic,
|
|
1917
|
+
baseline: Self.firstBaseline(child, cross: intrinsic.height),
|
|
1918
|
+
direction: nested?.axis ?? .horizontal,
|
|
1919
|
+
gap: nested.map { $0.axis == .horizontal ? ($0.columnGap ?? $0.gap) : ($0.rowGap ?? $0.gap) } ?? 0,
|
|
1920
|
+
rowGap: nested?.rowGap ?? 0,
|
|
1921
|
+
columnGap: nested?.columnGap ?? 0,
|
|
1922
|
+
wrap: nested?.wrap ?? false,
|
|
1923
|
+
justifyContent: nested?.justifyContent ?? "flex-start",
|
|
1924
|
+
alignItems: nested?.alignItems ?? "stretch",
|
|
1925
|
+
alignContent: nested?.alignContent ?? "flex-start",
|
|
1926
|
+
padding: nested?.effectivePadding ?? .zero,
|
|
1927
|
+
grid: nested?.grid ?? false,
|
|
1928
|
+
gridColumns: nested?.gridColumns ?? 1,
|
|
1929
|
+
reverse: nested?.reverse ?? false,
|
|
1930
|
+
wrapReverse: nested?.wrapReverse ?? false,
|
|
1931
|
+
children: nestedNode?.children ?? []
|
|
1932
|
+
)
|
|
1933
|
+
}
|
|
1934
|
+
guard nodes.allSatisfy({ $0 != nil }) else { return nil }
|
|
1935
|
+
let root = CraftNativeTreeLayoutBridge.TreeNode(
|
|
1936
|
+
view: nil,
|
|
1937
|
+
style: .empty,
|
|
1938
|
+
intrinsic: .zero,
|
|
1939
|
+
baseline: 0,
|
|
1940
|
+
direction: axis,
|
|
1941
|
+
gap: axis == .horizontal ? (columnGap ?? gap) : (rowGap ?? gap),
|
|
1942
|
+
rowGap: rowGap ?? 0,
|
|
1943
|
+
columnGap: columnGap ?? 0,
|
|
1944
|
+
wrap: wrap,
|
|
1945
|
+
justifyContent: justifyContent,
|
|
1946
|
+
alignItems: alignItems,
|
|
1947
|
+
alignContent: alignContent,
|
|
1948
|
+
padding: effectivePadding,
|
|
1949
|
+
grid: grid,
|
|
1950
|
+
gridColumns: gridColumns,
|
|
1951
|
+
reverse: reverse,
|
|
1952
|
+
wrapReverse: wrapReverse,
|
|
1953
|
+
children: nodes.compactMap { $0 }
|
|
1954
|
+
)
|
|
1955
|
+
return CraftNativeTreeLayoutBridge.treeFrames(root: root, width: width, height: height)
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1551
1958
|
/// The flexbox algorithm, single pass. `width`/`height` are this view's
|
|
1552
1959
|
/// outer size when its parent fixes it, nil when it sizes to its content
|
|
1553
1960
|
/// within `maxWidth`/`maxHeight`. Returns its outer size and, when
|
|
@@ -1609,7 +2016,19 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
1609
2016
|
frames: &frames
|
|
1610
2017
|
)
|
|
1611
2018
|
}
|
|
1612
|
-
if place, flow.count == children.count, let
|
|
2019
|
+
if place, flow.count == children.count, let sharedTree = sharedTreeFrames(flow, width: width, height: height), (sharedTree.nodes.count > flow.count + 1 || grid) {
|
|
2020
|
+
frames = []
|
|
2021
|
+
for index in 1..<sharedTree.nodes.count {
|
|
2022
|
+
guard let view = sharedTree.nodes[index].view else { continue }
|
|
2023
|
+
let offset = sharedTree.frames[index]
|
|
2024
|
+
let parent = sharedTree.parents[index]
|
|
2025
|
+
let parentFrame = sharedTree.frames[parent]
|
|
2026
|
+
frames.append((view, CGRect(
|
|
2027
|
+
x: CGFloat(offset.x - parentFrame.x), y: CGFloat(offset.y - parentFrame.y),
|
|
2028
|
+
width: CGFloat(offset.width), height: CGFloat(offset.height)
|
|
2029
|
+
)))
|
|
2030
|
+
}
|
|
2031
|
+
} else if place, flow.count == children.count, let shared = sharedDirectFrames(flow, width: width, height: height) {
|
|
1613
2032
|
frames = shared
|
|
1614
2033
|
}
|
|
1615
2034
|
let size = CGSize(width: width ?? contentSize.width + paddingWidth, height: height ?? contentSize.height + paddingHeight)
|
|
@@ -3321,6 +3740,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3321
3740
|
// a screen that hid its own.
|
|
3322
3741
|
navigationController?.setNavigationBarHidden(false, animated: animated)
|
|
3323
3742
|
}
|
|
3743
|
+
applyNavigationBarAppearance()
|
|
3324
3744
|
// Each screen says whether it wants the tab bar, so one that hid it
|
|
3325
3745
|
// never leaves it hidden for the next.
|
|
3326
3746
|
CraftChrome.shared.setHiddenByScreen(tabBarHidden)
|
|
@@ -3406,6 +3826,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3406
3826
|
|
|
3407
3827
|
private func refreshTraitDefaults() {
|
|
3408
3828
|
view.backgroundColor = screenBackgroundColor ?? config.resolvedBackgroundColor
|
|
3829
|
+
applyNavigationBarAppearance()
|
|
3409
3830
|
if let document = mutationDocument.node("root") {
|
|
3410
3831
|
renderCommitted(document)
|
|
3411
3832
|
} else {
|
|
@@ -3637,7 +4058,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3637
4058
|
/// - `craft.secureStorage.getSync(key)`: the Keychain item the web page's
|
|
3638
4059
|
/// `craft.secureStorage.set` wrote.
|
|
3639
4060
|
/// - `craft.navigation.setOptions({ title, largeTitle, hidden, backTitle,
|
|
3640
|
-
/// tabBarHidden, swipeBack, contentSwipeBack, tabSwipeEnabled,
|
|
4061
|
+
/// tabBarHidden, swipeBack, contentSwipeBack, tabSwipeEnabled,
|
|
4062
|
+
/// backgroundColor, navigationBarSolid, rightButtons })`;
|
|
3641
4063
|
/// a right button's tap is the event `navButton`.
|
|
3642
4064
|
///
|
|
3643
4065
|
/// A runtime that assigns `craft.storage` (or the others) afterwards keeps
|
|
@@ -3905,6 +4327,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3905
4327
|
/// own horizontal pager or chart gesture.
|
|
3906
4328
|
private(set) var tabSwipeEnabled = true
|
|
3907
4329
|
private var prefersLargeTitle: Bool?
|
|
4330
|
+
/// Whether this screen opts into an opaque navigation bar filled with its
|
|
4331
|
+
/// own background colour. The default keeps UIKit's material appearance.
|
|
4332
|
+
private var navigationBarSolid = false
|
|
3908
4333
|
/// The screen's own colour (`backgroundColor`), under the status bar and
|
|
3909
4334
|
/// the home indicator too, where its content never reaches: a black
|
|
3910
4335
|
/// full-screen timer is black edge to edge. nil is the app's.
|
|
@@ -3964,6 +4389,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3964
4389
|
screenBackgroundColor = color(options["backgroundColor"])
|
|
3965
4390
|
view.backgroundColor = screenBackgroundColor ?? config.resolvedBackgroundColor
|
|
3966
4391
|
}
|
|
4392
|
+
if let solid = options["navigationBarSolid"] as? Bool {
|
|
4393
|
+
navigationBarSolid = solid
|
|
4394
|
+
}
|
|
3967
4395
|
if let hidden = options["hidden"] as? Bool {
|
|
3968
4396
|
navigationBarHidden = hidden
|
|
3969
4397
|
if navigationController?.topViewController === self {
|
|
@@ -3974,6 +4402,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3974
4402
|
let buttons = (options["rightButtons"] as? [[String: Any]]) ?? []
|
|
3975
4403
|
navigationItem.rightBarButtonItems = buttons.compactMap(navigationButton).reversed()
|
|
3976
4404
|
}
|
|
4405
|
+
if options.keys.contains("backgroundColor") || options.keys.contains("navigationBarSolid") {
|
|
4406
|
+
applyNavigationBarAppearance()
|
|
4407
|
+
}
|
|
3977
4408
|
// Asked for after the bar was hidden for a bare root screen.
|
|
3978
4409
|
if navigationBarHidden == nil, wantsNavigationBar, navigationController?.topViewController === self,
|
|
3979
4410
|
navigationController?.isNavigationBarHidden == true {
|
|
@@ -3981,6 +4412,26 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3981
4412
|
}
|
|
3982
4413
|
}
|
|
3983
4414
|
|
|
4415
|
+
/// Applies the screen's optional opaque bar without changing the default
|
|
4416
|
+
/// UIKit material when the screen did not opt in.
|
|
4417
|
+
private func applyNavigationBarAppearance() {
|
|
4418
|
+
guard navigationBarSolid else {
|
|
4419
|
+
navigationItem.standardAppearance = nil
|
|
4420
|
+
navigationItem.scrollEdgeAppearance = nil
|
|
4421
|
+
navigationItem.compactAppearance = nil
|
|
4422
|
+
navigationItem.compactScrollEdgeAppearance = nil
|
|
4423
|
+
return
|
|
4424
|
+
}
|
|
4425
|
+
let appearance = UINavigationBarAppearance()
|
|
4426
|
+
appearance.configureWithOpaqueBackground()
|
|
4427
|
+
appearance.backgroundColor = screenBackgroundColor ?? config.resolvedBackgroundColor
|
|
4428
|
+
appearance.shadowColor = .clear
|
|
4429
|
+
navigationItem.standardAppearance = appearance
|
|
4430
|
+
navigationItem.scrollEdgeAppearance = appearance
|
|
4431
|
+
navigationItem.compactAppearance = appearance
|
|
4432
|
+
navigationItem.compactScrollEdgeAppearance = appearance
|
|
4433
|
+
}
|
|
4434
|
+
|
|
3984
4435
|
private func applyStatusBar(_ props: [String: Any]) {
|
|
3985
4436
|
let style: UIStatusBarStyle?
|
|
3986
4437
|
switch (props["barStyle"] as? String)?.lowercased() {
|