craft-native 0.0.201 → 0.0.203

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.
@@ -972,9 +972,26 @@ final class CraftHybridWebSlot: UIViewController {
972
972
  /// page's stack is all the page's.
973
973
  var depth: Int?
974
974
  let isTabRoot: Bool
975
- /// The page has not reached `path` yet: the entry shows its background,
976
- /// not the page it is leaving, until it has.
975
+ /// The page has not reached `path` yet: retain its last rendered content
976
+ /// until the replacement is ready.
977
977
  var awaitingPage = false
978
+ /// A slow page keeps the last rendered content until its replacement is
979
+ /// ready. This belongs to the incoming slot, beyond the push animation.
980
+ private(set) var waitingPageSnapshot: UIView?
981
+
982
+ func keepPageVisible(_ snapshot: UIView) {
983
+ waitingPageSnapshot?.removeFromSuperview()
984
+ snapshot.frame = view.bounds
985
+ snapshot.autoresizingMask = [.flexibleWidth, .flexibleHeight]
986
+ snapshot.accessibilityElementsHidden = true
987
+ view.addSubview(snapshot)
988
+ waitingPageSnapshot = snapshot
989
+ }
990
+
991
+ func pageBecameVisible() {
992
+ waitingPageSnapshot?.removeFromSuperview()
993
+ waitingPageSnapshot = nil
994
+ }
978
995
 
979
996
  init(path: String, depth: Int?, isTabRoot: Bool) {
980
997
  self.path = path
@@ -1023,6 +1040,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1023
1040
  private var tabSwipeRecognizers: [UIGestureRecognizer] = []
1024
1041
  private var activeTabSwipeTarget: String?
1025
1042
  private var tabSwipeSettling = false
1043
+ private var tabSwipeGeneration: UInt = 0
1026
1044
  /// The edge the incoming tab started from for the current gesture. Keep
1027
1045
  /// this separate from the live frame: deriving it after the drag begins
1028
1046
  /// makes the target jump when the translation crosses the midpoint.
@@ -1195,10 +1213,13 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1195
1213
  let end = completed ? 0 : direction * width
1196
1214
  let settlingDuration = completed ? 0.22 : 0.18
1197
1215
  let duration = CraftChrome.motion(.easeOut(duration: settlingDuration)) == nil ? 0 : settlingDuration
1216
+ let generation = tabSwipeGeneration
1198
1217
  UIView.animate(withDuration: duration, delay: 0, options: [.curveEaseOut, .beginFromCurrentState], animations: {
1199
1218
  target.view.frame.origin.x = end
1200
1219
  current.view.frame.origin.x = completed ? -direction * width : 0
1201
1220
  }, completion: { _ in
1221
+ guard self.tabSwipeGeneration == generation,
1222
+ self.activeTabSwipeTarget == targetId else { return }
1202
1223
  if completed {
1203
1224
  current.view.isHidden = true
1204
1225
  current.view.frame = self.view.bounds
@@ -1216,6 +1237,29 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1216
1237
  })
1217
1238
  }
1218
1239
 
1240
+ /// A newer navigation action wins immediately, including a tap back on
1241
+ /// the outgoing tab. Invalidate the old animation before removing it:
1242
+ /// UIKit may deliver its completion while the replacement is starting.
1243
+ private func interruptTabSwipe(restoringSelection: Bool) {
1244
+ guard let targetId = activeTabSwipeTarget else { return }
1245
+ tabSwipeGeneration &+= 1
1246
+ activeTabSwipeTarget = nil
1247
+ tabSwipeSettling = false
1248
+ for nav in [visibleStack, stacks[targetId]].compactMap({ $0 }) {
1249
+ nav.view.layer.removeAllAnimations()
1250
+ nav.view.frame = view.bounds
1251
+ nav.view.isHidden = nav !== visibleStack
1252
+ }
1253
+ // Cancel the current touch stream, so its later moved/ended events
1254
+ // cannot claim another tab after the explicit navigation.
1255
+ for recognizer in tabSwipeRecognizers {
1256
+ recognizer.isEnabled = false
1257
+ recognizer.isEnabled = true
1258
+ }
1259
+ CraftChrome.shared.cancelTabSwipe(restoringSelection: restoringSelection)
1260
+ if visibleStack?.topViewController is CraftNativeScreenController { parkWeb() }
1261
+ }
1262
+
1219
1263
  private func horizontalScrollAt(_ point: CGPoint, in root: UIView) -> Bool {
1220
1264
  let local = root.convert(point, from: view)
1221
1265
  guard root.bounds.contains(local), let hit = root.hitTest(local, with: nil) else { return false }
@@ -1304,6 +1348,10 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1304
1348
  snapshot.autoresizingMask = [.flexibleWidth, .flexibleHeight]
1305
1349
  source.view.addSubview(snapshot)
1306
1350
  webTransitionSnapshots.append(snapshot)
1351
+ if slot.awaitingPage,
1352
+ let waiting = webHost.view.snapshotView(afterScreenUpdates: false) {
1353
+ slot.keepPageVisible(waiting)
1354
+ }
1307
1355
  }
1308
1356
 
1309
1357
  private var visibleStack: UINavigationController? { stacks[currentTab] }
@@ -1365,6 +1413,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1365
1413
  }
1366
1414
 
1367
1415
  private func show(tab id: String, rootPath: String? = nil) {
1416
+ interruptTabSwipe(restoringSelection: false)
1368
1417
  cancelPendingTab()
1369
1418
  let nav = stacks[id] ?? makeStack(tab: id, rootPath: rootPath ?? (id.isEmpty ? hybrid.startPath : id))
1370
1419
  // A stack is hidden and shown rather than moved, so UIKit tells its
@@ -1395,6 +1444,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1395
1444
  attachWeb()
1396
1445
  moveWeb(to: slot, in: slot.view)
1397
1446
  webHost.view.alpha = slot.awaitingPage ? 0 : 1
1447
+ if !slot.awaitingPage { slot.pageBecameVisible() }
1398
1448
  hybrid.setPageBase(slot.isTabRoot ? nil : slot.depth)
1399
1449
  }
1400
1450
 
@@ -1448,6 +1498,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1448
1498
  /// it), a tab's root, or a new one pushed.
1449
1499
  func showNative(path raw: String, replace: Bool, fromLink: Bool = false) {
1450
1500
  guard let match = hybrid.routes.match(raw) else { return }
1501
+ interruptTabSwipe(restoringSelection: true)
1451
1502
  let tabs = hybrid.tabIds
1452
1503
  if let tab = tabs.first(where: { CraftHybridRoutes.normalize($0) == match.path }) {
1453
1504
  if tab != currentTab {
@@ -1483,6 +1534,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1483
1534
  /// slide, then the entry slides in over the screen that opened it.
1484
1535
  private func pushWeb(_ path: String) {
1485
1536
  guard hybrid.hasWeb, !openingWeb, let nav = visibleStack else { return }
1537
+ interruptTabSwipe(restoringSelection: true)
1486
1538
  openingWeb = true
1487
1539
  hybrid.navigatePage(path) { [weak self, weak nav] depth in
1488
1540
  guard let self else { return }
@@ -1504,7 +1556,10 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1504
1556
  private func reveal(_ slot: CraftHybridWebSlot) {
1505
1557
  slot.awaitingPage = false
1506
1558
  guard let webHost, webHost.view.superview === slot.view else { return }
1507
- UIView.animate(withDuration: 0.15) { webHost.view.alpha = 1 }
1559
+ // Commit the ready page and remove its placeholder in the same frame.
1560
+ // Fading it up from zero exposed the slot's empty background again.
1561
+ webHost.view.alpha = 1
1562
+ slot.pageBecameVisible()
1508
1563
  }
1509
1564
 
1510
1565
  /// Back from the web entry on top, as the page's Back at that entry asks.
@@ -1557,7 +1612,11 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1557
1612
  // MARK: Tabs
1558
1613
 
1559
1614
  func tabTapped(_ id: String, reselect: Bool) -> Bool {
1560
- if reselect || id == currentTab {
1615
+ let interruptedSwipe = activeTabSwipeTarget != nil
1616
+ interruptTabSwipe(restoringSelection: false)
1617
+ // The incoming tab is only a preview until the swipe commits, so a
1618
+ // tap on its already-lit indicator is a selection, not a reselect.
1619
+ if (reselect && !interruptedSwipe) || id == currentTab {
1561
1620
  guard let nav = stacks[id] else { return true }
1562
1621
  if nav.viewControllers.count > 1 {
1563
1622
  nav.popToRootViewController(animated: true)
@@ -555,11 +555,13 @@ enum CraftNativeMedia {
555
555
  let staging = root.appendingPathComponent(UUID().uuidString + ".part")
556
556
  defer { try? fm.removeItem(at: staging) }
557
557
  do {
558
- let size = (try location.resourceValues(forKeys: [.fileSizeKey])).fileSize ?? 0
558
+ // URL resource values can retain a previous size when a retry
559
+ // reuses a temporary location. Validate the current file itself.
560
+ let size = (try fm.attributesOfItem(atPath: location.path)[.size] as? NSNumber)?.int64Value ?? 0
559
561
  // URLSession normally rejects a truncated transfer; also guard this boundary.
560
562
  let expected = response.expectedContentLength
561
563
  let encoded = response.value(forHTTPHeaderField: "Content-Encoding")
562
- if size <= 0 || (encoded == nil && expected >= 0 && Int64(size) != expected) {
564
+ if size <= 0 || (encoded == nil && expected >= 0 && size != expected) {
563
565
  return .failure(CraftNativeActionError(code: "NETWORK_ERROR", message: "media.keep received an incomplete file"))
564
566
  }
565
567
  var folder = root
@@ -1,11 +1,40 @@
1
1
  import UIKit
2
2
 
3
3
  private final class CraftNativeFlatListLayout: UICollectionViewFlowLayout {
4
+ private var preparedExtent: CGFloat?
5
+
6
+ override func prepare() {
7
+ // Content-size queries can prepare layout before layoutSubviews runs.
8
+ (collectionView as? CraftNativeFlatList)?.updateItemEstimate()
9
+ super.prepare()
10
+ preparedExtent = collectionView.map { extent(of: $0.bounds) }
11
+ }
12
+
13
+ private func extent(of bounds: CGRect) -> CGFloat {
14
+ scrollDirection == .vertical ? bounds.width : bounds.height
15
+ }
16
+
4
17
  override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
5
- guard let current = collectionView?.bounds else { return true }
6
- return scrollDirection == .vertical
7
- ? abs(current.width - newBounds.width) > .ulpOfOne
8
- : abs(current.height - newBounds.height) > .ulpOfOne
18
+ guard let preparedExtent else { return true }
19
+ return abs(preparedExtent - extent(of: newBounds)) > .ulpOfOne
20
+ }
21
+
22
+ override func invalidationContext(forBoundsChange newBounds: CGRect) -> UICollectionViewLayoutInvalidationContext {
23
+ let context = super.invalidationContext(forBoundsChange: newBounds)
24
+ if shouldInvalidateLayout(forBoundsChange: newBounds), let flow = context as? UICollectionViewFlowLayoutInvalidationContext {
25
+ flow.invalidateFlowLayoutDelegateMetrics = true
26
+ flow.invalidateFlowLayoutAttributes = true
27
+ // Self-sized item dimensions are cached separately from flow
28
+ // metrics. Clear them before a narrower viewport validates old
29
+ // widths, while keeping the cells and their rendered state.
30
+ if let collectionView {
31
+ let paths = (0..<collectionView.numberOfSections).flatMap { section in
32
+ (0..<collectionView.numberOfItems(inSection: section)).map { IndexPath(item: $0, section: section) }
33
+ }
34
+ flow.invalidateItems(at: paths)
35
+ }
36
+ }
37
+ return context
9
38
  }
10
39
  }
11
40
 
@@ -190,9 +219,30 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
190
219
  /// room again as they are.
191
220
  func fittingHeight(width: CGFloat) -> CGFloat {
192
221
  guard !isScrollEnabled else { return 0 }
193
- if abs(bounds.width - width) > 0.5 { frame.size.width = width }
194
- layoutIfNeeded()
195
- let content = flowLayout.collectionViewContentSize.height
222
+ // Flex measurement proposes several widths before committing a frame.
223
+ // Mutating the collection here lets self-sizing cache a provisional
224
+ // width and can reenter parent measurement through contentSize.didSet.
225
+ // Read the committed measurement, or estimate until layout measures
226
+ // rows at their actual width and onFittingHeightChanged asks for room.
227
+ let content: CGFloat
228
+ if abs(bounds.width - width) <= 0.5, contentSize.height > 0 {
229
+ content = contentSize.height
230
+ } else if flowLayout.scrollDirection == .horizontal {
231
+ content = items.isEmpty ? 0 : 44
232
+ } else {
233
+ var rows = 0
234
+ var pending = 0
235
+ for item in items {
236
+ if item.isChrome {
237
+ rows += (pending + columns - 1) / columns + 1
238
+ pending = 0
239
+ } else {
240
+ pending += 1
241
+ }
242
+ }
243
+ rows += (pending + columns - 1) / columns
244
+ content = CGFloat(rows) * 44 + CGFloat(max(0, rows - 1)) * flowLayout.minimumLineSpacing
245
+ }
196
246
  return max(0, ceil(content + contentInset.top + contentInset.bottom))
197
247
  }
198
248
 
@@ -208,13 +258,17 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
208
258
  return max(1, (width - spacing) / CGFloat(count))
209
259
  }
210
260
 
211
- override func layoutSubviews() {
261
+ fileprivate func updateItemEstimate() {
212
262
  let estimate = flowLayout.scrollDirection == .horizontal
213
263
  ? CGSize(width: 44, height: itemExtent(isChrome: true))
214
264
  : CGSize(width: itemExtent(isChrome: false), height: 44)
215
265
  if flowLayout.estimatedItemSize != estimate, bounds.width > 0 {
216
266
  flowLayout.estimatedItemSize = estimate
217
267
  }
268
+ }
269
+
270
+ override func layoutSubviews() {
271
+ updateItemEstimate()
218
272
  super.layoutSubviews()
219
273
  craftApplyCornerRadius()
220
274
  }
@@ -4650,16 +4650,22 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4650
4650
  }
4651
4651
  /// The scroll view the screen starts with, when nothing sits above it:
4652
4652
  /// the root's first child, its first, and so on, none padded on top.
4653
- func leading(_ node: RenderedNode) -> UIScrollView? {
4654
- if let scroll = node.view as? CraftNativeScrollView { return scroll.isVertical && scroll.isScrollEnabled ? scroll : nil }
4653
+ func leading(_ node: RenderedNode) -> RenderedNode? {
4654
+ if let scroll = node.view as? CraftNativeScrollView { return scroll.isVertical && scroll.isScrollEnabled ? node : nil }
4655
4655
  if let list = node.view as? CraftNativeFlatList {
4656
- return list.isScrollEnabled && (list.collectionViewLayout as? UICollectionViewFlowLayout)?.scrollDirection != .horizontal ? list : nil
4656
+ return list.isScrollEnabled && (list.collectionViewLayout as? UICollectionViewFlowLayout)?.scrollDirection != .horizontal ? node : nil
4657
4657
  }
4658
4658
  guard paddingInsets(node.style).top == 0,
4659
4659
  let first = node.children.first(where: { $0.type != "Modal" && !$0.view.isHidden }) else { return nil }
4660
4660
  return leading(first)
4661
4661
  }
4662
4662
  let scroll = find(renderedRoot)
4663
+ let leadingScroll = leading(renderedRoot)
4664
+ // Only the screen's leading scroller draws beneath floating chrome.
4665
+ // A bounded list inside a form must clip its rows to its viewport.
4666
+ if let leadingScroll, leadingScroll.style["overflow"] as? String != "hidden" {
4667
+ leadingScroll.view.clipsToBounds = false
4668
+ }
4663
4669
  // Under a large title the screen runs edge to edge, as UIKit's own
4664
4670
  // screens do: the scroll view starts under the bar and insets its
4665
4671
  // content by it, so the title collapses and pull to refresh draws
@@ -4667,7 +4673,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4667
4673
  // view: its content scrolls up under the bar behind iOS's scroll edge
4668
4674
  // effect, rather than over the bar's back button. Other screens stay
4669
4675
  // inside the safe area.
4670
- let edgeToEdge = scroll != nil && (prefersLargeTitle == true || scroll === leading(renderedRoot))
4676
+ let edgeToEdge = scroll != nil && (prefersLargeTitle == true || scroll === leadingScroll?.view)
4671
4677
  let fullEdgeToEdge = rootIsSafeAreaView
4672
4678
  let topEdgeToEdge = fullEdgeToEdge || edgeToEdge
4673
4679
  rootTopToSafeArea?.isActive = !topEdgeToEdge
@@ -5801,6 +5807,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5801
5807
  case "sectionlist": return "SectionList"
5802
5808
  case "flatlist": return "FlatList"
5803
5809
  case "svg": return "Svg"
5810
+ case "g": return "G"
5811
+ case "svgtext", "svg-text": return "SvgText"
5804
5812
  case "touchableopacity": return "TouchableOpacity"
5805
5813
  case "touchablehighlight": return "TouchableHighlight"
5806
5814
  case "pressable": return "Pressable"
@@ -5848,7 +5856,20 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5848
5856
  stack.removeArrangedSubview(spacer)
5849
5857
  spacer.removeFromSuperview()
5850
5858
  }
5851
- let nodes = children.compactMap { $0 as? [String: Any] }
5859
+ // STX permits text directly inside containers (including HTML links).
5860
+ // Give it a native label, inheriting typography without duplicating
5861
+ // the parent's padding, dimensions or flex layout.
5862
+ let typography = style.filter { key, _ in
5863
+ key == "color" || key == "letterSpacing" || key == "lineHeight"
5864
+ || key.hasPrefix("font") || key.hasPrefix("text")
5865
+ }
5866
+ let nodes = children.compactMap { child -> [String: Any]? in
5867
+ if let node = child as? [String: Any] { return node }
5868
+ if let text = child as? String {
5869
+ return ["type": "Text", "style": typography, "children": [text]]
5870
+ }
5871
+ return nil
5872
+ }
5852
5873
  let keys = nodes.compactMap { explicitKey($0, props: $0["props"] as? [String: Any] ?? [:]) }
5853
5874
  let counts = Dictionary(keys.map { ($0, 1) }, uniquingKeysWith: +)
5854
5875
  let old = Dictionary(uniqueKeysWithValues: parent.children.map { ($0.identity, $0) })
@@ -6157,10 +6178,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6157
6178
  }
6158
6179
 
6159
6180
  /// Views that take their own touches whatever handlers the screen gives
6160
- /// them: a scroll view, a modal, and a video's system controls (with
6181
+ /// them: native controls, a scroll view, a modal, and a video's controls (with
6161
6182
  /// interaction off, its play, scrubber and full screen never answered).
6162
6183
  private func ownsTouches(_ view: UIView) -> Bool {
6163
- view is UIScrollView || view is CraftNativeModalView || view is CraftNativeVideoView
6184
+ view is UIControl || view is UIScrollView || view is CraftNativeModalView || view is CraftNativeVideoView
6164
6185
  }
6165
6186
 
6166
6187
  private func updatePressHandler(_ handler: String?, for view: UIView) {
@@ -6172,7 +6193,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6172
6193
  let recognizer = UITapGestureRecognizer(target: self, action: #selector(viewPressed(_:)))
6173
6194
  tapRecognizers[id] = recognizer
6174
6195
  view.addGestureRecognizer(recognizer)
6175
- } else if handler == nil, let recognizer = tapRecognizers.removeValue(forKey: id) {
6196
+ } else if !actionable, let recognizer = tapRecognizers.removeValue(forKey: id) {
6176
6197
  view.removeGestureRecognizer(recognizer)
6177
6198
  }
6178
6199
  // A container keeps receiving touches without a handler of its own,
@@ -6420,11 +6441,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6420
6441
  )
6421
6442
  // A label paints its background inside its own bounds, so a rounded
6422
6443
  // chip of text clips to its corners.
6423
- // A scroll view does not clip either: its content runs on under the
6424
- // floating tab bar and the home indicator, as iOS 26's own screens'
6425
- // does. At the top, a screen that starts with one runs edge to edge
6426
- // (trackContentScrollView), so nothing scrolls over the bar.
6427
- view.clipsToBounds = style["overflow"] as? String == "hidden"
6444
+ // Native scrollers clip their viewport unless explicitly made
6445
+ // visible. trackContentScrollView opens only the leading scroller
6446
+ // beneath the screen's floating chrome.
6447
+ let overflow = style["overflow"] as? String
6448
+ view.clipsToBounds = overflow == "hidden"
6449
+ || (view is UIScrollView && overflow != "visible")
6428
6450
  || (view is UILabel && (view.craftRequestedCornerRadius ?? 0) > 0)
6429
6451
  view.layer.masksToBounds = view.clipsToBounds
6430
6452
  view.craftLayoutStyle = CraftNativeLayoutStyle(style)
@@ -6809,7 +6831,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6809
6831
  } else if type != "TextInput" {
6810
6832
  view.accessibilityValue = nil
6811
6833
  }
6812
- let role = props["accessibilityRole"] as? String
6834
+ let hasLinkTarget = ["href", "to"].contains { !(props[$0] as? String ?? "").isEmpty }
6835
+ let linkedControl = ["Link", "TouchableOpacity", "TouchableHighlight", "Pressable"].contains(type) && hasLinkTarget
6836
+ let role = (props["accessibilityRole"] as? String) ?? (linkedControl ? "link" : nil)
6813
6837
  // A scroll view, a list or a group of controls holds what VoiceOver
6814
6838
  // reads: its label names the group, as a tab strip or a pager's
6815
6839
  // name, and never stands in for its content. As an element of its
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.201",
3
+ "version": "0.0.203",
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>",