@craft-native/ios 0.0.301 → 0.0.319
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/README.md +18 -0
- package/dist/index.js +6 -1
- package/dist/testing.d.ts +6 -0
- package/package.json +1 -1
- package/templates/CraftApp.swift +44 -6
- package/templates/CraftHybrid.swift +6 -0
- package/templates/CraftNativeActions.swift +1 -0
- package/templates/CraftNativeScreen.swift +119 -8
- package/templates/testing/DriverTests.swift.template +38 -0
package/README.md
CHANGED
|
@@ -1324,3 +1324,21 @@ and detents; Android sizes its native bottom panel to the selected detent
|
|
|
1324
1324
|
without an extra dependency. Omitting presentationStyle keeps the existing
|
|
1325
1325
|
full-screen overlay. A native sheet's close request still belongs to its
|
|
1326
1326
|
owner, which sets visible to false.
|
|
1327
|
+
|
|
1328
|
+
|
|
1329
|
+
### Controlled native paging and font families
|
|
1330
|
+
|
|
1331
|
+
A horizontal `ScrollView` with `pagingEnabled: true` and a numeric `pageIndex`
|
|
1332
|
+
uses viewport-sized direct children. The host advertises `nativePager` and emits
|
|
1333
|
+
`onPageSelected` with `{ index, count }` after initial layout, count changes and
|
|
1334
|
+
settled user selection. Updates to `pageIndex` select a page; removed pages clamp
|
|
1335
|
+
the index and resizing preserves it. Offscreen pages stay mounted but are hidden
|
|
1336
|
+
from native accessibility. `scrollEnabled: false` locks swiping while retaining
|
|
1337
|
+
controls in the selected page. Without `pageIndex`, scroll content keeps its
|
|
1338
|
+
ordinary intrinsic layout; this is an opt-in contract for a shared Pager.
|
|
1339
|
+
|
|
1340
|
+
Text `fontFamily` accepts a CSS fallback list. Bundled/custom fonts take priority
|
|
1341
|
+
when available. `monospace`/`ui-monospace`, `serif`/`ui-serif` and
|
|
1342
|
+
`sans-serif`/`ui-sans-serif`/`system-ui` select platform fonts, retaining requested
|
|
1343
|
+
weight, italic style and system text scaling. A missing custom face falls through
|
|
1344
|
+
to the next family rather than silently replacing a requested mono font with sans.
|
package/dist/index.js
CHANGED
|
@@ -189,7 +189,12 @@ function serializeSimulatorSteps(steps) {
|
|
|
189
189
|
};
|
|
190
190
|
const line = (step) => {
|
|
191
191
|
let arg = "";
|
|
192
|
-
if ("
|
|
192
|
+
if (step.command === "holdtab") {
|
|
193
|
+
const seconds = step.seconds ?? 6;
|
|
194
|
+
if (!step.text.trim() || step.text.includes("|") || number(seconds) < 2 || seconds > 10)
|
|
195
|
+
throw new Error("holdtab needs a nonempty tab name and 2\u201310 seconds");
|
|
196
|
+
arg = `${step.text} | ${seconds}`;
|
|
197
|
+
} else if ("paths" in step) {
|
|
193
198
|
if (!step.paths.length || step.paths.length > 64)
|
|
194
199
|
throw new Error("Expected 1\u201364 touch paths");
|
|
195
200
|
for (const path of step.paths) {
|
package/dist/testing.d.ts
CHANGED
|
@@ -61,6 +61,12 @@ export type SimulatorStep = {
|
|
|
61
61
|
| {
|
|
62
62
|
command: 'touches';
|
|
63
63
|
paths: TimedTouchPoint[][];
|
|
64
|
+
}
|
|
65
|
+
/** Standard XCTest tab hold; independent screenshots must be captured before release. */
|
|
66
|
+
| {
|
|
67
|
+
command: 'holdtab';
|
|
68
|
+
text: string;
|
|
69
|
+
seconds?: number;
|
|
64
70
|
} | {
|
|
65
71
|
command: 'field';
|
|
66
72
|
index: number;
|
package/package.json
CHANGED
package/templates/CraftApp.swift
CHANGED
|
@@ -766,6 +766,13 @@ final class CraftChrome: ObservableObject {
|
|
|
766
766
|
@Published var tabAccessory: Tab?
|
|
767
767
|
private weak var tabAccessoryOwner: AnyObject?
|
|
768
768
|
@Published var selected: String?
|
|
769
|
+
/// The visible destination may be the accessory, independently of its owning stack.
|
|
770
|
+
@Published var visiblePath: String?
|
|
771
|
+
var accessorySelected: Bool {
|
|
772
|
+
guard let path = visiblePath, let accessory = tabAccessory?.id else { return false }
|
|
773
|
+
return path == accessory || path.hasPrefix(accessory + "/")
|
|
774
|
+
}
|
|
775
|
+
var highlightedTab: String? { accessorySelected ? nil : selected }
|
|
769
776
|
let tabSwipeProgress = CraftTabSwipeProgress()
|
|
770
777
|
private(set) var tabSwipePosition: CGFloat? {
|
|
771
778
|
get { tabSwipeProgress.position }
|
|
@@ -1172,6 +1179,18 @@ final class CraftChromeRelay: NSObject, WKScriptMessageHandler {
|
|
|
1172
1179
|
|
|
1173
1180
|
/// The floating tab bar: Liquid Glass on iOS 26 and later, a material capsule before.
|
|
1174
1181
|
struct CraftTabBarView: View {
|
|
1182
|
+
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
|
1183
|
+
|
|
1184
|
+
var body: some View {
|
|
1185
|
+
// ScaledMetric reads the environment of its owning view. Put the
|
|
1186
|
+
// metrics in a child so this compact-chrome cap reaches them too.
|
|
1187
|
+
CraftTabBarContent(viewerTextSize: dynamicTypeSize)
|
|
1188
|
+
.dynamicTypeSize(...DynamicTypeSize.xxxLarge)
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
private struct CraftTabBarContent: View {
|
|
1193
|
+
let viewerTextSize: DynamicTypeSize
|
|
1175
1194
|
@ObservedObject private var chrome = CraftChrome.shared
|
|
1176
1195
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
1177
1196
|
@Environment(\.scenePhase) private var scenePhase
|
|
@@ -1183,7 +1202,7 @@ struct CraftTabBarView: View {
|
|
|
1183
1202
|
@ScaledMetric(relativeTo: .caption2) private var badgeSize: CGFloat = 10
|
|
1184
1203
|
|
|
1185
1204
|
var body: some View {
|
|
1186
|
-
if
|
|
1205
|
+
if !chrome.tabs.isEmpty {
|
|
1187
1206
|
HStack(spacing: 8) {
|
|
1188
1207
|
bar
|
|
1189
1208
|
if let accessory = chrome.tabAccessory {
|
|
@@ -1191,8 +1210,13 @@ struct CraftTabBarView: View {
|
|
|
1191
1210
|
surface {
|
|
1192
1211
|
Image(systemName: accessory.symbol)
|
|
1193
1212
|
.font(.system(size: iconSize))
|
|
1194
|
-
.foregroundStyle(Color.primary)
|
|
1213
|
+
.foregroundStyle(chrome.accessorySelected ? selectedColor : Color.primary)
|
|
1195
1214
|
.frame(width: 62, height: 62)
|
|
1215
|
+
.background {
|
|
1216
|
+
if chrome.accessorySelected {
|
|
1217
|
+
Circle().fill(selectedColor.opacity(0.14)).padding(4)
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1196
1220
|
}
|
|
1197
1221
|
.contentShape(Rectangle())
|
|
1198
1222
|
.overlay(alignment: .topTrailing) {
|
|
@@ -1207,9 +1231,17 @@ struct CraftTabBarView: View {
|
|
|
1207
1231
|
}
|
|
1208
1232
|
}
|
|
1209
1233
|
.buttonStyle(.plain)
|
|
1234
|
+
.accessibilityShowsLargeContentViewer {
|
|
1235
|
+
Image(systemName: accessory.symbol)
|
|
1236
|
+
Text(accessory.accessibilityLabel ?? accessory.title)
|
|
1237
|
+
}
|
|
1238
|
+
// Keep accessibility intent for the viewer; displayed
|
|
1239
|
+
// sizes come from the bounded metrics owned above.
|
|
1240
|
+
.environment(\.dynamicTypeSize, viewerTextSize)
|
|
1210
1241
|
.accessibilityLabel(accessory.accessibilityLabel ?? accessory.title)
|
|
1211
1242
|
.accessibilityValue(accessory.badge ?? "")
|
|
1212
1243
|
.accessibilityIdentifier("craft-tab-accessory")
|
|
1244
|
+
.accessibilityAddTraits(chrome.accessorySelected ? .isSelected : [])
|
|
1213
1245
|
}
|
|
1214
1246
|
}
|
|
1215
1247
|
.padding(.horizontal, 16)
|
|
@@ -1219,9 +1251,14 @@ struct CraftTabBarView: View {
|
|
|
1219
1251
|
.onAppear { report(proxy) }
|
|
1220
1252
|
.onChange(of: proxy.frame(in: .global)) { _ in report(proxy) }
|
|
1221
1253
|
})
|
|
1222
|
-
|
|
1254
|
+
// Keep the view mounted: a disappearing conditional inside a
|
|
1255
|
+
// child does not reliably animate in the UIKit navigation transaction.
|
|
1256
|
+
.offset(y: chrome.tabBarVisible || reduceMotion ? 0 : 120)
|
|
1257
|
+
.opacity(chrome.tabBarVisible ? 1 : 0)
|
|
1258
|
+
.allowsHitTesting(chrome.tabBarVisible)
|
|
1259
|
+
.accessibilityHidden(!chrome.tabBarVisible)
|
|
1260
|
+
.animation(.easeInOut(duration: reduceMotion ? 0.12 : 0.24), value: chrome.tabBarVisible)
|
|
1223
1261
|
.ignoresSafeArea(.keyboard)
|
|
1224
|
-
.dynamicTypeSize(...DynamicTypeSize.xxxLarge)
|
|
1225
1262
|
}
|
|
1226
1263
|
}
|
|
1227
1264
|
|
|
@@ -1260,7 +1297,7 @@ struct CraftTabBarView: View {
|
|
|
1260
1297
|
private var items: some View {
|
|
1261
1298
|
HStack(spacing: 0) {
|
|
1262
1299
|
ForEach(chrome.tabs) { tab in
|
|
1263
|
-
let isSelected = tab.id == chrome.
|
|
1300
|
+
let isSelected = tab.id == chrome.highlightedTab
|
|
1264
1301
|
Button { chrome.tap(tab) } label: {
|
|
1265
1302
|
VStack(spacing: 3) {
|
|
1266
1303
|
ZStack(alignment: .topTrailing) {
|
|
@@ -1305,6 +1342,7 @@ struct CraftTabBarView: View {
|
|
|
1305
1342
|
Image(systemName: symbol(tab.symbol, selected: isSelected))
|
|
1306
1343
|
Text(tab.displayTitle)
|
|
1307
1344
|
}
|
|
1345
|
+
.environment(\.dynamicTypeSize, viewerTextSize)
|
|
1308
1346
|
.accessibilityIdentifier("craft-tab-\(tab.id)")
|
|
1309
1347
|
.accessibilityLabel(tab.accessibilityLabel ?? tab.badge.map { "\(tab.displayTitle), \($0)" } ?? tab.displayTitle)
|
|
1310
1348
|
.accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton)
|
|
@@ -1312,7 +1350,7 @@ struct CraftTabBarView: View {
|
|
|
1312
1350
|
}
|
|
1313
1351
|
.background {
|
|
1314
1352
|
CraftTabSelectionIndicator(
|
|
1315
|
-
selected: chrome.tabs.firstIndex(where: { $0.id == chrome.
|
|
1353
|
+
selected: chrome.tabs.firstIndex(where: { $0.id == chrome.highlightedTab }),
|
|
1316
1354
|
count: chrome.tabs.count,
|
|
1317
1355
|
cornerRadius: chrome.tabSelectionCornerRadius
|
|
1318
1356
|
)
|
|
@@ -1664,6 +1664,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1664
1664
|
(nav.topViewController as? CraftNativeScreenController)?.screenVisibilityChanged(true)
|
|
1665
1665
|
}
|
|
1666
1666
|
currentTab = id
|
|
1667
|
+
if let top = nav.topViewController { CraftChrome.shared.visiblePath = nativePaths[ObjectIdentifier(top)] }
|
|
1667
1668
|
for (key, other) in stacks { other.view.isHidden = key != id }
|
|
1668
1669
|
nav.view.frame = view.bounds
|
|
1669
1670
|
view.bringSubviewToFront(nav.view)
|
|
@@ -2042,6 +2043,9 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
2042
2043
|
func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
|
|
2043
2044
|
lastNavigation = CACurrentMediaTime()
|
|
2044
2045
|
if animated { CraftFramePacing.shared.begin("navigation") }
|
|
2046
|
+
if navigationController === visibleStack {
|
|
2047
|
+
CraftChrome.shared.visiblePath = nativePaths[ObjectIdentifier(viewController)]
|
|
2048
|
+
}
|
|
2045
2049
|
// The page draws its own header, and a tab's root screen its own
|
|
2046
2050
|
// title unless it asked for the bar; a pushed native screen gets the
|
|
2047
2051
|
// bar and its Back.
|
|
@@ -2084,6 +2088,8 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
2084
2088
|
hybrid.pageReset(to: rootPath)
|
|
2085
2089
|
}
|
|
2086
2090
|
guard navigationController === visibleStack else { return }
|
|
2091
|
+
// Also restore selection after a cancelled interactive pop.
|
|
2092
|
+
CraftChrome.shared.visiblePath = nativePaths[ObjectIdentifier(viewController)]
|
|
2087
2093
|
if let slot = viewController as? CraftHybridWebSlot { adoptWeb(into: slot) } else { parkWeb() }
|
|
2088
2094
|
}
|
|
2089
2095
|
|
|
@@ -43,6 +43,7 @@ enum CraftNativeActions {
|
|
|
43
43
|
// `speech`, `audio` (background sound) and `eventStream` (a fetch read
|
|
44
44
|
// as it arrives) need nothing the app has to declare.
|
|
45
45
|
var values = ["device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "dialog", "speech", "audio", "eventStream", "linking", "badge", "animation", "viewFocus", "viewMeasure", "viewReparent", "viewNonModalPresentation", "nativePlatformControls"]
|
|
46
|
+
values.append("nativePager")
|
|
46
47
|
values.append("storageCompareAndSet")
|
|
47
48
|
values.append("storageSetIfAbsent")
|
|
48
49
|
values.append("viewScroll")
|
|
@@ -2744,6 +2744,7 @@ final class CraftNativeCelebration {
|
|
|
2744
2744
|
}
|
|
2745
2745
|
|
|
2746
2746
|
private class CraftNativeFlowView: UIStackView {
|
|
2747
|
+
var pageViewport: CGSize? { didSet { if pageViewport != oldValue { invalidateMeasurements() } } }
|
|
2747
2748
|
var celebration: CraftNativeCelebration?
|
|
2748
2749
|
func configureCelebration(_ raw: Any?) {
|
|
2749
2750
|
if raw != nil && celebration == nil { celebration = CraftNativeCelebration(view: self) }
|
|
@@ -2966,6 +2967,7 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
2966
2967
|
}
|
|
2967
2968
|
|
|
2968
2969
|
func measure(width: CGFloat?, height: CGFloat?, maxWidth: CGFloat, maxHeight: CGFloat) -> CGSize {
|
|
2970
|
+
if let page = pageViewport { return CGSize(width: page.width * CGFloat(arrangedSubviews.filter { !$0.isHidden }.count), height: page.height) }
|
|
2969
2971
|
let key = MeasureKey(width: width, height: height, maxWidth: maxWidth, maxHeight: maxHeight)
|
|
2970
2972
|
if let cached = measurements[key] { return cached }
|
|
2971
2973
|
let size = layout(width: width, height: height, maxWidth: maxWidth, maxHeight: maxHeight, place: false).size
|
|
@@ -2977,6 +2979,12 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
2977
2979
|
super.layoutSubviews()
|
|
2978
2980
|
craftApplyCornerRadius()
|
|
2979
2981
|
celebration?.layout()
|
|
2982
|
+
if let page = pageViewport {
|
|
2983
|
+
for (index, child) in arrangedSubviews.filter({ !$0.isHidden }).enumerated() {
|
|
2984
|
+
child.frame = CGRect(x: CGFloat(index) * page.width, y: 0, width: page.width, height: page.height)
|
|
2985
|
+
}
|
|
2986
|
+
return
|
|
2987
|
+
}
|
|
2980
2988
|
let scale = window?.screen.scale ?? traitCollection.displayScale
|
|
2981
2989
|
let result = layout(width: bounds.width, height: bounds.height, maxWidth: bounds.width, maxHeight: bounds.height, place: true)
|
|
2982
2990
|
var moved = false
|
|
@@ -4344,6 +4352,46 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
|
|
|
4344
4352
|
private var laidOutSize: CGSize = .zero
|
|
4345
4353
|
private let pullToRefresh = UIRefreshControl()
|
|
4346
4354
|
private var refreshHandler: (() -> Void)?
|
|
4355
|
+
private var authoredPageIndex: Int?
|
|
4356
|
+
private var pendingPageIndex: Int?
|
|
4357
|
+
private var currentPageIndex = 0
|
|
4358
|
+
private var pageAnimating = false
|
|
4359
|
+
private var reportedPage: (Int, Int)?
|
|
4360
|
+
private var pageHandler: ((Int, Int) -> Void)?
|
|
4361
|
+
|
|
4362
|
+
/// Opt-in viewport-sized pages. Ordinary horizontal scroll strips retain
|
|
4363
|
+
/// their intrinsic widths; only a controlled pageIndex enables this mode.
|
|
4364
|
+
func configurePager(_ raw: Any?, onSelect: ((Int, Int) -> Void)?) {
|
|
4365
|
+
let next = craftNativeNumber(raw).flatMap { number -> Int? in
|
|
4366
|
+
let value = number.doubleValue
|
|
4367
|
+
return value.isFinite ? Int(min(Double(Int.max / 2), max(0, value))) : nil
|
|
4368
|
+
}
|
|
4369
|
+
if next != authoredPageIndex { pendingPageIndex = next; contentNeedsLayout = true }
|
|
4370
|
+
authoredPageIndex = next
|
|
4371
|
+
pageHandler = onSelect
|
|
4372
|
+
if next == nil { contentStack.pageViewport = nil; reportedPage = nil; pageAnimating = false }
|
|
4373
|
+
setNeedsLayout()
|
|
4374
|
+
}
|
|
4375
|
+
|
|
4376
|
+
func beginPageDrag() { pageAnimating = false }
|
|
4377
|
+
|
|
4378
|
+
func finishPageMotion() {
|
|
4379
|
+
pageAnimating = false
|
|
4380
|
+
DispatchQueue.main.async { [weak self] in self?.settlePage() }
|
|
4381
|
+
}
|
|
4382
|
+
|
|
4383
|
+
func settlePage() {
|
|
4384
|
+
guard authoredPageIndex != nil, bounds.width > 0, !pageAnimating,
|
|
4385
|
+
!isTracking, !isDragging, !isDecelerating else { return }
|
|
4386
|
+
let pages = contentStack.arrangedSubviews.filter { !$0.isHidden }
|
|
4387
|
+
let index = min(max(0, Int((contentOffset.x / bounds.width).rounded())), max(0, pages.count - 1))
|
|
4388
|
+
currentPageIndex = index
|
|
4389
|
+
for (i, child) in pages.enumerated() { child.accessibilityElementsHidden = i != index }
|
|
4390
|
+
if reportedPage?.0 != index || reportedPage?.1 != pages.count {
|
|
4391
|
+
reportedPage = (index, pages.count)
|
|
4392
|
+
pageHandler?(index, pages.count)
|
|
4393
|
+
}
|
|
4394
|
+
}
|
|
4347
4395
|
|
|
4348
4396
|
override init(frame: CGRect) {
|
|
4349
4397
|
super.init(frame: frame)
|
|
@@ -4384,6 +4432,11 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
|
|
|
4384
4432
|
/// The size of the content laid out across the visible width (or, sideways,
|
|
4385
4433
|
/// height), which is also the scroll view's own size when nothing fixes it.
|
|
4386
4434
|
func contentFitting(width: CGFloat?, height: CGFloat?) -> CGSize {
|
|
4435
|
+
if authoredPageIndex != nil && !isVertical {
|
|
4436
|
+
let pageWidth = max(0, width ?? bounds.width)
|
|
4437
|
+
let pageHeight = max(0, height ?? bounds.height)
|
|
4438
|
+
return CGSize(width: pageWidth * CGFloat(contentStack.arrangedSubviews.filter { !$0.isHidden }.count), height: pageHeight)
|
|
4439
|
+
}
|
|
4387
4440
|
if isVertical {
|
|
4388
4441
|
let contentWidth = max(0, (width ?? bounds.width) - adjustedContentInset.left - adjustedContentInset.right)
|
|
4389
4442
|
return contentStack.measure(width: contentWidth, height: nil, maxWidth: contentWidth, maxHeight: CraftNativeFlexLayout.unbounded)
|
|
@@ -4406,7 +4459,9 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
|
|
|
4406
4459
|
craftApplyCornerRadius()
|
|
4407
4460
|
if contentNeedsLayout || laidOutSize != bounds.size {
|
|
4408
4461
|
contentNeedsLayout = false
|
|
4462
|
+
let resized = laidOutSize != bounds.size
|
|
4409
4463
|
laidOutSize = bounds.size
|
|
4464
|
+
if authoredPageIndex != nil && !isVertical { contentStack.pageViewport = bounds.size }
|
|
4410
4465
|
var size = contentFitting(width: bounds.width, height: bounds.height)
|
|
4411
4466
|
if isVertical {
|
|
4412
4467
|
size.width = max(0, bounds.width - adjustedContentInset.left - adjustedContentInset.right)
|
|
@@ -4416,12 +4471,31 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
|
|
|
4416
4471
|
let frame = CGRect(origin: .zero, size: size)
|
|
4417
4472
|
if contentStack.frame != frame { contentStack.frame = frame }
|
|
4418
4473
|
if contentSize != size { contentSize = size }
|
|
4474
|
+
if authoredPageIndex != nil && !isVertical {
|
|
4475
|
+
contentStack.layoutIfNeeded()
|
|
4476
|
+
let count = contentStack.arrangedSubviews.filter { !$0.isHidden }.count
|
|
4477
|
+
let index = min(max(0, pendingPageIndex ?? currentPageIndex), max(0, count - 1))
|
|
4478
|
+
let target = CGPoint(x: CGFloat(index) * bounds.width, y: 0)
|
|
4479
|
+
let moves = pendingPageIndex != nil || resized || index != currentPageIndex
|
|
4480
|
+
let animated = moves && target != contentOffset && !resized && !UIAccessibility.isReduceMotionEnabled
|
|
4481
|
+
if moves {
|
|
4482
|
+
pageAnimating = animated
|
|
4483
|
+
setContentOffset(target, animated: animated)
|
|
4484
|
+
}
|
|
4485
|
+
pendingPageIndex = nil
|
|
4486
|
+
currentPageIndex = index
|
|
4487
|
+
if !animated && !isTracking && !isDragging && !isDecelerating { settlePage() }
|
|
4488
|
+
}
|
|
4419
4489
|
}
|
|
4420
4490
|
// A scroll view lays out on every frame of a scroll: the sticky
|
|
4421
4491
|
// headers follow it here, over content placed by this same pass.
|
|
4422
4492
|
if !stickyIndices.isEmpty || pendingScrollTarget != nil { contentStack.layoutIfNeeded() }
|
|
4423
4493
|
updateStickyHeaders()
|
|
4424
4494
|
scrollToPendingTarget()
|
|
4495
|
+
// A nonanimated target changes the offset synchronously inside this
|
|
4496
|
+
// layout pass. Rebase the headers against that actual viewport, not
|
|
4497
|
+
// the viewport from before the target was applied.
|
|
4498
|
+
updateStickyHeaders()
|
|
4425
4499
|
}
|
|
4426
4500
|
|
|
4427
4501
|
// MARK: Sticky headers
|
|
@@ -4444,7 +4518,7 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
|
|
|
4444
4518
|
view.center.y - view.bounds.height / 2
|
|
4445
4519
|
}
|
|
4446
4520
|
|
|
4447
|
-
|
|
4521
|
+
fileprivate func updateStickyHeaders() {
|
|
4448
4522
|
let children = contentStack.arrangedSubviews
|
|
4449
4523
|
let headers = isVertical ? stickyIndices.compactMap { $0 < children.count ? children[$0] : nil }.filter { !$0.isHidden } : []
|
|
4450
4524
|
for view in stickyViews where !headers.contains(where: { $0 === view }) {
|
|
@@ -4617,11 +4691,13 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
|
|
|
4617
4691
|
craftScrollGesture(gestureRecognizer, runsWith: otherGestureRecognizer)
|
|
4618
4692
|
}
|
|
4619
4693
|
|
|
4620
|
-
func setAxis(_ axis: NSLayoutConstraint.Axis) {
|
|
4694
|
+
func setAxis(_ axis: NSLayoutConstraint.Axis, resetBounce: Bool = true) {
|
|
4621
4695
|
if contentStack.axis != axis { setContentNeedsLayout() }
|
|
4622
4696
|
contentStack.axis = axis
|
|
4623
|
-
|
|
4624
|
-
|
|
4697
|
+
if resetBounce {
|
|
4698
|
+
alwaysBounceVertical = axis == .vertical
|
|
4699
|
+
alwaysBounceHorizontal = axis == .horizontal
|
|
4700
|
+
}
|
|
4625
4701
|
}
|
|
4626
4702
|
}
|
|
4627
4703
|
|
|
@@ -8461,6 +8537,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8461
8537
|
scroll.isScrollEnabled = props["scrollEnabled"] as? Bool != false
|
|
8462
8538
|
scroll.bounces = props["bounces"] as? Bool ?? true
|
|
8463
8539
|
scroll.isPagingEnabled = props["pagingEnabled"] as? Bool ?? false
|
|
8540
|
+
let pageHandler = nonEmptyHandler(events["onPageSelected"])
|
|
8541
|
+
scroll.configurePager(props["pageIndex"], onSelect: pageHandler.map { handler in
|
|
8542
|
+
{ [weak self] index, count in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["index": index, "count": count]]) }
|
|
8543
|
+
})
|
|
8464
8544
|
scroll.keyboardDismissMode = keyboardDismissMode(props["keyboardDismissMode"])
|
|
8465
8545
|
scroll.setKeyboardShouldPersistTaps(props["keyboardShouldPersistTaps"])
|
|
8466
8546
|
scroll.showsVerticalScrollIndicator = props["showsVerticalScrollIndicator"] as? Bool != false
|
|
@@ -8492,6 +8572,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8492
8572
|
contentStyle.merge(override) { _, next in next }
|
|
8493
8573
|
}
|
|
8494
8574
|
configureStack(scroll.contentStack, style: contentStyle)
|
|
8575
|
+
// Content-style defaults are vertical; the scroll host's explicit
|
|
8576
|
+
// axis must survive that shared container configuration without
|
|
8577
|
+
// resetting the authored bounce options applied above.
|
|
8578
|
+
scroll.setAxis(direction, resetBounce: false)
|
|
8495
8579
|
reconcileChildren(children.filter {
|
|
8496
8580
|
guard let child = $0 as? [String: Any] else { return true }
|
|
8497
8581
|
return canonicalType(child["type"] as? String ?? "") != "RefreshControl"
|
|
@@ -9712,10 +9796,28 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9712
9796
|
for: size,
|
|
9713
9797
|
compatibleWith: traitCollection
|
|
9714
9798
|
)
|
|
9715
|
-
|
|
9716
|
-
|
|
9799
|
+
let families = (style["fontFamily"] as? String ?? "").split(separator: ",").map {
|
|
9800
|
+
$0.trimmingCharacters(in: .whitespacesAndNewlines).trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))
|
|
9801
|
+
}
|
|
9802
|
+
let selectedFont = families.lazy.compactMap { family -> UIFont? in
|
|
9803
|
+
switch family.lowercased() {
|
|
9804
|
+
case "monospace", "ui-monospace": return .monospacedSystemFont(ofSize: scaledSize, weight: weight)
|
|
9805
|
+
case "serif", "ui-serif":
|
|
9806
|
+
let system = UIFont.systemFont(ofSize: scaledSize, weight: weight)
|
|
9807
|
+
return system.fontDescriptor.withDesign(.serif).map { UIFont(descriptor: $0, size: scaledSize) }
|
|
9808
|
+
case "sans-serif", "ui-sans-serif", "system-ui", "-apple-system": return .systemFont(ofSize: scaledSize, weight: weight)
|
|
9809
|
+
default:
|
|
9810
|
+
guard let custom = UIFont(name: family, size: scaledSize) else { return nil }
|
|
9811
|
+
let weighted = custom.fontDescriptor.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: weight.rawValue]])
|
|
9812
|
+
var traits = custom.fontDescriptor.symbolicTraits
|
|
9813
|
+
if weight >= .semibold { traits.insert(.traitBold) } else { traits.remove(.traitBold) }
|
|
9814
|
+
let descriptor = weighted.withSymbolicTraits(traits) ?? weighted
|
|
9815
|
+
return UIFont(descriptor: descriptor, size: scaledSize)
|
|
9816
|
+
}
|
|
9817
|
+
}.first
|
|
9818
|
+
var font = selectedFont ?? .systemFont(ofSize: scaledSize, weight: weight)
|
|
9717
9819
|
if style["fontStyle"] as? String == "italic",
|
|
9718
|
-
let descriptor = font.fontDescriptor.withSymbolicTraits(.traitItalic) {
|
|
9820
|
+
let descriptor = font.fontDescriptor.withSymbolicTraits(font.fontDescriptor.symbolicTraits.union(.traitItalic)) {
|
|
9719
9821
|
font = UIFont(descriptor: descriptor, size: scaledSize)
|
|
9720
9822
|
}
|
|
9721
9823
|
return font
|
|
@@ -10502,6 +10604,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
10502
10604
|
}
|
|
10503
10605
|
|
|
10504
10606
|
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
|
10607
|
+
// Sticky positioning is a native scroll behavior and must also run
|
|
10608
|
+
// without a JavaScript onScroll subscription, including animations.
|
|
10609
|
+
(scrollView as? CraftNativeScrollView)?.updateStickyHeaders()
|
|
10505
10610
|
guard let handler = scrollHandlers[ObjectIdentifier(scrollView)] else { return }
|
|
10506
10611
|
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
|
|
10507
10612
|
}
|
|
@@ -10510,12 +10615,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
10510
10615
|
|
|
10511
10616
|
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
|
|
10512
10617
|
CraftFramePacing.shared.begin(pacingName)
|
|
10618
|
+
(scrollView as? CraftNativeScrollView)?.beginPageDrag()
|
|
10513
10619
|
guard let handler = scrollBeginHandlers[ObjectIdentifier(scrollView)] else { return }
|
|
10514
10620
|
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
|
|
10515
10621
|
}
|
|
10516
10622
|
|
|
10517
10623
|
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
|
|
10518
|
-
if !decelerate { CraftFramePacing.shared.end(pacingName) }
|
|
10624
|
+
if !decelerate { CraftFramePacing.shared.end(pacingName); (scrollView as? CraftNativeScrollView)?.finishPageMotion() }
|
|
10519
10625
|
guard let handler = scrollEndHandlers[ObjectIdentifier(scrollView)] else { return }
|
|
10520
10626
|
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
|
|
10521
10627
|
}
|
|
@@ -10527,10 +10633,15 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
10527
10633
|
|
|
10528
10634
|
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
|
10529
10635
|
CraftFramePacing.shared.end(pacingName)
|
|
10636
|
+
(scrollView as? CraftNativeScrollView)?.finishPageMotion()
|
|
10530
10637
|
guard let handler = scrollMomentumEndHandlers[ObjectIdentifier(scrollView)] else { return }
|
|
10531
10638
|
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
|
|
10532
10639
|
}
|
|
10533
10640
|
|
|
10641
|
+
func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
|
|
10642
|
+
(scrollView as? CraftNativeScrollView)?.finishPageMotion()
|
|
10643
|
+
}
|
|
10644
|
+
|
|
10534
10645
|
private func scrollEvent(_ scrollView: UIScrollView) -> [String: Any] {
|
|
10535
10646
|
[
|
|
10536
10647
|
"contentOffset": ["x": scrollView.contentOffset.x, "y": scrollView.contentOffset.y],
|
|
@@ -22,6 +22,7 @@ import Darwin
|
|
|
22
22
|
/// tapif <text> tap it when it is on screen, else carry on (the big timer's "Show the controls")
|
|
23
23
|
/// tapid <identifier>
|
|
24
24
|
/// longpressid <identifier> holds a hittable native element to open its edit menu
|
|
25
|
+
/// holdtab <name> [| secs] standard XCTest hold (2–10s, default 6), BEGIN/END timing receipt
|
|
25
26
|
/// touches <JSON> timed normalized touch paths in one native dispatch
|
|
26
27
|
/// tapxy <x> <y> normalised 0…1 of the screen
|
|
27
28
|
/// tapexact <label> a button whose label is exactly this
|
|
@@ -381,6 +382,43 @@ final class DriverTests: XCTestCase {
|
|
|
381
382
|
}
|
|
382
383
|
target.press(forDuration: 1.2)
|
|
383
384
|
note("longpressid \(arg): ok")
|
|
385
|
+
case "holdtab":
|
|
386
|
+
let parts = arg.components(separatedBy: "|").map { $0.trimmingCharacters(in: .whitespaces) }
|
|
387
|
+
guard parts.count <= 2, let name = parts.first, !name.isEmpty,
|
|
388
|
+
let duration = parts.count == 2 ? Double(parts[1]) : 6,
|
|
389
|
+
duration.isFinite, (2...10).contains(duration) else {
|
|
390
|
+
check(false, "holdtab needs a tab name and 2–10 seconds")
|
|
391
|
+
return
|
|
392
|
+
}
|
|
393
|
+
guard let button = poll(10, { tabButton(name) }) else {
|
|
394
|
+
check(false, "holdtab cannot find tab \(name)")
|
|
395
|
+
return
|
|
396
|
+
}
|
|
397
|
+
let frame = button.frame
|
|
398
|
+
let coordinate = button.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
|
|
399
|
+
let startedAt = Date().timeIntervalSince1970
|
|
400
|
+
let startedUptime = ProcessInfo.processInfo.systemUptime
|
|
401
|
+
// BEGIN arms the independent screenshot watcher. XCTest still
|
|
402
|
+
// performs its normal idle checks before synthesizing the press;
|
|
403
|
+
// the watcher must wait for that synthesis, not infer touch-down.
|
|
404
|
+
note("BEGIN holdtab \(name) | \(duration) | \(startedAt)")
|
|
405
|
+
fflush(nil)
|
|
406
|
+
coordinate.press(forDuration: duration)
|
|
407
|
+
let returnedAt = Date().timeIntervalSince1970
|
|
408
|
+
let returnedUptime = ProcessInfo.processInfo.systemUptime
|
|
409
|
+
note("END holdtab \(name) | \(returnedAt)")
|
|
410
|
+
let receipt: [String: Any] = [
|
|
411
|
+
"tab": name, "requestedSeconds": duration,
|
|
412
|
+
"input": "XCUICoordinate.press(forDuration:)",
|
|
413
|
+
"startedAt": startedAt, "returnedAt": returnedAt,
|
|
414
|
+
"startedUptime": startedUptime, "returnedUptime": returnedUptime,
|
|
415
|
+
"frame": ["x": frame.minX, "y": frame.minY, "width": frame.width, "height": frame.height],
|
|
416
|
+
"note": "Call times include XCTest idle checks; during-hold screenshots require an independent synthesis-timed capture.",
|
|
417
|
+
]
|
|
418
|
+
if let data = try? JSONSerialization.data(withJSONObject: receipt, options: [.sortedKeys]) {
|
|
419
|
+
post("/save?name=holdtab-\(Int(startedUptime * 1000)).json", data)
|
|
420
|
+
}
|
|
421
|
+
check(true, "holdtab \(name): standard \(duration)s press returned")
|
|
384
422
|
case "touches":
|
|
385
423
|
do {
|
|
386
424
|
guard let data = arg.data(using: .utf8),
|