@craft-native/ios 0.0.500 → 0.0.502

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.500",
3
+ "version": "0.0.502",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -214,31 +214,34 @@ private func craftNativePickerValue(_ value: Any?) -> String? {
214
214
  return nil
215
215
  }
216
216
 
217
- private func craftNativeGridColumnCount(_ value: Any?) -> Int {
218
- if let number = craftNativeNumber(value) { return max(1, number.intValue) }
219
- guard let text = value as? String else { return 1 }
220
- let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
221
- if trimmed.hasPrefix("repeat(") {
222
- let digits = trimmed.dropFirst("repeat(".count).prefix { $0.isNumber }
223
- if let count = Int(digits) { return max(1, count) }
217
+ /// The most tracks a grid is given, either way. A count is whatever the
218
+ /// script said (`gridTemplateRows: Number.MAX_VALUE`,
219
+ /// `repeat(4294967296, 1fr)`), and the layout makes an array of rows that
220
+ /// long and hands the shared engine both counts as a `UInt32`: the first ran
221
+ /// the process out of memory and the second trapped. No screen has room to
222
+ /// show more tracks than this.
223
+ let craftNativeGridTrackLimit = 1024
224
+
225
+ func craftNativeGridTrackCount(_ value: Any?) -> Int? {
226
+ if let number = craftNativeNumber(value) {
227
+ return min(craftNativeGridTrackLimit, max(1, craftNativeInt(number.doubleValue)))
224
228
  }
225
- let tracks = trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" || $0 == "\n" })
226
- return max(1, tracks.count)
227
- }
228
-
229
- private func craftNativeGridRowCount(_ value: Any?) -> Int? {
230
- if let number = craftNativeNumber(value) { return max(1, number.intValue) }
231
229
  guard let text = value as? String else { return nil }
232
230
  let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
233
231
  guard !trimmed.isEmpty else { return nil }
234
232
  if trimmed.hasPrefix("repeat(") {
235
- let digits = trimmed.dropFirst("repeat(".count).prefix { $0.isNumber }
236
- if let count = Int(digits) { return max(1, count) }
233
+ let digits = trimmed.dropFirst("repeat(".count).prefix { ("0"..."9").contains($0) }
234
+ // Digits past what an Int holds are still a count, and still too many.
235
+ if !digits.isEmpty { return min(craftNativeGridTrackLimit, max(1, Int(digits) ?? craftNativeGridTrackLimit)) }
237
236
  }
238
237
  let tracks = trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" || $0 == "\n" })
239
- return tracks.isEmpty ? nil : max(1, tracks.count)
238
+ return tracks.isEmpty ? nil : min(craftNativeGridTrackLimit, tracks.count)
240
239
  }
241
240
 
241
+ private func craftNativeGridColumnCount(_ value: Any?) -> Int { craftNativeGridTrackCount(value) ?? 1 }
242
+
243
+ private func craftNativeGridRowCount(_ value: Any?) -> Int? { craftNativeGridTrackCount(value) }
244
+
242
245
  /// Optional bridge to the shared Zig flex rule. Native hosts retain their
243
246
  /// local implementation when no Craft runtime is linked (the normal SDK
244
247
  /// fallback), while runtime-backed apps get one cross-platform calculation.
@@ -4376,7 +4379,11 @@ class CraftNativePositionedImageView: UIView {
4376
4379
  }
4377
4380
  let rawFit = value("objectFit"), rawPosition = value("objectPosition")
4378
4381
  if rawFit != nil || rawPosition != nil {
4379
- let requested = rawFit as? String ?? "fill"
4382
+ // A position does not choose a fit, as in CSS. STX compiles
4383
+ // `object-cover` to `resizeMode: "cover"` and sets no objectFit,
4384
+ // so a cover image given a position was drawn as "fill":
4385
+ // stretched to its box instead of cropped (#532).
4386
+ let requested = rawFit as? String ?? Self.objectFit(resizeMode: style["resizeMode"] as? String ?? props["resizeMode"] as? String)
4380
4387
  fit = ["fill", "contain", "cover", "none", "scale-down"].contains(requested) ? requested : "fill"
4381
4388
  position = Self.objectPosition(rawPosition)
4382
4389
  } else { fit = nil }
@@ -4404,6 +4411,17 @@ class CraftNativePositionedImageView: UIView {
4404
4411
  var usesObjectGeometry: Bool { fit != nil }
4405
4412
  var objectCovers: Bool { fit == "cover" }
4406
4413
 
4414
+ /// The fit a `resizeMode` already asks for, for an image that has a
4415
+ /// position and no `objectFit`; "fill" when it has neither.
4416
+ static func objectFit(resizeMode: String?) -> String {
4417
+ switch resizeMode {
4418
+ case "cover": return "cover"
4419
+ case "contain": return "contain"
4420
+ case "center": return "none"
4421
+ default: return "fill"
4422
+ }
4423
+ }
4424
+
4407
4425
  private static func objectPosition(_ raw: Any?) -> CGPoint {
4408
4426
  guard let text = raw as? String else { return CGPoint(x: 0.5, y: 0.5) }
4409
4427
  var parts = text.lowercased().split(whereSeparator: { $0.isWhitespace }).map(String.init)
@@ -7184,6 +7202,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7184
7202
  // a screen that hid its own.
7185
7203
  navigationController?.setNavigationBarHidden(false, animated: animated)
7186
7204
  }
7205
+ // Options can arrive before the screen is in a stack: whether it has
7206
+ // a screen to go back to is known now.
7207
+ if backButtonSpec != nil { applyLeadingBarItems() }
7187
7208
  applyNavigationBarAppearance()
7188
7209
  // Each screen says whether it wants the tab bar, so one that hid it
7189
7210
  // never leaves it hidden for the next. Only the screen on screen:
@@ -7858,7 +7879,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7858
7879
  /// - `craft.secureStorage.getSync(key)`: the Keychain item the web page's
7859
7880
  /// `craft.secureStorage.set` wrote.
7860
7881
  /// - `craft.navigation.setOptions({ title, titleColor, largeTitle, hidden,
7861
- /// backTitle, tabBarHidden, tabBarHiddenWhenPushed, tabItem, swipeBack,
7882
+ /// backTitle, backButton, tabBarHidden, tabBarHiddenWhenPushed, tabItem, swipeBack,
7862
7883
  /// contentSwipeBack,
7863
7884
  /// tabSwipeEnabled, backgroundColor, navigationBarSolid, leftButtons,
7864
7885
  /// rightButtons, tabAccessory })`; a button's tap is the event
@@ -8474,10 +8495,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8474
8495
  navigationController?.setNavigationBarHidden(hidden, animated: animatesNavigationBarChanges)
8475
8496
  }
8476
8497
  }
8498
+ if options.keys.contains("backButton") {
8499
+ backButtonSpec = options["backButton"] as? [String: Any]
8500
+ backButtonItem = nil
8501
+ }
8477
8502
  if options.keys.contains("leftButtons") {
8478
8503
  let buttons = (options["leftButtons"] as? [[String: Any]]) ?? []
8479
- navigationItem.leftItemsSupplementBackButton = true
8480
- navigationItem.leftBarButtonItems = buttons.compactMap(navigationButton)
8504
+ leftButtonItems = buttons.compactMap(navigationButton)
8505
+ }
8506
+ if options.keys.contains("backButton") || options.keys.contains("leftButtons") {
8507
+ applyLeadingBarItems()
8481
8508
  }
8482
8509
  if options.keys.contains("rightButtons") {
8483
8510
  let buttons = (options["rightButtons"] as? [[String: Any]]) ?? []
@@ -8575,6 +8602,44 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8575
8602
  return actions.isEmpty ? nil : UIMenu(children: actions)
8576
8603
  }
8577
8604
 
8605
+ /// `backButton`: what this screen's Back is drawn with, when it is not
8606
+ /// the chevron (`{ symbol: "xmark", accessibilityLabel: "Close" }` for a
8607
+ /// screen that closes rather than goes back). `leftButtons` are added
8608
+ /// beside Back, so a screen could not put its own symbol in Back's place
8609
+ /// (#527). Nil is the system's chevron.
8610
+ private var backButtonSpec: [String: Any]?
8611
+ private var backButtonItem: UIBarButtonItem?
8612
+ private var leftButtonItems: [UIBarButtonItem] = []
8613
+
8614
+ /// The screen's own Back, when it asked for one and has a screen to go
8615
+ /// back to, then its left buttons. The chevron is hidden only while the
8616
+ /// item that replaces it is in the bar: a symbol UIKit does not have
8617
+ /// leaves the chevron. The back-swipe is the shell's to allow, and is
8618
+ /// not affected.
8619
+ private func applyLeadingBarItems() {
8620
+ // Known to be the first screen of its stack: nothing to go back to.
8621
+ let isRoot = navigationController?.viewControllers.first === self
8622
+ if !isRoot, backButtonItem == nil, let spec = backButtonSpec,
8623
+ let symbol = spec["symbol"] as? String, let image = UIImage(systemName: symbol) {
8624
+ let item = UIBarButtonItem(image: image, style: .plain, target: self, action: #selector(backButtonTapped))
8625
+ item.accessibilityIdentifier = "nav-back"
8626
+ item.accessibilityLabel = (spec["accessibilityLabel"] as? String).flatMap { $0.isEmpty ? nil : $0 }
8627
+ ?? (symbol.hasPrefix("xmark") ? "Close" : "Back")
8628
+ backButtonItem = item
8629
+ }
8630
+ let back = isRoot ? nil : backButtonItem
8631
+ navigationItem.hidesBackButton = back != nil
8632
+ navigationItem.leftItemsSupplementBackButton = true
8633
+ navigationItem.leftBarButtonItems = (back.map { [$0] } ?? []) + leftButtonItems
8634
+ }
8635
+
8636
+ /// What `craft.navigation.back()` does.
8637
+ @objc private func backButtonTapped() {
8638
+ if !isAppScript, navigationController?.topViewController === self {
8639
+ navigationController?.popViewController(animated: true)
8640
+ }
8641
+ }
8642
+
8578
8643
  private func navigationButton(_ spec: [String: Any]) -> UIBarButtonItem? {
8579
8644
  guard let id = spec["id"] as? String, !id.isEmpty else { return nil }
8580
8645
  let action = UIAction { [weak self] _ in
@@ -9060,27 +9125,44 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9060
9125
  /// view is does not come into it: a page of a pager that is off to one
9061
9126
  /// side now is under the actions once it is swiped to. The safe area and
9062
9127
  /// the viewport are left alone: content still scrolls behind the
9063
- /// actions. Internal for XCTest.
9128
+ /// actions.
9129
+ ///
9130
+ /// Two things are not behind the actions, and get none. A scroll view
9131
+ /// that is not inside the shell the actions float in: a sheet is
9132
+ /// presented in front of them, and its scroll view was given the room of
9133
+ /// a capsule it covers, so every sheet could be dragged up into blank
9134
+ /// space. And a scroll view or list inside another that scrolls the same
9135
+ /// way: it is brought clear by the outer one, and where it sits in a page
9136
+ /// not yet scrolled to it says nothing about the glass; a short list
9137
+ /// below the fold was given the height of the page above it. Internal
9138
+ /// for XCTest.
9064
9139
  func insetScrollViewsForFloatingActions() {
9065
9140
  let chrome = CraftChrome.shared
9066
- let actions = chrome.floatingActions.flatMap { frame in
9067
- chrome.floatingActionsHost.flatMap { view.isDescendant(of: $0) ? frame : nil }
9068
- }
9141
+ let host = chrome.floatingActionsHost.flatMap { view.isDescendant(of: $0) ? $0 : nil }
9142
+ let actions = host == nil ? nil : chrome.floatingActions
9069
9143
  func cover(_ scroll: UIScrollView) -> CGFloat {
9070
- guard let actions, let window = scroll.window else { return 0 }
9144
+ guard let actions, let host, let window = scroll.window, scroll.isDescendant(of: host) else { return 0 }
9071
9145
  let frame = scroll.convert(scroll.bounds, to: window)
9146
+ // Nothing past the end of the shell is on the glass.
9147
+ let end = min(frame.maxY, host.convert(host.bounds, to: window).maxY)
9072
9148
  // An edge-to-edge scroller already clears the tab bar through
9073
9149
  // UIKit's safe area. Add only what the actions reach above it.
9074
9150
  let automaticBottom = max(0, scroll.adjustedContentInset.bottom - scroll.contentInset.bottom)
9075
- return max(0, frame.maxY - actions.minY - automaticBottom)
9151
+ return max(0, end - actions.minY - automaticBottom)
9076
9152
  }
9077
- func visit(_ node: RenderedNode?) {
9153
+ func visit(_ node: RenderedNode?, inside: Bool) {
9078
9154
  guard let node else { return }
9079
- if let scroll = node.view as? CraftNativeScrollView, scroll.isVertical { scroll.floatingCover = cover(scroll) }
9080
- else if let list = node.view as? CraftNativeFlatList { list.floatingCover = cover(list) }
9081
- node.children.forEach(visit)
9155
+ var inside = inside
9156
+ if let scroll = node.view as? CraftNativeScrollView, scroll.isVertical {
9157
+ scroll.floatingCover = inside ? 0 : cover(scroll)
9158
+ inside = true
9159
+ } else if let list = node.view as? CraftNativeFlatList {
9160
+ list.floatingCover = inside ? 0 : cover(list)
9161
+ if (list.collectionViewLayout as? UICollectionViewFlowLayout)?.scrollDirection != .horizontal { inside = true }
9162
+ }
9163
+ node.children.forEach { visit($0, inside: inside) }
9082
9164
  }
9083
- visit(renderedRoot)
9165
+ visit(renderedRoot, inside: false)
9084
9166
  floatingClearanceApplied = actions != nil
9085
9167
  }
9086
9168