@craft-native/ios 0.0.395 → 0.0.396

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.395",
3
+ "version": "0.0.396",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -42,6 +42,10 @@ private final class CraftNativeFlatListLayout: UICollectionViewFlowLayout {
42
42
  /// The screen controller owns rendered row state; this view owns collection
43
43
  /// diffs, viewport state, and UICollectionView cell reuse.
44
44
  final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowLayout, UIGestureRecognizerDelegate {
45
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
46
+ craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
47
+ }
48
+
45
49
  typealias RenderItem = (_ node: [String: Any], _ identity: String, _ previous: UIView?) -> UIView
46
50
 
47
51
  private final class Cell: UICollectionViewCell {
@@ -35,6 +35,9 @@ final class CraftNativeMutationDocument {
35
35
  var style: [String: Any]
36
36
  var events: [String: Any]
37
37
  var text: [String]
38
+ // Legacy snapshots may interleave bare text and native children.
39
+ // Keep each text fragment's slot among the child nodes on import.
40
+ var textPositions: [Int]? = nil
38
41
  var children: [String]
39
42
  var parent: String?
40
43
  }
@@ -43,6 +46,11 @@ final class CraftNativeMutationDocument {
43
46
  private var rootId: String?
44
47
  private(set) var revision = 0
45
48
 
49
+ /// The normalized committed tree, whose root need not be named "root".
50
+ var document: [String: Any]? {
51
+ rootId.flatMap { materialize($0, nodes: nodes) }
52
+ }
53
+
46
54
  func replace(with document: [String: Any]) {
47
55
  var replacement: [String: Node] = [:]
48
56
  let root = importNode(document, preferredId: "root", parent: nil, nodes: &replacement)
@@ -217,6 +225,7 @@ final class CraftNativeMutationDocument {
217
225
  if patch.keys.contains("events") { mergePatch(patch["events"], into: &node.events) }
218
226
  if patch.keys.contains("children") || patch.keys.contains("text") {
219
227
  node.text = try textChildren(patch["children"] ?? patch["text"])
228
+ node.textPositions = nil
220
229
  }
221
230
  nodes[id] = node
222
231
  }
@@ -422,9 +431,18 @@ final class CraftNativeMutationDocument {
422
431
  "events": node.events,
423
432
  ]
424
433
  if let key = node.key { value["key"] = key }
425
- let text: [Any] = node.text
426
434
  let children: [Any] = node.children.compactMap { materialize($0, nodes: nodes) }
427
- value["children"] = text + children
435
+ var content: [Any] = []
436
+ var textIndex = 0
437
+ for index in 0...children.count {
438
+ while textIndex < node.text.count,
439
+ min(node.textPositions?[textIndex] ?? 0, children.count) == index {
440
+ content.append(node.text[textIndex])
441
+ textIndex += 1
442
+ }
443
+ if index < children.count { content.append(children[index]) }
444
+ }
445
+ value["children"] = content
428
446
  return value
429
447
  }
430
448
 
@@ -440,6 +458,12 @@ final class CraftNativeMutationDocument {
440
458
  var id = explicit ?? key.map { "\(parent ?? "root")/key:\($0)" } ?? preferredId
441
459
  if nodes[id] != nil { id = "\(preferredId)#\(nodes.count)" }
442
460
  let children = value["children"] as? [Any] ?? []
461
+ var textPositions: [Int] = []
462
+ var childCount = 0
463
+ for child in children {
464
+ if child is String { textPositions.append(childCount) }
465
+ else if child is [String: Any] { childCount += 1 }
466
+ }
443
467
  nodes[id] = Node(
444
468
  id: id,
445
469
  key: value["key"] as? String,
@@ -448,6 +472,7 @@ final class CraftNativeMutationDocument {
448
472
  style: dictionary(value["style"]),
449
473
  events: dictionary(value["events"]),
450
474
  text: children.compactMap { $0 as? String },
475
+ textPositions: textPositions,
451
476
  children: [],
452
477
  parent: parent
453
478
  )
@@ -1108,6 +1108,10 @@ private func craftNativeAlignContentCode(_ value: String) -> UInt32 {
1108
1108
 
1109
1109
  /// Keep the indicator's intrinsic size separate from authored style transforms.
1110
1110
  private final class CraftNativeActivityIndicatorView: UIActivityIndicatorView {
1111
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
1112
+ craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
1113
+ }
1114
+
1111
1115
  var indicatorScale: CGFloat = 1
1112
1116
  }
1113
1117
 
@@ -1122,6 +1126,10 @@ private final class CraftNativeActivityIndicatorView: UIActivityIndicatorView {
1122
1126
  /// `enableBackgroundAudio` writes), and comes back into its place when the
1123
1127
  /// app does.
1124
1128
  private final class CraftNativeVideoView: UIView, AVPlayerViewControllerDelegate {
1129
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
1130
+ craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
1131
+ }
1132
+
1125
1133
  private let playerController = AVPlayerViewController()
1126
1134
  private var player: AVPlayer?
1127
1135
  private var sourceURI = ""
@@ -1454,6 +1462,10 @@ private final class CraftMarkerPhotoRedirects: NSObject, URLSessionTaskDelegate
1454
1462
  }
1455
1463
 
1456
1464
  private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1465
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
1466
+ craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
1467
+ }
1468
+
1457
1469
  private var annotationsByID: [String: MKPointAnnotation] = [:]
1458
1470
  private var overlaysByID: [String: MKPolyline] = [:]
1459
1471
  private var overlayCoordinatesByID: [String: [CLLocationCoordinate2D]] = [:]
@@ -2394,6 +2406,46 @@ private final class CraftNativeLayoutStyleBox {
2394
2406
  init(_ style: CraftNativeLayoutStyle) { self.style = style }
2395
2407
  }
2396
2408
 
2409
+ /// Pointer policy applies to authored nodes, not UIKit's private control views.
2410
+ enum CraftNativePointerEvents: String {
2411
+ case auto, none
2412
+ case boxNone = "box-none"
2413
+ case boxOnly = "box-only"
2414
+
2415
+ static func resolve(props: [String: Any], style: [String: Any]) -> Self {
2416
+ let prop = props["pointerEvents"]
2417
+ let value = prop == nil || prop is NSNull ? style["pointerEvents"] : prop
2418
+ return (value as? String).flatMap(Self.init(rawValue:)) ?? .auto
2419
+ }
2420
+ }
2421
+
2422
+ private var craftNativePointerEventsKey: UInt8 = 0
2423
+
2424
+ extension UIView {
2425
+ // nil identifies an internal view; even authored `auto` nodes are marked.
2426
+ var craftPointerEvents: CraftNativePointerEvents? {
2427
+ get { (objc_getAssociatedObject(self, &craftNativePointerEventsKey) as? String).flatMap(CraftNativePointerEvents.init(rawValue:)) }
2428
+ set { objc_setAssociatedObject(self, &craftNativePointerEventsKey, newValue?.rawValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) }
2429
+ }
2430
+
2431
+ func craftPointerHitTest(_ point: CGPoint, with event: UIEvent?, native: () -> UIView?) -> UIView? {
2432
+ guard let policy = craftPointerEvents, policy != .auto else { return native() }
2433
+ guard policy != .none, !isHidden, alpha > 0.01, isUserInteractionEnabled else { return nil }
2434
+ if policy == .boxOnly && !self.point(inside: point, with: event) { return nil }
2435
+ let hit = native()
2436
+ var candidate = hit
2437
+ var authoredChild = false
2438
+ while let view = candidate, view !== self {
2439
+ if view.craftPointerEvents != nil { authoredChild = true; break }
2440
+ candidate = view.superview
2441
+ }
2442
+ if policy == .boxNone { return authoredChild ? hit : nil }
2443
+ // Preserve private controls (picker wheels, text selection, map gestures),
2444
+ // while excluding authored descendants of a box-only container.
2445
+ return authoredChild ? self : hit ?? self
2446
+ }
2447
+ }
2448
+
2397
2449
  private var craftNativeLayoutStyleKey: UInt8 = 0
2398
2450
  private var craftNativeCornerRadiusKey: UInt8 = 0
2399
2451
  private var craftNativeCornerRadiiKey: UInt8 = 0
@@ -3021,12 +3073,14 @@ private class CraftNativeFlowView: UIStackView {
3021
3073
  /// is inside the view, so a floating button absolutely positioned out of a
3022
3074
  /// zero-height parent (a component's wrapper) drew but could not be tapped.
3023
3075
  override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
3024
- if let hit = super.hitTest(point, with: event) { return hit }
3025
- guard !clipsToBounds, !isHidden, alpha > 0.01, isUserInteractionEnabled else { return nil }
3026
- for child in subviews.reversed() where !child.isHidden && child.alpha > 0.01 && child.isUserInteractionEnabled {
3027
- if let hit = child.hitTest(child.convert(point, from: self), with: event) { return hit }
3076
+ return craftPointerHitTest(point, with: event) {
3077
+ if let hit = super.hitTest(point, with: event) { return hit }
3078
+ guard !clipsToBounds, !isHidden, alpha > 0.01, isUserInteractionEnabled else { return nil }
3079
+ for child in subviews.reversed() where !child.isHidden && child.alpha > 0.01 && child.isUserInteractionEnabled {
3080
+ if let hit = child.hitTest(child.convert(point, from: self), with: event) { return hit }
3081
+ }
3082
+ return nil
3028
3083
  }
3029
- return nil
3030
3084
  }
3031
3085
 
3032
3086
  // MARK: Children
@@ -4100,10 +4154,107 @@ private final class CraftNativeKeyboardAvoidingView: CraftNativeFlowView {
4100
4154
  }
4101
4155
  }
4102
4156
 
4157
+ // Preserve source dimensions when the loader downsamples decoded pixels. CSS
4158
+ // scale-down/none use intrinsic dimensions, never the thumbnail's display size.
4159
+ private var craftImageIntrinsicKey: UInt8 = 0
4160
+ private extension UIImage {
4161
+ var craftIntrinsicSize: CGSize {
4162
+ get { (objc_getAssociatedObject(self, &craftImageIntrinsicKey) as? NSValue)?.cgSizeValue ?? size }
4163
+ set { objc_setAssociatedObject(self, &craftImageIntrinsicKey, NSValue(cgSize: newValue), .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
4164
+ }
4165
+ }
4166
+
4167
+ /// Explicit CSS-facing image geometry; the loader and semantic view stay retained.
4168
+ class CraftNativePositionedImageView: UIImageView {
4169
+ private var storedImage: UIImage?
4170
+ private var fittedImage: UIImageView?
4171
+ private var fit: String?
4172
+ private var position = CGPoint(x: 0.5, y: 0.5)
4173
+
4174
+ override var image: UIImage? {
4175
+ get { storedImage }
4176
+ set {
4177
+ storedImage = newValue
4178
+ super.image = fit == nil ? newValue : nil
4179
+ fittedImage?.image = newValue
4180
+ invalidateIntrinsicContentSize()
4181
+ setNeedsLayout()
4182
+ }
4183
+ }
4184
+
4185
+ override var intrinsicContentSize: CGSize {
4186
+ fit == nil ? super.intrinsicContentSize : storedImage?.craftIntrinsicSize ?? CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
4187
+ }
4188
+
4189
+ func configureObjectGeometry(props: [String: Any], style: [String: Any]) {
4190
+ func value(_ name: String) -> Any? {
4191
+ if let raw = props[name], !(raw is NSNull) { return raw }
4192
+ if let raw = style[name], !(raw is NSNull) { return raw }
4193
+ return nil
4194
+ }
4195
+ let rawFit = value("objectFit"), rawPosition = value("objectPosition")
4196
+ if rawFit != nil || rawPosition != nil {
4197
+ let requested = rawFit as? String ?? "fill"
4198
+ fit = ["fill", "contain", "cover", "none", "scale-down"].contains(requested) ? requested : "fill"
4199
+ position = Self.objectPosition(rawPosition)
4200
+ if fittedImage == nil {
4201
+ let child = UIImageView()
4202
+ child.isUserInteractionEnabled = false
4203
+ child.isAccessibilityElement = false
4204
+ child.contentMode = .scaleToFill
4205
+ insertSubview(child, at: 0)
4206
+ fittedImage = child
4207
+ }
4208
+ } else { fit = nil }
4209
+ fittedImage?.isHidden = fit == nil
4210
+ super.image = fit == nil ? storedImage : nil
4211
+ fittedImage?.image = storedImage
4212
+ invalidateIntrinsicContentSize()
4213
+ setNeedsLayout()
4214
+ }
4215
+
4216
+ var usesObjectGeometry: Bool { fit != nil }
4217
+ var objectCovers: Bool { fit == "cover" }
4218
+
4219
+ private static func objectPosition(_ raw: Any?) -> CGPoint {
4220
+ guard let text = raw as? String else { return CGPoint(x: 0.5, y: 0.5) }
4221
+ var parts = text.lowercased().split(whereSeparator: { $0.isWhitespace }).map(String.init)
4222
+ guard (1...2).contains(parts.count) else { return CGPoint(x: 0.5, y: 0.5) }
4223
+ if parts.count == 1 {
4224
+ parts = ["top", "bottom"].contains(parts[0]) ? ["center", parts[0]] : [parts[0], "center"]
4225
+ } else if ["top", "bottom"].contains(parts[0]) || ["left", "right"].contains(parts[1]) { parts.swapAt(0, 1) }
4226
+ func fraction(_ part: String, low: String, high: String) -> CGFloat? {
4227
+ if part == low { return 0 }; if part == high { return 1 }; if part == "center" { return 0.5 }
4228
+ guard part.hasSuffix("%"), let number = Double(part.dropLast()), number.isFinite else { return nil }
4229
+ return CGFloat(number / 100)
4230
+ }
4231
+ guard let x = fraction(parts[0], low: "left", high: "right"), let y = fraction(parts[1], low: "top", high: "bottom") else { return CGPoint(x: 0.5, y: 0.5) }
4232
+ return CGPoint(x: x, y: y)
4233
+ }
4234
+
4235
+ override func layoutSubviews() {
4236
+ super.layoutSubviews()
4237
+ guard let fit, let child = fittedImage else { return }
4238
+ let source = storedImage?.craftIntrinsicSize ?? .zero
4239
+ guard source.width > 0, source.height > 0 else { child.frame = .zero; return }
4240
+ let contain = min(bounds.width / source.width, bounds.height / source.height)
4241
+ let scale: CGFloat = fit == "cover" ? max(bounds.width / source.width, bounds.height / source.height)
4242
+ : fit == "none" ? 1 : fit == "scale-down" ? min(1, contain) : contain
4243
+ let size = fit == "fill" ? bounds.size : CGSize(width: source.width * scale, height: source.height * scale)
4244
+ child.frame = CGRect(x: bounds.minX + (bounds.width - size.width) * position.x,
4245
+ y: bounds.minY + (bounds.height - size.height) * position.y, width: size.width, height: size.height)
4246
+ child.tintColor = tintColor
4247
+ }
4248
+ }
4249
+
4103
4250
  /// An image-backed flex container. The image stays behind ordinary native
4104
4251
  /// children so `ImageBackground` keeps the same layout and interaction
4105
4252
  /// semantics as a `View` while using the shared image loader.
4106
- private final class CraftNativeImageBackgroundView: UIImageView {
4253
+ private final class CraftNativeImageBackgroundView: CraftNativePositionedImageView {
4254
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
4255
+ craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
4256
+ }
4257
+
4107
4258
  let contentStack = CraftNativeFlowView()
4108
4259
 
4109
4260
  override init(frame: CGRect) {
@@ -4142,6 +4293,10 @@ private final class CraftNativeSegmentElement: UIAccessibilityElement {
4142
4293
  }
4143
4294
 
4144
4295
  private final class CraftNativeSegmentedControl: UISegmentedControl {
4296
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
4297
+ craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
4298
+ }
4299
+
4145
4300
  var values: [String] = []
4146
4301
  private var segments: [CraftNativeSegmentElement] = []
4147
4302
  override var accessibilityElements: [Any]? {
@@ -4391,28 +4546,30 @@ private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate, U
4391
4546
  }
4392
4547
 
4393
4548
  override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
4394
- guard !isHidden, alpha > 0.01, isUserInteractionEnabled else { return nil }
4395
- let contentPoint = contentStack.convert(point, from: self)
4396
- if contentStack.bounds.contains(contentPoint), let hit = contentStack.hitTest(contentPoint, with: event), hit !== contentStack {
4397
- // Flow/layout views occupy the modal's entire content rect, but a
4398
- // blank part of one must still be a scrim tap. Preserve touches
4399
- // only when the hit view (or one of its ancestors) actually owns
4400
- // a control or gesture recognizer.
4401
- var candidate: UIView? = hit
4402
- while let view = candidate, view !== contentStack {
4403
- if view is UIControl || !(view.gestureRecognizers?.isEmpty ?? true) { return hit }
4404
- candidate = view.superview
4405
- }
4406
- }
4407
- if !blocksBackground {
4408
- // A dismissible popup consumes its outside hit so the close event
4409
- // cannot also activate the screen below. Otherwise it owns the
4410
- // hit only when a blocking modal is underneath it; with no modal
4411
- // below, the screen remains pass-through.
4412
- if dismissOnOutsideTap { return self }
4413
- return craftScreenController?.hasBlockingModalBelow(self) == true ? self : nil
4414
- }
4415
- return blocker.hitTest(blocker.convert(point, from: self), with: event)
4549
+ return craftPointerHitTest(point, with: event) {
4550
+ guard !isHidden, alpha > 0.01, isUserInteractionEnabled else { return nil }
4551
+ let contentPoint = contentStack.convert(point, from: self)
4552
+ if contentStack.bounds.contains(contentPoint), let hit = contentStack.hitTest(contentPoint, with: event), hit !== contentStack {
4553
+ // Flow/layout views occupy the modal's entire content rect, but a
4554
+ // blank part of one must still be a scrim tap. Preserve touches
4555
+ // only when the hit view (or one of its ancestors) actually owns
4556
+ // a control or gesture recognizer.
4557
+ var candidate: UIView? = hit
4558
+ while let view = candidate, view !== contentStack {
4559
+ if view is UIControl || !(view.gestureRecognizers?.isEmpty ?? true) { return hit }
4560
+ candidate = view.superview
4561
+ }
4562
+ }
4563
+ if !blocksBackground {
4564
+ // A dismissible popup consumes its outside hit so the close event
4565
+ // cannot also activate the screen below. Otherwise it owns the
4566
+ // hit only when a blocking modal is underneath it; with no modal
4567
+ // below, the screen remains pass-through.
4568
+ if dismissOnOutsideTap { return self }
4569
+ return craftScreenController?.hasBlockingModalBelow(self) == true ? self : nil
4570
+ }
4571
+ return blocker.hitTest(blocker.convert(point, from: self), with: event)
4572
+ }
4416
4573
  }
4417
4574
 
4418
4575
  override func layoutSubviews() {
@@ -4513,6 +4670,10 @@ func craftHandleKeyboardTap(in container: UIView, hit: UIView?, persistTaps: Str
4513
4670
  }
4514
4671
 
4515
4672
  private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDelegate {
4673
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
4674
+ craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
4675
+ }
4676
+
4516
4677
  let contentStack = CraftNativeFlowView()
4517
4678
  private let keyboardTapGesture = UITapGestureRecognizer()
4518
4679
  private var keyboardShouldPersistTaps = "never"
@@ -4877,6 +5038,10 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
4877
5038
  /// inherited down the tree as SVG does; `dark:` classes arrive already
4878
5039
  /// resolved for the appearance, so a redraw is all a theme change needs.
4879
5040
  final class CraftNativeSvgView: UIView {
5041
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
5042
+ craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
5043
+ }
5044
+
4880
5045
  var drawing: [String: Any] = [:] {
4881
5046
  didSet {
4882
5047
  animates = Self.hasAnimation(drawing["children"] as? [Any] ?? [])
@@ -5282,6 +5447,10 @@ private final class CraftNativeGradientLayer: CAGradientLayer {
5282
5447
  /// pill with its label touching the edges. The padding is added to its size
5283
5448
  /// and the label laid out inside it, at the alignment the style asked for.
5284
5449
  private final class CraftNativeButton: UIButton {
5450
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
5451
+ craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
5452
+ }
5453
+
5285
5454
  var padding: UIEdgeInsets = .zero {
5286
5455
  didSet {
5287
5456
  guard padding != oldValue else { return }
@@ -5323,6 +5492,10 @@ private final class CraftNativeButton: UIButton {
5323
5492
  /// Opt-in solid switch. Draws only public UIControl content; it neither walks
5324
5493
  /// UISwitch's private views nor changes UIKit animations elsewhere in the app.
5325
5494
  final class CraftNativeSolidSwitch: UIControl {
5495
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
5496
+ craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
5497
+ }
5498
+
5326
5499
  private(set) var isOn = false
5327
5500
  var onColor: UIColor? { didSet { setNeedsDisplay() } }
5328
5501
  var offColor: UIColor? { didSet { setNeedsDisplay() } }
@@ -5433,10 +5606,18 @@ final class CraftNativeSolidSwitch: UIControl {
5433
5606
  /// square/checkmark symbols while keeping the control's title and accessibility
5434
5607
  /// state native and keyboard/touch reachable.
5435
5608
  private final class CraftNativeCheckboxButton: UIButton {
5609
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
5610
+ craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
5611
+ }
5612
+
5436
5613
  var indeterminate = false
5437
5614
  }
5438
5615
 
5439
5616
  private final class CraftNativeLabel: UILabel {
5617
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
5618
+ craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
5619
+ }
5620
+
5440
5621
  var insets = UIEdgeInsets.zero {
5441
5622
  didSet {
5442
5623
  guard insets != oldValue else { return }
@@ -5470,6 +5651,10 @@ private final class CraftNativeLabel: UILabel {
5470
5651
  /// selection. UILabel has no selection model; UITextView supplies UIKit's
5471
5652
  /// standard loupe, menu and copy behavior while remaining a plain text leaf.
5472
5653
  private final class CraftNativeSelectableTextView: UITextView {
5654
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
5655
+ craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
5656
+ }
5657
+
5473
5658
  convenience init() {
5474
5659
  self.init(frame: .zero, textContainer: nil)
5475
5660
  }
@@ -5498,6 +5683,10 @@ private final class CraftNativeSelectableTextView: UITextView {
5498
5683
  /// A one-line TextInput. Its style's padding is where the text and the
5499
5684
  /// placeholder start, as in a web input; UITextField has no padding of its own.
5500
5685
  final class CraftNativeTextField: UITextField {
5686
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
5687
+ craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
5688
+ }
5689
+
5501
5690
  var padding = UIEdgeInsets.zero {
5502
5691
  didSet {
5503
5692
  guard padding != oldValue else { return }
@@ -5529,6 +5718,10 @@ private final class CraftNativeFlexSpacer: UIView {}
5529
5718
  /// An SF Symbol leaf. Its natural size is the symbol's, or a 20 pt square
5530
5719
  /// when the style gives it no size at all.
5531
5720
  private final class CraftNativeIconView: UIImageView {
5721
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
5722
+ craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
5723
+ }
5724
+
5532
5725
  var defaultBox: CGFloat? { didSet { if defaultBox != oldValue { invalidateIntrinsicContentSize() } } }
5533
5726
 
5534
5727
  /// An icon says only what the screen labels it. iOS gives a symbol image
@@ -5554,6 +5747,10 @@ private final class CraftNativeIconView: UIImageView {
5554
5747
  }
5555
5748
 
5556
5749
  private final class CraftNativeTextView: UITextView {
5750
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
5751
+ craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
5752
+ }
5753
+
5557
5754
  private let placeholderLabel = UILabel()
5558
5755
  private var nativeTextContainerInset: UIEdgeInsets?
5559
5756
  private var nativeLineFragmentPadding: CGFloat?
@@ -5869,6 +6066,41 @@ enum CraftNativeBundle {
5869
6066
  }
5870
6067
  }
5871
6068
 
6069
+ private final class CraftNativePickerView: UIPickerView {
6070
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
6071
+ craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
6072
+ }
6073
+
6074
+ }
6075
+
6076
+ private final class CraftNativeDatePickerView: UIDatePicker {
6077
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
6078
+ craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
6079
+ }
6080
+
6081
+ }
6082
+
6083
+ private final class CraftNativeSwitch: UISwitch {
6084
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
6085
+ craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
6086
+ }
6087
+
6088
+ }
6089
+
6090
+ private final class CraftNativeSlider: UISlider {
6091
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
6092
+ craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
6093
+ }
6094
+
6095
+ }
6096
+
6097
+ private final class CraftNativeImageView: CraftNativePositionedImageView {
6098
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
6099
+ craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
6100
+ }
6101
+
6102
+ }
6103
+
5872
6104
  private final class CraftNativeScreenRootView: UIView {
5873
6105
  weak var controller: CraftNativeScreenController?
5874
6106
 
@@ -5879,7 +6111,8 @@ private final class CraftNativeScreenRootView: UIView {
5879
6111
  // overlays remain pass-through.
5880
6112
  if let controller {
5881
6113
  for modal in controller.view.subviews.compactMap({ $0 as? CraftNativeModalView }).reversed()
5882
- where !modal.blocksBackground && !modal.isHidden && modal.alpha > 0.01 {
6114
+ where !modal.blocksBackground && !modal.isHidden && modal.alpha > 0.01
6115
+ && modal.craftPointerEvents != CraftNativePointerEvents.none && modal.craftPointerEvents != .boxNone {
5883
6116
  let local = modal.convert(point, from: self)
5884
6117
  if !modal.bounds.contains(local) && (modal.dismissOnOutsideTap || controller.hasBlockingModalBelow(modal)) { return modal }
5885
6118
  }
@@ -6371,7 +6604,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6371
6604
  appliedTraitDefaults = traits
6372
6605
  view.backgroundColor = screenBackgroundColor ?? config.resolvedBackgroundColor
6373
6606
  applyNavigationBarAppearance()
6374
- if let document = mutationDocument.node("root") {
6607
+ if let document = mutationDocument.document {
6375
6608
  renderCommitted(document)
6376
6609
  } else {
6377
6610
  rootStack.setNeedsLayout()
@@ -6469,6 +6702,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6469
6702
  return view.subviews[..<index].contains { candidate in
6470
6703
  guard let other = candidate as? CraftNativeModalView else { return false }
6471
6704
  return other.blocksBackground && !other.isHidden && other.alpha > 0.01
6705
+ && other.craftPointerEvents != CraftNativePointerEvents.none && other.craftPointerEvents != .boxNone
6472
6706
  }
6473
6707
  }
6474
6708
 
@@ -7674,6 +7908,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7674
7908
  #if compiler(>=6.2)
7675
7909
  if #available(iOS 26.0, *) {
7676
7910
  trackedScrollView?.topEdgeEffect.isHidden = false
7911
+ trackedScrollView?.topEdgeEffect.style = .automatic
7677
7912
  trackedScrollView?.bottomEdgeEffect.isHidden = false
7678
7913
  }
7679
7914
  #endif
@@ -7684,10 +7919,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7684
7919
  }
7685
7920
  #if compiler(>=6.2)
7686
7921
  if #available(iOS 26.0, *) {
7687
- // Floating button groups carry their own material. UIKit's
7688
- // separate scroll-edge wash would cover the content behind
7689
- // and beyond them even with a transparent bar appearance.
7690
- scroll?.topEdgeEffect.isHidden = topEdgeToEdge && !navigationBarSolid
7922
+ // The clock and navigation title need separation from live
7923
+ // content. Keep UIKit's soft top scrim rather than removing
7924
+ // it or adding an opaque bar. The floating tab pill already
7925
+ // carries its own material, so its full-width wash stays off.
7926
+ scroll?.topEdgeEffect.isHidden = false
7927
+ scroll?.topEdgeEffect.style = topEdgeToEdge && !navigationBarSolid ? .soft : .automatic
7691
7928
  scroll?.bottomEdgeEffect.isHidden = tabbedScroll
7692
7929
  }
7693
7930
  #endif
@@ -8332,7 +8569,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8332
8569
  // Internal so simulator-hosted XCTest can assert actual UIView identity.
8333
8570
  func render(_ document: [String: Any]) {
8334
8571
  mutationDocument.replace(with: document)
8335
- renderCommitted(document)
8572
+ // Import assigns protocol IDs to legacy descendants. Draw that same
8573
+ // committed tree from the first frame, so later patches cannot turn
8574
+ // selector/positional identities into different native controls.
8575
+ guard let committed = mutationDocument.document else { return }
8576
+ renderCommitted(committed)
8336
8577
  if !hybridRendered {
8337
8578
  hybridRendered = true
8338
8579
  // The app script's drawing is never on screen: not a first frame.
@@ -8973,6 +9214,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8973
9214
  if let current = image.image { image.image = imageForDisplay(current, view: image) }
8974
9215
  let requestedResizeMode = style["resizeMode"] as? String ?? props["resizeMode"] as? String
8975
9216
  image.contentMode = imageContentMode(requestedResizeMode)
9217
+ (image as? CraftNativePositionedImageView)?.configureObjectGeometry(props: props, style: style)
8976
9218
  applyImageClipping(image, contentMode: image.contentMode, style: style)
8977
9219
  updateAuxiliaryHandler(events["onLoadStart"], in: &imageLoadStartHandlers, for: image)
8978
9220
  updateAuxiliaryHandler(events["onLoad"], in: &imageLoadHandlers, for: image)
@@ -8992,6 +9234,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8992
9234
  if let current = background.image { background.image = imageForDisplay(current, view: background) }
8993
9235
  let requestedResizeMode = style["resizeMode"] as? String ?? props["resizeMode"] as? String
8994
9236
  background.contentMode = imageContentMode(requestedResizeMode)
9237
+ background.configureObjectGeometry(props: props, style: style)
8995
9238
  applyImageClipping(background, contentMode: background.contentMode, style: style)
8996
9239
  updateAuxiliaryHandler(events["onLoadStart"], in: &imageLoadStartHandlers, for: background)
8997
9240
  updateAuxiliaryHandler(events["onLoad"], in: &imageLoadHandlers, for: background)
@@ -9133,6 +9376,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9133
9376
  )
9134
9377
  if disabled { result.isUserInteractionEnabled = false }
9135
9378
  }
9379
+ result.craftPointerEvents = CraftNativePointerEvents.resolve(props: props, style: style)
9136
9380
  applyAccessibility(props, type: type, to: result)
9137
9381
  (result as? CraftNativeFlowView)?.configureCelebration(props["celebration"])
9138
9382
  return current
@@ -9179,12 +9423,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9179
9423
  control.addTarget(self, action: #selector(segmentChanged(_:)), for: .valueChanged)
9180
9424
  return control
9181
9425
  case "Picker":
9182
- let picker = UIPickerView()
9426
+ let picker = CraftNativePickerView()
9183
9427
  picker.dataSource = self
9184
9428
  picker.delegate = self
9185
9429
  return picker
9186
9430
  case "DatePicker":
9187
- let picker = UIDatePicker()
9431
+ let picker = CraftNativeDatePickerView()
9188
9432
  picker.preferredDatePickerStyle = .compact
9189
9433
  picker.addTarget(self, action: #selector(datePicked(_:)), for: .valueChanged)
9190
9434
  return picker
@@ -9196,11 +9440,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9196
9440
  toggle.addTarget(self, action: #selector(solidSwitchChanged(_:)), for: .valueChanged)
9197
9441
  return toggle
9198
9442
  }
9199
- let toggle = UISwitch()
9443
+ let toggle = CraftNativeSwitch()
9200
9444
  toggle.addTarget(self, action: #selector(switchChanged(_:)), for: .valueChanged)
9201
9445
  return toggle
9202
9446
  case "Slider":
9203
- let slider = UISlider()
9447
+ let slider = CraftNativeSlider()
9204
9448
  slider.addTarget(self, action: #selector(sliderChanged(_:)), for: .valueChanged)
9205
9449
  slider.addTarget(self, action: #selector(sliderFinished(_:)), for: [.touchUpInside, .touchUpOutside, .touchCancel])
9206
9450
  return slider
@@ -9209,7 +9453,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9209
9453
  case "StatusBar", "RefreshControl":
9210
9454
  return CraftNativeFlowView()
9211
9455
  case "Image":
9212
- return UIImageView()
9456
+ return CraftNativeImageView()
9213
9457
  case "ImageBackground":
9214
9458
  return CraftNativeImageBackgroundView(frame: .zero)
9215
9459
  case "KeyboardAvoidingView":
@@ -10462,7 +10706,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10462
10706
  if let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options) {
10463
10707
  // At the screen's scale, so its size in points is the
10464
10708
  // size it is drawn at.
10465
- return UIImage(cgImage: image, scale: target.scale, orientation: .up)
10709
+ let decoded = UIImage(cgImage: image, scale: target.scale, orientation: .up)
10710
+ decoded.craftIntrinsicSize = CGSize(width: width, height: height)
10711
+ return decoded
10466
10712
  }
10467
10713
  }
10468
10714
  }
@@ -10499,7 +10745,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10499
10745
  let authored = (style["overflow"] as? String) == "hidden"
10500
10746
  || (view.craftRequestedCornerRadius ?? 0) > 0
10501
10747
  || view.craftRequestedCornerRadii != nil
10502
- let clips = authored || contentMode == .scaleAspectFill
10748
+ let clips = authored || contentMode == .scaleAspectFill || (view as? CraftNativePositionedImageView)?.usesObjectGeometry == true
10503
10749
  view.clipsToBounds = clips
10504
10750
  view.layer.masksToBounds = clips
10505
10751
  }
@@ -10546,7 +10792,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10546
10792
  if let url = imageURL(for: uri) {
10547
10793
  // Decoded at the size it is drawn, when the style fixes that size.
10548
10794
  let pixels = Self.displayPixels(of: view)
10549
- let fill = view.contentMode == .scaleAspectFill
10795
+ let fill = (view as? CraftNativePositionedImageView)?.objectCovers == true || view.contentMode == .scaleAspectFill
10550
10796
  // media.keep owns private/offline copies keyed by the authored URL.
10551
10797
  // Read before the decoded/network cache, without retaining private
10552
10798
  // pixels there after the app clears its media on sign-out.
@@ -10654,7 +10900,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10654
10900
  }
10655
10901
 
10656
10902
  private func imageForDisplay(_ image: UIImage, view: UIImageView) -> UIImage {
10657
- image.withRenderingMode(tintedImages.contains(ObjectIdentifier(view)) ? .alwaysTemplate : .alwaysOriginal)
10903
+ let displayed = image.withRenderingMode(tintedImages.contains(ObjectIdentifier(view)) ? .alwaysTemplate : .alwaysOriginal)
10904
+ displayed.craftIntrinsicSize = image.craftIntrinsicSize
10905
+ return displayed
10658
10906
  }
10659
10907
 
10660
10908
  private func emitImageEvent(