@craft-native/ios 0.0.499 → 0.0.502
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/package.json +1 -1
- package/templates/CraftApp.swift +17 -0
- package/templates/CraftHybrid.swift +16 -0
- package/templates/CraftNativeFiles.swift +152 -7
- package/templates/CraftNativeFlatList.swift +43 -6
- package/templates/CraftNativeScreen.swift +286 -86
- package/templates/CraftScheduledWorkouts.swift +11 -4
- package/templates/CraftWebAudio.swift +20 -1
package/package.json
CHANGED
package/templates/CraftApp.swift
CHANGED
|
@@ -838,6 +838,23 @@ final class CraftChrome: ObservableObject {
|
|
|
838
838
|
@Published var tabs: [Tab] = []
|
|
839
839
|
@Published var tabAccessory: Tab?
|
|
840
840
|
private weak var tabAccessoryOwner: AnyObject?
|
|
841
|
+
|
|
842
|
+
/// Where the compact tab actions float over the page, in window
|
|
843
|
+
/// coordinates, and the shell that drew them; nil when nothing floats.
|
|
844
|
+
/// They cover the trailing end of whatever scrolls behind them, so a
|
|
845
|
+
/// screen gives its scroll views that much room at the end (#523): the
|
|
846
|
+
/// last row can be brought clear without the app knowing this geometry.
|
|
847
|
+
private(set) var floatingActions: CGRect?
|
|
848
|
+
private(set) weak var floatingActionsHost: UIView?
|
|
849
|
+
static let floatingActionsDidMove = Notification.Name("CraftFloatingActionsDidMove")
|
|
850
|
+
|
|
851
|
+
func floatingActionsMoved(to frame: CGRect?, in host: UIView?) {
|
|
852
|
+
let next = frame.flatMap { $0.isNull || $0.isEmpty ? nil : $0.integral }
|
|
853
|
+
guard next != floatingActions || (next != nil && host !== floatingActionsHost) else { return }
|
|
854
|
+
floatingActions = next
|
|
855
|
+
floatingActionsHost = next == nil ? nil : host
|
|
856
|
+
NotificationCenter.default.post(name: Self.floatingActionsDidMove, object: self)
|
|
857
|
+
}
|
|
841
858
|
@Published var selected: String?
|
|
842
859
|
/// The visible destination may be the accessory, independently of its owning stack.
|
|
843
860
|
@Published var visiblePath: String?
|
|
@@ -1665,6 +1665,8 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
|
|
|
1665
1665
|
override func viewDidLayoutSubviews() {
|
|
1666
1666
|
super.viewDidLayoutSubviews()
|
|
1667
1667
|
guard hybrid.controller === self else { return }
|
|
1668
|
+
// Once the actions are where this pass puts them.
|
|
1669
|
+
defer { CraftChrome.shared.floatingActionsMoved(to: floatingActionsFrame(), in: view) }
|
|
1668
1670
|
#if compiler(>=6.2)
|
|
1669
1671
|
if #available(iOS 26.0, *), accessoryModel?.iconOnly != true {
|
|
1670
1672
|
// UIKit already supplies the native safe area. This measurement is
|
|
@@ -1701,6 +1703,20 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
|
|
|
1701
1703
|
CraftChrome.shared.layoutChanged(tabBar.isHidden ? 0 : max(0, view.bounds.maxY - tabBar.frame.minY) + accessoryInset)
|
|
1702
1704
|
}
|
|
1703
1705
|
|
|
1706
|
+
/// The compact actions' frame in the window, or nil when none float over
|
|
1707
|
+
/// the page: a full-width shelf is reserved for through the safe area,
|
|
1708
|
+
/// and a hidden tab bar takes its actions with it. A group is its one
|
|
1709
|
+
/// capsule; separate actions are the row of them.
|
|
1710
|
+
private func floatingActionsFrame() -> CGRect? {
|
|
1711
|
+
guard let model = accessoryModel, model.iconOnly, view.window != nil,
|
|
1712
|
+
accessoryGlass.superview === view, !accessoryGlass.isHidden, !tabBar.isHidden else { return nil }
|
|
1713
|
+
var frame = accessoryGlass.frame
|
|
1714
|
+
if model.accessoryLayout == nil {
|
|
1715
|
+
for glass in companionGlasses where !glass.isHidden { frame = frame.union(glass.frame) }
|
|
1716
|
+
}
|
|
1717
|
+
return view.convert(frame, to: view.window)
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1704
1720
|
/// One capsule above the trailing end of the tab bar, companions first
|
|
1705
1721
|
/// and the primary action last. Nothing divides the actions: the glass
|
|
1706
1722
|
/// is one control, and each action is found by its place and its symbol.
|
|
@@ -85,6 +85,84 @@ final class CraftNotificationAttachments {
|
|
|
85
85
|
// memory. `fetch` and the share sheet take the path; only files made here are
|
|
86
86
|
// read back for a form, so a screen cannot post an arbitrary file of the app's.
|
|
87
87
|
|
|
88
|
+
/// A request waiting on a system picker, as the one picker slot sees it.
|
|
89
|
+
protocol CraftPickerRequest: AnyObject {
|
|
90
|
+
/// Where its picker is.
|
|
91
|
+
var presence: CraftPickerPresence { get }
|
|
92
|
+
/// It has neither been answered by its picker nor finished.
|
|
93
|
+
var isWaiting: Bool { get }
|
|
94
|
+
/// The picker went without a choice or a cancel reaching its delegate.
|
|
95
|
+
/// One that was on screen answers as a cancel; one that never appeared
|
|
96
|
+
/// answers that it could not be presented.
|
|
97
|
+
func pickerLeft(shown: Bool)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
extension CraftPickerRequest {
|
|
101
|
+
/// Still waiting on a picker that can no longer answer.
|
|
102
|
+
var lostItsPicker: Bool { isWaiting && presence.lost }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/// Where a request's picker is: on its way to the screen, on it, or gone.
|
|
106
|
+
///
|
|
107
|
+
/// UIKit tells a picker's delegate about a choice and about a cancel. It
|
|
108
|
+
/// says nothing when the picker goes because what presented it did (an app
|
|
109
|
+
/// closing the sheet the picker was on), or when it gives a presentation up
|
|
110
|
+
/// before it began: a system picker runs in another process and is presented
|
|
111
|
+
/// once that process answers, and by then the sheet it was to appear on may
|
|
112
|
+
/// have closed ("whose view is not in the window hierarchy", in the console
|
|
113
|
+
/// only). The request then never answered and kept the one picker slot:
|
|
114
|
+
/// every later pick was refused as BUSY until the app was restarted.
|
|
115
|
+
final class CraftPickerPresence {
|
|
116
|
+
weak var picker: UIViewController?
|
|
117
|
+
/// What the picker was handed to for presenting.
|
|
118
|
+
weak var presenter: UIViewController?
|
|
119
|
+
weak var watch: CraftPickerWatch?
|
|
120
|
+
|
|
121
|
+
/// The picker has been on screen.
|
|
122
|
+
var shown: Bool { watch?.shown ?? false }
|
|
123
|
+
|
|
124
|
+
/// The picker cannot answer any more.
|
|
125
|
+
var lost: Bool {
|
|
126
|
+
guard let picker else { return true }
|
|
127
|
+
if picker.presentingViewController != nil { return false }
|
|
128
|
+
// It was on screen, is not now, and nothing presents it.
|
|
129
|
+
if shown { return picker.viewIfLoaded?.window == nil }
|
|
130
|
+
// It has not appeared yet, and what was to present it has gone.
|
|
131
|
+
guard let presenter else { return true }
|
|
132
|
+
return presenter.viewIfLoaded?.window == nil
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/// A view inside a system picker that says when the picker has been seen
|
|
137
|
+
/// and when it is gone: it leaves the window when the picker does and is
|
|
138
|
+
/// released when the picker is, whichever way that happens.
|
|
139
|
+
final class CraftPickerWatch: UIView {
|
|
140
|
+
private(set) var shown = false
|
|
141
|
+
private var left: ((_ shown: Bool) -> Void)?
|
|
142
|
+
|
|
143
|
+
static func attach(to picker: UIViewController, left: @escaping (_ shown: Bool) -> Void) -> CraftPickerWatch {
|
|
144
|
+
let watch = CraftPickerWatch(frame: .zero)
|
|
145
|
+
watch.isHidden = true
|
|
146
|
+
watch.isUserInteractionEnabled = false
|
|
147
|
+
watch.left = left
|
|
148
|
+
picker.view.addSubview(watch)
|
|
149
|
+
return watch
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
override func didMoveToWindow() {
|
|
153
|
+
super.didMoveToWindow()
|
|
154
|
+
if window != nil { shown = true } else if shown { report() }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
deinit { report() }
|
|
158
|
+
|
|
159
|
+
private func report() {
|
|
160
|
+
guard let left else { return }
|
|
161
|
+
let shown = shown
|
|
162
|
+
DispatchQueue.main.async { left(shown) }
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
88
166
|
enum CraftNativeFiles {
|
|
89
167
|
/// The longest side of a picked photo unless the screen asks otherwise:
|
|
90
168
|
/// sharp on any phone screen and in a share card, a few hundred kilobytes
|
|
@@ -104,6 +182,40 @@ enum CraftNativeFiles {
|
|
|
104
182
|
/// The picker on screen, kept alive until it answers. One at a time.
|
|
105
183
|
private static var activePicker: NSObject?
|
|
106
184
|
|
|
185
|
+
/// The slot is free when nothing holds it, or when what holds it is
|
|
186
|
+
/// waiting on a picker that can no longer answer.
|
|
187
|
+
private static func pickerSlotIsFree() -> Bool {
|
|
188
|
+
guard let request = activePicker as? CraftPickerRequest else { return activePicker == nil }
|
|
189
|
+
guard request.lostItsPicker else { return false }
|
|
190
|
+
activePicker = nil
|
|
191
|
+
request.pickerLeft(shown: request.presence.shown)
|
|
192
|
+
return true
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/// Follow `picker` for the request that is waiting on it.
|
|
196
|
+
private static func watch(_ picker: UIViewController, for request: CraftPickerRequest) {
|
|
197
|
+
let presence = request.presence
|
|
198
|
+
presence.picker = picker
|
|
199
|
+
presence.presenter = CraftPresenter.topViewController()
|
|
200
|
+
presence.watch = CraftPickerWatch.attach(to: picker) { [weak request] shown in request?.pickerLeft(shown: shown) }
|
|
201
|
+
awaitAppearance(of: request, checks: 60)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/// Until the picker is on screen nothing of its own can say it never
|
|
205
|
+
/// will be, and UIKit may hold a presentation it cannot make for as long
|
|
206
|
+
/// as the picker's process takes to answer. Look twice a second, for half
|
|
207
|
+
/// a minute at most, and answer for a picker whose presenter has gone.
|
|
208
|
+
private static func awaitAppearance(of request: CraftPickerRequest, checks: Int) {
|
|
209
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak request] in
|
|
210
|
+
guard let request, request.isWaiting, !request.presence.shown else { return }
|
|
211
|
+
if request.presence.lost {
|
|
212
|
+
request.pickerLeft(shown: false)
|
|
213
|
+
} else if checks > 1 {
|
|
214
|
+
awaitAppearance(of: request, checks: checks - 1)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
107
219
|
// MARK: Files of our own
|
|
108
220
|
|
|
109
221
|
/// A new file called `name`, in a folder of its own so two files can share
|
|
@@ -208,7 +320,7 @@ enum CraftNativeFiles {
|
|
|
208
320
|
.map { $0.doubleValue }
|
|
209
321
|
.flatMap { $0.isFinite ? $0 : nil } ?? defaultQuality))
|
|
210
322
|
let source = options["source"] as? String ?? "library"
|
|
211
|
-
guard
|
|
323
|
+
guard pickerSlotIsFree() else {
|
|
212
324
|
done(.failure(CraftNativeActionError(code: "BUSY", message: "A picker is already open")))
|
|
213
325
|
return
|
|
214
326
|
}
|
|
@@ -245,6 +357,7 @@ enum CraftNativeFiles {
|
|
|
245
357
|
picker.delegate = delegate
|
|
246
358
|
delegate.controller = picker
|
|
247
359
|
activePicker = delegate
|
|
360
|
+
watch(picker, for: delegate)
|
|
248
361
|
if !CraftPresenter.present(picker, from: nil) {
|
|
249
362
|
delegate.finish(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the photo picker")))
|
|
250
363
|
}
|
|
@@ -291,7 +404,7 @@ enum CraftNativeFiles {
|
|
|
291
404
|
return "\(base).jpg"
|
|
292
405
|
}
|
|
293
406
|
|
|
294
|
-
final class LibraryDelegate: NSObject, PHPickerViewControllerDelegate {
|
|
407
|
+
final class LibraryDelegate: NSObject, PHPickerViewControllerDelegate, CraftPickerRequest {
|
|
295
408
|
let maxPixels: Int
|
|
296
409
|
let quality: Double
|
|
297
410
|
let videoLimit: Int?
|
|
@@ -353,8 +466,18 @@ enum CraftNativeFiles {
|
|
|
353
466
|
}
|
|
354
467
|
}
|
|
355
468
|
|
|
469
|
+
let presence = CraftPickerPresence()
|
|
470
|
+
var isWaiting: Bool { !answered && !finished }
|
|
471
|
+
|
|
472
|
+
func pickerLeft(shown: Bool) {
|
|
473
|
+
guard lostItsPicker else { return }
|
|
474
|
+
answered = true
|
|
475
|
+
finish(shown ? .success(NSNull()) : .failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the \(videoLimit == nil ? "photo" : "video") picker")))
|
|
476
|
+
}
|
|
477
|
+
|
|
356
478
|
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
|
357
|
-
|
|
479
|
+
// A picker whose request was already answered still closes.
|
|
480
|
+
guard !answered else { picker.dismiss(animated: true); return }
|
|
358
481
|
picker.dismiss(animated: true)
|
|
359
482
|
guard let provider = results.first?.itemProvider else {
|
|
360
483
|
answered = true
|
|
@@ -403,12 +526,13 @@ enum CraftNativeFiles {
|
|
|
403
526
|
picker.delegate = delegate
|
|
404
527
|
delegate.controller = picker
|
|
405
528
|
activePicker = delegate
|
|
529
|
+
watch(picker, for: delegate)
|
|
406
530
|
if !CraftPresenter.present(picker, from: nil) {
|
|
407
531
|
delegate.finish(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the camera")))
|
|
408
532
|
}
|
|
409
533
|
}
|
|
410
534
|
|
|
411
|
-
final class CameraDelegate: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
|
|
535
|
+
final class CameraDelegate: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate, CraftPickerRequest {
|
|
412
536
|
let maxPixels: Int
|
|
413
537
|
let quality: Double
|
|
414
538
|
let requestToken: String?
|
|
@@ -483,6 +607,15 @@ enum CraftNativeFiles {
|
|
|
483
607
|
answered = true
|
|
484
608
|
finish(.success(NSNull()))
|
|
485
609
|
}
|
|
610
|
+
|
|
611
|
+
let presence = CraftPickerPresence()
|
|
612
|
+
var isWaiting: Bool { !answered && !finished }
|
|
613
|
+
|
|
614
|
+
func pickerLeft(shown: Bool) {
|
|
615
|
+
guard lostItsPicker else { return }
|
|
616
|
+
answered = true
|
|
617
|
+
finish(shown ? .success(NSNull()) : .failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the camera")))
|
|
618
|
+
}
|
|
486
619
|
}
|
|
487
620
|
|
|
488
621
|
#endif
|
|
@@ -493,7 +626,7 @@ enum CraftNativeFiles {
|
|
|
493
626
|
/// the provider's URL is valid; no movie bytes cross JavaScriptCore.
|
|
494
627
|
static func pickVideo(_ options: [String: Any], requestToken: String? = nil, isCancelled: @escaping () -> Bool = { false }, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
|
|
495
628
|
guard !isCancelled() else { done(.failure(importCancelled())); return }
|
|
496
|
-
guard
|
|
629
|
+
guard pickerSlotIsFree() else {
|
|
497
630
|
done(.failure(CraftNativeActionError(code: "BUSY", message: "A picker is already open")))
|
|
498
631
|
return
|
|
499
632
|
}
|
|
@@ -512,6 +645,7 @@ enum CraftNativeFiles {
|
|
|
512
645
|
picker.delegate = delegate
|
|
513
646
|
delegate.controller = picker
|
|
514
647
|
activePicker = delegate
|
|
648
|
+
watch(picker, for: delegate)
|
|
515
649
|
if !CraftPresenter.present(picker, from: nil) {
|
|
516
650
|
delegate.finish(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the video picker")))
|
|
517
651
|
}
|
|
@@ -525,6 +659,7 @@ enum CraftNativeFiles {
|
|
|
525
659
|
picker.delegate = delegate
|
|
526
660
|
delegate.controller = picker
|
|
527
661
|
activePicker = delegate
|
|
662
|
+
watch(picker, for: delegate)
|
|
528
663
|
if !CraftPresenter.present(picker, from: nil) {
|
|
529
664
|
delegate.finish(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the video picker")))
|
|
530
665
|
}
|
|
@@ -538,7 +673,7 @@ enum CraftNativeFiles {
|
|
|
538
673
|
/// UTIs, or filename extensions; an empty list means any document.
|
|
539
674
|
static func pickFile(_ options: [String: Any], requestToken: String? = nil, isCancelled: @escaping () -> Bool = { false }, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
|
|
540
675
|
guard !isCancelled() else { done(.failure(importCancelled())); return }
|
|
541
|
-
guard
|
|
676
|
+
guard pickerSlotIsFree() else {
|
|
542
677
|
done(.failure(CraftNativeActionError(code: "BUSY", message: "A picker is already open")))
|
|
543
678
|
return
|
|
544
679
|
}
|
|
@@ -567,6 +702,7 @@ enum CraftNativeFiles {
|
|
|
567
702
|
picker.delegate = delegate
|
|
568
703
|
delegate.controller = picker
|
|
569
704
|
activePicker = delegate
|
|
705
|
+
watch(picker, for: delegate)
|
|
570
706
|
if !CraftPresenter.present(picker, from: nil) {
|
|
571
707
|
delegate.finish(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the file picker")))
|
|
572
708
|
}
|
|
@@ -727,7 +863,7 @@ enum CraftNativeFiles {
|
|
|
727
863
|
}
|
|
728
864
|
}
|
|
729
865
|
|
|
730
|
-
final class DocumentDelegate: NSObject, UIDocumentPickerDelegate {
|
|
866
|
+
final class DocumentDelegate: NSObject, UIDocumentPickerDelegate, CraftPickerRequest {
|
|
731
867
|
let limit: Int
|
|
732
868
|
let video: Bool
|
|
733
869
|
let requestToken: String?
|
|
@@ -768,6 +904,15 @@ enum CraftNativeFiles {
|
|
|
768
904
|
finish(.success(video ? NSNull() : []))
|
|
769
905
|
}
|
|
770
906
|
|
|
907
|
+
let presence = CraftPickerPresence()
|
|
908
|
+
var isWaiting: Bool { !answered && !finished }
|
|
909
|
+
|
|
910
|
+
func pickerLeft(shown: Bool) {
|
|
911
|
+
guard lostItsPicker else { return }
|
|
912
|
+
answered = true
|
|
913
|
+
finish(shown ? .success(video ? NSNull() : []) : .failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the \(video ? "video" : "file") picker")))
|
|
914
|
+
}
|
|
915
|
+
|
|
771
916
|
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
|
772
917
|
guard !answered else { return }
|
|
773
918
|
answered = true
|
|
@@ -316,7 +316,8 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
316
316
|
rows += (pending + columns - 1) / columns
|
|
317
317
|
content = CGFloat(rows) * 44 + CGFloat(max(0, rows - 1)) * flowLayout.minimumLineSpacing
|
|
318
318
|
}
|
|
319
|
-
|
|
319
|
+
// Its rows and its own padding: not the room left for what floats over it.
|
|
320
|
+
return max(0, ceil(content + contentPadding.top + contentPadding.bottom))
|
|
320
321
|
}
|
|
321
322
|
|
|
322
323
|
/// The width a row gets: the list's own width inside its content padding,
|
|
@@ -406,6 +407,24 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
406
407
|
bottom: nonNegative(raw?["paddingBottom"]) ?? vertical,
|
|
407
408
|
right: nonNegative(raw?["paddingRight"]) ?? horizontal
|
|
408
409
|
)
|
|
410
|
+
guard contentPadding != inset else { return }
|
|
411
|
+
contentPadding = inset
|
|
412
|
+
applyContentInsets()
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/// `contentContainerStyle`'s padding, which the list draws as its insets.
|
|
416
|
+
private var contentPadding: UIEdgeInsets = .zero
|
|
417
|
+
|
|
418
|
+
/// How far the compact tab actions reach over the end of this list. The
|
|
419
|
+
/// rows get that much more room at the end, so the last one can be
|
|
420
|
+
/// brought clear of them (#523).
|
|
421
|
+
var floatingCover: CGFloat = 0 {
|
|
422
|
+
didSet { if abs(oldValue - floatingCover) > 0.5 { applyContentInsets() } }
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
private func applyContentInsets() {
|
|
426
|
+
var inset = contentPadding
|
|
427
|
+
if flowLayout.scrollDirection == .vertical { inset.bottom += floatingCover }
|
|
409
428
|
guard contentInset != inset else { return }
|
|
410
429
|
contentInset = inset
|
|
411
430
|
scrollIndicatorInsets = inset
|
|
@@ -507,11 +526,10 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
507
526
|
let oldItems = itemsByIdentity
|
|
508
527
|
let viewportAnchor = currentViewportAnchor()
|
|
509
528
|
var seen: [String: Int] = [:]
|
|
529
|
+
var taken = Set<String>()
|
|
510
530
|
var next = nodes.enumerated().map { index, node -> Item in
|
|
511
531
|
let base = Self.identity(for: node) ?? "index:\(index)"
|
|
512
|
-
let
|
|
513
|
-
seen[base] = occurrence + 1
|
|
514
|
-
let identity = occurrence == 0 ? base : "\(base)#\(occurrence)"
|
|
532
|
+
let identity = Self.uniqueIdentity(base, seen: &seen, taken: &taken)
|
|
515
533
|
let props = node["props"] as? [String: Any] ?? [:]
|
|
516
534
|
let role = props["listRole"] as? String
|
|
517
535
|
let signature = (try? JSONSerialization.data(withJSONObject: node, options: [.sortedKeys])) ?? Data()
|
|
@@ -532,7 +550,7 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
532
550
|
return dataItemCount - 1
|
|
533
551
|
}
|
|
534
552
|
items = next
|
|
535
|
-
itemsByIdentity = Dictionary(
|
|
553
|
+
itemsByIdentity = Dictionary(next.map { ($0.identity, $0) }, uniquingKeysWith: { first, _ in first })
|
|
536
554
|
|
|
537
555
|
flowLayout.scrollDirection = horizontal ? .horizontal : .vertical
|
|
538
556
|
alwaysBounceHorizontal = horizontal
|
|
@@ -680,7 +698,7 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
680
698
|
let lastDataOrdinal = dataOrdinals[lastVisible]
|
|
681
699
|
guard lastDataOrdinal >= 0 else { return }
|
|
682
700
|
let remaining = dataItemCount - lastDataOrdinal - 1
|
|
683
|
-
let thresholdItems = max(1,
|
|
701
|
+
let thresholdItems = max(1, craftNativeInt(ceil(Double(dataItemCount) * endReachedThreshold)))
|
|
684
702
|
guard remaining <= thresholdItems else { return }
|
|
685
703
|
let signature = dataContentSignature
|
|
686
704
|
guard endReachedSignature != signature else { return }
|
|
@@ -864,6 +882,25 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
864
882
|
setContentOffset(clamped(offset), animated: false)
|
|
865
883
|
}
|
|
866
884
|
|
|
885
|
+
/// A row's identity, its own in this list.
|
|
886
|
+
///
|
|
887
|
+
/// Rows that share a key are told apart by a suffix: `run`, then `run#1`.
|
|
888
|
+
/// A row whose own key was `run#1` then had the identity of the second
|
|
889
|
+
/// `run`, and the list's table and its snapshot both trap on a repeated
|
|
890
|
+
/// identity: three tags keyed `run`, `run` and `run#1` ended the process.
|
|
891
|
+
/// The suffix counts on until the identity is one no row has taken. A
|
|
892
|
+
/// list with no such key has the identities it always had.
|
|
893
|
+
static func uniqueIdentity(_ base: String, seen: inout [String: Int], taken: inout Set<String>) -> String {
|
|
894
|
+
var occurrence = seen[base, default: 0]
|
|
895
|
+
var identity = occurrence == 0 ? base : "\(base)#\(occurrence)"
|
|
896
|
+
while !taken.insert(identity).inserted {
|
|
897
|
+
occurrence += 1
|
|
898
|
+
identity = "\(base)#\(occurrence)"
|
|
899
|
+
}
|
|
900
|
+
seen[base] = occurrence + 1
|
|
901
|
+
return identity
|
|
902
|
+
}
|
|
903
|
+
|
|
867
904
|
private static func identity(for node: [String: Any]) -> String? {
|
|
868
905
|
let props = node["props"] as? [String: Any] ?? [:]
|
|
869
906
|
return [node["key"], props["key"], props["testID"], node["id"]]
|
|
@@ -150,6 +150,38 @@ func craftNativeNumber(_ value: Any?) -> NSNumber? {
|
|
|
150
150
|
return number
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
/// A number from a prop as an `Int`, the way `toInt()` gives it on the
|
|
154
|
+
/// Android host: toward zero, the nearest end of the 32-bit range when it is
|
|
155
|
+
/// outside it, and 0 when it is not a number.
|
|
156
|
+
///
|
|
157
|
+
/// `Int(_:)` traps on a value it cannot hold, and a prop is whatever the
|
|
158
|
+
/// screen's script said: `maxLength={Number.MAX_VALUE}` ended the process
|
|
159
|
+
/// (#510). The 32-bit range is the other host's, and it leaves room for the
|
|
160
|
+
/// arithmetic done on the result, which `Int.max` would not.
|
|
161
|
+
func craftNativeInt(_ value: Double) -> Int {
|
|
162
|
+
guard !value.isNaN else { return 0 }
|
|
163
|
+
return Int(max(Double(Int32.min), min(Double(Int32.max), value.rounded(.towardZero))))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
func craftNativeInt(_ value: CGFloat) -> Int { craftNativeInt(Double(value)) }
|
|
167
|
+
|
|
168
|
+
/// The most a number may be in layout, either side of zero: 2^25 points. It
|
|
169
|
+
/// is far past any screen, and small enough that what the layout makes of a
|
|
170
|
+
/// few such numbers multiplied together still fits a Float, which the flex
|
|
171
|
+
/// engine works in.
|
|
172
|
+
let craftNativeLayoutLimit: CGFloat = 33_554_432
|
|
173
|
+
|
|
174
|
+
/// A number for layout: inside that limit, and 0 when it is not a number.
|
|
175
|
+
///
|
|
176
|
+
/// A style is whatever the script said. `flex: Number.MAX_VALUE` is finite,
|
|
177
|
+
/// and became an infinite share of a row, an infinite frame, and then
|
|
178
|
+
/// UIKit's `NSLayoutConstraint constant is not finite! That's illegal`, an
|
|
179
|
+
/// exception nothing catches: the app ended. Lengths and flex factors are
|
|
180
|
+
/// held to this as they are read, and so is every frame the layout produces.
|
|
181
|
+
func craftNativeLayoutNumber(_ value: CGFloat) -> CGFloat {
|
|
182
|
+
value.isNaN ? 0 : max(-craftNativeLayoutLimit, min(craftNativeLayoutLimit, value))
|
|
183
|
+
}
|
|
184
|
+
|
|
153
185
|
/// A finite number from a prop, for the views outside the screen controller,
|
|
154
186
|
/// which has its own: the keyboard-avoiding view and SVG text read theirs
|
|
155
187
|
/// with it, and without it the template did not compile.
|
|
@@ -161,11 +193,19 @@ private func number(_ value: Any?) -> CGFloat? {
|
|
|
161
193
|
return value.isFinite ? value : nil
|
|
162
194
|
}
|
|
163
195
|
|
|
196
|
+
/// A finite number from a style, held to what layout can use (see
|
|
197
|
+
/// `craftNativeLayoutNumber`). A padding, radius, spacing or size of
|
|
198
|
+
/// `Number.MAX_VALUE` reached UIKit as an inset or a font it could not place,
|
|
199
|
+
/// and UIKit raised `CALayer position contains NaN`: the app ended.
|
|
200
|
+
private func styleNumber(_ value: Any?) -> CGFloat? {
|
|
201
|
+
number(value).map(craftNativeLayoutNumber)
|
|
202
|
+
}
|
|
203
|
+
|
|
164
204
|
private func craftNativeFontWeight(_ value: Any?) -> String? {
|
|
165
205
|
if let value = value as? String { return value }
|
|
166
206
|
guard let number = craftNativeNumber(value), number.doubleValue.isFinite else { return nil }
|
|
167
207
|
let integer = number.doubleValue.rounded()
|
|
168
|
-
return integer == number.doubleValue ? String(
|
|
208
|
+
return integer == number.doubleValue ? String(craftNativeInt(integer)) : nil
|
|
169
209
|
}
|
|
170
210
|
|
|
171
211
|
private func craftNativePickerValue(_ value: Any?) -> String? {
|
|
@@ -174,31 +214,34 @@ private func craftNativePickerValue(_ value: Any?) -> String? {
|
|
|
174
214
|
return nil
|
|
175
215
|
}
|
|
176
216
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
}
|
|
185
|
-
let tracks = trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" || $0 == "\n" })
|
|
186
|
-
return max(1, tracks.count)
|
|
187
|
-
}
|
|
217
|
+
/// The most tracks a grid is given, either way. A count is whatever the
|
|
218
|
+
/// script said (`gridTemplateRows: Number.MAX_VALUE`,
|
|
219
|
+
/// `repeat(4294967296, 1fr)`), and the layout makes an array of rows that
|
|
220
|
+
/// long and hands the shared engine both counts as a `UInt32`: the first ran
|
|
221
|
+
/// the process out of memory and the second trapped. No screen has room to
|
|
222
|
+
/// show more tracks than this.
|
|
223
|
+
let craftNativeGridTrackLimit = 1024
|
|
188
224
|
|
|
189
|
-
|
|
190
|
-
if let number = craftNativeNumber(value) {
|
|
225
|
+
func craftNativeGridTrackCount(_ value: Any?) -> Int? {
|
|
226
|
+
if let number = craftNativeNumber(value) {
|
|
227
|
+
return min(craftNativeGridTrackLimit, max(1, craftNativeInt(number.doubleValue)))
|
|
228
|
+
}
|
|
191
229
|
guard let text = value as? String else { return nil }
|
|
192
230
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
193
231
|
guard !trimmed.isEmpty else { return nil }
|
|
194
232
|
if trimmed.hasPrefix("repeat(") {
|
|
195
|
-
let digits = trimmed.dropFirst("repeat(".count).prefix { $0
|
|
196
|
-
|
|
233
|
+
let digits = trimmed.dropFirst("repeat(".count).prefix { ("0"..."9").contains($0) }
|
|
234
|
+
// Digits past what an Int holds are still a count, and still too many.
|
|
235
|
+
if !digits.isEmpty { return min(craftNativeGridTrackLimit, max(1, Int(digits) ?? craftNativeGridTrackLimit)) }
|
|
197
236
|
}
|
|
198
237
|
let tracks = trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" || $0 == "\n" })
|
|
199
|
-
return tracks.isEmpty ? nil :
|
|
238
|
+
return tracks.isEmpty ? nil : min(craftNativeGridTrackLimit, tracks.count)
|
|
200
239
|
}
|
|
201
240
|
|
|
241
|
+
private func craftNativeGridColumnCount(_ value: Any?) -> Int { craftNativeGridTrackCount(value) ?? 1 }
|
|
242
|
+
|
|
243
|
+
private func craftNativeGridRowCount(_ value: Any?) -> Int? { craftNativeGridTrackCount(value) }
|
|
244
|
+
|
|
202
245
|
/// Optional bridge to the shared Zig flex rule. Native hosts retain their
|
|
203
246
|
/// local implementation when no Craft runtime is linked (the normal SDK
|
|
204
247
|
/// fallback), while runtime-backed apps get one cross-platform calculation.
|
|
@@ -2389,16 +2432,16 @@ private enum CraftNativeLength: Equatable {
|
|
|
2389
2432
|
if let number = craftNativeNumber(value) {
|
|
2390
2433
|
let points = CGFloat(truncating: number)
|
|
2391
2434
|
guard points.isFinite else { return nil }
|
|
2392
|
-
self = .points(points)
|
|
2435
|
+
self = .points(craftNativeLayoutNumber(points))
|
|
2393
2436
|
return
|
|
2394
2437
|
}
|
|
2395
2438
|
guard let text = (value as? String)?.trimmingCharacters(in: .whitespaces), !text.isEmpty else { return nil }
|
|
2396
2439
|
if text.hasSuffix("%"), let fraction = Double(text.dropLast()), fraction.isFinite {
|
|
2397
|
-
self = .percent(CGFloat(fraction) / 100)
|
|
2440
|
+
self = .percent(craftNativeLayoutNumber(CGFloat(fraction) / 100))
|
|
2398
2441
|
} else if text.hasSuffix("px"), let points = Double(text.dropLast(2)), points.isFinite {
|
|
2399
|
-
self = .points(CGFloat(points))
|
|
2442
|
+
self = .points(craftNativeLayoutNumber(CGFloat(points)))
|
|
2400
2443
|
} else if let points = Double(text), points.isFinite {
|
|
2401
|
-
self = .points(CGFloat(points))
|
|
2444
|
+
self = .points(craftNativeLayoutNumber(CGFloat(points)))
|
|
2402
2445
|
} else {
|
|
2403
2446
|
return nil
|
|
2404
2447
|
}
|
|
@@ -2449,7 +2492,7 @@ private struct CraftNativeLayoutStyle {
|
|
|
2449
2492
|
func number(_ value: Any?) -> CGFloat? {
|
|
2450
2493
|
guard let number = craftNativeNumber(value) else { return nil }
|
|
2451
2494
|
let value = CGFloat(truncating: number)
|
|
2452
|
-
return value.isFinite ? value : nil
|
|
2495
|
+
return value.isFinite ? craftNativeLayoutNumber(value) : nil
|
|
2453
2496
|
}
|
|
2454
2497
|
width = CraftNativeLength(raw["width"])
|
|
2455
2498
|
height = CraftNativeLength(raw["height"])
|
|
@@ -2466,7 +2509,7 @@ private struct CraftNativeLayoutStyle {
|
|
|
2466
2509
|
left = number(raw["left"])
|
|
2467
2510
|
let align = raw["alignSelf"] as? String
|
|
2468
2511
|
alignSelf = align == "auto" ? nil : align
|
|
2469
|
-
order = number(raw["order"]).map {
|
|
2512
|
+
order = number(raw["order"]).map { craftNativeInt($0) } ?? 0
|
|
2470
2513
|
// `flex: n` is the web's and React Native's shorthand: a positive
|
|
2471
2514
|
// number grows by n, shrinks, and starts from nothing, so `flex-1`
|
|
2472
2515
|
// siblings share a row equally whatever their content.
|
|
@@ -2832,7 +2875,7 @@ enum CraftNativeFlexLayout {
|
|
|
2832
2875
|
static func ceiled(_ size: CGSize, scale: CGFloat, atLeast floor: CGSize) -> CGSize {
|
|
2833
2876
|
let scale = max(1, scale)
|
|
2834
2877
|
// Less a hair, so a size already on the pixel grid stays put.
|
|
2835
|
-
func up(_ value: CGFloat) -> CGFloat { (value * scale - 0.001).rounded(.up) / scale }
|
|
2878
|
+
func up(_ value: CGFloat) -> CGFloat { (craftNativeLayoutNumber(value) * scale - 0.001).rounded(.up) / scale }
|
|
2836
2879
|
return CGSize(width: max(floor.width, up(size.width)), height: max(floor.height, up(size.height)))
|
|
2837
2880
|
}
|
|
2838
2881
|
|
|
@@ -2840,7 +2883,9 @@ enum CraftNativeFlexLayout {
|
|
|
2840
2883
|
/// neither overlap nor leave a hairline between them.
|
|
2841
2884
|
static func snapped(_ rect: CGRect, scale: CGFloat) -> CGRect {
|
|
2842
2885
|
let scale = max(1, scale)
|
|
2843
|
-
|
|
2886
|
+
// Whatever the arithmetic before this made of the styles it was
|
|
2887
|
+
// given, the frame UIKit is handed is one it accepts.
|
|
2888
|
+
func snap(_ value: CGFloat) -> CGFloat { (craftNativeLayoutNumber(value) * scale).rounded() / scale }
|
|
2844
2889
|
let minX = snap(rect.minX), minY = snap(rect.minY)
|
|
2845
2890
|
return CGRect(x: minX, y: minY, width: max(0, snap(rect.maxX) - minX), height: max(0, snap(rect.maxY) - minY))
|
|
2846
2891
|
}
|
|
@@ -3314,6 +3359,7 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
3314
3359
|
if moved {
|
|
3315
3360
|
craftScreenController?.scheduleLayoutEvents()
|
|
3316
3361
|
craftScreenController?.scheduleKeyboardInsets()
|
|
3362
|
+
craftScreenController?.scheduleFloatingClearance()
|
|
3317
3363
|
}
|
|
3318
3364
|
}
|
|
3319
3365
|
for (child, frame) in result.frames {
|
|
@@ -4333,7 +4379,11 @@ class CraftNativePositionedImageView: UIView {
|
|
|
4333
4379
|
}
|
|
4334
4380
|
let rawFit = value("objectFit"), rawPosition = value("objectPosition")
|
|
4335
4381
|
if rawFit != nil || rawPosition != nil {
|
|
4336
|
-
|
|
4382
|
+
// A position does not choose a fit, as in CSS. STX compiles
|
|
4383
|
+
// `object-cover` to `resizeMode: "cover"` and sets no objectFit,
|
|
4384
|
+
// so a cover image given a position was drawn as "fill":
|
|
4385
|
+
// stretched to its box instead of cropped (#532).
|
|
4386
|
+
let requested = rawFit as? String ?? Self.objectFit(resizeMode: style["resizeMode"] as? String ?? props["resizeMode"] as? String)
|
|
4337
4387
|
fit = ["fill", "contain", "cover", "none", "scale-down"].contains(requested) ? requested : "fill"
|
|
4338
4388
|
position = Self.objectPosition(rawPosition)
|
|
4339
4389
|
} else { fit = nil }
|
|
@@ -4361,6 +4411,17 @@ class CraftNativePositionedImageView: UIView {
|
|
|
4361
4411
|
var usesObjectGeometry: Bool { fit != nil }
|
|
4362
4412
|
var objectCovers: Bool { fit == "cover" }
|
|
4363
4413
|
|
|
4414
|
+
/// The fit a `resizeMode` already asks for, for an image that has a
|
|
4415
|
+
/// position and no `objectFit`; "fill" when it has neither.
|
|
4416
|
+
static func objectFit(resizeMode: String?) -> String {
|
|
4417
|
+
switch resizeMode {
|
|
4418
|
+
case "cover": return "cover"
|
|
4419
|
+
case "contain": return "contain"
|
|
4420
|
+
case "center": return "none"
|
|
4421
|
+
default: return "fill"
|
|
4422
|
+
}
|
|
4423
|
+
}
|
|
4424
|
+
|
|
4364
4425
|
private static func objectPosition(_ raw: Any?) -> CGPoint {
|
|
4365
4426
|
guard let text = raw as? String else { return CGPoint(x: 0.5, y: 0.5) }
|
|
4366
4427
|
var parts = text.lowercased().split(whereSeparator: { $0.isWhitespace }).map(String.init)
|
|
@@ -5001,7 +5062,7 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
|
|
|
5001
5062
|
/// these indices stay at the top of the visible area once scrolled to,
|
|
5002
5063
|
/// each pushed up by the next. Vertical scroll views only.
|
|
5003
5064
|
func setStickyHeaderIndices(_ value: Any?) {
|
|
5004
|
-
let next = Array(Set(((value as? [Any]) ?? []).compactMap { number($0).map {
|
|
5065
|
+
let next = Array(Set(((value as? [Any]) ?? []).compactMap { number($0).map { craftNativeInt($0) } }.filter { $0 >= 0 })).sorted()
|
|
5005
5066
|
guard next != stickyIndices else { return }
|
|
5006
5067
|
stickyIndices = next
|
|
5007
5068
|
setNeedsLayout()
|
|
@@ -5117,6 +5178,19 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
|
|
|
5117
5178
|
setContentNeedsLayout()
|
|
5118
5179
|
}
|
|
5119
5180
|
|
|
5181
|
+
/// What covers the end of this scroll view from below: the keyboard, and
|
|
5182
|
+
/// the compact tab actions floating above the tab bar (#523). The content
|
|
5183
|
+
/// is inset by the larger, so its last lines can be scrolled clear of
|
|
5184
|
+
/// whichever reaches further.
|
|
5185
|
+
var keyboardCover: CGFloat = 0 { didSet { applyCover() } }
|
|
5186
|
+
var floatingCover: CGFloat = 0 { didSet { applyCover() } }
|
|
5187
|
+
|
|
5188
|
+
private func applyCover() {
|
|
5189
|
+
let cover = max(keyboardCover, floatingCover)
|
|
5190
|
+
if abs(contentInset.bottom - cover) > 0.5 { contentInset.bottom = cover }
|
|
5191
|
+
if abs(verticalScrollIndicatorInsets.bottom - cover) > 0.5 { verticalScrollIndicatorInsets.bottom = cover }
|
|
5192
|
+
}
|
|
5193
|
+
|
|
5120
5194
|
private var refreshingProp = false
|
|
5121
5195
|
|
|
5122
5196
|
/// `onRefresh` and `refreshing`: the system's pull to refresh, controlled
|
|
@@ -5525,7 +5599,7 @@ final class CraftNativeSvgView: UIView {
|
|
|
5525
5599
|
var out = from
|
|
5526
5600
|
var i = 0
|
|
5527
5601
|
while i < from.count {
|
|
5528
|
-
let command =
|
|
5602
|
+
let command = craftNativeInt(from[i])
|
|
5529
5603
|
guard to[i] == from[i] else { return from }
|
|
5530
5604
|
let count: Int
|
|
5531
5605
|
switch command {
|
|
@@ -5710,7 +5784,7 @@ final class CraftNativeSvgView: UIView {
|
|
|
5710
5784
|
guard !text.isEmpty else { return }
|
|
5711
5785
|
let props = node["props"] as? [String: Any] ?? [:]
|
|
5712
5786
|
let style = node["style"] as? [String: Any] ?? [:]
|
|
5713
|
-
let size = number(props["fontSize"]) ??
|
|
5787
|
+
let size = number(props["fontSize"]) ?? styleNumber(style["fontSize"]) ?? 12
|
|
5714
5788
|
let weight: UIFont.Weight = {
|
|
5715
5789
|
switch paint.fontWeight?.lowercased() {
|
|
5716
5790
|
case "500": return .medium
|
|
@@ -5752,7 +5826,7 @@ final class CraftNativeSvgView: UIView {
|
|
|
5752
5826
|
var i = 0
|
|
5753
5827
|
func point(_ at: Int) -> CGPoint { CGPoint(x: n[at], y: n[at + 1]) }
|
|
5754
5828
|
while i < n.count {
|
|
5755
|
-
switch
|
|
5829
|
+
switch craftNativeInt(n[i]) {
|
|
5756
5830
|
case 0 where i + 2 < n.count: path.move(to: point(i + 1)); i += 3
|
|
5757
5831
|
case 1 where i + 2 < n.count: path.addLine(to: point(i + 1)); i += 3
|
|
5758
5832
|
case 2 where i + 6 < n.count: path.addCurve(to: point(i + 5), controlPoint1: point(i + 1), controlPoint2: point(i + 3)); i += 7
|
|
@@ -6857,6 +6931,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6857
6931
|
/// targeted update of this subtree inherits it without its ancestors.
|
|
6858
6932
|
var inheritedFontFamily: String?
|
|
6859
6933
|
var children: [RenderedNode] = []
|
|
6934
|
+
/// `autoFocus` was on when this input was last drawn. Focus is taken
|
|
6935
|
+
/// when it turns on, which mounting with it on is, and not on each
|
|
6936
|
+
/// render while it stays on (#511).
|
|
6937
|
+
var autoFocus = false
|
|
6860
6938
|
var widthConstraint: NSLayoutConstraint?
|
|
6861
6939
|
var heightConstraint: NSLayoutConstraint?
|
|
6862
6940
|
|
|
@@ -7015,6 +7093,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7015
7093
|
/// Where the keyboard is on the screen while it is up, in screen coordinates.
|
|
7016
7094
|
private var keyboardScreenFrame: CGRect?
|
|
7017
7095
|
private var keyboardInsetsScheduled = false
|
|
7096
|
+
private var floatingClearanceScheduled = false
|
|
7097
|
+
/// Whether any scroll view may still hold room for actions that have gone.
|
|
7098
|
+
private var floatingClearanceApplied = false
|
|
7018
7099
|
/// The hybrid shell (CraftHybrid.swift), when this screen is one of a web
|
|
7019
7100
|
/// app's native screens: told of the first frame, of a first frame that
|
|
7020
7101
|
/// could not be drawn, and of `craft.navigation.open(path)`.
|
|
@@ -7121,6 +7202,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7121
7202
|
// a screen that hid its own.
|
|
7122
7203
|
navigationController?.setNavigationBarHidden(false, animated: animated)
|
|
7123
7204
|
}
|
|
7205
|
+
// Options can arrive before the screen is in a stack: whether it has
|
|
7206
|
+
// a screen to go back to is known now.
|
|
7207
|
+
if backButtonSpec != nil { applyLeadingBarItems() }
|
|
7124
7208
|
applyNavigationBarAppearance()
|
|
7125
7209
|
// Each screen says whether it wants the tab bar, so one that hid it
|
|
7126
7210
|
// never leaves it hidden for the next. Only the screen on screen:
|
|
@@ -7396,6 +7480,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7396
7480
|
emitLayoutEvents()
|
|
7397
7481
|
sendViewportIfChanged()
|
|
7398
7482
|
scheduleKeyboardInsets()
|
|
7483
|
+
scheduleFloatingClearance()
|
|
7399
7484
|
}
|
|
7400
7485
|
|
|
7401
7486
|
/// The width the bundle last heard, so a layout pass at the same size says nothing.
|
|
@@ -7794,7 +7879,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7794
7879
|
/// - `craft.secureStorage.getSync(key)`: the Keychain item the web page's
|
|
7795
7880
|
/// `craft.secureStorage.set` wrote.
|
|
7796
7881
|
/// - `craft.navigation.setOptions({ title, titleColor, largeTitle, hidden,
|
|
7797
|
-
/// backTitle, tabBarHidden, tabBarHiddenWhenPushed, tabItem, swipeBack,
|
|
7882
|
+
/// backTitle, backButton, tabBarHidden, tabBarHiddenWhenPushed, tabItem, swipeBack,
|
|
7798
7883
|
/// contentSwipeBack,
|
|
7799
7884
|
/// tabSwipeEnabled, backgroundColor, navigationBarSolid, leftButtons,
|
|
7800
7885
|
/// rightButtons, tabAccessory })`; a button's tap is the event
|
|
@@ -8410,10 +8495,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8410
8495
|
navigationController?.setNavigationBarHidden(hidden, animated: animatesNavigationBarChanges)
|
|
8411
8496
|
}
|
|
8412
8497
|
}
|
|
8498
|
+
if options.keys.contains("backButton") {
|
|
8499
|
+
backButtonSpec = options["backButton"] as? [String: Any]
|
|
8500
|
+
backButtonItem = nil
|
|
8501
|
+
}
|
|
8413
8502
|
if options.keys.contains("leftButtons") {
|
|
8414
8503
|
let buttons = (options["leftButtons"] as? [[String: Any]]) ?? []
|
|
8415
|
-
|
|
8416
|
-
|
|
8504
|
+
leftButtonItems = buttons.compactMap(navigationButton)
|
|
8505
|
+
}
|
|
8506
|
+
if options.keys.contains("backButton") || options.keys.contains("leftButtons") {
|
|
8507
|
+
applyLeadingBarItems()
|
|
8417
8508
|
}
|
|
8418
8509
|
if options.keys.contains("rightButtons") {
|
|
8419
8510
|
let buttons = (options["rightButtons"] as? [[String: Any]]) ?? []
|
|
@@ -8511,6 +8602,44 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8511
8602
|
return actions.isEmpty ? nil : UIMenu(children: actions)
|
|
8512
8603
|
}
|
|
8513
8604
|
|
|
8605
|
+
/// `backButton`: what this screen's Back is drawn with, when it is not
|
|
8606
|
+
/// the chevron (`{ symbol: "xmark", accessibilityLabel: "Close" }` for a
|
|
8607
|
+
/// screen that closes rather than goes back). `leftButtons` are added
|
|
8608
|
+
/// beside Back, so a screen could not put its own symbol in Back's place
|
|
8609
|
+
/// (#527). Nil is the system's chevron.
|
|
8610
|
+
private var backButtonSpec: [String: Any]?
|
|
8611
|
+
private var backButtonItem: UIBarButtonItem?
|
|
8612
|
+
private var leftButtonItems: [UIBarButtonItem] = []
|
|
8613
|
+
|
|
8614
|
+
/// The screen's own Back, when it asked for one and has a screen to go
|
|
8615
|
+
/// back to, then its left buttons. The chevron is hidden only while the
|
|
8616
|
+
/// item that replaces it is in the bar: a symbol UIKit does not have
|
|
8617
|
+
/// leaves the chevron. The back-swipe is the shell's to allow, and is
|
|
8618
|
+
/// not affected.
|
|
8619
|
+
private func applyLeadingBarItems() {
|
|
8620
|
+
// Known to be the first screen of its stack: nothing to go back to.
|
|
8621
|
+
let isRoot = navigationController?.viewControllers.first === self
|
|
8622
|
+
if !isRoot, backButtonItem == nil, let spec = backButtonSpec,
|
|
8623
|
+
let symbol = spec["symbol"] as? String, let image = UIImage(systemName: symbol) {
|
|
8624
|
+
let item = UIBarButtonItem(image: image, style: .plain, target: self, action: #selector(backButtonTapped))
|
|
8625
|
+
item.accessibilityIdentifier = "nav-back"
|
|
8626
|
+
item.accessibilityLabel = (spec["accessibilityLabel"] as? String).flatMap { $0.isEmpty ? nil : $0 }
|
|
8627
|
+
?? (symbol.hasPrefix("xmark") ? "Close" : "Back")
|
|
8628
|
+
backButtonItem = item
|
|
8629
|
+
}
|
|
8630
|
+
let back = isRoot ? nil : backButtonItem
|
|
8631
|
+
navigationItem.hidesBackButton = back != nil
|
|
8632
|
+
navigationItem.leftItemsSupplementBackButton = true
|
|
8633
|
+
navigationItem.leftBarButtonItems = (back.map { [$0] } ?? []) + leftButtonItems
|
|
8634
|
+
}
|
|
8635
|
+
|
|
8636
|
+
/// What `craft.navigation.back()` does.
|
|
8637
|
+
@objc private func backButtonTapped() {
|
|
8638
|
+
if !isAppScript, navigationController?.topViewController === self {
|
|
8639
|
+
navigationController?.popViewController(animated: true)
|
|
8640
|
+
}
|
|
8641
|
+
}
|
|
8642
|
+
|
|
8514
8643
|
private func navigationButton(_ spec: [String: Any]) -> UIBarButtonItem? {
|
|
8515
8644
|
guard let id = spec["id"] as? String, !id.isEmpty else { return nil }
|
|
8516
8645
|
let action = UIAction { [weak self] _ in
|
|
@@ -8816,6 +8945,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8816
8945
|
}
|
|
8817
8946
|
// The app script has no screen for the keyboard to cover.
|
|
8818
8947
|
if !isAppScript {
|
|
8948
|
+
lifecycleObservers.append(NotificationCenter.default.addObserver(
|
|
8949
|
+
forName: CraftChrome.floatingActionsDidMove,
|
|
8950
|
+
object: nil,
|
|
8951
|
+
queue: .main
|
|
8952
|
+
) { [weak self] _ in
|
|
8953
|
+
self?.scheduleFloatingClearance()
|
|
8954
|
+
})
|
|
8819
8955
|
lifecycleObservers.append(NotificationCenter.default.addObserver(
|
|
8820
8956
|
forName: UIResponder.keyboardWillChangeFrameNotification,
|
|
8821
8957
|
object: nil,
|
|
@@ -8960,7 +9096,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8960
9096
|
/// took account of) insets its content by that much, so everything in it
|
|
8961
9097
|
/// can be scrolled above the keyboard. Internal for XCTest.
|
|
8962
9098
|
func insetScrollViewsForKeyboard() {
|
|
8963
|
-
var scrolls: [
|
|
9099
|
+
var scrolls: [CraftNativeScrollView] = []
|
|
8964
9100
|
func collect(_ node: RenderedNode?) {
|
|
8965
9101
|
guard let node else { return }
|
|
8966
9102
|
if let scroll = node.view as? CraftNativeScrollView, scroll.isVertical { scrolls.append(scroll) }
|
|
@@ -8977,8 +9113,68 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8977
9113
|
let automaticBottom = max(0, scroll.adjustedContentInset.bottom - scroll.contentInset.bottom)
|
|
8978
9114
|
overlap = max(0, overlap - automaticBottom)
|
|
8979
9115
|
}
|
|
8980
|
-
|
|
8981
|
-
|
|
9116
|
+
// Its own inset is what is being measured, not part of the answer.
|
|
9117
|
+
scroll.keyboardCover = overlap
|
|
9118
|
+
}
|
|
9119
|
+
}
|
|
9120
|
+
|
|
9121
|
+
/// The compact tab actions float over the trailing end of whatever
|
|
9122
|
+
/// scrolls behind them. Each vertical scroll view and list they reach
|
|
9123
|
+
/// into gets that much room at its end, so its last row can be brought
|
|
9124
|
+
/// clear of them; one that ends above them gets none. How wide a scroll
|
|
9125
|
+
/// view is does not come into it: a page of a pager that is off to one
|
|
9126
|
+
/// side now is under the actions once it is swiped to. The safe area and
|
|
9127
|
+
/// the viewport are left alone: content still scrolls behind the
|
|
9128
|
+
/// actions.
|
|
9129
|
+
///
|
|
9130
|
+
/// Two things are not behind the actions, and get none. A scroll view
|
|
9131
|
+
/// that is not inside the shell the actions float in: a sheet is
|
|
9132
|
+
/// presented in front of them, and its scroll view was given the room of
|
|
9133
|
+
/// a capsule it covers, so every sheet could be dragged up into blank
|
|
9134
|
+
/// space. And a scroll view or list inside another that scrolls the same
|
|
9135
|
+
/// way: it is brought clear by the outer one, and where it sits in a page
|
|
9136
|
+
/// not yet scrolled to it says nothing about the glass; a short list
|
|
9137
|
+
/// below the fold was given the height of the page above it. Internal
|
|
9138
|
+
/// for XCTest.
|
|
9139
|
+
func insetScrollViewsForFloatingActions() {
|
|
9140
|
+
let chrome = CraftChrome.shared
|
|
9141
|
+
let host = chrome.floatingActionsHost.flatMap { view.isDescendant(of: $0) ? $0 : nil }
|
|
9142
|
+
let actions = host == nil ? nil : chrome.floatingActions
|
|
9143
|
+
func cover(_ scroll: UIScrollView) -> CGFloat {
|
|
9144
|
+
guard let actions, let host, let window = scroll.window, scroll.isDescendant(of: host) else { return 0 }
|
|
9145
|
+
let frame = scroll.convert(scroll.bounds, to: window)
|
|
9146
|
+
// Nothing past the end of the shell is on the glass.
|
|
9147
|
+
let end = min(frame.maxY, host.convert(host.bounds, to: window).maxY)
|
|
9148
|
+
// An edge-to-edge scroller already clears the tab bar through
|
|
9149
|
+
// UIKit's safe area. Add only what the actions reach above it.
|
|
9150
|
+
let automaticBottom = max(0, scroll.adjustedContentInset.bottom - scroll.contentInset.bottom)
|
|
9151
|
+
return max(0, end - actions.minY - automaticBottom)
|
|
9152
|
+
}
|
|
9153
|
+
func visit(_ node: RenderedNode?, inside: Bool) {
|
|
9154
|
+
guard let node else { return }
|
|
9155
|
+
var inside = inside
|
|
9156
|
+
if let scroll = node.view as? CraftNativeScrollView, scroll.isVertical {
|
|
9157
|
+
scroll.floatingCover = inside ? 0 : cover(scroll)
|
|
9158
|
+
inside = true
|
|
9159
|
+
} else if let list = node.view as? CraftNativeFlatList {
|
|
9160
|
+
list.floatingCover = inside ? 0 : cover(list)
|
|
9161
|
+
if (list.collectionViewLayout as? UICollectionViewFlowLayout)?.scrollDirection != .horizontal { inside = true }
|
|
9162
|
+
}
|
|
9163
|
+
node.children.forEach { visit($0, inside: inside) }
|
|
9164
|
+
}
|
|
9165
|
+
visit(renderedRoot, inside: false)
|
|
9166
|
+
floatingClearanceApplied = actions != nil
|
|
9167
|
+
}
|
|
9168
|
+
|
|
9169
|
+
/// A layout moved a view, or the actions moved: measured again once the
|
|
9170
|
+
/// pass is over, where each scroll view now ends.
|
|
9171
|
+
fileprivate func scheduleFloatingClearance() {
|
|
9172
|
+
guard CraftChrome.shared.floatingActions != nil || floatingClearanceApplied, !floatingClearanceScheduled else { return }
|
|
9173
|
+
floatingClearanceScheduled = true
|
|
9174
|
+
DispatchQueue.main.async { [weak self] in
|
|
9175
|
+
guard let self else { return }
|
|
9176
|
+
self.floatingClearanceScheduled = false
|
|
9177
|
+
self.insetScrollViewsForFloatingActions()
|
|
8982
9178
|
}
|
|
8983
9179
|
}
|
|
8984
9180
|
|
|
@@ -9766,12 +9962,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9766
9962
|
case "Text":
|
|
9767
9963
|
let text = children.compactMap { $0 as? String }.joined()
|
|
9768
9964
|
if let selectable = result as? CraftNativeSelectableTextView {
|
|
9769
|
-
selectable.textContainer.maximumNumberOfLines = max(0,
|
|
9965
|
+
selectable.textContainer.maximumNumberOfLines = max(0, craftNativeInt(number(props["numberOfLines"]) ?? 0))
|
|
9770
9966
|
selectable.textContainer.lineBreakMode = textLineBreakMode(props["ellipsizeMode"])
|
|
9771
9967
|
configureSelectableText(selectable, text: text, style: style)
|
|
9772
9968
|
} else {
|
|
9773
9969
|
let label = result as! UILabel
|
|
9774
|
-
label.numberOfLines = max(0,
|
|
9970
|
+
label.numberOfLines = max(0, craftNativeInt(number(props["numberOfLines"]) ?? 0))
|
|
9775
9971
|
label.lineBreakMode = textLineBreakMode(props["ellipsizeMode"])
|
|
9776
9972
|
(label as? CraftNativeLabel)?.insets = paddingInsets(style)
|
|
9777
9973
|
configureText(label, text: text, style: style)
|
|
@@ -9830,6 +10026,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9830
10026
|
? nil
|
|
9831
10027
|
: inputDrafts[current.identity] ?? props["defaultValue"] as? String
|
|
9832
10028
|
)
|
|
10029
|
+
applyAutoFocus(props["autoFocus"], to: textView, node: current)
|
|
9833
10030
|
break
|
|
9834
10031
|
}
|
|
9835
10032
|
let field = result as! UITextField
|
|
@@ -9848,7 +10045,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9848
10045
|
field.tintColor = color(props["selectionColor"])
|
|
9849
10046
|
let fieldId = ObjectIdentifier(field)
|
|
9850
10047
|
inputIdentities[fieldId] = current.identity
|
|
9851
|
-
let maxLength =
|
|
10048
|
+
let maxLength = craftNativeInt(number(props["maxLength"]) ?? 0)
|
|
9852
10049
|
if maxLength > 0 {
|
|
9853
10050
|
textMaxLengths[fieldId] = maxLength
|
|
9854
10051
|
} else {
|
|
@@ -9890,12 +10087,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9890
10087
|
updateAuxiliaryHandler(events["onBlur"], in: &blurHandlers, for: field)
|
|
9891
10088
|
updateAuxiliaryHandler(events["onEndEditing"], in: &endEditingHandlers, for: field)
|
|
9892
10089
|
updateAuxiliaryHandler(events["onSubmitEditing"], in: &submitHandlers, for: field)
|
|
9893
|
-
|
|
9894
|
-
DispatchQueue.main.async { [weak self] in
|
|
9895
|
-
guard self?.isLeavingScreen == false else { return }
|
|
9896
|
-
_ = field.becomeFirstResponder()
|
|
9897
|
-
}
|
|
9898
|
-
}
|
|
10090
|
+
applyAutoFocus(props["autoFocus"], to: field, node: current)
|
|
9899
10091
|
case "SegmentedControl":
|
|
9900
10092
|
let control = result as! CraftNativeSegmentedControl
|
|
9901
10093
|
let items = props["items"] as? [[String: Any]] ?? []
|
|
@@ -9964,7 +10156,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9964
10156
|
if picker.preferredDatePickerStyle != display { picker.preferredDatePickerStyle = display }
|
|
9965
10157
|
}
|
|
9966
10158
|
// UIKit takes an interval that divides an hour, and ignores others.
|
|
9967
|
-
let interval =
|
|
10159
|
+
let interval = craftNativeInt(number(props["minuteInterval"]) ?? 1)
|
|
9968
10160
|
picker.minuteInterval = (1...30).contains(interval) && 60 % interval == 0 ? interval : 1
|
|
9969
10161
|
picker.minimumDate = Self.pickerDate(props["minimumDate"])
|
|
9970
10162
|
picker.maximumDate = Self.pickerDate(props["maximumDate"])
|
|
@@ -10010,7 +10202,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
10010
10202
|
modalFocusRestore[modalID] = focusBeforeContent
|
|
10011
10203
|
}
|
|
10012
10204
|
if visible, wasVisible != true { positionModals() }
|
|
10013
|
-
modal.setVisible(visible, opacity:
|
|
10205
|
+
modal.setVisible(visible, opacity: styleNumber(style["opacity"]) ?? 1, animationType: props["animationType"] as? String)
|
|
10014
10206
|
if !visible, wasVisible == true {
|
|
10015
10207
|
let restore = modalFocusRestore.removeValue(forKey: modalID)
|
|
10016
10208
|
DispatchQueue.main.async { [weak self, weak restore] in
|
|
@@ -10037,7 +10229,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
10037
10229
|
let disabled = props["disabled"] as? Bool == true
|
|
10038
10230
|
pressable.pressActiveOpacity = disabled
|
|
10039
10231
|
? nil : number(props["activeOpacity"]) ?? 0.5
|
|
10040
|
-
pressable.pressBaseOpacity =
|
|
10232
|
+
pressable.pressBaseOpacity = styleNumber(style["opacity"]) ?? 1
|
|
10041
10233
|
pressable.pressUnderlayColor = type == "TouchableHighlight" && !disabled ? color(props["underlayColor"]) : nil
|
|
10042
10234
|
case "Switch":
|
|
10043
10235
|
if let toggle = result as? CraftNativeSolidSwitch {
|
|
@@ -10647,7 +10839,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
10647
10839
|
list.apply(
|
|
10648
10840
|
nodes: children,
|
|
10649
10841
|
horizontal: props["horizontal"] as? Bool == true,
|
|
10650
|
-
columns:
|
|
10842
|
+
columns: craftNativeInt(number(props["numColumns"]) ?? 1),
|
|
10651
10843
|
inverted: props["inverted"] as? Bool == true,
|
|
10652
10844
|
endReachedThreshold: threshold,
|
|
10653
10845
|
renderItem: { [weak self] node, identity, reusable in
|
|
@@ -11180,13 +11372,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
11180
11372
|
}
|
|
11181
11373
|
// An input's own scrolling follows its height (CraftNativeTextView).
|
|
11182
11374
|
if !(textView is CraftNativeTextView) { textView.isScrollEnabled = false }
|
|
11183
|
-
let numberOfLines =
|
|
11375
|
+
let numberOfLines = craftNativeInt(number(props["numberOfLines"]) ?? 0)
|
|
11184
11376
|
if numberOfLines > 0 {
|
|
11185
11377
|
textView.textContainer.maximumNumberOfLines = numberOfLines
|
|
11186
11378
|
} else {
|
|
11187
11379
|
textView.textContainer.maximumNumberOfLines = 0
|
|
11188
11380
|
}
|
|
11189
|
-
let maxLength =
|
|
11381
|
+
let maxLength = craftNativeInt(number(props["maxLength"]) ?? 0)
|
|
11190
11382
|
if maxLength > 0 {
|
|
11191
11383
|
textMaxLengths[id] = maxLength
|
|
11192
11384
|
} else {
|
|
@@ -11216,11 +11408,19 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
11216
11408
|
updateAuxiliaryHandler(events["onBlur"], in: &blurHandlers, for: textView)
|
|
11217
11409
|
updateAuxiliaryHandler(events["onEndEditing"], in: &endEditingHandlers, for: textView)
|
|
11218
11410
|
updateAuxiliaryHandler(events["onSubmitEditing"], in: &submitHandlers, for: textView)
|
|
11219
|
-
|
|
11220
|
-
|
|
11221
|
-
|
|
11222
|
-
|
|
11223
|
-
|
|
11411
|
+
}
|
|
11412
|
+
|
|
11413
|
+
/// `autoFocus`: focus when the input mounts with it, or when it is turned
|
|
11414
|
+
/// on later. Applied on every render it took the keyboard back each time
|
|
11415
|
+
/// anything else on the screen changed (#511).
|
|
11416
|
+
private func applyAutoFocus(_ value: Any?, to input: UIView, node: RenderedNode) {
|
|
11417
|
+
let wanted = value as? Bool == true
|
|
11418
|
+
let turnedOn = wanted && !node.autoFocus
|
|
11419
|
+
node.autoFocus = wanted
|
|
11420
|
+
guard turnedOn, !input.isFirstResponder else { return }
|
|
11421
|
+
DispatchQueue.main.async { [weak self] in
|
|
11422
|
+
guard self?.isLeavingScreen == false else { return }
|
|
11423
|
+
_ = input.becomeFirstResponder()
|
|
11224
11424
|
}
|
|
11225
11425
|
}
|
|
11226
11426
|
|
|
@@ -11230,8 +11430,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
11230
11430
|
/// leaving a hole.
|
|
11231
11431
|
private func configureIcon(_ icon: UIImageView, props: [String: Any], style: [String: Any]) {
|
|
11232
11432
|
let name = (props["symbol"] as? String) ?? (props["name"] as? String) ?? ""
|
|
11233
|
-
let box = [
|
|
11234
|
-
let fontSize =
|
|
11433
|
+
let box = [styleNumber(style["width"]), styleNumber(style["height"])].compactMap { $0 }.min()
|
|
11434
|
+
let fontSize = styleNumber(style["fontSize"])
|
|
11235
11435
|
// Without a size of its own an icon is a 20 pt square (contract A2).
|
|
11236
11436
|
(icon as? CraftNativeIconView)?.defaultBox = box == nil && fontSize == nil ? 20 : nil
|
|
11237
11437
|
let pointSize = fontSize ?? (box ?? 20) * 0.84
|
|
@@ -11272,10 +11472,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
11272
11472
|
stack.alignItems = style["alignItems"] as? String ?? "stretch"
|
|
11273
11473
|
stack.alignContent = style["alignContent"] as? String ?? "flex-start"
|
|
11274
11474
|
stack.justifyContent = style["justifyContent"] as? String ?? "flex-start"
|
|
11275
|
-
let gap = max(0,
|
|
11475
|
+
let gap = max(0, styleNumber(style["gap"]) ?? 0)
|
|
11276
11476
|
stack.gap = gap
|
|
11277
|
-
stack.rowGap = max(0,
|
|
11278
|
-
stack.columnGap = max(0,
|
|
11477
|
+
stack.rowGap = max(0, styleNumber(style["rowGap"]) ?? gap)
|
|
11478
|
+
stack.columnGap = max(0, styleNumber(style["columnGap"]) ?? gap)
|
|
11279
11479
|
stack.grid = style["display"] as? String == "grid"
|
|
11280
11480
|
stack.gridColumns = craftNativeGridColumnCount(style["gridTemplateColumns"] ?? style["gridColumns"])
|
|
11281
11481
|
stack.gridRows = craftNativeGridRowCount(style["gridTemplateRows"])
|
|
@@ -11287,8 +11487,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
11287
11487
|
private func addJustificationSpacers(to stack: CraftNativeFlowView, value: String?) { }
|
|
11288
11488
|
|
|
11289
11489
|
private func applyViewStyle(_ style: [String: Any], to view: UIView, node: RenderedNode) {
|
|
11290
|
-
let width = style["minWidth"] == nil && style["maxWidth"] == nil ?
|
|
11291
|
-
let height = style["minHeight"] == nil && style["maxHeight"] == nil ?
|
|
11490
|
+
let width = style["minWidth"] == nil && style["maxWidth"] == nil ? styleNumber(style["width"]) : nil
|
|
11491
|
+
let height = style["minHeight"] == nil && style["maxHeight"] == nil ? styleNumber(style["height"]) : nil
|
|
11292
11492
|
updateDimension(width, constraint: &node.widthConstraint, anchor: view.widthAnchor)
|
|
11293
11493
|
updateDimension(height, constraint: &node.heightConstraint, anchor: view.heightAnchor)
|
|
11294
11494
|
view.backgroundColor = color(style["backgroundColor"]) ?? .clear
|
|
@@ -11307,31 +11507,31 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
11307
11507
|
direction: style["gradientDirection"] as? String,
|
|
11308
11508
|
colors: [color(style["gradientFrom"]), color(style["gradientVia"]), color(style["gradientTo"])]
|
|
11309
11509
|
)
|
|
11310
|
-
view.alpha =
|
|
11510
|
+
view.alpha = styleNumber(style["opacity"]) ?? 1
|
|
11311
11511
|
view.isHidden = style["display"] as? String == "none"
|
|
11312
|
-
let borderRadius =
|
|
11512
|
+
let borderRadius = styleNumber(style["borderRadius"]) ?? 0
|
|
11313
11513
|
let cornerRadiusKeys = ["borderTopLeftRadius", "borderTopRightRadius", "borderBottomRightRadius", "borderBottomLeftRadius"]
|
|
11314
11514
|
let hasCornerRadii = cornerRadiusKeys.contains { style[$0] != nil }
|
|
11315
11515
|
view.craftRequestedCornerRadius = borderRadius
|
|
11316
11516
|
view.craftRequestedCornerRadii = hasCornerRadii
|
|
11317
|
-
? cornerRadiusKeys.map {
|
|
11517
|
+
? cornerRadiusKeys.map { styleNumber(style[$0]) ?? borderRadius }
|
|
11318
11518
|
: nil
|
|
11319
|
-
let borderWidth =
|
|
11519
|
+
let borderWidth = styleNumber(style["borderWidth"]) ?? 0
|
|
11320
11520
|
let borderColor = color(style["borderColor"]) ?? .clear
|
|
11321
11521
|
let sideWidthKeys = ["borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth"]
|
|
11322
11522
|
let sideColorKeys = ["borderTopColor", "borderRightColor", "borderBottomColor", "borderLeftColor"]
|
|
11323
11523
|
view.craftApplySideBorders(
|
|
11324
11524
|
(sideWidthKeys + sideColorKeys).contains { style[$0] != nil },
|
|
11325
11525
|
style: style["borderStyle"] as? String,
|
|
11326
|
-
widths: sideWidthKeys.map {
|
|
11526
|
+
widths: sideWidthKeys.map { styleNumber(style[$0]) ?? borderWidth },
|
|
11327
11527
|
colors: sideColorKeys.map { color(style[$0]) ?? borderColor }
|
|
11328
11528
|
)
|
|
11329
|
-
view.layer.zPosition =
|
|
11529
|
+
view.layer.zPosition = styleNumber(style["zIndex"]) ?? 0
|
|
11330
11530
|
applyTransform(style["transform"], to: view)
|
|
11331
|
-
let elevation = max(0,
|
|
11531
|
+
let elevation = max(0, styleNumber(style["elevation"]) ?? 0)
|
|
11332
11532
|
let shadowColor = color(style["shadowColor"]) ?? .black
|
|
11333
|
-
let shadowOpacity = min(1, max(0,
|
|
11334
|
-
let shadowRadius = max(0,
|
|
11533
|
+
let shadowOpacity = min(1, max(0, styleNumber(style["shadowOpacity"]) ?? (elevation > 0 ? min(0.28, 0.12 + elevation * 0.02) : 0)))
|
|
11534
|
+
let shadowRadius = max(0, styleNumber(style["shadowRadius"]) ?? elevation * 0.5)
|
|
11335
11535
|
let shadowOffset = style["shadowOffset"] as? [String: Any]
|
|
11336
11536
|
view.layer.shadowColor = shadowColor.cgColor
|
|
11337
11537
|
view.layer.shadowOpacity = Float(shadowOpacity)
|
|
@@ -11364,7 +11564,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
11364
11564
|
|
|
11365
11565
|
private func paddingInsets(_ style: [String: Any]) -> UIEdgeInsets {
|
|
11366
11566
|
func nonNegative(_ value: Any?) -> CGFloat? {
|
|
11367
|
-
guard let value =
|
|
11567
|
+
guard let value = styleNumber(value), value >= 0 else { return nil }
|
|
11368
11568
|
return value
|
|
11369
11569
|
}
|
|
11370
11570
|
let padding = nonNegative(style["padding"]) ?? 0
|
|
@@ -11387,11 +11587,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
11387
11587
|
var threeDimensional = CATransform3DIdentity
|
|
11388
11588
|
var hasThreeDimensionalRotation = false
|
|
11389
11589
|
for transform in transforms {
|
|
11390
|
-
if let value =
|
|
11391
|
-
if let value =
|
|
11392
|
-
if let value =
|
|
11393
|
-
if let value =
|
|
11394
|
-
if let value =
|
|
11590
|
+
if let value = styleNumber(transform["translateX"]) { affine = affine.translatedBy(x: value, y: 0) }
|
|
11591
|
+
if let value = styleNumber(transform["translateY"]) { affine = affine.translatedBy(x: 0, y: value) }
|
|
11592
|
+
if let value = styleNumber(transform["scale"]) { affine = affine.scaledBy(x: value, y: value) }
|
|
11593
|
+
if let value = styleNumber(transform["scaleX"]) { affine = affine.scaledBy(x: value, y: 1) }
|
|
11594
|
+
if let value = styleNumber(transform["scaleY"]) { affine = affine.scaledBy(x: 1, y: value) }
|
|
11395
11595
|
if let value = transformAngle(transform["rotate"] ?? transform["rotateZ"]) { affine = affine.rotated(by: value) }
|
|
11396
11596
|
if let value = transformAngle(transform["skewX"]) { affine = affine.concatenating(CGAffineTransform(a: 1, b: 0, c: tan(value), d: 1, tx: 0, ty: 0)) }
|
|
11397
11597
|
if let value = transformAngle(transform["skewY"]) { affine = affine.concatenating(CGAffineTransform(a: 1, b: tan(value), c: 0, d: 1, tx: 0, ty: 0)) }
|
|
@@ -11429,10 +11629,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
11429
11629
|
label.font = textFont(style, default: .systemFont(ofSize: 16))
|
|
11430
11630
|
label.textAlignment = textAlignment(style["textAlign"])
|
|
11431
11631
|
var attributes: [NSAttributedString.Key: Any] = [:]
|
|
11432
|
-
if let spacing =
|
|
11632
|
+
if let spacing = styleNumber(style["letterSpacing"]) { attributes[.kern] = spacing }
|
|
11433
11633
|
addTextDecorationAttributes(style, to: &attributes)
|
|
11434
11634
|
if let shadow = textShadow(style) { attributes[.shadow] = shadow }
|
|
11435
|
-
if let lineHeight =
|
|
11635
|
+
if let lineHeight = styleNumber(style["lineHeight"]) {
|
|
11436
11636
|
let paragraph = NSMutableParagraphStyle()
|
|
11437
11637
|
paragraph.minimumLineHeight = lineHeight
|
|
11438
11638
|
paragraph.maximumLineHeight = lineHeight
|
|
@@ -11486,8 +11686,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
11486
11686
|
.foregroundColor: color,
|
|
11487
11687
|
.font: font,
|
|
11488
11688
|
]
|
|
11489
|
-
if let spacing =
|
|
11490
|
-
if let lineHeight =
|
|
11689
|
+
if let spacing = styleNumber(style["letterSpacing"]) { attributes[.kern] = spacing }
|
|
11690
|
+
if let lineHeight = styleNumber(style["lineHeight"]) {
|
|
11491
11691
|
let paragraph = NSMutableParagraphStyle()
|
|
11492
11692
|
paragraph.minimumLineHeight = lineHeight
|
|
11493
11693
|
paragraph.maximumLineHeight = lineHeight
|
|
@@ -11509,14 +11709,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
11509
11709
|
.foregroundColor: color(style["color"]) ?? .label,
|
|
11510
11710
|
.font: font,
|
|
11511
11711
|
]
|
|
11512
|
-
if let spacing =
|
|
11712
|
+
if let spacing = styleNumber(style["letterSpacing"]) { attributes[.kern] = spacing }
|
|
11513
11713
|
// UIKit otherwise resets the field's alignment to natural when these attributes are applied.
|
|
11514
11714
|
let paragraph = NSMutableParagraphStyle()
|
|
11515
11715
|
paragraph.alignment = alignment
|
|
11516
11716
|
// The default paragraph wraps even inside a UITextField. A long URL
|
|
11517
11717
|
// then paints a second line outside the field instead of scrolling.
|
|
11518
11718
|
if singleLine { paragraph.lineBreakMode = .byClipping }
|
|
11519
|
-
if let lineHeight =
|
|
11719
|
+
if let lineHeight = styleNumber(style["lineHeight"]) {
|
|
11520
11720
|
paragraph.minimumLineHeight = lineHeight
|
|
11521
11721
|
paragraph.maximumLineHeight = lineHeight
|
|
11522
11722
|
}
|
|
@@ -11535,7 +11735,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
11535
11735
|
width: number(offset?["width"]) ?? 0,
|
|
11536
11736
|
height: number(offset?["height"]) ?? 0
|
|
11537
11737
|
)
|
|
11538
|
-
shadow.shadowBlurRadius = max(0,
|
|
11738
|
+
shadow.shadowBlurRadius = max(0, styleNumber(style["textShadowRadius"]) ?? 0)
|
|
11539
11739
|
return shadow
|
|
11540
11740
|
}
|
|
11541
11741
|
|
|
@@ -11574,7 +11774,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
11574
11774
|
}
|
|
11575
11775
|
|
|
11576
11776
|
private func textFont(_ style: [String: Any], default fallback: UIFont) -> UIFont {
|
|
11577
|
-
let size =
|
|
11777
|
+
let size = styleNumber(style["fontSize"]) ?? fallback.pointSize
|
|
11578
11778
|
let weight: UIFont.Weight
|
|
11579
11779
|
switch craftNativeFontWeight(style["fontWeight"]) {
|
|
11580
11780
|
case "100": weight = .ultraLight
|
|
@@ -317,6 +317,13 @@ enum CraftWorkoutPlanBuilder {
|
|
|
317
317
|
return double.isFinite ? double : nil
|
|
318
318
|
}
|
|
319
319
|
|
|
320
|
+
/// A number as a whole one, toward zero, or nil when no `Int` holds it.
|
|
321
|
+
/// `Int(_:)` traps there, and these come from the caller: an `hour` of
|
|
322
|
+
/// `1e30` ended the process where it should have been refused (#510).
|
|
323
|
+
private static func whole(_ value: Any?) -> Int? {
|
|
324
|
+
number(value).flatMap { Int(exactly: $0.rounded(.towardZero)) }
|
|
325
|
+
}
|
|
326
|
+
|
|
320
327
|
/// Two schedule dates as WorkoutKit compares them: the day and the time of day.
|
|
321
328
|
static func sameMoment(_ a: DateComponents, _ b: DateComponents) -> Bool {
|
|
322
329
|
a.year == b.year && a.month == b.month && a.day == b.day && (a.hour ?? 0) == (b.hour ?? 0) && (a.minute ?? 0) == (b.minute ?? 0)
|
|
@@ -328,8 +335,8 @@ enum CraftWorkoutPlanBuilder {
|
|
|
328
335
|
let parts = text.split(separator: "-", omittingEmptySubsequences: false)
|
|
329
336
|
guard parts.count == 3, parts[0].count == 4, parts[1].count == 2, parts[2].count == 2,
|
|
330
337
|
let year = Int(parts[0]), let month = Int(parts[1]), let day = Int(parts[2]) else { return nil }
|
|
331
|
-
let hour = body["hour"] == nil || body["hour"] is NSNull ? 7 :
|
|
332
|
-
let minute = body["minute"] == nil || body["minute"] is NSNull ? 0 :
|
|
338
|
+
let hour = body["hour"] == nil || body["hour"] is NSNull ? 7 : whole(body["hour"])
|
|
339
|
+
let minute = body["minute"] == nil || body["minute"] is NSNull ? 0 : whole(body["minute"])
|
|
333
340
|
guard let hour, let minute, (0...23).contains(hour), (0...59).contains(minute) else { return nil }
|
|
334
341
|
var components = DateComponents()
|
|
335
342
|
components.year = year
|
|
@@ -379,7 +386,7 @@ enum CraftWorkoutPlanBuilder {
|
|
|
379
386
|
}
|
|
380
387
|
func zone() -> Int? {
|
|
381
388
|
guard let value = number(body["zone"]), value >= 1, value.rounded() == value else { return nil }
|
|
382
|
-
return Int(value)
|
|
389
|
+
return Int(exactly: value)
|
|
383
390
|
}
|
|
384
391
|
switch type {
|
|
385
392
|
case "heartRate":
|
|
@@ -485,7 +492,7 @@ enum CraftWorkoutPlanBuilder {
|
|
|
485
492
|
var blocks: [IntervalBlock] = []
|
|
486
493
|
for (blockIndex, value) in (workout["blocks"] as? [Any] ?? []).enumerated() {
|
|
487
494
|
let block = value as? [String: Any] ?? [:]
|
|
488
|
-
let iterations =
|
|
495
|
+
let iterations = whole(block["iterations"] ?? 1) ?? 0
|
|
489
496
|
guard iterations >= 1 else { return refuse("block \(blockIndex + 1) needs iterations of 1 or more") }
|
|
490
497
|
var steps: [IntervalStep] = []
|
|
491
498
|
for (stepIndex, entry) in (block["steps"] as? [Any] ?? []).enumerated() {
|
|
@@ -503,6 +503,25 @@ final class CraftAudioBufferSource: CraftAudioSource {
|
|
|
503
503
|
private var played = 0.0
|
|
504
504
|
private var began = false
|
|
505
505
|
|
|
506
|
+
/// An arbitrary limit on the rate, and WebKit's: `MaxRate` in its
|
|
507
|
+
/// AudioBufferSourceNode.
|
|
508
|
+
static let maximumRate = 1024.0
|
|
509
|
+
|
|
510
|
+
/// The rate a buffer plays at: its own sample rate over the context's,
|
|
511
|
+
/// times `playbackRate`, times two to the power of `detune` in octaves.
|
|
512
|
+
///
|
|
513
|
+
/// Neither param is bounded for a buffer source, so the product can be
|
|
514
|
+
/// infinite (a detune of 2,000,000 cents) or not a number (that, with a
|
|
515
|
+
/// playbackRate of 0). A looping source wrapped an infinite position into
|
|
516
|
+
/// one that is not a number, and `Int(position)` traps on it: the render
|
|
517
|
+
/// thread ended the process. WebKit clamps the rate to its limit either
|
|
518
|
+
/// side of zero and plays a rate that is not a number at 1; so does this.
|
|
519
|
+
static func rate(ratio: Double, playbackRate: Double, detune: Double) -> Double {
|
|
520
|
+
let rate = ratio * playbackRate * pow(2, detune / 1200)
|
|
521
|
+
guard !rate.isNaN else { return 1 }
|
|
522
|
+
return max(-maximumRate, min(maximumRate, rate))
|
|
523
|
+
}
|
|
524
|
+
|
|
506
525
|
override init(id: Int) {
|
|
507
526
|
super.init(id: id)
|
|
508
527
|
params = [playbackRate, detune]
|
|
@@ -530,7 +549,7 @@ final class CraftAudioBufferSource: CraftAudioSource {
|
|
|
530
549
|
position = loopFrom + (position - loopFrom).truncatingRemainder(dividingBy: span)
|
|
531
550
|
}
|
|
532
551
|
}
|
|
533
|
-
let rate = buffer.sampleRate / render.sampleRate
|
|
552
|
+
let rate = Self.rate(ratio: buffer.sampleRate / render.sampleRate, playbackRate: Double(playbackRate.values[0]), detune: Double(detune.values[0]))
|
|
534
553
|
let first = buffer.channels[0]
|
|
535
554
|
let second = buffer.channels.count > 1 ? buffer.channels[1] : first
|
|
536
555
|
let frames = buffer.length
|