craft-native 0.0.164 → 0.0.167

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.
@@ -550,6 +550,27 @@ private final class CraftNativeLayoutStyleBox {
550
550
 
551
551
  private var craftNativeLayoutStyleKey: UInt8 = 0
552
552
  private var craftNativeCornerRadiusKey: UInt8 = 0
553
+ private var craftNativeCornerRadiiKey: UInt8 = 0
554
+ private var craftNativeBorderLayerKey: UInt8 = 0
555
+ private var craftNativeSideBorderLayersKey: UInt8 = 0
556
+
557
+ private func craftRoundedPath(_ rect: CGRect, _ rawRadii: [CGFloat]) -> CGPath {
558
+ let limit = min(rect.width, rect.height) / 2
559
+ let radii = (0..<4).map { min(max(0, rawRadii.count > $0 ? rawRadii[$0] : 0), limit) }
560
+ let path = CGMutablePath()
561
+ let minX = rect.minX, maxX = rect.maxX, minY = rect.minY, maxY = rect.maxY
562
+ path.move(to: CGPoint(x: minX + radii[0], y: minY))
563
+ path.addLine(to: CGPoint(x: maxX - radii[1], y: minY))
564
+ path.addArc(center: CGPoint(x: maxX - radii[1], y: minY + radii[1]), radius: radii[1], startAngle: -.pi / 2, endAngle: 0, clockwise: false)
565
+ path.addLine(to: CGPoint(x: maxX, y: maxY - radii[2]))
566
+ path.addArc(center: CGPoint(x: maxX - radii[2], y: maxY - radii[2]), radius: radii[2], startAngle: 0, endAngle: .pi / 2, clockwise: false)
567
+ path.addLine(to: CGPoint(x: minX + radii[3], y: maxY))
568
+ path.addArc(center: CGPoint(x: minX + radii[3], y: maxY - radii[3]), radius: radii[3], startAngle: .pi / 2, endAngle: .pi, clockwise: false)
569
+ path.addLine(to: CGPoint(x: minX, y: minY + radii[0]))
570
+ path.addArc(center: CGPoint(x: minX + radii[0], y: minY + radii[0]), radius: radii[0], startAngle: .pi, endAngle: .pi * 1.5, clockwise: false)
571
+ path.closeSubpath()
572
+ return path
573
+ }
553
574
 
554
575
  extension UIView {
555
576
  /// The layout style this view's parent reads. It lives on the view, so an
@@ -571,11 +592,98 @@ extension UIView {
571
592
  }
572
593
  }
573
594
 
595
+ var craftRequestedCornerRadii: [CGFloat]? {
596
+ get { (objc_getAssociatedObject(self, &craftNativeCornerRadiiKey) as? [NSNumber])?.map { CGFloat(truncating: $0) } }
597
+ set {
598
+ objc_setAssociatedObject(self, &craftNativeCornerRadiiKey, newValue?.map { NSNumber(value: Double($0)) }, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
599
+ craftApplyCornerRadius()
600
+ }
601
+ }
602
+
574
603
  func craftApplyCornerRadius() {
575
604
  let requested = craftRequestedCornerRadius ?? 0
576
605
  let limit = min(bounds.width, bounds.height) / 2
577
606
  let radius = limit > 0 ? min(requested, limit) : min(requested, 9_998)
578
- if layer.cornerRadius != radius { layer.cornerRadius = radius }
607
+ if let radii = craftRequestedCornerRadii {
608
+ let mask = (layer.mask as? CAShapeLayer) ?? CAShapeLayer()
609
+ mask.frame = bounds
610
+ mask.path = craftRoundedPath(bounds, radii)
611
+ layer.mask = mask
612
+ layer.cornerRadius = 0
613
+ } else {
614
+ layer.mask = nil
615
+ if layer.cornerRadius != radius { layer.cornerRadius = radius }
616
+ }
617
+ if let border = objc_getAssociatedObject(self, &craftNativeBorderLayerKey) as? CAShapeLayer {
618
+ border.frame = bounds
619
+ let inset = border.lineWidth / 2
620
+ border.path = UIBezierPath(
621
+ roundedRect: bounds.insetBy(dx: inset, dy: inset),
622
+ cornerRadius: max(0, radius - inset)
623
+ ).cgPath
624
+ }
625
+ if let sides = objc_getAssociatedObject(self, &craftNativeSideBorderLayersKey) as? [CALayer], sides.count == 4 {
626
+ let top = sides[0].frame.height
627
+ let right = sides[1].frame.width
628
+ let bottom = sides[2].frame.height
629
+ let left = sides[3].frame.width
630
+ sides[0].frame = CGRect(x: 0, y: 0, width: bounds.width, height: top)
631
+ sides[1].frame = CGRect(x: max(0, bounds.width - right), y: 0, width: right, height: bounds.height)
632
+ sides[2].frame = CGRect(x: 0, y: max(0, bounds.height - bottom), width: bounds.width, height: bottom)
633
+ sides[3].frame = CGRect(x: 0, y: 0, width: left, height: bounds.height)
634
+ }
635
+ }
636
+
637
+ fileprivate func craftApplyBorderStyle(_ style: String?, width: CGFloat, color: UIColor) {
638
+ guard width > 0, style == "dashed" || style == "dotted" else {
639
+ (objc_getAssociatedObject(self, &craftNativeBorderLayerKey) as? CAShapeLayer)?.removeFromSuperlayer()
640
+ objc_setAssociatedObject(self, &craftNativeBorderLayerKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
641
+ layer.borderWidth = width
642
+ layer.borderColor = color.cgColor
643
+ return
644
+ }
645
+ layer.borderWidth = 0
646
+ let border = (objc_getAssociatedObject(self, &craftNativeBorderLayerKey) as? CAShapeLayer) ?? CAShapeLayer()
647
+ border.strokeColor = color.cgColor
648
+ border.fillColor = nil
649
+ border.lineWidth = width
650
+ border.lineDashPattern = style == "dotted"
651
+ ? [NSNumber(value: Double(width)), NSNumber(value: Double(width * 1.5))]
652
+ : [NSNumber(value: Double(width * 3)), NSNumber(value: Double(width * 2))]
653
+ border.contentsScale = window?.screen.scale ?? traitCollection.displayScale
654
+ if border.superlayer !== layer { layer.addSublayer(border) }
655
+ objc_setAssociatedObject(self, &craftNativeBorderLayerKey, border, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
656
+ craftApplyCornerRadius()
657
+ }
658
+
659
+ fileprivate func craftApplySideBorders(_ hasSideBorders: Bool, style: String?, widths: [CGFloat], colors: [UIColor]) {
660
+ guard hasSideBorders else {
661
+ (objc_getAssociatedObject(self, &craftNativeSideBorderLayersKey) as? [CALayer])?.forEach { $0.removeFromSuperlayer() }
662
+ objc_setAssociatedObject(self, &craftNativeSideBorderLayersKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
663
+ craftApplyBorderStyle(style, width: widths.first ?? 0, color: colors.first ?? .clear)
664
+ return
665
+ }
666
+ (objc_getAssociatedObject(self, &craftNativeBorderLayerKey) as? CAShapeLayer)?.removeFromSuperlayer()
667
+ objc_setAssociatedObject(self, &craftNativeBorderLayerKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
668
+ layer.borderWidth = 0
669
+ let top = max(0, widths.count > 0 ? widths[0] : 0)
670
+ let right = max(0, widths.count > 1 ? widths[1] : 0)
671
+ let bottom = max(0, widths.count > 2 ? widths[2] : 0)
672
+ let left = max(0, widths.count > 3 ? widths[3] : 0)
673
+ let frames = [
674
+ CGRect(x: 0, y: 0, width: bounds.width, height: top),
675
+ CGRect(x: max(0, bounds.width - right), y: 0, width: right, height: bounds.height),
676
+ CGRect(x: 0, y: max(0, bounds.height - bottom), width: bounds.width, height: bottom),
677
+ CGRect(x: 0, y: 0, width: left, height: bounds.height),
678
+ ]
679
+ let layers = frames.enumerated().map { index, frame in
680
+ let side = CALayer()
681
+ side.frame = frame
682
+ side.backgroundColor = (colors.count > index ? colors[index] : .clear).cgColor
683
+ layer.addSublayer(side)
684
+ return side
685
+ }
686
+ objc_setAssociatedObject(self, &craftNativeSideBorderLayersKey, layers, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
579
687
  }
580
688
  }
581
689
 
@@ -670,6 +778,7 @@ private class CraftNativeFlowView: UIStackView {
670
778
  var gridColumns = 1 { didSet { if gridColumns != oldValue { invalidateMeasurements() } } }
671
779
  var gridAutoRows: CraftNativeLength? { didSet { if gridAutoRows != oldValue { invalidateMeasurements() } } }
672
780
  var reverse = false { didSet { if reverse != oldValue { invalidateMeasurements() } } }
781
+ var wrapReverse = false { didSet { if wrapReverse != oldValue { invalidateMeasurements() } } }
673
782
  var padding = UIEdgeInsets.zero {
674
783
  didSet {
675
784
  layoutMargins = padding
@@ -869,6 +978,29 @@ private class CraftNativeFlowView: UIStackView {
869
978
  return max(0, result)
870
979
  }
871
980
 
981
+ /// The first baseline is the cross-axis anchor for a horizontal baseline
982
+ /// flex line. Views without a baseline participate at their bottom edge,
983
+ /// matching the CSS fallback for replaced and non-text elements.
984
+ private static func firstBaseline(_ view: UIView, cross: CGFloat) -> CGFloat {
985
+ let baseline: CGFloat?
986
+ if let label = view as? CraftNativeLabel {
987
+ // CraftNativeLabel draws inside its inset rect, so include the
988
+ // authored top padding before the font's own baseline (UIKit's
989
+ // labels expose no measured first-baseline offset).
990
+ baseline = label.insets.top + label.font.ascender
991
+ } else if let label = view as? UILabel {
992
+ baseline = label.font.ascender
993
+ } else if let field = view as? UITextField {
994
+ baseline = field.font?.ascender
995
+ } else if let textView = view as? UITextView {
996
+ baseline = textView.font?.ascender
997
+ } else {
998
+ baseline = nil
999
+ }
1000
+ guard let baseline, baseline.isFinite else { return cross }
1001
+ return min(max(0, baseline), cross)
1002
+ }
1003
+
872
1004
  /// Children that take part in the flow: shown, not a modal overlay.
873
1005
  private var visibleChildren: [UIView] {
874
1006
  flowChildren.filter { $0.superview === self && !$0.isHidden && !($0 is CraftNativeModalView) }
@@ -1255,31 +1387,53 @@ private class CraftNativeFlowView: UIStackView {
1255
1387
  }
1256
1388
  let lineCross = lineCrosses[lineIndex]
1257
1389
  let lineOffset = sharedCrossOffsets?[lineIndex] ?? crossOffset
1390
+ let crossReversed = wrapReverse && wrap
1391
+ let physicalLineOffset = crossReversed ? contentCross - lineOffset - lineCross : lineOffset
1258
1392
  let sharedLine = sharedLines[lineIndex]
1259
1393
  let useSharedPositions = !reverse && sharedLine != nil
1394
+ let baseline = horizontal
1395
+ ? line.compactMap { index -> CGFloat? in
1396
+ let item = items[index]
1397
+ guard item.align == "baseline" else { return nil }
1398
+ return item.crossLeading + Self.firstBaseline(item.view, cross: item.cross)
1399
+ }.max()
1400
+ : nil
1260
1401
  var mainOffset = leading
1261
1402
  for (offset, index) in line.enumerated() {
1262
1403
  let item = items[index]
1263
- let alignment = item.align == "center" ? UInt32(1) : item.align == "flex-end" ? UInt32(2) : UInt32(0)
1264
- let crossPosition = CraftNativeCrossAxisBridge.position(
1265
- available: lineCross,
1266
- size: item.cross,
1267
- leading: item.crossLeading,
1268
- trailing: item.crossTrailing,
1269
- alignment: alignment
1270
- ) ?? {
1271
- switch alignment {
1272
- case 1: return item.crossLeading + (lineCross - item.crossMargins - item.cross) / 2
1273
- case 2: return lineCross - item.crossTrailing - item.cross
1274
- default: return item.crossLeading
1275
- }
1276
- }()
1404
+ let crossPosition: CGFloat
1405
+ if item.align == "baseline", let baseline {
1406
+ let childBaseline = Self.firstBaseline(item.view, cross: item.cross)
1407
+ crossPosition = CraftNativeCrossAxisBridge.position(
1408
+ available: baseline,
1409
+ size: childBaseline,
1410
+ leading: item.crossLeading,
1411
+ trailing: item.crossTrailing,
1412
+ alignment: 3
1413
+ ) ?? max(item.crossLeading, baseline - childBaseline)
1414
+ } else {
1415
+ let alignment = item.align == "center" ? UInt32(1) : item.align == "flex-end" ? UInt32(2) : UInt32(0)
1416
+ crossPosition = CraftNativeCrossAxisBridge.position(
1417
+ available: lineCross,
1418
+ size: item.cross,
1419
+ leading: item.crossLeading,
1420
+ trailing: item.crossTrailing,
1421
+ alignment: alignment
1422
+ ) ?? {
1423
+ switch alignment {
1424
+ case 1: return item.crossLeading + (lineCross - item.crossMargins - item.cross) / 2
1425
+ case 2: return lineCross - item.crossTrailing - item.cross
1426
+ default: return item.crossLeading
1427
+ }
1428
+ }()
1429
+ }
1430
+ let physicalCrossPosition = crossReversed ? lineCross - crossPosition - item.cross : crossPosition
1277
1431
  let mainPosition = useSharedPositions
1278
1432
  ? (sharedLine?[offset].position ?? (mainOffset + item.mainLeading))
1279
1433
  : mainOffset + item.mainLeading
1280
1434
  let frame = horizontal
1281
- ? CGRect(x: padding.left + mainPosition, y: padding.top + lineOffset + crossPosition, width: item.main, height: item.cross)
1282
- : CGRect(x: padding.left + lineOffset + crossPosition, y: padding.top + mainPosition, width: item.cross, height: item.main)
1435
+ ? CGRect(x: padding.left + mainPosition, y: padding.top + physicalLineOffset + physicalCrossPosition, width: item.main, height: item.cross)
1436
+ : CGRect(x: padding.left + physicalLineOffset + physicalCrossPosition, y: padding.top + mainPosition, width: item.cross, height: item.main)
1283
1437
  frames.append((item.view, frame))
1284
1438
  if !useSharedPositions { mainOffset += item.mainMargins + item.main + mainGap + between }
1285
1439
  }
@@ -2448,9 +2602,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2448
2602
 
2449
2603
  override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
2450
2604
  super.traitCollectionDidChange(previousTraitCollection)
2451
- guard previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle else { return }
2605
+ let colorChanged = previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle
2606
+ let textSizeChanged = previousTraitCollection?.preferredContentSizeCategory != traitCollection.preferredContentSizeCategory
2607
+ guard colorChanged || textSizeChanged else { return }
2452
2608
  refreshTraitDefaults()
2453
- send(type: "APPEARANCE", payload: ["colorScheme": colorScheme])
2609
+ sendAppearance()
2454
2610
  }
2455
2611
 
2456
2612
  override func viewWillAppear(_ animated: Bool) {
@@ -2538,7 +2694,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2538
2694
  }
2539
2695
 
2540
2696
  private func refreshTraitDefaults() {
2541
- view.backgroundColor = config.resolvedBackgroundColor
2697
+ view.backgroundColor = screenBackgroundColor ?? config.resolvedBackgroundColor
2542
2698
  if let document = mutationDocument.node("root") {
2543
2699
  renderCommitted(document)
2544
2700
  } else {
@@ -2621,6 +2777,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2621
2777
  capabilities: \(capabilityJSON),
2622
2778
  initialAppState: "\(CraftNativeActions.currentAppState())",
2623
2779
  colorScheme: "\(colorScheme)",
2780
+ appearance: \(appearanceJSON()),
2624
2781
  appURL: \(appURL),
2625
2782
  appVersion: \(appVersion),
2626
2783
  appBuild: \(appBuild),
@@ -2701,6 +2858,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2701
2858
  return config.appearance == "dark" ? "dark" : "light"
2702
2859
  }
2703
2860
 
2861
+ private func sendAppearance() {
2862
+ let style: UIUserInterfaceStyle = colorScheme == "dark" ? .dark : .light
2863
+ send(type: "APPEARANCE", payload: CraftPageAppearance.snapshot(style: style))
2864
+ }
2865
+
2866
+ private func appearanceJSON() -> String {
2867
+ let style: UIUserInterfaceStyle = colorScheme == "dark" ? .dark : .light
2868
+ guard let data = try? JSONSerialization.data(withJSONObject: CraftPageAppearance.snapshot(style: style)),
2869
+ let json = String(data: data, encoding: .utf8) else { return "{}" }
2870
+ return json
2871
+ }
2872
+
2704
2873
  /// The screen's name as its script knows it: the route the host opened,
2705
2874
  /// else the one a route bundle selected for itself.
2706
2875
  private func screenName(in context: JSContext? = nil) -> String {
@@ -2726,8 +2895,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2726
2895
  /// - `craft.secureStorage.getSync(key)`: the Keychain item the web page's
2727
2896
  /// `craft.secureStorage.set` wrote.
2728
2897
  /// - `craft.navigation.setOptions({ title, largeTitle, hidden, backTitle,
2729
- /// tabBarHidden, contentSwipeBack, tabSwipeEnabled, rightButtons })`; a right button's
2730
- /// tap is the event `navButton`.
2898
+ /// tabBarHidden, contentSwipeBack, tabSwipeEnabled, backgroundColor, rightButtons })`;
2899
+ /// a right button's tap is the event `navButton`.
2731
2900
  ///
2732
2901
  /// A runtime that assigns `craft.storage` (or the others) afterwards keeps
2733
2902
  /// these: each namespace is a property whose setter adds them back to the
@@ -2979,6 +3148,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2979
3148
  /// own horizontal pager or chart gesture.
2980
3149
  private(set) var tabSwipeEnabled = true
2981
3150
  private var prefersLargeTitle: Bool?
3151
+ /// The screen's own colour (`backgroundColor`), under the status bar and
3152
+ /// the home indicator too, where its content never reaches: a black
3153
+ /// full-screen timer is black edge to edge. nil is the app's.
3154
+ private var screenBackgroundColor: UIColor?
2982
3155
 
2983
3156
  /// Whether this screen asked for a bar of its own through `setOptions`: a
2984
3157
  /// title, a large title or buttons, and not `hidden`. A tab's root screen
@@ -3019,6 +3192,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3019
3192
  if let tabSwipeEnabled = options["tabSwipeEnabled"] as? Bool {
3020
3193
  self.tabSwipeEnabled = tabSwipeEnabled
3021
3194
  }
3195
+ if options.keys.contains("backgroundColor") {
3196
+ screenBackgroundColor = color(options["backgroundColor"])
3197
+ view.backgroundColor = screenBackgroundColor ?? config.resolvedBackgroundColor
3198
+ }
3022
3199
  if let hidden = options["hidden"] as? Bool {
3023
3200
  navigationBarHidden = hidden
3024
3201
  if navigationController?.topViewController === self {
@@ -3203,6 +3380,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3203
3380
  observe(UIApplication.didBecomeActiveNotification, state: "active")
3204
3381
  observe(UIApplication.willResignActiveNotification, state: "inactive")
3205
3382
  observe(UIApplication.didEnterBackgroundNotification, state: "background")
3383
+ for name in [UIContentSizeCategory.didChangeNotification, UIAccessibility.reduceMotionStatusDidChangeNotification, UIAccessibility.reduceTransparencyStatusDidChangeNotification, .craftColorSchemeChanged] {
3384
+ lifecycleObservers.append(NotificationCenter.default.addObserver(
3385
+ forName: name,
3386
+ object: nil,
3387
+ queue: .main
3388
+ ) { [weak self] _ in
3389
+ self?.refreshTraitDefaults()
3390
+ self?.sendAppearance()
3391
+ })
3392
+ }
3206
3393
  // The app script has no screen for the keyboard to cover.
3207
3394
  if !isAppScript {
3208
3395
  lifecycleObservers.append(NotificationCenter.default.addObserver(
@@ -4674,7 +4861,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4674
4861
  let direction = style["flexDirection"] as? String
4675
4862
  stack.axis = direction == "row" || direction == "row-reverse" ? .horizontal : .vertical
4676
4863
  stack.reverse = direction?.hasSuffix("-reverse") == true
4677
- stack.wrap = style["flexWrap"] as? String == "wrap" || style["flexWrap"] as? Bool == true
4864
+ let flexWrap = style["flexWrap"] as? String
4865
+ stack.wrap = flexWrap == "wrap" || flexWrap == "wrap-reverse" || style["flexWrap"] as? Bool == true
4866
+ stack.wrapReverse = flexWrap == "wrap-reverse"
4678
4867
  stack.alignItems = style["alignItems"] as? String ?? "stretch"
4679
4868
  stack.alignContent = style["alignContent"] as? String ?? "flex-start"
4680
4869
  stack.justifyContent = style["justifyContent"] as? String ?? "flex-start"
@@ -4710,17 +4899,45 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4710
4899
  )
4711
4900
  view.alpha = number(style["opacity"]) ?? 1
4712
4901
  view.isHidden = style["display"] as? String == "none"
4713
- view.craftRequestedCornerRadius = number(style["borderRadius"])
4714
- view.layer.borderWidth = number(style["borderWidth"]) ?? 0
4715
- view.layer.borderColor = (color(style["borderColor"]) ?? .clear).cgColor
4902
+ let borderRadius = number(style["borderRadius"]) ?? 0
4903
+ let cornerRadiusKeys = ["borderTopLeftRadius", "borderTopRightRadius", "borderBottomRightRadius", "borderBottomLeftRadius"]
4904
+ let hasCornerRadii = cornerRadiusKeys.contains { style[$0] != nil }
4905
+ view.craftRequestedCornerRadius = borderRadius
4906
+ view.craftRequestedCornerRadii = hasCornerRadii
4907
+ ? cornerRadiusKeys.map { number(style[$0]) ?? borderRadius }
4908
+ : nil
4909
+ let borderWidth = number(style["borderWidth"]) ?? 0
4910
+ let borderColor = color(style["borderColor"]) ?? .clear
4911
+ let sideWidthKeys = ["borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth"]
4912
+ let sideColorKeys = ["borderTopColor", "borderRightColor", "borderBottomColor", "borderLeftColor"]
4913
+ view.craftApplySideBorders(
4914
+ (sideWidthKeys + sideColorKeys).contains { style[$0] != nil },
4915
+ style: style["borderStyle"] as? String,
4916
+ widths: sideWidthKeys.map { number(style[$0]) ?? borderWidth },
4917
+ colors: sideColorKeys.map { color(style[$0]) ?? borderColor }
4918
+ )
4919
+ view.layer.zPosition = number(style["zIndex"]) ?? 0
4920
+ applyTransform(style["transform"], to: view)
4716
4921
  let elevation = max(0, number(style["elevation"]) ?? 0)
4717
- view.layer.shadowColor = UIColor.black.cgColor
4718
- view.layer.shadowOpacity = elevation > 0 ? Float(min(0.28, 0.12 + elevation * 0.02)) : 0
4719
- view.layer.shadowRadius = elevation * 0.5
4720
- view.layer.shadowOffset = CGSize(width: 0, height: elevation * 0.25)
4922
+ let shadowColor = color(style["shadowColor"]) ?? .black
4923
+ let shadowOpacity = min(1, max(0, number(style["shadowOpacity"]) ?? (elevation > 0 ? min(0.28, 0.12 + elevation * 0.02) : 0)))
4924
+ let shadowRadius = max(0, number(style["shadowRadius"]) ?? elevation * 0.5)
4925
+ let shadowOffset = style["shadowOffset"] as? [String: Any]
4926
+ view.layer.shadowColor = shadowColor.cgColor
4927
+ view.layer.shadowOpacity = Float(shadowOpacity)
4928
+ view.layer.shadowRadius = shadowRadius
4929
+ view.layer.shadowOffset = CGSize(
4930
+ width: number(shadowOffset?["width"]) ?? 0,
4931
+ height: number(shadowOffset?["height"]) ?? (elevation * 0.25)
4932
+ )
4721
4933
  // A label paints its background inside its own bounds, so a rounded
4722
- // chip of text clips to its corners.
4723
- view.clipsToBounds = style["overflow"] as? String == "hidden"
4934
+ // chip of text clips to its corners. A scroll view clips its content
4935
+ // unless told `overflow: visible`, as UIKit's and React Native's do:
4936
+ // unclipped, a screen's content scrolled up over its bar's back
4937
+ // button and down into the home indicator.
4938
+ let overflow = style["overflow"] as? String
4939
+ view.clipsToBounds = overflow == "hidden"
4940
+ || (view is UIScrollView && overflow != "visible")
4724
4941
  || (view is UILabel && (view.craftRequestedCornerRadius ?? 0) > 0)
4725
4942
  view.layer.masksToBounds = view.clipsToBounds
4726
4943
  view.craftLayoutStyle = CraftNativeLayoutStyle(style)
@@ -4740,6 +4957,49 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4740
4957
  )
4741
4958
  }
4742
4959
 
4960
+ private func applyTransform(_ raw: Any?, to view: UIView) {
4961
+ view.transform = .identity
4962
+ view.layer.transform = CATransform3DIdentity
4963
+ guard let transforms = raw as? [[String: Any]] else { return }
4964
+ var affine = CGAffineTransform.identity
4965
+ var threeDimensional = CATransform3DIdentity
4966
+ var hasThreeDimensionalRotation = false
4967
+ for transform in transforms {
4968
+ if let value = number(transform["translateX"]) { affine = affine.translatedBy(x: value, y: 0) }
4969
+ if let value = number(transform["translateY"]) { affine = affine.translatedBy(x: 0, y: value) }
4970
+ if let value = number(transform["scale"]) { affine = affine.scaledBy(x: value, y: value) }
4971
+ if let value = number(transform["scaleX"]) { affine = affine.scaledBy(x: value, y: 1) }
4972
+ if let value = number(transform["scaleY"]) { affine = affine.scaledBy(x: 1, y: value) }
4973
+ if let value = transformAngle(transform["rotate"] ?? transform["rotateZ"]) { affine = affine.rotated(by: value) }
4974
+ if let value = transformAngle(transform["skewX"]) { affine = affine.concatenating(CGAffineTransform(a: 1, b: 0, c: tan(value), d: 1, tx: 0, ty: 0)) }
4975
+ if let value = transformAngle(transform["skewY"]) { affine = affine.concatenating(CGAffineTransform(a: 1, b: tan(value), c: 0, d: 1, tx: 0, ty: 0)) }
4976
+ if let value = transformAngle(transform["rotateX"]) {
4977
+ threeDimensional = CATransform3DRotate(threeDimensional, value, 1, 0, 0)
4978
+ hasThreeDimensionalRotation = true
4979
+ }
4980
+ if let value = transformAngle(transform["rotateY"]) {
4981
+ threeDimensional = CATransform3DRotate(threeDimensional, value, 0, 1, 0)
4982
+ hasThreeDimensionalRotation = true
4983
+ }
4984
+ }
4985
+ if hasThreeDimensionalRotation {
4986
+ view.layer.transform = CATransform3DConcat(CATransform3DMakeAffineTransform(affine), threeDimensional)
4987
+ } else {
4988
+ view.transform = affine
4989
+ }
4990
+ }
4991
+
4992
+ private func transformAngle(_ raw: Any?) -> CGFloat? {
4993
+ if let value = number(raw) { return value }
4994
+ guard let text = raw as? String else { return nil }
4995
+ if text.hasSuffix("rad") { return CGFloat(Double(text.dropLast(3)) ?? 0) }
4996
+ if text.hasSuffix("deg") { return CGFloat((Double(text.dropLast(3)) ?? 0) * .pi / 180) }
4997
+ // Keep the conversion explicit: newer Swift toolchains see both the
4998
+ // CGFloat(Double) and CGFloat(Float) initializers through map and
4999
+ // reject the overloaded constructor reference.
5000
+ return Double(text).map { CGFloat($0) }
5001
+ }
5002
+
4743
5003
  private func configureText(_ label: UILabel, text: String, style: [String: Any]) {
4744
5004
  let transformed = transformedText(text, style: style)
4745
5005
  label.textColor = color(style["color"]) ?? .label
@@ -4747,14 +5007,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4747
5007
  label.textAlignment = textAlignment(style["textAlign"])
4748
5008
  var attributes: [NSAttributedString.Key: Any] = [:]
4749
5009
  if let spacing = number(style["letterSpacing"]) { attributes[.kern] = spacing }
4750
- switch style["textDecorationLine"] as? String {
4751
- case "underline": attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
4752
- case "line-through": attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
4753
- case "underline line-through":
4754
- attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
4755
- attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
4756
- default: break
4757
- }
5010
+ addTextDecorationAttributes(style, to: &attributes)
5011
+ if let shadow = textShadow(style) { attributes[.shadow] = shadow }
4758
5012
  if let lineHeight = number(style["lineHeight"]) {
4759
5013
  let paragraph = NSMutableParagraphStyle()
4760
5014
  paragraph.minimumLineHeight = lineHeight
@@ -4799,14 +5053,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4799
5053
  paragraph.maximumLineHeight = lineHeight
4800
5054
  attributes[.paragraphStyle] = paragraph
4801
5055
  }
4802
- switch style["textDecorationLine"] as? String {
4803
- case "underline": attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
4804
- case "line-through": attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
4805
- case "underline line-through":
4806
- attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
4807
- attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
4808
- default: break
4809
- }
5056
+ addTextDecorationAttributes(style, to: &attributes)
5057
+ if let shadow = textShadow(style) { attributes[.shadow] = shadow }
4810
5058
  if forceUnderline { attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue }
4811
5059
  return attributes
4812
5060
  }
@@ -4828,17 +5076,46 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4828
5076
  paragraph.alignment = alignment
4829
5077
  attributes[.paragraphStyle] = paragraph
4830
5078
  }
4831
- switch style["textDecorationLine"] as? String {
4832
- case "underline": attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
4833
- case "line-through": attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
4834
- case "underline line-through":
4835
- attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
4836
- attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
4837
- default: break
4838
- }
5079
+ addTextDecorationAttributes(style, to: &attributes)
5080
+ if let shadow = textShadow(style) { attributes[.shadow] = shadow }
4839
5081
  return attributes
4840
5082
  }
4841
5083
 
5084
+ private func textShadow(_ style: [String: Any]) -> NSShadow? {
5085
+ guard let shadowColor = color(style["textShadowColor"]) else { return nil }
5086
+ let offset = style["textShadowOffset"] as? [String: Any]
5087
+ let shadow = NSShadow()
5088
+ shadow.shadowColor = shadowColor
5089
+ shadow.shadowOffset = CGSize(
5090
+ width: number(offset?["width"]) ?? 0,
5091
+ height: number(offset?["height"]) ?? 0
5092
+ )
5093
+ shadow.shadowBlurRadius = max(0, number(style["textShadowRadius"]) ?? 0)
5094
+ return shadow
5095
+ }
5096
+
5097
+ private func addTextDecorationAttributes(_ style: [String: Any], to attributes: inout [NSAttributedString.Key: Any]) {
5098
+ let line = style["textDecorationLine"] as? String
5099
+ let decorationStyle: NSUnderlineStyle = {
5100
+ switch style["textDecorationStyle"] as? String {
5101
+ case "double": return .double
5102
+ case "dotted": return [.single, .patternDot]
5103
+ case "dashed": return [.single, .patternDash]
5104
+ default: return .single
5105
+ }
5106
+ }()
5107
+ let rawStyle = decorationStyle.rawValue
5108
+ let decorationColor = color(style["textDecorationColor"])
5109
+ if line == "underline" || line == "underline line-through" {
5110
+ attributes[.underlineStyle] = rawStyle
5111
+ if let decorationColor { attributes[.underlineColor] = decorationColor }
5112
+ }
5113
+ if line == "line-through" || line == "underline line-through" {
5114
+ attributes[.strikethroughStyle] = rawStyle
5115
+ if let decorationColor { attributes[.strikethroughColor] = decorationColor }
5116
+ }
5117
+ }
5118
+
4842
5119
  private func transformedText(_ text: String, style: [String: Any]) -> String {
4843
5120
  switch style["textTransform"] as? String {
4844
5121
  case "uppercase": return text.uppercased()
@@ -5237,11 +5514,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5237
5514
  if let number = value as? NSNumber, !(value is Bool) { return min(maximum, max(0, number.doubleValue)) }
5238
5515
  guard let text = (value as? String)?.trimmingCharacters(in: .whitespaces), !text.isEmpty else { return nil }
5239
5516
  if let seconds = Double(text), seconds.isFinite { return min(maximum, max(0, seconds)) }
5240
- let parts = text.split(separator: ":").compactMap { Double($0) }
5241
- guard parts.count == 2 || parts.count == 3 else { return nil }
5242
- let hours = parts[0]
5243
- let minutes = parts[1]
5244
- let seconds = parts.count == 3 ? parts[2] : 0
5517
+ let parts = text.split(separator: ":")
5518
+ guard parts.count == 2 || parts.count == 3,
5519
+ let hours = Double(parts[0]),
5520
+ let minutes = Double(parts[1]) else { return nil }
5521
+ let seconds = parts.count == 3 ? (Double(parts[2]) ?? -1) : 0
5245
5522
  guard hours >= 0, minutes >= 0, minutes < 60, seconds >= 0, seconds < 60 else { return nil }
5246
5523
  return min(maximum, hours * 3600 + minutes * 60 + seconds)
5247
5524
  }