@craft-native/ios 0.0.202 → 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.
- package/README.md +7 -0
- package/dist/cli.js +17 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.js +17 -4
- package/package.json +1 -1
- package/templates/CraftHybrid.swift +28 -3
- package/templates/CraftNativeFiles.swift +4 -2
- package/templates/CraftNativeFlatList.swift +62 -8
- package/templates/CraftNativeScreen.swift +38 -14
package/README.md
CHANGED
|
@@ -129,6 +129,9 @@ authorization or requiring local notifications to be enabled.
|
|
|
129
129
|
Keyboard frame changes update the native controller's additional safe area, so
|
|
130
130
|
focused inputs and responsive `onLayout` measurements move with the keyboard.
|
|
131
131
|
|
|
132
|
+
Shared view transforms, z-order, and view/text shadows are applied by UIKit
|
|
133
|
+
with the same style keys as Android; `elevation` remains Android-specific.
|
|
134
|
+
|
|
132
135
|
The opt-in `native` renderer hosts compiled `.stx` screens in JavaScriptCore and renders their `View`, `SafeAreaView`, `Text`, `Button`, `Link`, `Checkbox`, `TextInput`, `Picker`, `DatePicker`, `Switch`, `Slider`, `ActivityIndicator`, `Icon`, `Image`, `ImageBackground`, `Video`, `Map`, `Svg`, `G`, `SvgText`, `ScrollView`, `SectionList`, and recycling `FlatList` nodes as UIKit controls. `Map` uses MapKit for trail polylines and marker taps; its overlays carry flat latitude/longitude `geometry` arrays and can fit the visible region to their bounds. `Svg` is a DOM-free drawing surface; grouped `G` and `SvgText` nodes are painted inside it. `Icon` uses SF Symbols with the shared symbol/name contract and accessibility metadata. `SectionList` uses the same native scroll host for ordinary `.stx` slot children; grouped section data should use `FlatList` until the section-data protocol is available. `FlatList` uses `UICollectionView` with stable keyed rows, incremental diffs, grids, horizontal and inverted layouts, list chrome, and end-reached pagination. Native views support `onLayout` callbacks with point-based frame measurements that refresh after safe-area or orientation changes. Flex items support `flex`, `flexGrow`, `flexShrink`, and numeric `flexBasis` for main-axis space distribution. Native screens also expose the versioned capability bridge: JSON storage and SQLite survive relaunch, lifecycle and deep-link events are scoped to the top route, and local notifications use `UNUserNotificationCenter`. Enable each capability in `craft.config.json`; disabled calls preserve Craft's `CAPABILITY_DISABLED` rejection code and every request has deterministic timeout/cancellation behavior. The default remains `web`; existing iOS apps are unchanged. `Checkbox` supports controlled `checked`/`value`, indeterminate state, and boolean `onValueChange`; `Picker` renders `<select>`/`<option>` values with `onValueChange` or `onChange`; `DatePicker` emits local date/time values plus ISO and millisecond timestamps; `Switch` supports state-aware `trackColor`, `thumbColor`, and iOS `ios_backgroundColor`; `Slider` supports track/thumb tint props and `step` snapping; `Image` reports load lifecycle events and structured failures; `Video` uses an `AVPlayerViewController` attached to the native screen for URI playback, native controls, and route-safe teardown. The portable component, capability, list, style, image-source, event, accessibility, and reconciliation behavior is specified in the [Native STX guide](../../docs/guides/native-stx.md).
|
|
133
136
|
|
|
134
137
|
```bash
|
|
@@ -996,6 +999,10 @@ Options:
|
|
|
996
999
|
-s, --simulator Run on simulator
|
|
997
1000
|
```
|
|
998
1001
|
|
|
1002
|
+
Simulator selection uses the active Xcode's iOS Simulator SDK. Runtimes newer
|
|
1003
|
+
than that SDK are excluded, including already booted devices. Select a matching
|
|
1004
|
+
Xcode or install a compatible runtime when none is available.
|
|
1005
|
+
|
|
999
1006
|
## Requirements
|
|
1000
1007
|
|
|
1001
1008
|
- macOS with Xcode 15+
|
package/dist/cli.js
CHANGED
|
@@ -1123,21 +1123,34 @@ async function open(options) {
|
|
|
1123
1123
|
console.log(`\uD83D\uDE80 Opening ${xcodeproj}...`);
|
|
1124
1124
|
await $`open ${projectPath}`;
|
|
1125
1125
|
}
|
|
1126
|
-
function orderSimulators(devices) {
|
|
1127
|
-
|
|
1126
|
+
function orderSimulators(devices, sdkVersion) {
|
|
1127
|
+
const sdk = sdkVersion?.match(/^(\d+)\.(\d+)/);
|
|
1128
|
+
if (sdkVersion !== undefined && !sdk)
|
|
1129
|
+
throw new Error(`Invalid iOS Simulator SDK version: ${sdkVersion}`);
|
|
1130
|
+
return devices.filter((device) => device.runtime.startsWith("iOS")).filter((device) => {
|
|
1131
|
+
if (!sdk)
|
|
1132
|
+
return true;
|
|
1133
|
+
const runtime = device.runtime.match(/^iOS-(\d+)-(\d+)/);
|
|
1134
|
+
if (!runtime)
|
|
1135
|
+
return false;
|
|
1136
|
+
return Number(runtime[1]) < Number(sdk[1]) || Number(runtime[1]) === Number(sdk[1]) && Number(runtime[2]) <= Number(sdk[2]);
|
|
1137
|
+
}).sort((left, right) => {
|
|
1128
1138
|
const booted = Number(right.state === "Booted") - Number(left.state === "Booted");
|
|
1129
1139
|
if (booted !== 0)
|
|
1130
1140
|
return booted;
|
|
1131
1141
|
const phone = Number(right.name.startsWith("iPhone")) - Number(left.name.startsWith("iPhone"));
|
|
1132
1142
|
if (phone !== 0)
|
|
1133
1143
|
return phone;
|
|
1134
|
-
return right.runtime.localeCompare(left.runtime);
|
|
1144
|
+
return right.runtime.localeCompare(left.runtime, undefined, { numeric: true });
|
|
1135
1145
|
});
|
|
1136
1146
|
}
|
|
1137
1147
|
async function pickSimulator() {
|
|
1138
1148
|
const listed = await $`xcrun simctl list devices available --json`.quiet().nothrow();
|
|
1139
1149
|
if (listed.exitCode !== 0)
|
|
1140
1150
|
return null;
|
|
1151
|
+
const sdk = await $`xcrun --sdk iphonesimulator --show-sdk-version`.quiet().nothrow();
|
|
1152
|
+
if (sdk.exitCode !== 0)
|
|
1153
|
+
throw new Error("Could not determine the selected Xcode iOS Simulator SDK");
|
|
1141
1154
|
let parsed;
|
|
1142
1155
|
try {
|
|
1143
1156
|
parsed = JSON.parse(listed.stdout.toString());
|
|
@@ -1149,7 +1162,7 @@ async function pickSimulator() {
|
|
|
1149
1162
|
for (const device of list)
|
|
1150
1163
|
devices.push({ ...device, runtime: runtime.split(".").pop() ?? runtime });
|
|
1151
1164
|
}
|
|
1152
|
-
return orderSimulators(devices)[0] ?? null;
|
|
1165
|
+
return orderSimulators(devices, sdk.stdout.toString().trim())[0] ?? null;
|
|
1153
1166
|
}
|
|
1154
1167
|
async function bootSimulator(device) {
|
|
1155
1168
|
if (device.state !== "Booted")
|
package/dist/index.d.ts
CHANGED
|
@@ -524,7 +524,7 @@ export interface SimulatorDevice {
|
|
|
524
524
|
* Exported because the choice is worth testing without a simulator present -
|
|
525
525
|
* `pickSimulator` is the policy and this is the data it reads.
|
|
526
526
|
*/
|
|
527
|
-
export declare function orderSimulators(devices: SimulatorDevice[]): SimulatorDevice[];
|
|
527
|
+
export declare function orderSimulators(devices: SimulatorDevice[], sdkVersion?: string): SimulatorDevice[];
|
|
528
528
|
/**
|
|
529
529
|
* The device to run on.
|
|
530
530
|
*
|
package/dist/index.js
CHANGED
|
@@ -1260,21 +1260,34 @@ async function open(options) {
|
|
|
1260
1260
|
console.log(`\uD83D\uDE80 Opening ${xcodeproj}...`);
|
|
1261
1261
|
await $`open ${projectPath}`;
|
|
1262
1262
|
}
|
|
1263
|
-
function orderSimulators(devices) {
|
|
1264
|
-
|
|
1263
|
+
function orderSimulators(devices, sdkVersion) {
|
|
1264
|
+
const sdk = sdkVersion?.match(/^(\d+)\.(\d+)/);
|
|
1265
|
+
if (sdkVersion !== undefined && !sdk)
|
|
1266
|
+
throw new Error(`Invalid iOS Simulator SDK version: ${sdkVersion}`);
|
|
1267
|
+
return devices.filter((device) => device.runtime.startsWith("iOS")).filter((device) => {
|
|
1268
|
+
if (!sdk)
|
|
1269
|
+
return true;
|
|
1270
|
+
const runtime = device.runtime.match(/^iOS-(\d+)-(\d+)/);
|
|
1271
|
+
if (!runtime)
|
|
1272
|
+
return false;
|
|
1273
|
+
return Number(runtime[1]) < Number(sdk[1]) || Number(runtime[1]) === Number(sdk[1]) && Number(runtime[2]) <= Number(sdk[2]);
|
|
1274
|
+
}).sort((left, right) => {
|
|
1265
1275
|
const booted = Number(right.state === "Booted") - Number(left.state === "Booted");
|
|
1266
1276
|
if (booted !== 0)
|
|
1267
1277
|
return booted;
|
|
1268
1278
|
const phone = Number(right.name.startsWith("iPhone")) - Number(left.name.startsWith("iPhone"));
|
|
1269
1279
|
if (phone !== 0)
|
|
1270
1280
|
return phone;
|
|
1271
|
-
return right.runtime.localeCompare(left.runtime);
|
|
1281
|
+
return right.runtime.localeCompare(left.runtime, undefined, { numeric: true });
|
|
1272
1282
|
});
|
|
1273
1283
|
}
|
|
1274
1284
|
async function pickSimulator() {
|
|
1275
1285
|
const listed = await $`xcrun simctl list devices available --json`.quiet().nothrow();
|
|
1276
1286
|
if (listed.exitCode !== 0)
|
|
1277
1287
|
return null;
|
|
1288
|
+
const sdk = await $`xcrun --sdk iphonesimulator --show-sdk-version`.quiet().nothrow();
|
|
1289
|
+
if (sdk.exitCode !== 0)
|
|
1290
|
+
throw new Error("Could not determine the selected Xcode iOS Simulator SDK");
|
|
1278
1291
|
let parsed;
|
|
1279
1292
|
try {
|
|
1280
1293
|
parsed = JSON.parse(listed.stdout.toString());
|
|
@@ -1286,7 +1299,7 @@ async function pickSimulator() {
|
|
|
1286
1299
|
for (const device of list)
|
|
1287
1300
|
devices.push({ ...device, runtime: runtime.split(".").pop() ?? runtime });
|
|
1288
1301
|
}
|
|
1289
|
-
return orderSimulators(devices)[0] ?? null;
|
|
1302
|
+
return orderSimulators(devices, sdk.stdout.toString().trim())[0] ?? null;
|
|
1290
1303
|
}
|
|
1291
1304
|
async function bootSimulator(device) {
|
|
1292
1305
|
if (device.state !== "Booted")
|
package/package.json
CHANGED
|
@@ -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:
|
|
976
|
-
///
|
|
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
|
|
@@ -1331,6 +1348,10 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1331
1348
|
snapshot.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
1332
1349
|
source.view.addSubview(snapshot)
|
|
1333
1350
|
webTransitionSnapshots.append(snapshot)
|
|
1351
|
+
if slot.awaitingPage,
|
|
1352
|
+
let waiting = webHost.view.snapshotView(afterScreenUpdates: false) {
|
|
1353
|
+
slot.keepPageVisible(waiting)
|
|
1354
|
+
}
|
|
1334
1355
|
}
|
|
1335
1356
|
|
|
1336
1357
|
private var visibleStack: UINavigationController? { stacks[currentTab] }
|
|
@@ -1423,6 +1444,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1423
1444
|
attachWeb()
|
|
1424
1445
|
moveWeb(to: slot, in: slot.view)
|
|
1425
1446
|
webHost.view.alpha = slot.awaitingPage ? 0 : 1
|
|
1447
|
+
if !slot.awaitingPage { slot.pageBecameVisible() }
|
|
1426
1448
|
hybrid.setPageBase(slot.isTabRoot ? nil : slot.depth)
|
|
1427
1449
|
}
|
|
1428
1450
|
|
|
@@ -1534,7 +1556,10 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1534
1556
|
private func reveal(_ slot: CraftHybridWebSlot) {
|
|
1535
1557
|
slot.awaitingPage = false
|
|
1536
1558
|
guard let webHost, webHost.view.superview === slot.view else { return }
|
|
1537
|
-
|
|
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()
|
|
1538
1563
|
}
|
|
1539
1564
|
|
|
1540
1565
|
/// Back from the web entry on top, as the page's Back at that entry asks.
|
|
@@ -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
|
-
|
|
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 &&
|
|
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
|
|
6
|
-
return
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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) ->
|
|
4654
|
-
if let scroll = node.view as? CraftNativeScrollView { return scroll.isVertical && scroll.isScrollEnabled ?
|
|
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 ?
|
|
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 ===
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
6424
|
-
//
|
|
6425
|
-
//
|
|
6426
|
-
|
|
6427
|
-
view.clipsToBounds =
|
|
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
|
|
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
|