craft-native 0.0.497 → 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.
@@ -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 {
@@ -4798,14 +4841,23 @@ final class CraftPressFeedbackDelegate: NSObject, UIGestureRecognizerDelegate {
4798
4841
 
4799
4842
  /// Whether a scrolling view's gesture runs together with `other`. A scroll
4800
4843
  /// view is the delegate of its own pan as well as of its keyboard tap, so this
4801
- /// answers for both. Everything runs together (the keyboard tap watches
4844
+ /// answers for both. Most things run together (the keyboard tap watches
4802
4845
  /// alongside a control's own gestures; the back-swipe and the tab swipe decide
4803
- /// for themselves) except two scroll views' pans: run together, a drag on a
4804
- /// nested scroll view scrolled its parent at the same time.
4846
+ /// for themselves), with two exceptions for a scroll view's pan.
4847
+ ///
4848
+ /// Another scroll view's pan: run together, a drag on a nested scroll view
4849
+ /// scrolled its parent at the same time.
4850
+ ///
4851
+ /// A press. A row or a card that can be pressed is a view with a tap
4852
+ /// recognizer, and UIKit fails a tap the moment a drag begins on the scroll
4853
+ /// view around it. That is what keeps a flick, or the touch that stops a
4854
+ /// moving list, from also pressing whatever is under the finger. Told to run
4855
+ /// together, both happened: a short scroll pressed the thing it began on.
4805
4856
  func craftScrollGesture(_ gesture: UIGestureRecognizer, runsWith other: UIGestureRecognizer) -> Bool {
4806
- guard let scroll = gesture.view as? UIScrollView, gesture === scroll.panGestureRecognizer,
4807
- let otherScroll = other.view as? UIScrollView, other === otherScroll.panGestureRecognizer else { return true }
4808
- return false
4857
+ guard let scroll = gesture.view as? UIScrollView, gesture === scroll.panGestureRecognizer else { return true }
4858
+ if let otherScroll = other.view as? UIScrollView, other === otherScroll.panGestureRecognizer { return false }
4859
+ if other is UITapGestureRecognizer, other.delegate is CraftPressGestureDelegate { return false }
4860
+ return true
4809
4861
  }
4810
4862
 
4811
4863
  /// Scroll containers observe taps without cancelling native input focus or
@@ -4992,7 +5044,7 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
4992
5044
  /// these indices stay at the top of the visible area once scrolled to,
4993
5045
  /// each pushed up by the next. Vertical scroll views only.
4994
5046
  func setStickyHeaderIndices(_ value: Any?) {
4995
- 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()
4996
5048
  guard next != stickyIndices else { return }
4997
5049
  stickyIndices = next
4998
5050
  setNeedsLayout()
@@ -5108,6 +5160,19 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
5108
5160
  setContentNeedsLayout()
5109
5161
  }
5110
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
+
5111
5176
  private var refreshingProp = false
5112
5177
 
5113
5178
  /// `onRefresh` and `refreshing`: the system's pull to refresh, controlled
@@ -5516,7 +5581,7 @@ final class CraftNativeSvgView: UIView {
5516
5581
  var out = from
5517
5582
  var i = 0
5518
5583
  while i < from.count {
5519
- let command = Int(from[i])
5584
+ let command = craftNativeInt(from[i])
5520
5585
  guard to[i] == from[i] else { return from }
5521
5586
  let count: Int
5522
5587
  switch command {
@@ -5701,7 +5766,7 @@ final class CraftNativeSvgView: UIView {
5701
5766
  guard !text.isEmpty else { return }
5702
5767
  let props = node["props"] as? [String: Any] ?? [:]
5703
5768
  let style = node["style"] as? [String: Any] ?? [:]
5704
- let size = number(props["fontSize"]) ?? number(style["fontSize"]) ?? 12
5769
+ let size = number(props["fontSize"]) ?? styleNumber(style["fontSize"]) ?? 12
5705
5770
  let weight: UIFont.Weight = {
5706
5771
  switch paint.fontWeight?.lowercased() {
5707
5772
  case "500": return .medium
@@ -5743,7 +5808,7 @@ final class CraftNativeSvgView: UIView {
5743
5808
  var i = 0
5744
5809
  func point(_ at: Int) -> CGPoint { CGPoint(x: n[at], y: n[at + 1]) }
5745
5810
  while i < n.count {
5746
- switch Int(n[i]) {
5811
+ switch craftNativeInt(n[i]) {
5747
5812
  case 0 where i + 2 < n.count: path.move(to: point(i + 1)); i += 3
5748
5813
  case 1 where i + 2 < n.count: path.addLine(to: point(i + 1)); i += 3
5749
5814
  case 2 where i + 6 < n.count: path.addCurve(to: point(i + 5), controlPoint1: point(i + 1), controlPoint2: point(i + 3)); i += 7
@@ -6848,6 +6913,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6848
6913
  /// targeted update of this subtree inherits it without its ancestors.
6849
6914
  var inheritedFontFamily: String?
6850
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
6851
6920
  var widthConstraint: NSLayoutConstraint?
6852
6921
  var heightConstraint: NSLayoutConstraint?
6853
6922
 
@@ -6930,6 +6999,17 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6930
6999
  private var runningAnimation: CraftNativeRunningAnimation?
6931
7000
  private var renderedRoot: RenderedNode?
6932
7001
  private weak var lastFocusedInput: UIView?
7002
+ /// On its way off screen, or off it: from `viewWillDisappear` until the
7003
+ /// screen is next about to appear.
7004
+ ///
7005
+ /// A screen ends editing as it starts to leave (#497), and its view stays
7006
+ /// in the window for the length of the transition. Whatever put focus
7007
+ /// back in that time brought the keyboard up for a screen that was then
7008
+ /// removed, where nothing could put it away: a render, and a sign-in
7009
+ /// screen makes one as it navigates; the acknowledgement an input had
7010
+ /// scheduled a tenth of a second before; a modal closing; `autoFocus`.
7011
+ /// Each of them asks this first.
7012
+ private var isLeavingScreen = false
6933
7013
 
6934
7014
  /// The person put the keyboard away (Return, or a tap on nothing in a
6935
7015
  /// scroll view): the last input is let go. Kept, the next render or
@@ -6995,6 +7075,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6995
7075
  /// Where the keyboard is on the screen while it is up, in screen coordinates.
6996
7076
  private var keyboardScreenFrame: CGRect?
6997
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
6998
7081
  /// The hybrid shell (CraftHybrid.swift), when this screen is one of a web
6999
7082
  /// app's native screens: told of the first frame, of a first frame that
7000
7083
  /// could not be drawn, and of `craft.navigation.open(path)`.
@@ -7035,6 +7118,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7035
7118
  let timers = self.timers
7036
7119
  CraftNativeScriptThread.run { timers.pending.values.forEach { $0.cancel() } }
7037
7120
  pendingCapabilityRequests.forEach { CraftNativeActions.cancel(requestToken: $0) }
7121
+ CraftNativeActions.endScope(capabilityScope)
7038
7122
  lifecycleObservers.forEach(NotificationCenter.default.removeObserver)
7039
7123
  if let deepLinkListener = deepLinkListener { DeepLinkManager.shared.removeNativeListener(deepLinkListener) }
7040
7124
  }
@@ -7091,6 +7175,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7091
7175
 
7092
7176
  override func viewWillAppear(_ animated: Bool) {
7093
7177
  super.viewWillAppear(animated)
7178
+ isLeavingScreen = false
7094
7179
  if let navigationBarHidden, navigationController?.isNavigationBarHidden != navigationBarHidden {
7095
7180
  navigationController?.setNavigationBarHidden(navigationBarHidden, animated: animated)
7096
7181
  } else if navigationBarHidden == nil, navigationController?.isNavigationBarHidden == true,
@@ -7133,7 +7218,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7133
7218
  //
7134
7219
  // `lastFocusedInput` is deliberately left alone. A screen merely
7135
7220
  // covered by a push comes back, and restoreFocus putting the person
7136
- // back where they were is the behaviour that was already there.
7221
+ // back where they were is the behaviour that was already there. It
7222
+ // is not consulted again until the screen is coming back, or what
7223
+ // is ended here would be started again a moment later.
7224
+ isLeavingScreen = true
7137
7225
  view.endEditing(true)
7138
7226
  screenVisibilityChanged(false)
7139
7227
  stopCelebrations(in: view)
@@ -7298,6 +7386,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7298
7386
  pendingCapabilityDeadlines.values.forEach { $0.cancel() }
7299
7387
  pendingCapabilityDeadlines.removeAll()
7300
7388
  pendingCapabilityRequests.forEach { CraftNativeActions.cancel(requestToken: $0) }
7389
+ CraftNativeActions.endScope(capabilityScope)
7301
7390
  pendingCapabilityRequests.removeAll()
7302
7391
  if let owner = voiceRecordingOwner {
7303
7392
  voiceRecordingOwner = nil
@@ -7370,6 +7459,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7370
7459
  emitLayoutEvents()
7371
7460
  sendViewportIfChanged()
7372
7461
  scheduleKeyboardInsets()
7462
+ scheduleFloatingClearance()
7373
7463
  }
7374
7464
 
7375
7465
  /// The width the bundle last heard, so a layout pass at the same size says nothing.
@@ -8790,6 +8880,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8790
8880
  }
8791
8881
  // The app script has no screen for the keyboard to cover.
8792
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
+ })
8793
8890
  lifecycleObservers.append(NotificationCenter.default.addObserver(
8794
8891
  forName: UIResponder.keyboardWillChangeFrameNotification,
8795
8892
  object: nil,
@@ -8934,7 +9031,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8934
9031
  /// took account of) insets its content by that much, so everything in it
8935
9032
  /// can be scrolled above the keyboard. Internal for XCTest.
8936
9033
  func insetScrollViewsForKeyboard() {
8937
- var scrolls: [UIScrollView] = []
9034
+ var scrolls: [CraftNativeScrollView] = []
8938
9035
  func collect(_ node: RenderedNode?) {
8939
9036
  guard let node else { return }
8940
9037
  if let scroll = node.view as? CraftNativeScrollView, scroll.isVertical { scrolls.append(scroll) }
@@ -8951,8 +9048,51 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8951
9048
  let automaticBottom = max(0, scroll.adjustedContentInset.bottom - scroll.contentInset.bottom)
8952
9049
  overlap = max(0, overlap - automaticBottom)
8953
9050
  }
8954
- if abs(scroll.contentInset.bottom - overlap) > 0.5 { scroll.contentInset.bottom = overlap }
8955
- 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()
8956
9096
  }
8957
9097
  }
8958
9098
 
@@ -9307,7 +9447,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9307
9447
  reply(.failure(CraftNativeActionError(code: "UNSUPPORTED_TARGET", message: "Keyboard focus is supported only for text inputs")))
9308
9448
  return true
9309
9449
  }
9310
- guard node.view.becomeFirstResponder() else {
9450
+ // A screen that is leaving does not open a keyboard it will
9451
+ // not be there to close.
9452
+ guard !isLeavingScreen, node.view.becomeFirstResponder() else {
9311
9453
  reply(.failure(CraftNativeActionError(code: "FOCUS_FAILED", message: "The mounted view refused keyboard focus")))
9312
9454
  return true
9313
9455
  }
@@ -9652,6 +9794,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9652
9794
  }
9653
9795
 
9654
9796
  private func restoreFocus(_ focused: UIView?) {
9797
+ guard !isLeavingScreen else { return }
9655
9798
  guard let focused, focused.window != nil, !focused.isFirstResponder,
9656
9799
  lastFocusedInput === focused, firstResponder(in: rootStack) == nil else { return }
9657
9800
  var ancestor: UIView? = focused
@@ -9737,12 +9880,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9737
9880
  case "Text":
9738
9881
  let text = children.compactMap { $0 as? String }.joined()
9739
9882
  if let selectable = result as? CraftNativeSelectableTextView {
9740
- selectable.textContainer.maximumNumberOfLines = max(0, Int(number(props["numberOfLines"]) ?? 0))
9883
+ selectable.textContainer.maximumNumberOfLines = max(0, craftNativeInt(number(props["numberOfLines"]) ?? 0))
9741
9884
  selectable.textContainer.lineBreakMode = textLineBreakMode(props["ellipsizeMode"])
9742
9885
  configureSelectableText(selectable, text: text, style: style)
9743
9886
  } else {
9744
9887
  let label = result as! UILabel
9745
- label.numberOfLines = max(0, Int(number(props["numberOfLines"]) ?? 0))
9888
+ label.numberOfLines = max(0, craftNativeInt(number(props["numberOfLines"]) ?? 0))
9746
9889
  label.lineBreakMode = textLineBreakMode(props["ellipsizeMode"])
9747
9890
  (label as? CraftNativeLabel)?.insets = paddingInsets(style)
9748
9891
  configureText(label, text: text, style: style)
@@ -9801,6 +9944,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9801
9944
  ? nil
9802
9945
  : inputDrafts[current.identity] ?? props["defaultValue"] as? String
9803
9946
  )
9947
+ applyAutoFocus(props["autoFocus"], to: textView, node: current)
9804
9948
  break
9805
9949
  }
9806
9950
  let field = result as! UITextField
@@ -9819,7 +9963,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9819
9963
  field.tintColor = color(props["selectionColor"])
9820
9964
  let fieldId = ObjectIdentifier(field)
9821
9965
  inputIdentities[fieldId] = current.identity
9822
- let maxLength = Int(number(props["maxLength"]) ?? 0)
9966
+ let maxLength = craftNativeInt(number(props["maxLength"]) ?? 0)
9823
9967
  if maxLength > 0 {
9824
9968
  textMaxLengths[fieldId] = maxLength
9825
9969
  } else {
@@ -9861,9 +10005,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9861
10005
  updateAuxiliaryHandler(events["onBlur"], in: &blurHandlers, for: field)
9862
10006
  updateAuxiliaryHandler(events["onEndEditing"], in: &endEditingHandlers, for: field)
9863
10007
  updateAuxiliaryHandler(events["onSubmitEditing"], in: &submitHandlers, for: field)
9864
- if props["autoFocus"] as? Bool == true, !field.isFirstResponder {
9865
- DispatchQueue.main.async { _ = field.becomeFirstResponder() }
9866
- }
10008
+ applyAutoFocus(props["autoFocus"], to: field, node: current)
9867
10009
  case "SegmentedControl":
9868
10010
  let control = result as! CraftNativeSegmentedControl
9869
10011
  let items = props["items"] as? [[String: Any]] ?? []
@@ -9932,7 +10074,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9932
10074
  if picker.preferredDatePickerStyle != display { picker.preferredDatePickerStyle = display }
9933
10075
  }
9934
10076
  // UIKit takes an interval that divides an hour, and ignores others.
9935
- let interval = Int(number(props["minuteInterval"]) ?? 1)
10077
+ let interval = craftNativeInt(number(props["minuteInterval"]) ?? 1)
9936
10078
  picker.minuteInterval = (1...30).contains(interval) && 60 % interval == 0 ? interval : 1
9937
10079
  picker.minimumDate = Self.pickerDate(props["minimumDate"])
9938
10080
  picker.maximumDate = Self.pickerDate(props["maximumDate"])
@@ -9978,11 +10120,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9978
10120
  modalFocusRestore[modalID] = focusBeforeContent
9979
10121
  }
9980
10122
  if visible, wasVisible != true { positionModals() }
9981
- 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)
9982
10124
  if !visible, wasVisible == true {
9983
10125
  let restore = modalFocusRestore.removeValue(forKey: modalID)
9984
10126
  DispatchQueue.main.async { [weak self, weak restore] in
9985
- guard let self, let restore, restore.window != nil, restore.isDescendant(of: self.view) else { return }
10127
+ guard let self, !self.isLeavingScreen, let restore, restore.window != nil, restore.isDescendant(of: self.view) else { return }
9986
10128
  self.lastFocusedInput = restore
9987
10129
  _ = restore.becomeFirstResponder()
9988
10130
  }
@@ -10005,7 +10147,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10005
10147
  let disabled = props["disabled"] as? Bool == true
10006
10148
  pressable.pressActiveOpacity = disabled
10007
10149
  ? nil : number(props["activeOpacity"]) ?? 0.5
10008
- pressable.pressBaseOpacity = number(style["opacity"]) ?? 1
10150
+ pressable.pressBaseOpacity = styleNumber(style["opacity"]) ?? 1
10009
10151
  pressable.pressUnderlayColor = type == "TouchableHighlight" && !disabled ? color(props["underlayColor"]) : nil
10010
10152
  case "Switch":
10011
10153
  if let toggle = result as? CraftNativeSolidSwitch {
@@ -10615,7 +10757,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10615
10757
  list.apply(
10616
10758
  nodes: children,
10617
10759
  horizontal: props["horizontal"] as? Bool == true,
10618
- columns: Int(number(props["numColumns"]) ?? 1),
10760
+ columns: craftNativeInt(number(props["numColumns"]) ?? 1),
10619
10761
  inverted: props["inverted"] as? Bool == true,
10620
10762
  endReachedThreshold: threshold,
10621
10763
  renderItem: { [weak self] node, identity, reusable in
@@ -11148,13 +11290,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11148
11290
  }
11149
11291
  // An input's own scrolling follows its height (CraftNativeTextView).
11150
11292
  if !(textView is CraftNativeTextView) { textView.isScrollEnabled = false }
11151
- let numberOfLines = Int(number(props["numberOfLines"]) ?? 0)
11293
+ let numberOfLines = craftNativeInt(number(props["numberOfLines"]) ?? 0)
11152
11294
  if numberOfLines > 0 {
11153
11295
  textView.textContainer.maximumNumberOfLines = numberOfLines
11154
11296
  } else {
11155
11297
  textView.textContainer.maximumNumberOfLines = 0
11156
11298
  }
11157
- let maxLength = Int(number(props["maxLength"]) ?? 0)
11299
+ let maxLength = craftNativeInt(number(props["maxLength"]) ?? 0)
11158
11300
  if maxLength > 0 {
11159
11301
  textMaxLengths[id] = maxLength
11160
11302
  } else {
@@ -11184,8 +11326,19 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11184
11326
  updateAuxiliaryHandler(events["onBlur"], in: &blurHandlers, for: textView)
11185
11327
  updateAuxiliaryHandler(events["onEndEditing"], in: &endEditingHandlers, for: textView)
11186
11328
  updateAuxiliaryHandler(events["onSubmitEditing"], in: &submitHandlers, for: textView)
11187
- if props["autoFocus"] as? Bool == true, !textView.isFirstResponder {
11188
- DispatchQueue.main.async { _ = textView.becomeFirstResponder() }
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()
11189
11342
  }
11190
11343
  }
11191
11344
 
@@ -11195,8 +11348,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11195
11348
  /// leaving a hole.
11196
11349
  private func configureIcon(_ icon: UIImageView, props: [String: Any], style: [String: Any]) {
11197
11350
  let name = (props["symbol"] as? String) ?? (props["name"] as? String) ?? ""
11198
- let box = [number(style["width"]), number(style["height"])].compactMap { $0 }.min()
11199
- let fontSize = number(style["fontSize"])
11351
+ let box = [styleNumber(style["width"]), styleNumber(style["height"])].compactMap { $0 }.min()
11352
+ let fontSize = styleNumber(style["fontSize"])
11200
11353
  // Without a size of its own an icon is a 20 pt square (contract A2).
11201
11354
  (icon as? CraftNativeIconView)?.defaultBox = box == nil && fontSize == nil ? 20 : nil
11202
11355
  let pointSize = fontSize ?? (box ?? 20) * 0.84
@@ -11237,10 +11390,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11237
11390
  stack.alignItems = style["alignItems"] as? String ?? "stretch"
11238
11391
  stack.alignContent = style["alignContent"] as? String ?? "flex-start"
11239
11392
  stack.justifyContent = style["justifyContent"] as? String ?? "flex-start"
11240
- let gap = max(0, number(style["gap"]) ?? 0)
11393
+ let gap = max(0, styleNumber(style["gap"]) ?? 0)
11241
11394
  stack.gap = gap
11242
- stack.rowGap = max(0, number(style["rowGap"]) ?? gap)
11243
- 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)
11244
11397
  stack.grid = style["display"] as? String == "grid"
11245
11398
  stack.gridColumns = craftNativeGridColumnCount(style["gridTemplateColumns"] ?? style["gridColumns"])
11246
11399
  stack.gridRows = craftNativeGridRowCount(style["gridTemplateRows"])
@@ -11252,8 +11405,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11252
11405
  private func addJustificationSpacers(to stack: CraftNativeFlowView, value: String?) { }
11253
11406
 
11254
11407
  private func applyViewStyle(_ style: [String: Any], to view: UIView, node: RenderedNode) {
11255
- let width = style["minWidth"] == nil && style["maxWidth"] == nil ? number(style["width"]) : nil
11256
- 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
11257
11410
  updateDimension(width, constraint: &node.widthConstraint, anchor: view.widthAnchor)
11258
11411
  updateDimension(height, constraint: &node.heightConstraint, anchor: view.heightAnchor)
11259
11412
  view.backgroundColor = color(style["backgroundColor"]) ?? .clear
@@ -11272,31 +11425,31 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11272
11425
  direction: style["gradientDirection"] as? String,
11273
11426
  colors: [color(style["gradientFrom"]), color(style["gradientVia"]), color(style["gradientTo"])]
11274
11427
  )
11275
- view.alpha = number(style["opacity"]) ?? 1
11428
+ view.alpha = styleNumber(style["opacity"]) ?? 1
11276
11429
  view.isHidden = style["display"] as? String == "none"
11277
- let borderRadius = number(style["borderRadius"]) ?? 0
11430
+ let borderRadius = styleNumber(style["borderRadius"]) ?? 0
11278
11431
  let cornerRadiusKeys = ["borderTopLeftRadius", "borderTopRightRadius", "borderBottomRightRadius", "borderBottomLeftRadius"]
11279
11432
  let hasCornerRadii = cornerRadiusKeys.contains { style[$0] != nil }
11280
11433
  view.craftRequestedCornerRadius = borderRadius
11281
11434
  view.craftRequestedCornerRadii = hasCornerRadii
11282
- ? cornerRadiusKeys.map { number(style[$0]) ?? borderRadius }
11435
+ ? cornerRadiusKeys.map { styleNumber(style[$0]) ?? borderRadius }
11283
11436
  : nil
11284
- let borderWidth = number(style["borderWidth"]) ?? 0
11437
+ let borderWidth = styleNumber(style["borderWidth"]) ?? 0
11285
11438
  let borderColor = color(style["borderColor"]) ?? .clear
11286
11439
  let sideWidthKeys = ["borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth"]
11287
11440
  let sideColorKeys = ["borderTopColor", "borderRightColor", "borderBottomColor", "borderLeftColor"]
11288
11441
  view.craftApplySideBorders(
11289
11442
  (sideWidthKeys + sideColorKeys).contains { style[$0] != nil },
11290
11443
  style: style["borderStyle"] as? String,
11291
- widths: sideWidthKeys.map { number(style[$0]) ?? borderWidth },
11444
+ widths: sideWidthKeys.map { styleNumber(style[$0]) ?? borderWidth },
11292
11445
  colors: sideColorKeys.map { color(style[$0]) ?? borderColor }
11293
11446
  )
11294
- view.layer.zPosition = number(style["zIndex"]) ?? 0
11447
+ view.layer.zPosition = styleNumber(style["zIndex"]) ?? 0
11295
11448
  applyTransform(style["transform"], to: view)
11296
- let elevation = max(0, number(style["elevation"]) ?? 0)
11449
+ let elevation = max(0, styleNumber(style["elevation"]) ?? 0)
11297
11450
  let shadowColor = color(style["shadowColor"]) ?? .black
11298
- let shadowOpacity = min(1, max(0, number(style["shadowOpacity"]) ?? (elevation > 0 ? min(0.28, 0.12 + elevation * 0.02) : 0)))
11299
- 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)
11300
11453
  let shadowOffset = style["shadowOffset"] as? [String: Any]
11301
11454
  view.layer.shadowColor = shadowColor.cgColor
11302
11455
  view.layer.shadowOpacity = Float(shadowOpacity)
@@ -11329,7 +11482,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11329
11482
 
11330
11483
  private func paddingInsets(_ style: [String: Any]) -> UIEdgeInsets {
11331
11484
  func nonNegative(_ value: Any?) -> CGFloat? {
11332
- guard let value = number(value), value >= 0 else { return nil }
11485
+ guard let value = styleNumber(value), value >= 0 else { return nil }
11333
11486
  return value
11334
11487
  }
11335
11488
  let padding = nonNegative(style["padding"]) ?? 0
@@ -11352,11 +11505,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11352
11505
  var threeDimensional = CATransform3DIdentity
11353
11506
  var hasThreeDimensionalRotation = false
11354
11507
  for transform in transforms {
11355
- if let value = number(transform["translateX"]) { affine = affine.translatedBy(x: value, y: 0) }
11356
- if let value = number(transform["translateY"]) { affine = affine.translatedBy(x: 0, y: value) }
11357
- if let value = number(transform["scale"]) { affine = affine.scaledBy(x: value, y: value) }
11358
- if let value = number(transform["scaleX"]) { affine = affine.scaledBy(x: value, y: 1) }
11359
- 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) }
11360
11513
  if let value = transformAngle(transform["rotate"] ?? transform["rotateZ"]) { affine = affine.rotated(by: value) }
11361
11514
  if let value = transformAngle(transform["skewX"]) { affine = affine.concatenating(CGAffineTransform(a: 1, b: 0, c: tan(value), d: 1, tx: 0, ty: 0)) }
11362
11515
  if let value = transformAngle(transform["skewY"]) { affine = affine.concatenating(CGAffineTransform(a: 1, b: tan(value), c: 0, d: 1, tx: 0, ty: 0)) }
@@ -11394,10 +11547,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11394
11547
  label.font = textFont(style, default: .systemFont(ofSize: 16))
11395
11548
  label.textAlignment = textAlignment(style["textAlign"])
11396
11549
  var attributes: [NSAttributedString.Key: Any] = [:]
11397
- if let spacing = number(style["letterSpacing"]) { attributes[.kern] = spacing }
11550
+ if let spacing = styleNumber(style["letterSpacing"]) { attributes[.kern] = spacing }
11398
11551
  addTextDecorationAttributes(style, to: &attributes)
11399
11552
  if let shadow = textShadow(style) { attributes[.shadow] = shadow }
11400
- if let lineHeight = number(style["lineHeight"]) {
11553
+ if let lineHeight = styleNumber(style["lineHeight"]) {
11401
11554
  let paragraph = NSMutableParagraphStyle()
11402
11555
  paragraph.minimumLineHeight = lineHeight
11403
11556
  paragraph.maximumLineHeight = lineHeight
@@ -11451,8 +11604,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11451
11604
  .foregroundColor: color,
11452
11605
  .font: font,
11453
11606
  ]
11454
- if let spacing = number(style["letterSpacing"]) { attributes[.kern] = spacing }
11455
- if let lineHeight = number(style["lineHeight"]) {
11607
+ if let spacing = styleNumber(style["letterSpacing"]) { attributes[.kern] = spacing }
11608
+ if let lineHeight = styleNumber(style["lineHeight"]) {
11456
11609
  let paragraph = NSMutableParagraphStyle()
11457
11610
  paragraph.minimumLineHeight = lineHeight
11458
11611
  paragraph.maximumLineHeight = lineHeight
@@ -11474,14 +11627,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11474
11627
  .foregroundColor: color(style["color"]) ?? .label,
11475
11628
  .font: font,
11476
11629
  ]
11477
- if let spacing = number(style["letterSpacing"]) { attributes[.kern] = spacing }
11630
+ if let spacing = styleNumber(style["letterSpacing"]) { attributes[.kern] = spacing }
11478
11631
  // UIKit otherwise resets the field's alignment to natural when these attributes are applied.
11479
11632
  let paragraph = NSMutableParagraphStyle()
11480
11633
  paragraph.alignment = alignment
11481
11634
  // The default paragraph wraps even inside a UITextField. A long URL
11482
11635
  // then paints a second line outside the field instead of scrolling.
11483
11636
  if singleLine { paragraph.lineBreakMode = .byClipping }
11484
- if let lineHeight = number(style["lineHeight"]) {
11637
+ if let lineHeight = styleNumber(style["lineHeight"]) {
11485
11638
  paragraph.minimumLineHeight = lineHeight
11486
11639
  paragraph.maximumLineHeight = lineHeight
11487
11640
  }
@@ -11500,7 +11653,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11500
11653
  width: number(offset?["width"]) ?? 0,
11501
11654
  height: number(offset?["height"]) ?? 0
11502
11655
  )
11503
- shadow.shadowBlurRadius = max(0, number(style["textShadowRadius"]) ?? 0)
11656
+ shadow.shadowBlurRadius = max(0, styleNumber(style["textShadowRadius"]) ?? 0)
11504
11657
  return shadow
11505
11658
  }
11506
11659
 
@@ -11539,7 +11692,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11539
11692
  }
11540
11693
 
11541
11694
  private func textFont(_ style: [String: Any], default fallback: UIFont) -> UIFont {
11542
- let size = number(style["fontSize"]) ?? fallback.pointSize
11695
+ let size = styleNumber(style["fontSize"]) ?? fallback.pointSize
11543
11696
  let weight: UIFont.Weight
11544
11697
  switch craftNativeFontWeight(style["fontWeight"]) {
11545
11698
  case "100": weight = .ultraLight