@craft-native/ios 0.0.499 → 0.0.500

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.499",
3
+ "version": "0.0.500",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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 activePicker == nil else {
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
- guard !answered else { return }
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 activePicker == nil else {
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 activePicker == nil else {
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
- return max(0, ceil(content + contentInset.top + contentInset.bottom))
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 occurrence = seen[base, default: 0]
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(uniqueKeysWithValues: next.map { ($0.identity, $0) })
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, Int(ceil(Double(dataItemCount) * endReachedThreshold)))
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(Int(integer)) : nil
208
+ return integer == number.doubleValue ? String(craftNativeInt(integer)) : nil
169
209
  }
170
210
 
171
211
  private func craftNativePickerValue(_ value: Any?) -> String? {
@@ -2389,16 +2429,16 @@ private enum CraftNativeLength: Equatable {
2389
2429
  if let number = craftNativeNumber(value) {
2390
2430
  let points = CGFloat(truncating: number)
2391
2431
  guard points.isFinite else { return nil }
2392
- self = .points(points)
2432
+ self = .points(craftNativeLayoutNumber(points))
2393
2433
  return
2394
2434
  }
2395
2435
  guard let text = (value as? String)?.trimmingCharacters(in: .whitespaces), !text.isEmpty else { return nil }
2396
2436
  if text.hasSuffix("%"), let fraction = Double(text.dropLast()), fraction.isFinite {
2397
- self = .percent(CGFloat(fraction) / 100)
2437
+ self = .percent(craftNativeLayoutNumber(CGFloat(fraction) / 100))
2398
2438
  } else if text.hasSuffix("px"), let points = Double(text.dropLast(2)), points.isFinite {
2399
- self = .points(CGFloat(points))
2439
+ self = .points(craftNativeLayoutNumber(CGFloat(points)))
2400
2440
  } else if let points = Double(text), points.isFinite {
2401
- self = .points(CGFloat(points))
2441
+ self = .points(craftNativeLayoutNumber(CGFloat(points)))
2402
2442
  } else {
2403
2443
  return nil
2404
2444
  }
@@ -2449,7 +2489,7 @@ private struct CraftNativeLayoutStyle {
2449
2489
  func number(_ value: Any?) -> CGFloat? {
2450
2490
  guard let number = craftNativeNumber(value) else { return nil }
2451
2491
  let value = CGFloat(truncating: number)
2452
- return value.isFinite ? value : nil
2492
+ return value.isFinite ? craftNativeLayoutNumber(value) : nil
2453
2493
  }
2454
2494
  width = CraftNativeLength(raw["width"])
2455
2495
  height = CraftNativeLength(raw["height"])
@@ -2466,7 +2506,7 @@ private struct CraftNativeLayoutStyle {
2466
2506
  left = number(raw["left"])
2467
2507
  let align = raw["alignSelf"] as? String
2468
2508
  alignSelf = align == "auto" ? nil : align
2469
- order = number(raw["order"]).map { Int($0.rounded(.towardZero)) } ?? 0
2509
+ order = number(raw["order"]).map { craftNativeInt($0) } ?? 0
2470
2510
  // `flex: n` is the web's and React Native's shorthand: a positive
2471
2511
  // number grows by n, shrinks, and starts from nothing, so `flex-1`
2472
2512
  // siblings share a row equally whatever their content.
@@ -2832,7 +2872,7 @@ enum CraftNativeFlexLayout {
2832
2872
  static func ceiled(_ size: CGSize, scale: CGFloat, atLeast floor: CGSize) -> CGSize {
2833
2873
  let scale = max(1, scale)
2834
2874
  // 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 }
2875
+ func up(_ value: CGFloat) -> CGFloat { (craftNativeLayoutNumber(value) * scale - 0.001).rounded(.up) / scale }
2836
2876
  return CGSize(width: max(floor.width, up(size.width)), height: max(floor.height, up(size.height)))
2837
2877
  }
2838
2878
 
@@ -2840,7 +2880,9 @@ enum CraftNativeFlexLayout {
2840
2880
  /// neither overlap nor leave a hairline between them.
2841
2881
  static func snapped(_ rect: CGRect, scale: CGFloat) -> CGRect {
2842
2882
  let scale = max(1, scale)
2843
- func snap(_ value: CGFloat) -> CGFloat { (value * scale).rounded() / scale }
2883
+ // Whatever the arithmetic before this made of the styles it was
2884
+ // given, the frame UIKit is handed is one it accepts.
2885
+ func snap(_ value: CGFloat) -> CGFloat { (craftNativeLayoutNumber(value) * scale).rounded() / scale }
2844
2886
  let minX = snap(rect.minX), minY = snap(rect.minY)
2845
2887
  return CGRect(x: minX, y: minY, width: max(0, snap(rect.maxX) - minX), height: max(0, snap(rect.maxY) - minY))
2846
2888
  }
@@ -3314,6 +3356,7 @@ private class CraftNativeFlowView: UIStackView {
3314
3356
  if moved {
3315
3357
  craftScreenController?.scheduleLayoutEvents()
3316
3358
  craftScreenController?.scheduleKeyboardInsets()
3359
+ craftScreenController?.scheduleFloatingClearance()
3317
3360
  }
3318
3361
  }
3319
3362
  for (child, frame) in result.frames {
@@ -5001,7 +5044,7 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
5001
5044
  /// these indices stay at the top of the visible area once scrolled to,
5002
5045
  /// each pushed up by the next. Vertical scroll views only.
5003
5046
  func setStickyHeaderIndices(_ value: Any?) {
5004
- let next = Array(Set(((value as? [Any]) ?? []).compactMap { number($0).map { Int($0) } }.filter { $0 >= 0 })).sorted()
5047
+ let next = Array(Set(((value as? [Any]) ?? []).compactMap { number($0).map { craftNativeInt($0) } }.filter { $0 >= 0 })).sorted()
5005
5048
  guard next != stickyIndices else { return }
5006
5049
  stickyIndices = next
5007
5050
  setNeedsLayout()
@@ -5117,6 +5160,19 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
5117
5160
  setContentNeedsLayout()
5118
5161
  }
5119
5162
 
5163
+ /// What covers the end of this scroll view from below: the keyboard, and
5164
+ /// the compact tab actions floating above the tab bar (#523). The content
5165
+ /// is inset by the larger, so its last lines can be scrolled clear of
5166
+ /// whichever reaches further.
5167
+ var keyboardCover: CGFloat = 0 { didSet { applyCover() } }
5168
+ var floatingCover: CGFloat = 0 { didSet { applyCover() } }
5169
+
5170
+ private func applyCover() {
5171
+ let cover = max(keyboardCover, floatingCover)
5172
+ if abs(contentInset.bottom - cover) > 0.5 { contentInset.bottom = cover }
5173
+ if abs(verticalScrollIndicatorInsets.bottom - cover) > 0.5 { verticalScrollIndicatorInsets.bottom = cover }
5174
+ }
5175
+
5120
5176
  private var refreshingProp = false
5121
5177
 
5122
5178
  /// `onRefresh` and `refreshing`: the system's pull to refresh, controlled
@@ -5525,7 +5581,7 @@ final class CraftNativeSvgView: UIView {
5525
5581
  var out = from
5526
5582
  var i = 0
5527
5583
  while i < from.count {
5528
- let command = Int(from[i])
5584
+ let command = craftNativeInt(from[i])
5529
5585
  guard to[i] == from[i] else { return from }
5530
5586
  let count: Int
5531
5587
  switch command {
@@ -5710,7 +5766,7 @@ final class CraftNativeSvgView: UIView {
5710
5766
  guard !text.isEmpty else { return }
5711
5767
  let props = node["props"] as? [String: Any] ?? [:]
5712
5768
  let style = node["style"] as? [String: Any] ?? [:]
5713
- let size = number(props["fontSize"]) ?? number(style["fontSize"]) ?? 12
5769
+ let size = number(props["fontSize"]) ?? styleNumber(style["fontSize"]) ?? 12
5714
5770
  let weight: UIFont.Weight = {
5715
5771
  switch paint.fontWeight?.lowercased() {
5716
5772
  case "500": return .medium
@@ -5752,7 +5808,7 @@ final class CraftNativeSvgView: UIView {
5752
5808
  var i = 0
5753
5809
  func point(_ at: Int) -> CGPoint { CGPoint(x: n[at], y: n[at + 1]) }
5754
5810
  while i < n.count {
5755
- switch Int(n[i]) {
5811
+ switch craftNativeInt(n[i]) {
5756
5812
  case 0 where i + 2 < n.count: path.move(to: point(i + 1)); i += 3
5757
5813
  case 1 where i + 2 < n.count: path.addLine(to: point(i + 1)); i += 3
5758
5814
  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 +6913,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6857
6913
  /// targeted update of this subtree inherits it without its ancestors.
6858
6914
  var inheritedFontFamily: String?
6859
6915
  var children: [RenderedNode] = []
6916
+ /// `autoFocus` was on when this input was last drawn. Focus is taken
6917
+ /// when it turns on, which mounting with it on is, and not on each
6918
+ /// render while it stays on (#511).
6919
+ var autoFocus = false
6860
6920
  var widthConstraint: NSLayoutConstraint?
6861
6921
  var heightConstraint: NSLayoutConstraint?
6862
6922
 
@@ -7015,6 +7075,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7015
7075
  /// Where the keyboard is on the screen while it is up, in screen coordinates.
7016
7076
  private var keyboardScreenFrame: CGRect?
7017
7077
  private var keyboardInsetsScheduled = false
7078
+ private var floatingClearanceScheduled = false
7079
+ /// Whether any scroll view may still hold room for actions that have gone.
7080
+ private var floatingClearanceApplied = false
7018
7081
  /// The hybrid shell (CraftHybrid.swift), when this screen is one of a web
7019
7082
  /// app's native screens: told of the first frame, of a first frame that
7020
7083
  /// could not be drawn, and of `craft.navigation.open(path)`.
@@ -7396,6 +7459,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7396
7459
  emitLayoutEvents()
7397
7460
  sendViewportIfChanged()
7398
7461
  scheduleKeyboardInsets()
7462
+ scheduleFloatingClearance()
7399
7463
  }
7400
7464
 
7401
7465
  /// The width the bundle last heard, so a layout pass at the same size says nothing.
@@ -8816,6 +8880,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8816
8880
  }
8817
8881
  // The app script has no screen for the keyboard to cover.
8818
8882
  if !isAppScript {
8883
+ lifecycleObservers.append(NotificationCenter.default.addObserver(
8884
+ forName: CraftChrome.floatingActionsDidMove,
8885
+ object: nil,
8886
+ queue: .main
8887
+ ) { [weak self] _ in
8888
+ self?.scheduleFloatingClearance()
8889
+ })
8819
8890
  lifecycleObservers.append(NotificationCenter.default.addObserver(
8820
8891
  forName: UIResponder.keyboardWillChangeFrameNotification,
8821
8892
  object: nil,
@@ -8960,7 +9031,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8960
9031
  /// took account of) insets its content by that much, so everything in it
8961
9032
  /// can be scrolled above the keyboard. Internal for XCTest.
8962
9033
  func insetScrollViewsForKeyboard() {
8963
- var scrolls: [UIScrollView] = []
9034
+ var scrolls: [CraftNativeScrollView] = []
8964
9035
  func collect(_ node: RenderedNode?) {
8965
9036
  guard let node else { return }
8966
9037
  if let scroll = node.view as? CraftNativeScrollView, scroll.isVertical { scrolls.append(scroll) }
@@ -8977,8 +9048,51 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8977
9048
  let automaticBottom = max(0, scroll.adjustedContentInset.bottom - scroll.contentInset.bottom)
8978
9049
  overlap = max(0, overlap - automaticBottom)
8979
9050
  }
8980
- if abs(scroll.contentInset.bottom - overlap) > 0.5 { scroll.contentInset.bottom = overlap }
8981
- if abs(scroll.verticalScrollIndicatorInsets.bottom - overlap) > 0.5 { scroll.verticalScrollIndicatorInsets.bottom = overlap }
9051
+ // Its own inset is what is being measured, not part of the answer.
9052
+ scroll.keyboardCover = overlap
9053
+ }
9054
+ }
9055
+
9056
+ /// The compact tab actions float over the trailing end of whatever
9057
+ /// scrolls behind them. Each vertical scroll view and list they reach
9058
+ /// into gets that much room at its end, so its last row can be brought
9059
+ /// clear of them; one that ends above them gets none. How wide a scroll
9060
+ /// view is does not come into it: a page of a pager that is off to one
9061
+ /// side now is under the actions once it is swiped to. The safe area and
9062
+ /// the viewport are left alone: content still scrolls behind the
9063
+ /// actions. Internal for XCTest.
9064
+ func insetScrollViewsForFloatingActions() {
9065
+ let chrome = CraftChrome.shared
9066
+ let actions = chrome.floatingActions.flatMap { frame in
9067
+ chrome.floatingActionsHost.flatMap { view.isDescendant(of: $0) ? frame : nil }
9068
+ }
9069
+ func cover(_ scroll: UIScrollView) -> CGFloat {
9070
+ guard let actions, let window = scroll.window else { return 0 }
9071
+ let frame = scroll.convert(scroll.bounds, to: window)
9072
+ // An edge-to-edge scroller already clears the tab bar through
9073
+ // UIKit's safe area. Add only what the actions reach above it.
9074
+ let automaticBottom = max(0, scroll.adjustedContentInset.bottom - scroll.contentInset.bottom)
9075
+ return max(0, frame.maxY - actions.minY - automaticBottom)
9076
+ }
9077
+ func visit(_ node: RenderedNode?) {
9078
+ guard let node else { return }
9079
+ if let scroll = node.view as? CraftNativeScrollView, scroll.isVertical { scroll.floatingCover = cover(scroll) }
9080
+ else if let list = node.view as? CraftNativeFlatList { list.floatingCover = cover(list) }
9081
+ node.children.forEach(visit)
9082
+ }
9083
+ visit(renderedRoot)
9084
+ floatingClearanceApplied = actions != nil
9085
+ }
9086
+
9087
+ /// A layout moved a view, or the actions moved: measured again once the
9088
+ /// pass is over, where each scroll view now ends.
9089
+ fileprivate func scheduleFloatingClearance() {
9090
+ guard CraftChrome.shared.floatingActions != nil || floatingClearanceApplied, !floatingClearanceScheduled else { return }
9091
+ floatingClearanceScheduled = true
9092
+ DispatchQueue.main.async { [weak self] in
9093
+ guard let self else { return }
9094
+ self.floatingClearanceScheduled = false
9095
+ self.insetScrollViewsForFloatingActions()
8982
9096
  }
8983
9097
  }
8984
9098
 
@@ -9766,12 +9880,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9766
9880
  case "Text":
9767
9881
  let text = children.compactMap { $0 as? String }.joined()
9768
9882
  if let selectable = result as? CraftNativeSelectableTextView {
9769
- selectable.textContainer.maximumNumberOfLines = max(0, Int(number(props["numberOfLines"]) ?? 0))
9883
+ selectable.textContainer.maximumNumberOfLines = max(0, craftNativeInt(number(props["numberOfLines"]) ?? 0))
9770
9884
  selectable.textContainer.lineBreakMode = textLineBreakMode(props["ellipsizeMode"])
9771
9885
  configureSelectableText(selectable, text: text, style: style)
9772
9886
  } else {
9773
9887
  let label = result as! UILabel
9774
- label.numberOfLines = max(0, Int(number(props["numberOfLines"]) ?? 0))
9888
+ label.numberOfLines = max(0, craftNativeInt(number(props["numberOfLines"]) ?? 0))
9775
9889
  label.lineBreakMode = textLineBreakMode(props["ellipsizeMode"])
9776
9890
  (label as? CraftNativeLabel)?.insets = paddingInsets(style)
9777
9891
  configureText(label, text: text, style: style)
@@ -9830,6 +9944,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9830
9944
  ? nil
9831
9945
  : inputDrafts[current.identity] ?? props["defaultValue"] as? String
9832
9946
  )
9947
+ applyAutoFocus(props["autoFocus"], to: textView, node: current)
9833
9948
  break
9834
9949
  }
9835
9950
  let field = result as! UITextField
@@ -9848,7 +9963,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9848
9963
  field.tintColor = color(props["selectionColor"])
9849
9964
  let fieldId = ObjectIdentifier(field)
9850
9965
  inputIdentities[fieldId] = current.identity
9851
- let maxLength = Int(number(props["maxLength"]) ?? 0)
9966
+ let maxLength = craftNativeInt(number(props["maxLength"]) ?? 0)
9852
9967
  if maxLength > 0 {
9853
9968
  textMaxLengths[fieldId] = maxLength
9854
9969
  } else {
@@ -9890,12 +10005,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9890
10005
  updateAuxiliaryHandler(events["onBlur"], in: &blurHandlers, for: field)
9891
10006
  updateAuxiliaryHandler(events["onEndEditing"], in: &endEditingHandlers, for: field)
9892
10007
  updateAuxiliaryHandler(events["onSubmitEditing"], in: &submitHandlers, for: field)
9893
- if props["autoFocus"] as? Bool == true, !field.isFirstResponder {
9894
- DispatchQueue.main.async { [weak self] in
9895
- guard self?.isLeavingScreen == false else { return }
9896
- _ = field.becomeFirstResponder()
9897
- }
9898
- }
10008
+ applyAutoFocus(props["autoFocus"], to: field, node: current)
9899
10009
  case "SegmentedControl":
9900
10010
  let control = result as! CraftNativeSegmentedControl
9901
10011
  let items = props["items"] as? [[String: Any]] ?? []
@@ -9964,7 +10074,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9964
10074
  if picker.preferredDatePickerStyle != display { picker.preferredDatePickerStyle = display }
9965
10075
  }
9966
10076
  // UIKit takes an interval that divides an hour, and ignores others.
9967
- let interval = Int(number(props["minuteInterval"]) ?? 1)
10077
+ let interval = craftNativeInt(number(props["minuteInterval"]) ?? 1)
9968
10078
  picker.minuteInterval = (1...30).contains(interval) && 60 % interval == 0 ? interval : 1
9969
10079
  picker.minimumDate = Self.pickerDate(props["minimumDate"])
9970
10080
  picker.maximumDate = Self.pickerDate(props["maximumDate"])
@@ -10010,7 +10120,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10010
10120
  modalFocusRestore[modalID] = focusBeforeContent
10011
10121
  }
10012
10122
  if visible, wasVisible != true { positionModals() }
10013
- modal.setVisible(visible, opacity: number(style["opacity"]) ?? 1, animationType: props["animationType"] as? String)
10123
+ modal.setVisible(visible, opacity: styleNumber(style["opacity"]) ?? 1, animationType: props["animationType"] as? String)
10014
10124
  if !visible, wasVisible == true {
10015
10125
  let restore = modalFocusRestore.removeValue(forKey: modalID)
10016
10126
  DispatchQueue.main.async { [weak self, weak restore] in
@@ -10037,7 +10147,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10037
10147
  let disabled = props["disabled"] as? Bool == true
10038
10148
  pressable.pressActiveOpacity = disabled
10039
10149
  ? nil : number(props["activeOpacity"]) ?? 0.5
10040
- pressable.pressBaseOpacity = number(style["opacity"]) ?? 1
10150
+ pressable.pressBaseOpacity = styleNumber(style["opacity"]) ?? 1
10041
10151
  pressable.pressUnderlayColor = type == "TouchableHighlight" && !disabled ? color(props["underlayColor"]) : nil
10042
10152
  case "Switch":
10043
10153
  if let toggle = result as? CraftNativeSolidSwitch {
@@ -10647,7 +10757,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10647
10757
  list.apply(
10648
10758
  nodes: children,
10649
10759
  horizontal: props["horizontal"] as? Bool == true,
10650
- columns: Int(number(props["numColumns"]) ?? 1),
10760
+ columns: craftNativeInt(number(props["numColumns"]) ?? 1),
10651
10761
  inverted: props["inverted"] as? Bool == true,
10652
10762
  endReachedThreshold: threshold,
10653
10763
  renderItem: { [weak self] node, identity, reusable in
@@ -11180,13 +11290,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11180
11290
  }
11181
11291
  // An input's own scrolling follows its height (CraftNativeTextView).
11182
11292
  if !(textView is CraftNativeTextView) { textView.isScrollEnabled = false }
11183
- let numberOfLines = Int(number(props["numberOfLines"]) ?? 0)
11293
+ let numberOfLines = craftNativeInt(number(props["numberOfLines"]) ?? 0)
11184
11294
  if numberOfLines > 0 {
11185
11295
  textView.textContainer.maximumNumberOfLines = numberOfLines
11186
11296
  } else {
11187
11297
  textView.textContainer.maximumNumberOfLines = 0
11188
11298
  }
11189
- let maxLength = Int(number(props["maxLength"]) ?? 0)
11299
+ let maxLength = craftNativeInt(number(props["maxLength"]) ?? 0)
11190
11300
  if maxLength > 0 {
11191
11301
  textMaxLengths[id] = maxLength
11192
11302
  } else {
@@ -11216,11 +11326,19 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11216
11326
  updateAuxiliaryHandler(events["onBlur"], in: &blurHandlers, for: textView)
11217
11327
  updateAuxiliaryHandler(events["onEndEditing"], in: &endEditingHandlers, for: textView)
11218
11328
  updateAuxiliaryHandler(events["onSubmitEditing"], in: &submitHandlers, for: textView)
11219
- if props["autoFocus"] as? Bool == true, !textView.isFirstResponder {
11220
- DispatchQueue.main.async { [weak self] in
11221
- guard self?.isLeavingScreen == false else { return }
11222
- _ = textView.becomeFirstResponder()
11223
- }
11329
+ }
11330
+
11331
+ /// `autoFocus`: focus when the input mounts with it, or when it is turned
11332
+ /// on later. Applied on every render it took the keyboard back each time
11333
+ /// anything else on the screen changed (#511).
11334
+ private func applyAutoFocus(_ value: Any?, to input: UIView, node: RenderedNode) {
11335
+ let wanted = value as? Bool == true
11336
+ let turnedOn = wanted && !node.autoFocus
11337
+ node.autoFocus = wanted
11338
+ guard turnedOn, !input.isFirstResponder else { return }
11339
+ DispatchQueue.main.async { [weak self] in
11340
+ guard self?.isLeavingScreen == false else { return }
11341
+ _ = input.becomeFirstResponder()
11224
11342
  }
11225
11343
  }
11226
11344
 
@@ -11230,8 +11348,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11230
11348
  /// leaving a hole.
11231
11349
  private func configureIcon(_ icon: UIImageView, props: [String: Any], style: [String: Any]) {
11232
11350
  let name = (props["symbol"] as? String) ?? (props["name"] as? String) ?? ""
11233
- let box = [number(style["width"]), number(style["height"])].compactMap { $0 }.min()
11234
- let fontSize = number(style["fontSize"])
11351
+ let box = [styleNumber(style["width"]), styleNumber(style["height"])].compactMap { $0 }.min()
11352
+ let fontSize = styleNumber(style["fontSize"])
11235
11353
  // Without a size of its own an icon is a 20 pt square (contract A2).
11236
11354
  (icon as? CraftNativeIconView)?.defaultBox = box == nil && fontSize == nil ? 20 : nil
11237
11355
  let pointSize = fontSize ?? (box ?? 20) * 0.84
@@ -11272,10 +11390,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11272
11390
  stack.alignItems = style["alignItems"] as? String ?? "stretch"
11273
11391
  stack.alignContent = style["alignContent"] as? String ?? "flex-start"
11274
11392
  stack.justifyContent = style["justifyContent"] as? String ?? "flex-start"
11275
- let gap = max(0, number(style["gap"]) ?? 0)
11393
+ let gap = max(0, styleNumber(style["gap"]) ?? 0)
11276
11394
  stack.gap = gap
11277
- stack.rowGap = max(0, number(style["rowGap"]) ?? gap)
11278
- stack.columnGap = max(0, number(style["columnGap"]) ?? gap)
11395
+ stack.rowGap = max(0, styleNumber(style["rowGap"]) ?? gap)
11396
+ stack.columnGap = max(0, styleNumber(style["columnGap"]) ?? gap)
11279
11397
  stack.grid = style["display"] as? String == "grid"
11280
11398
  stack.gridColumns = craftNativeGridColumnCount(style["gridTemplateColumns"] ?? style["gridColumns"])
11281
11399
  stack.gridRows = craftNativeGridRowCount(style["gridTemplateRows"])
@@ -11287,8 +11405,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11287
11405
  private func addJustificationSpacers(to stack: CraftNativeFlowView, value: String?) { }
11288
11406
 
11289
11407
  private func applyViewStyle(_ style: [String: Any], to view: UIView, node: RenderedNode) {
11290
- let width = style["minWidth"] == nil && style["maxWidth"] == nil ? number(style["width"]) : nil
11291
- let height = style["minHeight"] == nil && style["maxHeight"] == nil ? number(style["height"]) : nil
11408
+ let width = style["minWidth"] == nil && style["maxWidth"] == nil ? styleNumber(style["width"]) : nil
11409
+ let height = style["minHeight"] == nil && style["maxHeight"] == nil ? styleNumber(style["height"]) : nil
11292
11410
  updateDimension(width, constraint: &node.widthConstraint, anchor: view.widthAnchor)
11293
11411
  updateDimension(height, constraint: &node.heightConstraint, anchor: view.heightAnchor)
11294
11412
  view.backgroundColor = color(style["backgroundColor"]) ?? .clear
@@ -11307,31 +11425,31 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11307
11425
  direction: style["gradientDirection"] as? String,
11308
11426
  colors: [color(style["gradientFrom"]), color(style["gradientVia"]), color(style["gradientTo"])]
11309
11427
  )
11310
- view.alpha = number(style["opacity"]) ?? 1
11428
+ view.alpha = styleNumber(style["opacity"]) ?? 1
11311
11429
  view.isHidden = style["display"] as? String == "none"
11312
- let borderRadius = number(style["borderRadius"]) ?? 0
11430
+ let borderRadius = styleNumber(style["borderRadius"]) ?? 0
11313
11431
  let cornerRadiusKeys = ["borderTopLeftRadius", "borderTopRightRadius", "borderBottomRightRadius", "borderBottomLeftRadius"]
11314
11432
  let hasCornerRadii = cornerRadiusKeys.contains { style[$0] != nil }
11315
11433
  view.craftRequestedCornerRadius = borderRadius
11316
11434
  view.craftRequestedCornerRadii = hasCornerRadii
11317
- ? cornerRadiusKeys.map { number(style[$0]) ?? borderRadius }
11435
+ ? cornerRadiusKeys.map { styleNumber(style[$0]) ?? borderRadius }
11318
11436
  : nil
11319
- let borderWidth = number(style["borderWidth"]) ?? 0
11437
+ let borderWidth = styleNumber(style["borderWidth"]) ?? 0
11320
11438
  let borderColor = color(style["borderColor"]) ?? .clear
11321
11439
  let sideWidthKeys = ["borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth"]
11322
11440
  let sideColorKeys = ["borderTopColor", "borderRightColor", "borderBottomColor", "borderLeftColor"]
11323
11441
  view.craftApplySideBorders(
11324
11442
  (sideWidthKeys + sideColorKeys).contains { style[$0] != nil },
11325
11443
  style: style["borderStyle"] as? String,
11326
- widths: sideWidthKeys.map { number(style[$0]) ?? borderWidth },
11444
+ widths: sideWidthKeys.map { styleNumber(style[$0]) ?? borderWidth },
11327
11445
  colors: sideColorKeys.map { color(style[$0]) ?? borderColor }
11328
11446
  )
11329
- view.layer.zPosition = number(style["zIndex"]) ?? 0
11447
+ view.layer.zPosition = styleNumber(style["zIndex"]) ?? 0
11330
11448
  applyTransform(style["transform"], to: view)
11331
- let elevation = max(0, number(style["elevation"]) ?? 0)
11449
+ let elevation = max(0, styleNumber(style["elevation"]) ?? 0)
11332
11450
  let shadowColor = color(style["shadowColor"]) ?? .black
11333
- let shadowOpacity = min(1, max(0, number(style["shadowOpacity"]) ?? (elevation > 0 ? min(0.28, 0.12 + elevation * 0.02) : 0)))
11334
- let shadowRadius = max(0, number(style["shadowRadius"]) ?? elevation * 0.5)
11451
+ let shadowOpacity = min(1, max(0, styleNumber(style["shadowOpacity"]) ?? (elevation > 0 ? min(0.28, 0.12 + elevation * 0.02) : 0)))
11452
+ let shadowRadius = max(0, styleNumber(style["shadowRadius"]) ?? elevation * 0.5)
11335
11453
  let shadowOffset = style["shadowOffset"] as? [String: Any]
11336
11454
  view.layer.shadowColor = shadowColor.cgColor
11337
11455
  view.layer.shadowOpacity = Float(shadowOpacity)
@@ -11364,7 +11482,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11364
11482
 
11365
11483
  private func paddingInsets(_ style: [String: Any]) -> UIEdgeInsets {
11366
11484
  func nonNegative(_ value: Any?) -> CGFloat? {
11367
- guard let value = number(value), value >= 0 else { return nil }
11485
+ guard let value = styleNumber(value), value >= 0 else { return nil }
11368
11486
  return value
11369
11487
  }
11370
11488
  let padding = nonNegative(style["padding"]) ?? 0
@@ -11387,11 +11505,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11387
11505
  var threeDimensional = CATransform3DIdentity
11388
11506
  var hasThreeDimensionalRotation = false
11389
11507
  for transform in transforms {
11390
- if let value = number(transform["translateX"]) { affine = affine.translatedBy(x: value, y: 0) }
11391
- if let value = number(transform["translateY"]) { affine = affine.translatedBy(x: 0, y: value) }
11392
- if let value = number(transform["scale"]) { affine = affine.scaledBy(x: value, y: value) }
11393
- if let value = number(transform["scaleX"]) { affine = affine.scaledBy(x: value, y: 1) }
11394
- if let value = number(transform["scaleY"]) { affine = affine.scaledBy(x: 1, y: value) }
11508
+ if let value = styleNumber(transform["translateX"]) { affine = affine.translatedBy(x: value, y: 0) }
11509
+ if let value = styleNumber(transform["translateY"]) { affine = affine.translatedBy(x: 0, y: value) }
11510
+ if let value = styleNumber(transform["scale"]) { affine = affine.scaledBy(x: value, y: value) }
11511
+ if let value = styleNumber(transform["scaleX"]) { affine = affine.scaledBy(x: value, y: 1) }
11512
+ if let value = styleNumber(transform["scaleY"]) { affine = affine.scaledBy(x: 1, y: value) }
11395
11513
  if let value = transformAngle(transform["rotate"] ?? transform["rotateZ"]) { affine = affine.rotated(by: value) }
11396
11514
  if let value = transformAngle(transform["skewX"]) { affine = affine.concatenating(CGAffineTransform(a: 1, b: 0, c: tan(value), d: 1, tx: 0, ty: 0)) }
11397
11515
  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 +11547,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11429
11547
  label.font = textFont(style, default: .systemFont(ofSize: 16))
11430
11548
  label.textAlignment = textAlignment(style["textAlign"])
11431
11549
  var attributes: [NSAttributedString.Key: Any] = [:]
11432
- if let spacing = number(style["letterSpacing"]) { attributes[.kern] = spacing }
11550
+ if let spacing = styleNumber(style["letterSpacing"]) { attributes[.kern] = spacing }
11433
11551
  addTextDecorationAttributes(style, to: &attributes)
11434
11552
  if let shadow = textShadow(style) { attributes[.shadow] = shadow }
11435
- if let lineHeight = number(style["lineHeight"]) {
11553
+ if let lineHeight = styleNumber(style["lineHeight"]) {
11436
11554
  let paragraph = NSMutableParagraphStyle()
11437
11555
  paragraph.minimumLineHeight = lineHeight
11438
11556
  paragraph.maximumLineHeight = lineHeight
@@ -11486,8 +11604,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11486
11604
  .foregroundColor: color,
11487
11605
  .font: font,
11488
11606
  ]
11489
- if let spacing = number(style["letterSpacing"]) { attributes[.kern] = spacing }
11490
- if let lineHeight = number(style["lineHeight"]) {
11607
+ if let spacing = styleNumber(style["letterSpacing"]) { attributes[.kern] = spacing }
11608
+ if let lineHeight = styleNumber(style["lineHeight"]) {
11491
11609
  let paragraph = NSMutableParagraphStyle()
11492
11610
  paragraph.minimumLineHeight = lineHeight
11493
11611
  paragraph.maximumLineHeight = lineHeight
@@ -11509,14 +11627,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11509
11627
  .foregroundColor: color(style["color"]) ?? .label,
11510
11628
  .font: font,
11511
11629
  ]
11512
- if let spacing = number(style["letterSpacing"]) { attributes[.kern] = spacing }
11630
+ if let spacing = styleNumber(style["letterSpacing"]) { attributes[.kern] = spacing }
11513
11631
  // UIKit otherwise resets the field's alignment to natural when these attributes are applied.
11514
11632
  let paragraph = NSMutableParagraphStyle()
11515
11633
  paragraph.alignment = alignment
11516
11634
  // The default paragraph wraps even inside a UITextField. A long URL
11517
11635
  // then paints a second line outside the field instead of scrolling.
11518
11636
  if singleLine { paragraph.lineBreakMode = .byClipping }
11519
- if let lineHeight = number(style["lineHeight"]) {
11637
+ if let lineHeight = styleNumber(style["lineHeight"]) {
11520
11638
  paragraph.minimumLineHeight = lineHeight
11521
11639
  paragraph.maximumLineHeight = lineHeight
11522
11640
  }
@@ -11535,7 +11653,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11535
11653
  width: number(offset?["width"]) ?? 0,
11536
11654
  height: number(offset?["height"]) ?? 0
11537
11655
  )
11538
- shadow.shadowBlurRadius = max(0, number(style["textShadowRadius"]) ?? 0)
11656
+ shadow.shadowBlurRadius = max(0, styleNumber(style["textShadowRadius"]) ?? 0)
11539
11657
  return shadow
11540
11658
  }
11541
11659
 
@@ -11574,7 +11692,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11574
11692
  }
11575
11693
 
11576
11694
  private func textFont(_ style: [String: Any], default fallback: UIFont) -> UIFont {
11577
- let size = number(style["fontSize"]) ?? fallback.pointSize
11695
+ let size = styleNumber(style["fontSize"]) ?? fallback.pointSize
11578
11696
  let weight: UIFont.Weight
11579
11697
  switch craftNativeFontWeight(style["fontWeight"]) {
11580
11698
  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 : number(body["hour"]).map { Int($0) }
332
- let minute = body["minute"] == nil || body["minute"] is NSNull ? 0 : number(body["minute"]).map { Int($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 = number(block["iterations"] ?? 1).map { Int($0) } ?? 0
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 * Double(playbackRate.values[0]) * pow(2, Double(detune.values[0]) / 1200)
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