craft-native 0.0.226 → 0.0.228

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.
@@ -643,6 +643,10 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
643
643
  private var accessorySpec: JSONObject? = null
644
644
  private val tabViews = mutableListOf<Pair<TextView, TextView>>()
645
645
  private val tabIconText = mutableMapOf<String, String>()
646
+ private val tabTitles = mutableMapOf<String, String>()
647
+ private val tabAccessibilityLabels = mutableMapOf<String, String>()
648
+ private val tabIconTints = mutableMapOf<String, Int>()
649
+ private val tabIconPulses = mutableSetOf<String>()
646
650
  private val tabImageURLs = mutableMapOf<String, String>()
647
651
  private val tabImages = mutableMapOf<String, android.graphics.drawable.Drawable>()
648
652
  private val navButtonSpecs = mutableMapOf<TextView, JSONObject>()
@@ -706,6 +710,7 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
706
710
  cell.gravity = Gravity.CENTER
707
711
  cell.background = rippleBackground()
708
712
  cell.contentDescription = tab.title
713
+ cell.tag = "craft-tab-${tab.id}"
709
714
  val icon = TextView(context)
710
715
  icon.text = CraftNativeSymbols.glyph(tab.symbol)
711
716
  icon.textSize = 22f
@@ -716,6 +721,7 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
716
721
  val label = TextView(context)
717
722
  label.text = tab.title
718
723
  label.textSize = 12f
724
+ label.fontFeatureSettings = "tnum"
719
725
  label.gravity = Gravity.CENTER
720
726
  label.isSingleLine = true
721
727
  cell.addView(icon, LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT))
@@ -844,12 +850,25 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
844
850
  val id = item.optString("id")
845
851
  val index = tabs.indexOfFirst { it.id == id }
846
852
  if (index < 0) return
847
- val cell = tabBar.getChildAt(index)
848
- if (item.has("accessibilityLabel")) cell.contentDescription = item.optString("accessibilityLabel").takeUnless { item.isNull("accessibilityLabel") || it.isBlank() } ?: tabs[index].title
853
+ if (item.has("accessibilityLabel")) {
854
+ tabAccessibilityLabels.remove(id)
855
+ item.optString("accessibilityLabel").takeUnless { item.isNull("accessibilityLabel") || it.isBlank() }?.let { tabAccessibilityLabels[id] = it.take(160) }
856
+ }
849
857
  if (item.has("iconText")) {
850
858
  tabIconText.remove(id)
851
859
  item.optString("iconText").takeUnless { item.isNull("iconText") || it.isBlank() }?.let { tabIconText[id] = it.take(8) }
852
860
  }
861
+ if (item.has("title")) {
862
+ tabTitles.remove(id)
863
+ item.optString("title").takeUnless { item.isNull("title") || it.isBlank() }?.let { tabTitles[id] = it.take(40) }
864
+ }
865
+ if (item.has("iconTint")) {
866
+ tabIconTints.remove(id)
867
+ if (!item.isNull("iconTint")) runCatching { Color.parseColor(item.optString("iconTint")) }.getOrNull()?.let { tabIconTints[id] = it }
868
+ }
869
+ if (item.has("iconPulse")) {
870
+ if (item.optBoolean("iconPulse", false)) tabIconPulses.add(id) else tabIconPulses.remove(id)
871
+ }
853
872
  if (item.has("image")) {
854
873
  val address = item.optString("image").takeIf { !item.isNull("image") && it.startsWith("https://") }
855
874
  if (address != tabImageURLs[id]) {
@@ -896,16 +915,41 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
896
915
  tabViews.forEachIndexed { index, (icon, label) ->
897
916
  tabBar.getChildAt(index).isSelected = index == selectedTab
898
917
  val color = if (index == selectedTab) accent else muted
899
- val number = tabIconText[tabs[index].id]
900
- icon.setTextColor(if (number != null) Color.parseColor("#FF2D55") else color)
918
+ val id = tabs[index].id
919
+ val number = tabIconText[id]
920
+ val iconColor = tabIconTints[id] ?: if (number != null) Color.parseColor("#FF2D55") else color
921
+ icon.setTextColor(iconColor)
922
+ label.text = tabTitles[id] ?: tabs[index].title
923
+ tabBar.getChildAt(index).contentDescription = tabAccessibilityLabels[id] ?: label.text
901
924
  label.setTextColor(color)
902
- val vector = tabImages[tabs[index].id] ?: if (number == null) CraftNativeIcons.drawable(context, null, tabs[index].symbol, color) else null
925
+ val vector = tabImages[id] ?: if (number == null) CraftNativeIcons.drawable(context, null, tabs[index].symbol, iconColor) else null
926
+ val pulse = tabIconPulses.contains(id) && number == null && tabImages[id] == null && hasWindowFocus() && android.animation.ValueAnimator.areAnimatorsEnabled()
927
+ if (pulse && icon.animation == null) {
928
+ icon.startAnimation(android.view.animation.AlphaAnimation(1f, 0.55f).apply {
929
+ duration = 700
930
+ repeatCount = android.view.animation.Animation.INFINITE
931
+ repeatMode = android.view.animation.Animation.REVERSE
932
+ })
933
+ } else if (!pulse) {
934
+ icon.clearAnimation()
935
+ icon.alpha = 1f
936
+ }
903
937
  icon.foreground = vector
904
938
  icon.text = if (vector != null) "" else number ?: CraftNativeSymbols.glyph(tabs[index].symbol)
905
939
  }
906
940
  navButtonSpecs.forEach { (view, spec) -> applyNavButtonTint(view, spec, dark) }
907
941
  }
908
942
 
943
+ override fun onWindowFocusChanged(hasWindowFocus: Boolean) {
944
+ super.onWindowFocusChanged(hasWindowFocus)
945
+ if (hasWindowFocus) refreshColors() else tabViews.forEach { (icon, _) -> icon.clearAnimation() }
946
+ }
947
+
948
+ override fun onDetachedFromWindow() {
949
+ tabViews.forEach { (icon, _) -> icon.clearAnimation() }
950
+ super.onDetachedFromWindow()
951
+ }
952
+
909
953
  private fun navButton(button: JSONObject): View {
910
954
  val id = button.optString("id")
911
955
  val title = button.optString("title")
package/dist/cli.js CHANGED
@@ -3424,7 +3424,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3424
3424
  `);
3425
3425
  }
3426
3426
  // package.json
3427
- var version = "0.0.226";
3427
+ var version = "0.0.228";
3428
3428
 
3429
3429
  // bin/cli.ts
3430
3430
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -309,7 +309,7 @@ function craftBinaryNotFoundMessage(triedPath) {
309
309
  `);
310
310
  }
311
311
  // package.json
312
- var version = "0.0.226";
312
+ var version = "0.0.228";
313
313
 
314
314
  // src/sdk-version.ts
315
315
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -57,7 +57,7 @@ function craftBinaryNotFoundMessage(triedPath) {
57
57
  `);
58
58
  }
59
59
  // package.json
60
- var version = "0.0.226";
60
+ var version = "0.0.228";
61
61
 
62
62
  // src/sdk-version.ts
63
63
  var SDK_VERSION = version;
@@ -616,6 +616,10 @@ final class CraftChrome: ObservableObject {
616
616
  let symbol: String
617
617
  var badge: String?
618
618
  var iconText: String? = nil
619
+ var titleOverride: String? = nil
620
+ var iconTint: String? = nil
621
+ var iconPulse = false
622
+ var displayTitle: String { titleOverride ?? title }
619
623
  var accessibilityLabel: String? = nil
620
624
  var image: String? = nil
621
625
  }
@@ -623,6 +627,7 @@ final class CraftChrome: ObservableObject {
623
627
  @Published var tabs: [Tab] = []
624
628
  @Published var tabAccessory: Tab?
625
629
  @Published var selected: String?
630
+ @Published private(set) var tabSwipePosition: CGFloat?
626
631
  @Published var tabBarVisible = false
627
632
  @Published var splashVisible = true
628
633
  @Published var tint: UIColor?
@@ -636,6 +641,7 @@ final class CraftChrome: ObservableObject {
636
641
  private var pendingHide: DispatchWorkItem?
637
642
  private var splashDeadline: DispatchWorkItem?
638
643
  private var tabSwipePrevious: String?
644
+ private var tabSwipeTarget: String?
639
645
  /// The keyboard is up and covers the tab bar, so the page need not leave
640
646
  /// room for it: the web view ends at the keyboard (CraftHybrid's webHost).
641
647
  private var keyboardUp = false
@@ -673,12 +679,21 @@ final class CraftChrome: ObservableObject {
673
679
  /// the next screen shown says again. The page's own hideTabBar is apart.
674
680
  private(set) var hiddenByScreen = false
675
681
 
676
- /// Change one tab's icon without replacing tabs, navigation stacks or selection.
682
+ /// Change one tab's caption or icon without replacing stacks or selection.
677
683
  func updateTab(_ item: [String: Any]) {
678
684
  guard let id = item["id"] as? String, let index = tabs.firstIndex(where: { $0.id == id }) else { return }
679
685
  if item.keys.contains("iconText") {
680
686
  tabs[index].iconText = (item["iconText"] as? String).flatMap { $0.isEmpty ? nil : String($0.prefix(8)) }
681
687
  }
688
+ if item.keys.contains("title") {
689
+ tabs[index].titleOverride = (item["title"] as? String).flatMap { $0.isEmpty ? nil : String($0.prefix(40)) }
690
+ }
691
+ if item.keys.contains("iconTint") {
692
+ tabs[index].iconTint = (item["iconTint"] as? String).flatMap { UIColor(hex: $0) == nil ? nil : $0 }
693
+ }
694
+ if item.keys.contains("iconPulse") {
695
+ tabs[index].iconPulse = item["iconPulse"] as? Bool ?? false
696
+ }
682
697
  if item.keys.contains("accessibilityLabel") {
683
698
  tabs[index].accessibilityLabel = (item["accessibilityLabel"] as? String).map { String($0.prefix(160)) }
684
699
  }
@@ -808,12 +823,34 @@ final class CraftChrome: ObservableObject {
808
823
  // A page's pending selection report may disagree with the native
809
824
  // stack. Cancellation belongs to the stack the finger left.
810
825
  tabSwipePrevious = previous
811
- withAnimation(nil) { selected = id }
826
+ tabSwipeTarget = id
827
+ withAnimation(nil) {
828
+ tabSwipePosition = tabs.firstIndex(where: { $0.id == previous }).map { CGFloat($0) }
829
+ selected = id
830
+ }
831
+ }
832
+
833
+ /// The capsule shares the gesture's progress, rather than jumping to the
834
+ /// next item as soon as the pan recognizer begins.
835
+ func updateTabSwipe(progress: CGFloat) {
836
+ guard let previous = tabSwipePrevious, let target = tabSwipeTarget,
837
+ let from = tabs.firstIndex(where: { $0.id == previous }),
838
+ let to = tabs.firstIndex(where: { $0.id == target }) else { return }
839
+ let position = CGFloat(from) + CGFloat(to - from) * min(1, max(0, progress))
840
+ if tabSwipePosition != position { withAnimation(nil) { tabSwipePosition = position } }
841
+ }
842
+
843
+ func settleTabSwipe(completed: Bool, duration: TimeInterval) {
844
+ guard let id = completed ? tabSwipeTarget : tabSwipePrevious,
845
+ let index = tabs.firstIndex(where: { $0.id == id }) else { return }
846
+ withAnimation(Self.motion(.easeOut(duration: duration))) { tabSwipePosition = CGFloat(index) }
812
847
  }
813
848
 
814
849
  func cancelTabSwipe(restoringSelection: Bool = true) {
815
850
  guard let previous = tabSwipePrevious else { return }
816
851
  tabSwipePrevious = nil
852
+ tabSwipeTarget = nil
853
+ withAnimation(nil) { tabSwipePosition = nil }
817
854
  if restoringSelection {
818
855
  withAnimation(nil) { selected = previous }
819
856
  }
@@ -821,6 +858,8 @@ final class CraftChrome: ObservableObject {
821
858
 
822
859
  func commitTabSwipe(to id: String) {
823
860
  tabSwipePrevious = nil
861
+ tabSwipeTarget = nil
862
+ withAnimation(nil) { tabSwipePosition = nil }
824
863
  guard tabs.contains(where: { $0.id == id }) else { return }
825
864
  withAnimation(nil) { selected = id }
826
865
  UISelectionFeedbackGenerator().selectionChanged()
@@ -921,8 +960,8 @@ final class CraftChromeRelay: NSObject, WKScriptMessageHandler {
921
960
  /// The floating tab bar: Liquid Glass on iOS 26 and later, a material capsule before.
922
961
  struct CraftTabBarView: View {
923
962
  @ObservedObject private var chrome = CraftChrome.shared
924
- @Namespace private var selection
925
963
  @Environment(\.accessibilityReduceMotion) private var reduceMotion
964
+ @Environment(\.scenePhase) private var scenePhase
926
965
  // Dynamic Type, relative to the styles UIKit's own bar uses. The bar is
927
966
  // capped at the largest standard size (below), as UITabBar is; past that
928
967
  // a long press shows the large content viewer instead.
@@ -1030,9 +1069,7 @@ struct CraftTabBarView: View {
1030
1069
  .minimumScaleFactor(0.7)
1031
1070
  .frame(height: iconSize + 6)
1032
1071
  } else {
1033
- Image(systemName: symbol(tab.symbol, selected: isSelected))
1034
- .font(.system(size: iconSize, weight: isSelected ? .semibold : .regular))
1035
- .frame(height: iconSize + 6)
1072
+ tabSymbol(tab, selected: isSelected)
1036
1073
  }
1037
1074
  if let badge = tab.badge {
1038
1075
  Text(badge)
@@ -1044,37 +1081,60 @@ struct CraftTabBarView: View {
1044
1081
  .offset(x: 10, y: -4)
1045
1082
  }
1046
1083
  }
1047
- Text(tab.title)
1084
+ Text(tab.displayTitle)
1048
1085
  .font(.system(size: labelSize, weight: isSelected ? .semibold : .medium))
1086
+ .monospacedDigit()
1049
1087
  .lineLimit(1)
1050
1088
  .minimumScaleFactor(0.8)
1051
1089
  }
1052
1090
  .foregroundStyle(isSelected ? selectedColor : Color.primary)
1053
1091
  .frame(maxWidth: .infinity, minHeight: 54)
1054
- .background {
1055
- if isSelected {
1056
- Capsule()
1057
- .fill(Color.primary.opacity(0.09))
1058
- .matchedGeometryEffect(id: "selection", in: selection)
1059
- }
1060
- }
1061
1092
  .contentShape(Capsule())
1062
1093
  }
1063
1094
  .buttonStyle(.plain)
1064
1095
  .accessibilityShowsLargeContentViewer {
1065
1096
  Image(systemName: symbol(tab.symbol, selected: isSelected))
1066
- Text(tab.title)
1097
+ Text(tab.displayTitle)
1067
1098
  }
1068
- .accessibilityLabel(tab.accessibilityLabel ?? tab.badge.map { "\(tab.title), \($0)" } ?? tab.title)
1099
+ .accessibilityIdentifier("craft-tab-\(tab.id)")
1100
+ .accessibilityLabel(tab.accessibilityLabel ?? tab.badge.map { "\(tab.displayTitle), \($0)" } ?? tab.displayTitle)
1069
1101
  .accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton)
1070
1102
  }
1071
1103
  }
1104
+ .background {
1105
+ GeometryReader { geometry in
1106
+ if let position = chrome.tabSwipePosition
1107
+ ?? chrome.tabs.firstIndex(where: { $0.id == chrome.selected }).map({ CGFloat($0) }) {
1108
+ let width = geometry.size.width / CGFloat(max(1, chrome.tabs.count))
1109
+ Capsule()
1110
+ .fill(Color.primary.opacity(0.09))
1111
+ .frame(width: width, height: geometry.size.height)
1112
+ .offset(x: position * width)
1113
+ }
1114
+ }
1115
+ .allowsHitTesting(false)
1116
+ .accessibilityHidden(true)
1117
+ }
1072
1118
  }
1073
1119
 
1074
1120
  private var selectedColor: Color {
1075
1121
  chrome.tint.map { Color($0) } ?? Color.primary
1076
1122
  }
1077
1123
 
1124
+ /// Live styling belongs to the icon, leaving selection and the caption clear.
1125
+ @ViewBuilder private func tabSymbol(_ tab: CraftChrome.Tab, selected: Bool) -> some View {
1126
+ let color = tab.iconTint.flatMap { UIColor(hex: $0) }.map { Color($0) } ?? (selected ? selectedColor : Color.primary)
1127
+ let icon = Image(systemName: symbol(tab.symbol, selected: selected))
1128
+ .font(.system(size: iconSize, weight: selected ? .semibold : .regular))
1129
+ .foregroundStyle(color)
1130
+ .frame(height: iconSize + 6)
1131
+ if #available(iOS 17.0, *) {
1132
+ icon.symbolEffect(.pulse, options: .repeating, isActive: tab.iconPulse && !reduceMotion && scenePhase == .active)
1133
+ } else {
1134
+ icon
1135
+ }
1136
+ }
1137
+
1078
1138
  /// The filled variant for the current tab, when the symbol has one.
1079
1139
  private func symbol(_ name: String, selected: Bool) -> String {
1080
1140
  guard selected, !name.hasSuffix(".fill"), UIImage(systemName: "\(name).fill") != nil else { return name }
@@ -1104,6 +1104,11 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1104
1104
  DispatchQueue.main.async { [weak self] in self?.hybrid.shellReady() }
1105
1105
  }
1106
1106
 
1107
+ override func viewDidAppear(_ animated: Bool) {
1108
+ super.viewDidAppear(animated)
1109
+ prepareAdjacentNativeTabs()
1110
+ }
1111
+
1107
1112
  private var webAttached = false
1108
1113
 
1109
1114
  private func installTabSwipeGestures() {
@@ -1152,9 +1157,10 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1152
1157
  let speed = velocity * direction
1153
1158
  let complete = recognizer.state == .ended && speed > -700
1154
1159
  && (progress >= max(32, view.bounds.width * 0.28) || speed > 700)
1155
- finishInteractiveTabSwipe(completed: complete) { [weak self] in
1160
+ finishInteractiveTabSwipe(completed: complete, velocity: velocity) { [weak self] in
1156
1161
  self?.activeTabSwipeTarget = nil
1157
1162
  self?.tabSwipeSettling = false
1163
+ self?.prepareAdjacentNativeTabs()
1158
1164
  guard complete else {
1159
1165
  CraftChrome.shared.cancelTabSwipe()
1160
1166
  return
@@ -1224,9 +1230,10 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1224
1230
  : max(0, min(width, translation))
1225
1231
  target.view.frame.origin.x = tabSwipeFrom + offset
1226
1232
  current.view.frame.origin.x = offset
1233
+ CraftChrome.shared.updateTabSwipe(progress: abs(offset) / width)
1227
1234
  }
1228
1235
 
1229
- private func finishInteractiveTabSwipe(completed: Bool, completion: @escaping () -> Void) {
1236
+ private func finishInteractiveTabSwipe(completed: Bool, velocity: CGFloat, completion: @escaping () -> Void) {
1230
1237
  guard let targetId = activeTabSwipeTarget, let current = visibleStack,
1231
1238
  let target = stacks[targetId] else { completion(); return }
1232
1239
  let width = view.bounds.width
@@ -1235,7 +1242,12 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1235
1242
  let settlingDuration = completed ? 0.22 : 0.18
1236
1243
  let duration = CraftChrome.motion(.easeOut(duration: settlingDuration)) == nil ? 0 : settlingDuration
1237
1244
  let generation = tabSwipeGeneration
1238
- UIView.animate(withDuration: duration, delay: 0, options: [.curveEaseOut, .beginFromCurrentState], animations: {
1245
+ CraftChrome.shared.settleTabSwipe(completed: completed, duration: duration)
1246
+ let remaining = end - target.view.frame.minX
1247
+ let initialVelocity = abs(remaining) > 1 ? max(0, min(12, velocity / remaining)) : 0
1248
+ UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: 1,
1249
+ initialSpringVelocity: initialVelocity,
1250
+ options: [.beginFromCurrentState, .allowUserInteraction], animations: {
1239
1251
  target.view.frame.origin.x = end
1240
1252
  current.view.frame.origin.x = completed ? -direction * width : 0
1241
1253
  }, completion: { _ in
@@ -1449,6 +1461,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1449
1461
  for (key, other) in stacks { other.view.isHidden = key != id }
1450
1462
  nav.view.frame = view.bounds
1451
1463
  view.bringSubviewToFront(nav.view)
1464
+ prepareAdjacentNativeTabs()
1452
1465
  if let slot = nav.topViewController as? CraftHybridWebSlot, slot.isViewLoaded {
1453
1466
  adoptWeb(into: slot)
1454
1467
  } else if !(nav.topViewController is CraftHybridWebSlot) {
@@ -1456,6 +1469,31 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1456
1469
  }
1457
1470
  }
1458
1471
 
1472
+ /// Prepare one neighbour after the first frame, never inside the finger's
1473
+ /// tracking run-loop mode. A first swipe then uses a laid-out native root
1474
+ /// rather than compiling, measuring and fetching at gesture start.
1475
+ private func prepareAdjacentNativeTabs() {
1476
+ let owner = currentTab
1477
+ let timer = Timer(timeInterval: 0.15, repeats: false) { [weak self] _ in
1478
+ guard let self, self.currentTab == owner, self.activeTabSwipeTarget == nil,
1479
+ self.view.window != nil,
1480
+ let index = self.hybrid.tabIds.firstIndex(of: owner) else { return }
1481
+ let tabs = self.hybrid.tabIds
1482
+ for neighbour in [index + 1, index - 1] where tabs.indices.contains(neighbour) {
1483
+ let id = tabs[neighbour]
1484
+ guard self.stacks[id] == nil, self.hybrid.routes.match(id) != nil else { continue }
1485
+ let nav = self.makeStack(tab: id, rootPath: id)
1486
+ nav.view.isHidden = true
1487
+ nav.view.layoutIfNeeded()
1488
+ (nav.topViewController as? CraftNativeScreenController)?.screenVisibilityChanged(false)
1489
+ (self.visibleStack?.topViewController as? CraftNativeScreenController)?.screenVisibilityChanged(true)
1490
+ self.prepareAdjacentNativeTabs()
1491
+ break
1492
+ }
1493
+ }
1494
+ RunLoop.main.add(timer, forMode: .default)
1495
+ }
1496
+
1459
1497
  private func cancelPendingTab() {
1460
1498
  pendingTab?.deadline.cancel()
1461
1499
  pendingTab = nil
@@ -1436,6 +1436,16 @@ enum CraftSnapshots {
1436
1436
  private static let lock = NSLock()
1437
1437
  /// Writes not on disk yet, so a read right after a write sees it.
1438
1438
  private static var pending: [String: String?] = [:]
1439
+ private static var revisions: [String: UInt64] = [:]
1440
+ private static var revision: UInt64 = 0
1441
+ // Screens read shared headers many times while evaluating bindings. Keep
1442
+ // settled JSON in memory instead of reopening the same file on the UI thread.
1443
+ private static let cache: NSCache<NSString, NSString> = {
1444
+ let cache = NSCache<NSString, NSString>()
1445
+ cache.countLimit = 32
1446
+ cache.totalCostLimit = 8 * 1024 * 1024
1447
+ return cache
1448
+ }()
1439
1449
 
1440
1450
  static var directory: URL? {
1441
1451
  FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first?
@@ -1458,11 +1468,13 @@ enum CraftSnapshots {
1458
1468
  static func read(_ name: String) -> String? {
1459
1469
  guard let url = url(for: name) else { return nil }
1460
1470
  lock.lock()
1461
- let waiting = pending[name]
1462
- lock.unlock()
1463
- if let waiting { return waiting }
1471
+ defer { lock.unlock() }
1472
+ if let waiting = pending[name] { return waiting }
1473
+ if let cached = cache.object(forKey: name as NSString) { return cached as String }
1464
1474
  guard let data = try? Data(contentsOf: url) else { return nil }
1465
- return String(decoding: data, as: UTF8.self)
1475
+ let json = String(decoding: data, as: UTF8.self)
1476
+ cache.setObject(json as NSString, forKey: name as NSString, cost: data.count)
1477
+ return json
1466
1478
  }
1467
1479
 
1468
1480
  /// Replaces the snapshot with JSON text (nil deletes it) off the main
@@ -1477,7 +1489,11 @@ enum CraftSnapshots {
1477
1489
  return
1478
1490
  }
1479
1491
  lock.lock()
1492
+ revision &+= 1
1493
+ let mine = revision
1494
+ revisions[name] = mine
1480
1495
  pending[name] = .some(json)
1496
+ cache.removeObject(forKey: name as NSString)
1481
1497
  lock.unlock()
1482
1498
  queue.async {
1483
1499
  var ok = true
@@ -1492,7 +1508,13 @@ enum CraftSnapshots {
1492
1508
  ok = false
1493
1509
  }
1494
1510
  lock.lock()
1495
- if let current = pending[name], current == json { pending.removeValue(forKey: name) }
1511
+ if revisions[name] == mine {
1512
+ revisions.removeValue(forKey: name)
1513
+ pending.removeValue(forKey: name)
1514
+ if ok, let json {
1515
+ cache.setObject(json as NSString, forKey: name as NSString, cost: json.utf8.count)
1516
+ }
1517
+ }
1496
1518
  lock.unlock()
1497
1519
  DispatchQueue.main.async {
1498
1520
  if ok { NotificationCenter.default.post(name: changed, object: nil, userInfo: ["name": name]) }
@@ -4224,7 +4224,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4224
4224
  screenRequestCancellers.removeAll()
4225
4225
  }
4226
4226
 
4227
+ private var appliedTraitDefaults: String?
4228
+
4227
4229
  private func refreshTraitDefaults() {
4230
+ // Tab navigation is not a theme change. Reapplying the entire tree
4231
+ // discards every measurement just as an interactive transition starts.
4232
+ let traits = "\(colorScheme):\(traitCollection.preferredContentSizeCategory.rawValue):\(UIAccessibility.isReduceTransparencyEnabled)"
4233
+ guard traits != appliedTraitDefaults else { return }
4234
+ appliedTraitDefaults = traits
4228
4235
  view.backgroundColor = screenBackgroundColor ?? config.resolvedBackgroundColor
4229
4236
  applyNavigationBarAppearance()
4230
4237
  if let document = mutationDocument.node("root") {
@@ -20,6 +20,8 @@
20
20
  <string>{{VERSION}}</string>
21
21
  <key>CFBundleVersion</key>
22
22
  <string>{{BUILD_NUMBER}}</string>
23
+ <key>CADisableMinimumFrameDurationOnPhone</key>
24
+ <true/>
23
25
  <key>LSRequiresIPhoneOS</key>
24
26
  <true/>
25
27
  <key>ITSAppUsesNonExemptEncryption</key>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.226",
3
+ "version": "0.0.228",
4
4
  "type": "module",
5
5
  "description": "Build desktop apps with web languages - TypeScript SDK for Craft",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",