craft-native 0.0.227 → 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.
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.227";
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.227";
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.227";
60
+ var version = "0.0.228";
61
61
 
62
62
  // src/sdk-version.ts
63
63
  var SDK_VERSION = version;
@@ -627,6 +627,7 @@ final class CraftChrome: ObservableObject {
627
627
  @Published var tabs: [Tab] = []
628
628
  @Published var tabAccessory: Tab?
629
629
  @Published var selected: String?
630
+ @Published private(set) var tabSwipePosition: CGFloat?
630
631
  @Published var tabBarVisible = false
631
632
  @Published var splashVisible = true
632
633
  @Published var tint: UIColor?
@@ -640,6 +641,7 @@ final class CraftChrome: ObservableObject {
640
641
  private var pendingHide: DispatchWorkItem?
641
642
  private var splashDeadline: DispatchWorkItem?
642
643
  private var tabSwipePrevious: String?
644
+ private var tabSwipeTarget: String?
643
645
  /// The keyboard is up and covers the tab bar, so the page need not leave
644
646
  /// room for it: the web view ends at the keyboard (CraftHybrid's webHost).
645
647
  private var keyboardUp = false
@@ -821,12 +823,34 @@ final class CraftChrome: ObservableObject {
821
823
  // A page's pending selection report may disagree with the native
822
824
  // stack. Cancellation belongs to the stack the finger left.
823
825
  tabSwipePrevious = previous
824
- 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) }
825
847
  }
826
848
 
827
849
  func cancelTabSwipe(restoringSelection: Bool = true) {
828
850
  guard let previous = tabSwipePrevious else { return }
829
851
  tabSwipePrevious = nil
852
+ tabSwipeTarget = nil
853
+ withAnimation(nil) { tabSwipePosition = nil }
830
854
  if restoringSelection {
831
855
  withAnimation(nil) { selected = previous }
832
856
  }
@@ -834,6 +858,8 @@ final class CraftChrome: ObservableObject {
834
858
 
835
859
  func commitTabSwipe(to id: String) {
836
860
  tabSwipePrevious = nil
861
+ tabSwipeTarget = nil
862
+ withAnimation(nil) { tabSwipePosition = nil }
837
863
  guard tabs.contains(where: { $0.id == id }) else { return }
838
864
  withAnimation(nil) { selected = id }
839
865
  UISelectionFeedbackGenerator().selectionChanged()
@@ -934,7 +960,6 @@ final class CraftChromeRelay: NSObject, WKScriptMessageHandler {
934
960
  /// The floating tab bar: Liquid Glass on iOS 26 and later, a material capsule before.
935
961
  struct CraftTabBarView: View {
936
962
  @ObservedObject private var chrome = CraftChrome.shared
937
- @Namespace private var selection
938
963
  @Environment(\.accessibilityReduceMotion) private var reduceMotion
939
964
  @Environment(\.scenePhase) private var scenePhase
940
965
  // Dynamic Type, relative to the styles UIKit's own bar uses. The bar is
@@ -1064,13 +1089,6 @@ struct CraftTabBarView: View {
1064
1089
  }
1065
1090
  .foregroundStyle(isSelected ? selectedColor : Color.primary)
1066
1091
  .frame(maxWidth: .infinity, minHeight: 54)
1067
- .background {
1068
- if isSelected {
1069
- Capsule()
1070
- .fill(Color.primary.opacity(0.09))
1071
- .matchedGeometryEffect(id: "selection", in: selection)
1072
- }
1073
- }
1074
1092
  .contentShape(Capsule())
1075
1093
  }
1076
1094
  .buttonStyle(.plain)
@@ -1083,6 +1101,20 @@ struct CraftTabBarView: View {
1083
1101
  .accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton)
1084
1102
  }
1085
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
+ }
1086
1118
  }
1087
1119
 
1088
1120
  private var selectedColor: Color {
@@ -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.227",
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>",