craft-native 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.
@@ -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? {
@@ -174,31 +214,34 @@ private func craftNativePickerValue(_ value: Any?) -> String? {
174
214
  return nil
175
215
  }
176
216
 
177
- private func craftNativeGridColumnCount(_ value: Any?) -> Int {
178
- if let number = craftNativeNumber(value) { return max(1, number.intValue) }
179
- guard let text = value as? String else { return 1 }
180
- let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
181
- if trimmed.hasPrefix("repeat(") {
182
- let digits = trimmed.dropFirst("repeat(".count).prefix { $0.isNumber }
183
- if let count = Int(digits) { return max(1, count) }
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
- private func craftNativeGridRowCount(_ value: Any?) -> Int? {
190
- if let number = craftNativeNumber(value) { return max(1, number.intValue) }
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.isNumber }
196
- if let count = Int(digits) { return max(1, count) }
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 : max(1, tracks.count)
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 { Int($0.rounded(.towardZero)) } ?? 0
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
- func snap(_ value: CGFloat) -> CGFloat { (value * scale).rounded() / scale }
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
- let requested = rawFit as? String ?? "fill"
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 { Int($0) } }.filter { $0 >= 0 })).sorted()
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 = Int(from[i])
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"]) ?? number(style["fontSize"]) ?? 12
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 Int(n[i]) {
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
- navigationItem.leftItemsSupplementBackButton = true
8416
- navigationItem.leftBarButtonItems = buttons.compactMap(navigationButton)
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: [UIScrollView] = []
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
- 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 }
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, Int(number(props["numberOfLines"]) ?? 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, Int(number(props["numberOfLines"]) ?? 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 = Int(number(props["maxLength"]) ?? 0)
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
- 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
- }
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 = Int(number(props["minuteInterval"]) ?? 1)
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: number(style["opacity"]) ?? 1, animationType: props["animationType"] as? String)
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 = number(style["opacity"]) ?? 1
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: Int(number(props["numColumns"]) ?? 1),
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 = Int(number(props["numberOfLines"]) ?? 0)
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 = Int(number(props["maxLength"]) ?? 0)
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
- 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
- }
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 = [number(style["width"]), number(style["height"])].compactMap { $0 }.min()
11234
- let fontSize = number(style["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, number(style["gap"]) ?? 0)
11475
+ let gap = max(0, styleNumber(style["gap"]) ?? 0)
11276
11476
  stack.gap = gap
11277
- stack.rowGap = max(0, number(style["rowGap"]) ?? gap)
11278
- stack.columnGap = max(0, number(style["columnGap"]) ?? gap)
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 ? number(style["width"]) : nil
11291
- let height = style["minHeight"] == nil && style["maxHeight"] == nil ? number(style["height"]) : 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 = number(style["opacity"]) ?? 1
11510
+ view.alpha = styleNumber(style["opacity"]) ?? 1
11311
11511
  view.isHidden = style["display"] as? String == "none"
11312
- let borderRadius = number(style["borderRadius"]) ?? 0
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 { number(style[$0]) ?? borderRadius }
11517
+ ? cornerRadiusKeys.map { styleNumber(style[$0]) ?? borderRadius }
11318
11518
  : nil
11319
- let borderWidth = number(style["borderWidth"]) ?? 0
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 { number(style[$0]) ?? borderWidth },
11526
+ widths: sideWidthKeys.map { styleNumber(style[$0]) ?? borderWidth },
11327
11527
  colors: sideColorKeys.map { color(style[$0]) ?? borderColor }
11328
11528
  )
11329
- view.layer.zPosition = number(style["zIndex"]) ?? 0
11529
+ view.layer.zPosition = styleNumber(style["zIndex"]) ?? 0
11330
11530
  applyTransform(style["transform"], to: view)
11331
- let elevation = max(0, number(style["elevation"]) ?? 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, 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)
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 = number(value), value >= 0 else { return nil }
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 = 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) }
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 = number(style["letterSpacing"]) { attributes[.kern] = 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 = number(style["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 = number(style["letterSpacing"]) { attributes[.kern] = spacing }
11490
- if let lineHeight = number(style["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 = number(style["letterSpacing"]) { attributes[.kern] = 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 = number(style["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, number(style["textShadowRadius"]) ?? 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 = number(style["fontSize"]) ?? fallback.pointSize
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