craft-native 0.0.204 → 0.0.205

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.
@@ -21,6 +21,12 @@ private func number(_ value: Any?) -> CGFloat? {
21
21
  return value.isFinite ? value : nil
22
22
  }
23
23
 
24
+ private func craftNativePickerValue(_ value: Any?) -> String? {
25
+ if let value = value as? String, !value.isEmpty { return value }
26
+ if let value = value as? NSNumber, value.doubleValue.isFinite { return String(value.doubleValue) }
27
+ return nil
28
+ }
29
+
24
30
  private func craftNativeGridColumnCount(_ value: Any?) -> Int {
25
31
  if let number = value as? NSNumber { return max(1, number.intValue) }
26
32
  guard let text = value as? String else { return 1 }
@@ -751,6 +757,9 @@ private final class CraftNativeVideoView: UIView, AVPlayerViewControllerDelegate
751
757
  private var timeObserver: Any?
752
758
  private var loops = false
753
759
  private var wasPlaying: Bool?
760
+ private var playIntent: Bool?
761
+ private var userPaused = false
762
+ private var ended = false
754
763
  /// This video holds a claim on the audio session (`CraftNativeSounds`).
755
764
  private var claimedSession = false
756
765
  /// The screen's handlers by event name (`onEnd`, `onPlay`, `onPause`,
@@ -829,9 +838,17 @@ private final class CraftNativeVideoView: UIView, AVPlayerViewControllerDelegate
829
838
 
830
839
  func configure(source: Any?, autoplay: Bool, paused: Bool, loop: Bool, muted: Bool, controls: Bool, resizeMode: String?, pictureInPicture: Bool = false) {
831
840
  let uri = (source as? String) ?? (source as? [String: Any])?["uri"] as? String ?? ""
841
+ let nextShouldPlay = autoplay && !paused
842
+ if playIntent != nextShouldPlay {
843
+ userPaused = false
844
+ ended = false
845
+ }
846
+ playIntent = nextShouldPlay
832
847
  if uri != sourceURI {
833
848
  detachPlayer()
834
849
  sourceURI = uri
850
+ userPaused = false
851
+ ended = false
835
852
  loops = false
836
853
  // The copy `craft.media.keep` put on the phone plays in place of
837
854
  // the address, so a kept video plays with no signal.
@@ -871,7 +888,11 @@ private final class CraftNativeVideoView: UIView, AVPlayerViewControllerDelegate
871
888
  playerController.canStartPictureInPictureAutomaticallyFromInline = floating
872
889
  }
873
890
  loops = loop
874
- if paused || !autoplay { player.pause() } else { player.play() }
891
+ if nextShouldPlay {
892
+ if !userPaused && !ended { player.play() }
893
+ } else {
894
+ player.pause()
895
+ }
875
896
  }
876
897
 
877
898
  // MARK: AVPlayerViewControllerDelegate
@@ -915,8 +936,12 @@ private final class CraftNativeVideoView: UIView, AVPlayerViewControllerDelegate
915
936
  queue: .main
916
937
  ) { [weak self, weak player] _ in
917
938
  guard let self else { return }
939
+ self.ended = !self.loops
918
940
  self.fire("onEnd")
919
- if self.loops { player?.seek(to: .zero) { _ in player?.play() } }
941
+ if self.loops {
942
+ self.ended = false
943
+ player?.seek(to: .zero) { _ in player?.play() }
944
+ }
920
945
  }
921
946
  }
922
947
  playingObservation = player.observe(\.timeControlStatus, options: [.new]) { [weak self] player, _ in
@@ -927,6 +952,12 @@ private final class CraftNativeVideoView: UIView, AVPlayerViewControllerDelegate
927
952
  // Paused before it ever played is where it starts, not news.
928
953
  if self.wasPlaying == nil && !playing { self.wasPlaying = false; return }
929
954
  self.wasPlaying = playing
955
+ if playing {
956
+ self.userPaused = false
957
+ self.ended = false
958
+ } else if self.playIntent == true {
959
+ self.userPaused = true
960
+ }
930
961
  self.fire(playing ? "onPlay" : "onPause")
931
962
  }
932
963
  }
@@ -940,6 +971,9 @@ private final class CraftNativeVideoView: UIView, AVPlayerViewControllerDelegate
940
971
  detachPlayer()
941
972
  playerController.player = nil
942
973
  sourceURI = ""
974
+ playIntent = nil
975
+ userPaused = false
976
+ ended = false
943
977
  if playerController.parent != nil {
944
978
  playerController.willMove(toParent: nil)
945
979
  playerController.view.removeFromSuperview()
@@ -956,6 +990,8 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
956
990
  private var overlayCoordinatesByID: [String: [CLLocationCoordinate2D]] = [:]
957
991
  private var overlayColors: [ObjectIdentifier: UIColor] = [:]
958
992
  private var overlayWidths: [ObjectIdentifier: CGFloat] = [:]
993
+ private var initialCameraKey: String?
994
+ private var pendingFitKey: String?
959
995
  private(set) var markerHandlers: [String: String] = [:]
960
996
  var onMarkerTap: ((String) -> Void)?
961
997
 
@@ -998,7 +1034,8 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
998
1034
  if type == "mapmarker" || type == "marker" {
999
1035
  guard let point = points.first else { continue }
1000
1036
  nextMarkers[id] = (point, childProps["title"] as? String)
1001
- if let handler = childEvents["onPress"] ?? childEvents["onMarkerPress"], !handler.isEmpty {
1037
+ if let handler = nonEmptyHandler(childEvents["onPress"])
1038
+ ?? nonEmptyHandler(childEvents["onMarkerPress"]) {
1002
1039
  nextHandlers[id] = handler
1003
1040
  }
1004
1041
  } else if type == "mappolyline" || type == "polyline" {
@@ -1056,23 +1093,62 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1056
1093
  addOverlay(overlay)
1057
1094
  }
1058
1095
 
1059
- if let region = Self.region(props["region"] ?? props["initialRegion"]) {
1096
+ if let region = Self.region(props["region"]) {
1097
+ // `region` is controlled: an authored change is allowed to move
1098
+ // the camera, even when the map itself was already mounted.
1099
+ pendingFitKey = nil
1060
1100
  setRegion(region, animated: false)
1101
+ initialCameraKey = nil
1102
+ } else if let initial = Self.region(props["initialRegion"]) {
1103
+ pendingFitKey = nil
1104
+ let key = "region:\(Self.regionKey(initial))"
1105
+ if initialCameraKey != key {
1106
+ setRegion(initial, animated: false)
1107
+ initialCameraKey = key
1108
+ }
1061
1109
  } else if props["fitToBounds"] as? Bool == true {
1062
- fitToOverlays()
1110
+ let key = "fit:\(Self.overlayKey(markers: nextMarkers.mapValues { $0.coordinate }, polylines: nextPolylines.mapValues { $0.coordinates }))"
1111
+ if initialCameraKey != key {
1112
+ // A native screen can reconcile before UIKit assigns the map
1113
+ // its final safe-area-constrained bounds. Keep the fit
1114
+ // pending until there is a real viewport, matching Android's
1115
+ // onSizeChanged retry instead of silently accepting a no-op.
1116
+ pendingFitKey = key
1117
+ if fitToOverlays() {
1118
+ initialCameraKey = key
1119
+ pendingFitKey = nil
1120
+ }
1121
+ }
1122
+ } else {
1123
+ initialCameraKey = nil
1124
+ pendingFitKey = nil
1063
1125
  }
1064
1126
  }
1065
1127
 
1066
- private func fitToOverlays() {
1128
+ override func layoutSubviews() {
1129
+ super.layoutSubviews()
1130
+ guard let key = pendingFitKey else { return }
1131
+ pendingFitKey = nil
1132
+ guard fitToOverlays() else {
1133
+ pendingFitKey = key
1134
+ return
1135
+ }
1136
+ initialCameraKey = key
1137
+ }
1138
+
1139
+ @discardableResult
1140
+ private func fitToOverlays() -> Bool {
1141
+ guard bounds.width > 0, bounds.height > 0 else { return false }
1067
1142
  var rect = MKMapRect.null
1068
1143
  for overlay in overlaysByID.values { rect = rect.union(overlay.boundingMapRect) }
1069
1144
  for annotation in annotationsByID.values {
1070
1145
  let point = MKMapPoint(annotation.coordinate)
1071
1146
  rect = rect.union(MKMapRect(x: point.x, y: point.y, width: 0, height: 0))
1072
1147
  }
1073
- guard !rect.isNull else { return }
1148
+ guard !rect.isNull else { return false }
1074
1149
  let inset = UIEdgeInsets(top: 24, left: 24, bottom: 24, right: 24)
1075
1150
  setVisibleMapRect(rect, edgePadding: inset, animated: false)
1151
+ return true
1076
1152
  }
1077
1153
 
1078
1154
  func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
@@ -1118,6 +1194,25 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1118
1194
  }
1119
1195
  }
1120
1196
 
1197
+ private static func regionKey(_ region: MKCoordinateRegion) -> String {
1198
+ "\(region.center.latitude),\(region.center.longitude),\(region.span.latitudeDelta),\(region.span.longitudeDelta)"
1199
+ }
1200
+
1201
+ private static func overlayKey(
1202
+ markers: [String: CLLocationCoordinate2D],
1203
+ polylines: [String: [CLLocationCoordinate2D]]
1204
+ ) -> String {
1205
+ let markerKey = markers.keys.sorted().compactMap { id in
1206
+ guard let point = markers[id] else { return nil }
1207
+ return "\(id):\(point.latitude),\(point.longitude)"
1208
+ }.joined(separator: "|")
1209
+ let polylineKey = polylines.keys.sorted().compactMap { id in
1210
+ guard let points = polylines[id] else { return nil }
1211
+ return "\(id):" + points.map { "\($0.latitude),\($0.longitude)" }.joined(separator: ";")
1212
+ }.joined(separator: "|")
1213
+ return "m[\(markerKey)]p[\(polylineKey)]"
1214
+ }
1215
+
1121
1216
  private static func region(_ value: Any?) -> MKCoordinateRegion? {
1122
1217
  guard let value = value as? [String: Any],
1123
1218
  let center = value["center"] as? [String: Any],
@@ -1140,7 +1235,9 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1140
1235
  case "orange": return .systemOrange
1141
1236
  case "purple": return .systemPurple
1142
1237
  case "yellow": return .systemYellow
1143
- default: return .systemBlue
1238
+ default:
1239
+ guard let value else { return .systemBlue }
1240
+ return UIColor(css: value) ?? UIColor(hex: value) ?? .systemBlue
1144
1241
  }
1145
1242
  }
1146
1243
 
@@ -1371,13 +1468,32 @@ extension UIView {
1371
1468
  let right = sides[1].frame.width
1372
1469
  let bottom = sides[2].frame.height
1373
1470
  let left = sides[3].frame.width
1374
- sides[0].frame = CGRect(x: 0, y: 0, width: bounds.width, height: top)
1375
- sides[1].frame = CGRect(x: max(0, bounds.width - right), y: 0, width: right, height: bounds.height)
1376
- sides[2].frame = CGRect(x: 0, y: max(0, bounds.height - bottom), width: bounds.width, height: bottom)
1377
- sides[3].frame = CGRect(x: 0, y: 0, width: left, height: bounds.height)
1471
+ let frames = [
1472
+ CGRect(x: 0, y: 0, width: bounds.width, height: top),
1473
+ CGRect(x: max(0, bounds.width - right), y: 0, width: right, height: bounds.height),
1474
+ CGRect(x: 0, y: max(0, bounds.height - bottom), width: bounds.width, height: bottom),
1475
+ CGRect(x: 0, y: 0, width: left, height: bounds.height),
1476
+ ]
1477
+ for (index, side) in sides.enumerated() {
1478
+ side.frame = frames[index]
1479
+ if let shape = side as? CAShapeLayer {
1480
+ shape.path = craftSideBorderPath(shape.bounds, index: index)
1481
+ }
1482
+ }
1378
1483
  }
1379
1484
  }
1380
1485
 
1486
+ private func craftSideBorderPath(_ bounds: CGRect, index: Int) -> CGPath {
1487
+ let path = UIBezierPath()
1488
+ switch index {
1489
+ case 0: path.move(to: CGPoint(x: bounds.minX, y: bounds.midY)); path.addLine(to: CGPoint(x: bounds.maxX, y: bounds.midY))
1490
+ case 1: path.move(to: CGPoint(x: bounds.midX, y: bounds.minY)); path.addLine(to: CGPoint(x: bounds.midX, y: bounds.maxY))
1491
+ case 2: path.move(to: CGPoint(x: bounds.minX, y: bounds.midY)); path.addLine(to: CGPoint(x: bounds.maxX, y: bounds.midY))
1492
+ default: path.move(to: CGPoint(x: bounds.midX, y: bounds.minY)); path.addLine(to: CGPoint(x: bounds.midX, y: bounds.maxY))
1493
+ }
1494
+ return path.cgPath
1495
+ }
1496
+
1381
1497
  fileprivate func craftApplyBorderStyle(_ style: String?, width: CGFloat, color: UIColor) {
1382
1498
  guard width > 0, style == "dashed" || style == "dotted" else {
1383
1499
  (objc_getAssociatedObject(self, &craftNativeBorderLayerKey) as? CAShapeLayer)?.removeFromSuperlayer()
@@ -1410,6 +1526,7 @@ extension UIView {
1410
1526
  (objc_getAssociatedObject(self, &craftNativeBorderLayerKey) as? CAShapeLayer)?.removeFromSuperlayer()
1411
1527
  objc_setAssociatedObject(self, &craftNativeBorderLayerKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
1412
1528
  layer.borderWidth = 0
1529
+ (objc_getAssociatedObject(self, &craftNativeSideBorderLayersKey) as? [CALayer])?.forEach { $0.removeFromSuperlayer() }
1413
1530
  let top = max(0, widths.count > 0 ? widths[0] : 0)
1414
1531
  let right = max(0, widths.count > 1 ? widths[1] : 0)
1415
1532
  let bottom = max(0, widths.count > 2 ? widths[2] : 0)
@@ -1421,9 +1538,25 @@ extension UIView {
1421
1538
  CGRect(x: 0, y: 0, width: left, height: bounds.height),
1422
1539
  ]
1423
1540
  let layers = frames.enumerated().map { index, frame in
1424
- let side = CALayer()
1541
+ let side: CALayer
1542
+ if style == "dashed" || style == "dotted" {
1543
+ let shape = CAShapeLayer()
1544
+ shape.strokeColor = (colors.count > index ? colors[index] : .clear).cgColor
1545
+ shape.fillColor = nil
1546
+ shape.lineWidth = index % 2 == 0 ? frame.height : frame.width
1547
+ shape.lineCap = style == "dotted" ? .round : .butt
1548
+ shape.lineDashPattern = style == "dotted"
1549
+ ? [NSNumber(value: Double(shape.lineWidth)), NSNumber(value: Double(shape.lineWidth * 1.5))]
1550
+ : [NSNumber(value: Double(shape.lineWidth * 3)), NSNumber(value: Double(shape.lineWidth * 2))]
1551
+ shape.contentsScale = window?.screen.scale ?? traitCollection.displayScale
1552
+ shape.frame = frame
1553
+ shape.path = craftSideBorderPath(shape.bounds, index: index)
1554
+ side = shape
1555
+ } else {
1556
+ side = CALayer()
1557
+ side.backgroundColor = (colors.count > index ? colors[index] : .clear).cgColor
1558
+ }
1425
1559
  side.frame = frame
1426
- side.backgroundColor = (colors.count > index ? colors[index] : .clear).cgColor
1427
1560
  layer.addSublayer(side)
1428
1561
  return side
1429
1562
  }
@@ -1523,6 +1656,8 @@ extension UIView {
1523
1656
  private class CraftNativeFlowView: UIStackView {
1524
1657
  var pressActiveOpacity: CGFloat?
1525
1658
  var pressBaseOpacity: CGFloat = 1
1659
+ var pressUnderlayColor: UIColor?
1660
+ var pressBaseBackgroundColor: UIColor?
1526
1661
  var wrap = false { didSet { if wrap != oldValue { invalidateMeasurements() } } }
1527
1662
  var alignItems = "stretch" { didSet { alignment = alignmentValue; if alignItems != oldValue { invalidateMeasurements() } } }
1528
1663
  var alignContent = "flex-start" { didSet { if alignContent != oldValue { invalidateMeasurements() } } }
@@ -3174,10 +3309,16 @@ private final class CraftNativeSvgView: UIView {
3174
3309
  var strokeWidth: CGFloat = 1
3175
3310
  var color: String? = "#000000"
3176
3311
  var opacity: CGFloat = 1
3312
+ var fillOpacity: CGFloat = 1
3313
+ var strokeOpacity: CGFloat = 1
3314
+ var strokeDashoffset: CGFloat = 0
3315
+ var strokeMiterlimit: CGFloat = 4
3316
+ var fillRule: String = "nonzero"
3177
3317
  var linecap: String?
3178
3318
  var linejoin: String?
3179
3319
  var dashes: [CGFloat]?
3180
3320
  var vectorEffect: String?
3321
+ var dominantBaseline: String?
3181
3322
 
3182
3323
  func merged(_ node: [String: Any]) -> Paint {
3183
3324
  var next = self
@@ -3196,10 +3337,16 @@ private final class CraftNativeSvgView: UIView {
3196
3337
  // Computed style wins over the component prop, matching Android
3197
3338
  // and the browser's cascade when both provide opacity.
3198
3339
  if let opacity = finite(style["opacity"] ?? props["opacity"]) { next.opacity *= opacity }
3340
+ if let opacity = finite(value("fillOpacity")) { next.fillOpacity = opacity }
3341
+ if let opacity = finite(value("strokeOpacity")) { next.strokeOpacity = opacity }
3342
+ if let offset = finite(value("strokeDashoffset")) { next.strokeDashoffset = offset }
3343
+ if let limit = finite(value("strokeMiterlimit")), limit > 0 { next.strokeMiterlimit = limit }
3344
+ if let rule = value("fillRule") as? String { next.fillRule = rule }
3199
3345
  if let cap = value("strokeLinecap") as? String { next.linecap = cap }
3200
3346
  if let join = value("strokeLinejoin") as? String { next.linejoin = join }
3201
3347
  if let dashes = value("strokeDasharray") as? [Any] { next.dashes = dashes.compactMap(finite).filter { $0 >= 0 } }
3202
3348
  if let vectorEffect = value("vectorEffect") as? String { next.vectorEffect = vectorEffect }
3349
+ if let baseline = value("dominantBaseline") as? String { next.dominantBaseline = baseline }
3203
3350
  return next
3204
3351
  }
3205
3352
 
@@ -3257,10 +3404,13 @@ private final class CraftNativeSvgView: UIView {
3257
3404
  default:
3258
3405
  guard let path = CraftNativeSvgView.path((shape["props"] as? [String: Any])?["geometry"]) else { continue }
3259
3406
  context.saveGState()
3260
- context.setAlpha(own.opacity)
3407
+ path.usesEvenOddFillRule = own.fillRule == "evenodd"
3261
3408
  if let fill = own.resolve(own.fill) {
3409
+ context.saveGState()
3410
+ context.setAlpha(max(0, min(1, own.opacity * own.fillOpacity)))
3262
3411
  fill.setFill()
3263
3412
  path.fill()
3413
+ context.restoreGState()
3264
3414
  }
3265
3415
  if let stroke = own.resolve(own.stroke), own.strokeWidth > 0 {
3266
3416
  // `vector-effect="non-scaling-stroke"`: the stroke is in the
@@ -3277,10 +3427,12 @@ private final class CraftNativeSvgView: UIView {
3277
3427
  line = path
3278
3428
  }
3279
3429
  stroke.setStroke()
3430
+ context.setAlpha(max(0, min(1, own.opacity * own.strokeOpacity)))
3280
3431
  line.lineWidth = own.strokeWidth
3432
+ line.miterLimit = own.strokeMiterlimit
3281
3433
  line.lineCapStyle = own.linecap == "round" ? .round : own.linecap == "square" ? .square : .butt
3282
3434
  line.lineJoinStyle = own.linejoin == "round" ? .round : own.linejoin == "bevel" ? .bevel : .miter
3283
- if let dashes = own.dashes, !dashes.isEmpty { line.setLineDash(dashes, count: dashes.count, phase: 0) }
3435
+ if let dashes = own.dashes, !dashes.isEmpty { line.setLineDash(dashes, count: dashes.count, phase: own.strokeDashoffset) }
3284
3436
  line.stroke()
3285
3437
  }
3286
3438
  context.restoreGState()
@@ -3295,9 +3447,13 @@ private final class CraftNativeSvgView: UIView {
3295
3447
  let style = node["style"] as? [String: Any] ?? [:]
3296
3448
  let size = number(props["fontSize"]) ?? number(style["fontSize"]) ?? 12
3297
3449
  let weight: UIFont.Weight = (props["fontWeight"] as? String ?? style["fontWeight"] as? String).map { ["500": .medium, "600": .semibold, "700": .bold, "bold": .bold][$0] ?? .regular } ?? .regular
3450
+ let font = UIFont.systemFont(ofSize: size, weight: weight)
3451
+ let resolvedTextColor = paint.resolve(paint.fill) ?? UIColor.label
3452
+ let textAlpha = max(0, min(1, paint.opacity * paint.fillOpacity))
3453
+ let textColor = resolvedTextColor.withAlphaComponent(resolvedTextColor.cgColor.alpha * textAlpha)
3298
3454
  let string = NSAttributedString(string: text, attributes: [
3299
- .font: UIFont.systemFont(ofSize: size, weight: weight),
3300
- .foregroundColor: paint.resolve(paint.fill) ?? UIColor.label,
3455
+ .font: font,
3456
+ .foregroundColor: textColor,
3301
3457
  ])
3302
3458
  var x = number(props["x"]) ?? 0
3303
3459
  let y = number(props["y"]) ?? 0
@@ -3306,8 +3462,14 @@ private final class CraftNativeSvgView: UIView {
3306
3462
  case "end": x -= string.size().width
3307
3463
  default: break
3308
3464
  }
3309
- // y is the baseline, as in SVG.
3310
- string.draw(at: CGPoint(x: x, y: y - UIFont.systemFont(ofSize: size).ascender))
3465
+ let baseline: CGFloat
3466
+ switch paint.dominantBaseline {
3467
+ case "middle", "central", "mathematical": baseline = y - (font.ascender + font.descender) / 2
3468
+ case "hanging", "text-before-edge", "before-edge": baseline = y + font.ascender
3469
+ case "text-after-edge", "after-edge": baseline = y + font.descender
3470
+ default: baseline = y
3471
+ }
3472
+ string.draw(at: CGPoint(x: x, y: baseline - font.ascender))
3311
3473
  }
3312
3474
 
3313
3475
  static func path(_ value: Any?) -> UIBezierPath? {
@@ -3505,6 +3667,8 @@ private final class CraftNativeIconView: UIImageView {
3505
3667
 
3506
3668
  private final class CraftNativeTextView: UITextView {
3507
3669
  private let placeholderLabel = UILabel()
3670
+ private var nativeTextContainerInset: UIEdgeInsets?
3671
+ private var nativeLineFragmentPadding: CGFloat?
3508
3672
  var placeholder: String? {
3509
3673
  didSet { refreshPlaceholder() }
3510
3674
  }
@@ -3529,6 +3693,20 @@ private final class CraftNativeTextView: UITextView {
3529
3693
  configurePlaceholder()
3530
3694
  }
3531
3695
 
3696
+ func applyAuthoredPadding(_ padding: UIEdgeInsets) {
3697
+ if nativeTextContainerInset == nil {
3698
+ nativeTextContainerInset = textContainerInset
3699
+ nativeLineFragmentPadding = textContainer.lineFragmentPadding
3700
+ }
3701
+ if padding == .zero {
3702
+ textContainerInset = nativeTextContainerInset ?? .zero
3703
+ textContainer.lineFragmentPadding = nativeLineFragmentPadding ?? 0
3704
+ } else {
3705
+ textContainerInset = padding
3706
+ textContainer.lineFragmentPadding = 0
3707
+ }
3708
+ }
3709
+
3532
3710
  override func layoutSubviews() {
3533
3711
  super.layoutSubviews()
3534
3712
  let inset = textContainerInset
@@ -3582,6 +3760,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3582
3760
  private var handlers: [ObjectIdentifier: String] = [:]
3583
3761
  private var pressInHandlers: [ObjectIdentifier: String] = [:]
3584
3762
  private var pressOutHandlers: [ObjectIdentifier: String] = [:]
3763
+ private var underlayShowHandlers: [ObjectIdentifier: String] = [:]
3764
+ private var underlayHideHandlers: [ObjectIdentifier: String] = [:]
3585
3765
  private var focusHandlers: [ObjectIdentifier: String] = [:]
3586
3766
  private var blurHandlers: [ObjectIdentifier: String] = [:]
3587
3767
  private var endEditingHandlers: [ObjectIdentifier: String] = [:]
@@ -3607,6 +3787,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3607
3787
  private var imageSources: [ObjectIdentifier: String] = [:]
3608
3788
  private var imageTasks: [ObjectIdentifier: URLSessionDataTask] = [:]
3609
3789
  private var imageErrors: [ObjectIdentifier: String] = [:]
3790
+ /// Text inputs retain UIKit's generated accessibility value unless the
3791
+ /// bundle authored one; remember that distinction so removing the prop
3792
+ /// restores the native value instead of leaving stale metadata behind.
3793
+ private var authoredAccessibilityValues = Set<ObjectIdentifier>()
3610
3794
  private var checkboxIndeterminate: [ObjectIdentifier: Bool] = [:]
3611
3795
  private var tintedImages = Set<ObjectIdentifier>()
3612
3796
  private var imageLoadStartHandlers: [ObjectIdentifier: String] = [:]
@@ -5138,6 +5322,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5138
5322
  // reads the node's new layout style on its next pass.
5139
5323
  CraftNativeFlexLayout.invalidateAncestors(of: next.view)
5140
5324
  }
5325
+ if !applied.isEmpty {
5326
+ // A targeted mutation can change geometry without causing UIKit
5327
+ // to call viewDidLayoutSubviews again. Keep onLayout delivery
5328
+ // aligned with Android's post-mutation layout pass.
5329
+ scheduleLayoutEvents()
5330
+ }
5141
5331
  restoreFocus(focused)
5142
5332
  return true
5143
5333
  }
@@ -5406,17 +5596,23 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5406
5596
  }
5407
5597
  case "Picker":
5408
5598
  let picker = result as! UIPickerView
5409
- let options = children.compactMap { child -> (value: String, label: String)? in
5410
- guard let node = child as? [String: Any], node["type"] as? String == "Text" else { return nil }
5411
- let optionProps = node["props"] as? [String: Any] ?? [:]
5412
- let label = (node["children"] as? [Any] ?? []).compactMap { $0 as? String }.joined()
5413
- guard !label.isEmpty else { return nil }
5414
- return (value: optionProps["value"] as? String ?? label, label: label)
5415
- }
5599
+ let itemOptions = (props["items"] as? [[String: Any]])?.compactMap { item -> (value: String, label: String)? in
5600
+ guard let label = item["label"] as? String, !label.isEmpty else { return nil }
5601
+ return (value: craftNativePickerValue(item["value"]) ?? label, label: label)
5602
+ } ?? []
5603
+ let options = itemOptions.isEmpty
5604
+ ? children.compactMap { child -> (value: String, label: String)? in
5605
+ guard let node = child as? [String: Any], node["type"] as? String == "Text" else { return nil }
5606
+ let optionProps = node["props"] as? [String: Any] ?? [:]
5607
+ let label = (node["children"] as? [Any] ?? []).compactMap { $0 as? String }.joined()
5608
+ guard !label.isEmpty else { return nil }
5609
+ return (value: craftNativePickerValue(optionProps["value"]) ?? label, label: label)
5610
+ }
5611
+ : itemOptions
5416
5612
  let pickerID = ObjectIdentifier(picker)
5417
5613
  pickerOptions[pickerID] = options
5418
5614
  picker.reloadAllComponents()
5419
- if let selected = (props["selectedValue"] as? String) ?? (props["value"] as? String),
5615
+ if let selected = craftNativePickerValue(props["selectedValue"]) ?? craftNativePickerValue(props["value"]),
5420
5616
  let row = options.firstIndex(where: { $0.value == selected }) {
5421
5617
  picker.selectRow(row, inComponent: 0, animated: false)
5422
5618
  } else if !options.isEmpty {
@@ -5492,9 +5688,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5492
5688
  let pressable = result as! CraftNativeFlowView
5493
5689
  configureStack(pressable, style: style)
5494
5690
  reconcileChildren(children, in: pressable, parent: current, path: path, style: style)
5495
- pressable.pressActiveOpacity = props["disabled"] as? Bool == true
5691
+ let disabled = props["disabled"] as? Bool == true
5692
+ pressable.pressActiveOpacity = disabled
5496
5693
  ? nil : number(props["activeOpacity"]) ?? 0.5
5497
5694
  pressable.pressBaseOpacity = number(style["opacity"]) ?? 1
5695
+ pressable.pressUnderlayColor = type == "TouchableHighlight" && !disabled ? color(props["underlayColor"]) : nil
5696
+ pressable.pressBaseBackgroundColor = pressable.backgroundColor
5498
5697
  case "Switch":
5499
5698
  let toggle = result as! UISwitch
5500
5699
  toggle.isOn = props["value"] as? Bool ?? props["checked"] as? Bool ?? false
@@ -5614,8 +5813,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5614
5813
  )
5615
5814
  case "Map":
5616
5815
  let map = result as! CraftNativeMapView
5816
+ let mapMarkerHandler = nonEmptyHandler(events["onMarkerPress"])
5617
5817
  map.configure(props: props, children: children) { [weak self, weak map] markerID in
5618
- guard let self, let map, let handler = map.markerHandlers[markerID], !handler.isEmpty else { return }
5818
+ guard let self, let map,
5819
+ let handler = map.markerHandlers[markerID] ?? mapMarkerHandler else { return }
5619
5820
  self.send(type: "EVENT", payload: [
5620
5821
  "handlerName": handler,
5621
5822
  "nativeEvent": ["id": markerID],
@@ -5649,13 +5850,15 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5649
5850
  let refreshNode = children.compactMap { $0 as? [String: Any] }.first {
5650
5851
  canonicalType($0["type"] as? String ?? "") == "RefreshControl"
5651
5852
  }
5652
- let refreshProps = refreshNode?["props"] as? [String: Any] ?? [:]
5853
+ let propRefresh = props["refreshControl"] as? [String: Any] ?? [:]
5854
+ let refreshProps = refreshNode?["props"] as? [String: Any] ?? propRefresh
5653
5855
  let refreshEvents = refreshNode?["events"] as? [String: String] ?? [:]
5654
5856
  let refreshHandlerName = nonEmptyHandler(refreshEvents["onRefresh"])
5655
5857
  ?? nonEmptyHandler(events["onRefresh"])
5858
+ let refreshing = (refreshProps["refreshing"] ?? props["refreshing"]) as? Bool == true
5656
5859
  scroll.setRefreshHandler(refreshHandlerName.map { [weak self] handler in
5657
5860
  { self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
5658
- }, refreshing: (refreshNode == nil ? props["refreshing"] : refreshProps["refreshing"]) as? Bool == true)
5861
+ }, refreshing: refreshing)
5659
5862
  scroll.setRefreshAppearance(refreshProps)
5660
5863
  var contentStyle = style
5661
5864
  if let override = props["contentContainerStyle"] as? [String: Any] {
@@ -5690,6 +5893,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5690
5893
  disabled ? nil : nonEmptyHandler(events["onPressOut"]),
5691
5894
  for: result
5692
5895
  )
5896
+ updateUnderlayHandlers(
5897
+ type == "TouchableHighlight" && !disabled ? nonEmptyHandler(events["onShowUnderlay"]) : nil,
5898
+ type == "TouchableHighlight" && !disabled ? nonEmptyHandler(events["onHideUnderlay"]) : nil,
5899
+ for: result
5900
+ )
5693
5901
  if disabled { result.isUserInteractionEnabled = false }
5694
5902
  }
5695
5903
  applyAccessibility(props, type: type, to: result)
@@ -5993,6 +6201,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5993
6201
  { self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
5994
6202
  }
5995
6203
  )
6204
+ let horizontal = props["horizontal"] as? Bool == true
6205
+ list.showsVerticalScrollIndicator = props["showsVerticalScrollIndicator"] as? Bool ?? true
6206
+ list.showsHorizontalScrollIndicator = props["showsHorizontalScrollIndicator"] as? Bool ?? true
6207
+ list.isPagingEnabled = props["pagingEnabled"] as? Bool ?? false
6208
+ list.setScrollTarget(props["scrollTarget"])
6209
+ list.alwaysBounceVertical = props["alwaysBounceVertical"] as? Bool ?? !horizontal
6210
+ list.alwaysBounceHorizontal = props["alwaysBounceHorizontal"] as? Bool ?? horizontal
5996
6211
  parent.children = []
5997
6212
  }
5998
6213
 
@@ -6045,6 +6260,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6045
6260
  transitionIDs.removeValue(forKey: id)
6046
6261
  pressInHandlers.removeValue(forKey: id)
6047
6262
  pressOutHandlers.removeValue(forKey: id)
6263
+ underlayShowHandlers.removeValue(forKey: id)
6264
+ underlayHideHandlers.removeValue(forKey: id)
6048
6265
  layoutHandlers.removeValue(forKey: id)
6049
6266
  lastLayoutFrames.removeValue(forKey: id)
6050
6267
  focusHandlers.removeValue(forKey: id)
@@ -6076,6 +6293,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6076
6293
  imageSources.removeValue(forKey: id)
6077
6294
  imageTasks.removeValue(forKey: id)?.cancel()
6078
6295
  imageErrors.removeValue(forKey: id)
6296
+ authoredAccessibilityValues.remove(id)
6079
6297
  tintedImages.remove(id)
6080
6298
  imageLoadStartHandlers.removeValue(forKey: id)
6081
6299
  imageLoadHandlers.removeValue(forKey: id)
@@ -6117,6 +6335,19 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6117
6335
 
6118
6336
  private var layoutEventsScheduled = false
6119
6337
 
6338
+ /// UIView.frame is undefined while a transform is applied. onLayout is
6339
+ /// the authored layout in the parent coordinate space, matching Android's
6340
+ /// left/top/right/bottom values rather than the transformed visual bounds.
6341
+ private func layoutFrame(of view: UIView) -> CGRect {
6342
+ guard !view.transform.isIdentity else { return view.frame }
6343
+ return CGRect(
6344
+ x: view.center.x - view.bounds.width / 2,
6345
+ y: view.center.y - view.bounds.height / 2,
6346
+ width: view.bounds.width,
6347
+ height: view.bounds.height
6348
+ )
6349
+ }
6350
+
6120
6351
  /// A view inside the screen moved: report onLayout once the pass that
6121
6352
  /// moved it is over, however deep it was.
6122
6353
  fileprivate func scheduleLayoutEvents() {
@@ -6132,7 +6363,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6132
6363
  private func emitLayoutEvents(for node: RenderedNode) {
6133
6364
  let id = ObjectIdentifier(node.view)
6134
6365
  if let handler = layoutHandlers[id], isEffectivelyVisible(node.view) {
6135
- let frame = node.view.frame
6366
+ let frame = layoutFrame(of: node.view)
6136
6367
  if lastLayoutFrames[id] != frame {
6137
6368
  lastLayoutFrames[id] = frame
6138
6369
  send(type: "EVENT", payload: [
@@ -6205,8 +6436,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6205
6436
  let id = ObjectIdentifier(view)
6206
6437
  if let pressIn, !pressIn.isEmpty { pressInHandlers[id] = pressIn } else { pressInHandlers.removeValue(forKey: id) }
6207
6438
  if let pressOut, !pressOut.isEmpty { pressOutHandlers[id] = pressOut } else { pressOutHandlers.removeValue(forKey: id) }
6208
- guard pressInHandlers[id] != nil || pressOutHandlers[id] != nil else {
6439
+ let hasUnderlay = (view as? CraftNativeFlowView)?.pressUnderlayColor != nil
6440
+ guard pressInHandlers[id] != nil || pressOutHandlers[id] != nil || hasUnderlay else {
6209
6441
  if let recognizer = pressFeedbackRecognizers.removeValue(forKey: id) { view.removeGestureRecognizer(recognizer) }
6442
+ if let pressable = view as? CraftNativeFlowView { pressable.backgroundColor = pressable.pressBaseBackgroundColor }
6210
6443
  if !ownsTouches(view) { view.isUserInteractionEnabled = handlers[id] != nil || linkTargets[id] != nil || longPressHandlers[id] != nil || view is CraftNativeFlowView }
6211
6444
  return
6212
6445
  }
@@ -6222,6 +6455,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6222
6455
  if !ownsTouches(view) { view.isUserInteractionEnabled = true }
6223
6456
  }
6224
6457
 
6458
+ private func updateUnderlayHandlers(_ show: String?, _ hide: String?, for view: UIView) {
6459
+ let id = ObjectIdentifier(view)
6460
+ if let show, !show.isEmpty { underlayShowHandlers[id] = show } else { underlayShowHandlers.removeValue(forKey: id) }
6461
+ if let hide, !hide.isEmpty { underlayHideHandlers[id] = hide } else { underlayHideHandlers.removeValue(forKey: id) }
6462
+ }
6463
+
6225
6464
  private func updateLongPressHandler(_ handler: String?, for view: UIView) {
6226
6465
  let id = ObjectIdentifier(view)
6227
6466
  if let handler, !handler.isEmpty {
@@ -6289,7 +6528,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6289
6528
  applyAutofill(to: textView, props: props)
6290
6529
  // The style's padding is where the text starts, as in a web textarea.
6291
6530
  let padding = paddingInsets(style)
6292
- if padding != .zero {
6531
+ if let craftTextView = textView as? CraftNativeTextView {
6532
+ craftTextView.applyAuthoredPadding(padding)
6533
+ } else if padding != .zero {
6293
6534
  if textView.textContainerInset != padding { textView.textContainerInset = padding }
6294
6535
  textView.textContainer.lineFragmentPadding = 0
6295
6536
  }
@@ -6714,7 +6955,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6714
6955
  }
6715
6956
 
6716
6957
  private func updateImage(_ view: UIImageView, source: Any?, defaultSource: Any? = nil) {
6717
- let uri = (source as? String) ?? (source as? [String: Any])?["uri"] as? String
6958
+ let sourceObject = source as? [String: Any]
6959
+ let uri = (source as? String) ?? sourceObject?["uri"] as? String
6960
+ let headers = (sourceObject?["headers"] as? [String: Any])?.reduce(into: [String: String]()) { result, entry in
6961
+ if let value = entry.value as? String { result[entry.key] = value }
6962
+ } ?? [:]
6963
+ let requestKey = uri.map { value in
6964
+ value + "\u{0}" + headers.sorted { $0.key < $1.key }.map { "\($0.key)=\($0.value)" }.joined(separator: "\u{0}")
6965
+ }
6718
6966
  let id = ObjectIdentifier(view)
6719
6967
  guard let uri, !uri.isEmpty else {
6720
6968
  if imageSources[id] == "" { return }
@@ -6723,9 +6971,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6723
6971
  imageFailure(view, uri: nil, message: "Image source is missing")
6724
6972
  return
6725
6973
  }
6726
- guard imageSources[id] != uri else { return }
6974
+ guard let requestKey, imageSources[id] != requestKey else { return }
6727
6975
  imageTasks.removeValue(forKey: id)?.cancel()
6728
- imageSources[id] = uri
6976
+ imageSources[id] = requestKey
6729
6977
  imageErrors.removeValue(forKey: id)
6730
6978
  view.image = immediateImage(defaultSource).map { imageForDisplay($0, view: view) }
6731
6979
  imageDidChange(view)
@@ -6746,10 +6994,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6746
6994
  return
6747
6995
  }
6748
6996
  if let url = URL(string: uri), Self.loadsImage(from: url) {
6749
- let task = URLSession.shared.dataTask(with: url) { [weak self, weak view] data, _, _ in
6997
+ var request = URLRequest(url: url)
6998
+ for (name, value) in headers { request.setValue(value, forHTTPHeaderField: name) }
6999
+ let task = URLSession.shared.dataTask(with: request) { [weak self, weak view] data, _, _ in
6750
7000
  guard let self, let view else { return }
6751
7001
  DispatchQueue.main.async {
6752
- guard self.imageSources[ObjectIdentifier(view)] == uri else { return }
7002
+ guard self.imageSources[ObjectIdentifier(view)] == requestKey else { return }
6753
7003
  self.imageTasks.removeValue(forKey: ObjectIdentifier(view))
6754
7004
  guard let data, let image = UIImage(data: data) else {
6755
7005
  self.imageFailure(view, uri: uri, message: "Image download failed")
@@ -6823,11 +7073,17 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6823
7073
  }
6824
7074
 
6825
7075
  private func applyAccessibility(_ props: [String: Any], type: String, to view: UIView) {
7076
+ let id = ObjectIdentifier(view)
6826
7077
  view.accessibilityLabel = (props["accessibilityLabel"] as? String)
6827
7078
  ?? ((type == "Image" || type == "ImageBackground") ? props["alt"] as? String : nil)
6828
7079
  view.accessibilityHint = props["accessibilityHint"] as? String
6829
7080
  if let value = props["accessibilityValue"] {
6830
7081
  view.accessibilityValue = (value as? String) ?? (value as? NSNumber)?.stringValue
7082
+ authoredAccessibilityValues.insert(id)
7083
+ } else if authoredAccessibilityValues.remove(id) != nil {
7084
+ // Clearing an authored value lets UIKit expose the field's live
7085
+ // value again (particularly important for TextInput).
7086
+ view.accessibilityValue = nil
6831
7087
  } else if type != "TextInput" {
6832
7088
  view.accessibilityValue = nil
6833
7089
  }
@@ -7216,13 +7472,21 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7216
7472
  let id = ObjectIdentifier(view)
7217
7473
  switch sender.state {
7218
7474
  case .began:
7219
- if let pressable = view as? CraftNativeFlowView, let activeOpacity = pressable.pressActiveOpacity {
7220
- UIView.animate(withDuration: 0.1) { pressable.alpha = pressable.pressBaseOpacity * activeOpacity }
7475
+ if let pressable = view as? CraftNativeFlowView {
7476
+ if let underlayColor = pressable.pressUnderlayColor { pressable.backgroundColor = underlayColor }
7477
+ if let activeOpacity = pressable.pressActiveOpacity {
7478
+ UIView.animate(withDuration: 0.1) { pressable.alpha = pressable.pressBaseOpacity * activeOpacity }
7479
+ }
7221
7480
  }
7222
7481
  if let handler = pressInHandlers[id] { send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
7482
+ if let handler = underlayShowHandlers[id] { send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
7223
7483
  case .ended, .cancelled, .failed:
7224
- if let pressable = view as? CraftNativeFlowView { UIView.animate(withDuration: 0.1) { pressable.alpha = pressable.pressBaseOpacity } }
7484
+ if let pressable = view as? CraftNativeFlowView {
7485
+ pressable.backgroundColor = pressable.pressBaseBackgroundColor
7486
+ UIView.animate(withDuration: 0.1) { pressable.alpha = pressable.pressBaseOpacity }
7487
+ }
7225
7488
  if let handler = pressOutHandlers[id] { send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
7489
+ if let handler = underlayHideHandlers[id] { send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
7226
7490
  default:
7227
7491
  break
7228
7492
  }