@craft-native/ios 0.0.207 → 0.0.210
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 -1
- package/package.json +1 -1
- package/templates/CraftApp.swift +48 -8
- package/templates/CraftNativeScreen.swift +41 -6
package/README.md
CHANGED
|
@@ -137,7 +137,13 @@ and linear gradients are applied by UIKit with the same style keys as Android;
|
|
|
137
137
|
`elevation` remains Android-specific. Gradient layers follow the native view's
|
|
138
138
|
bounds and corners through layout changes.
|
|
139
139
|
|
|
140
|
-
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, `strokeWidth` is expressed in logical points, and the camera supports controlled `region`, one-time `initialRegion`, and `fitToBounds`. `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
|
|
140
|
+
The opt-in `native` renderer hosts compiled `.stx` screens in JavaScriptCore and renders their `View`, `SafeAreaView`, `KeyboardAvoidingView`, `StatusBar`, `RefreshControl`, `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. `StatusBar` and `RefreshControl` are screen/scroll utility nodes rather than visible children. `Map` uses MapKit for trail polylines and marker taps; its overlays carry flat latitude/longitude `geometry` arrays, `strokeWidth` is expressed in logical points, and the camera supports controlled `region`, one-time `initialRegion`, and `fitToBounds`. `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 `timestamp` fields; `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. `craft.navigation.setOptions` also accepts a `tabAccessory` destination beside the tab pill; it uses an app-local `path`, `title`, `symbol`, optional `badge`, and `accessibilityLabel`, and `null` clears it. 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).
|
|
141
|
+
|
|
142
|
+
JavaScriptCore has no DOM `window`; Craft aliases `globalThis.window` to
|
|
143
|
+
`globalThis` before installing the native bridge so browser-safe SDK calls such
|
|
144
|
+
as `window.craft.dialog.actionSheet()` reach the native capability host. This
|
|
145
|
+
does not add `document` or DOM rendering; routes that do not opt into `native`
|
|
146
|
+
continue using the WebView renderer.
|
|
141
147
|
|
|
142
148
|
```bash
|
|
143
149
|
craft ios init NativeDemo --renderer native --output ./ios-native
|
package/package.json
CHANGED
package/templates/CraftApp.swift
CHANGED
|
@@ -621,6 +621,7 @@ final class CraftChrome: ObservableObject {
|
|
|
621
621
|
}
|
|
622
622
|
|
|
623
623
|
@Published var tabs: [Tab] = []
|
|
624
|
+
@Published var tabAccessory: Tab?
|
|
624
625
|
@Published var selected: String?
|
|
625
626
|
@Published var tabBarVisible = false
|
|
626
627
|
@Published var splashVisible = true
|
|
@@ -688,6 +689,16 @@ final class CraftChrome: ObservableObject {
|
|
|
688
689
|
tabs[index].badge = (item["badge"] as? String).flatMap { $0.isEmpty ? nil : String($0.prefix(8)) }
|
|
689
690
|
}
|
|
690
691
|
}
|
|
692
|
+
/// A destination beside the tab pill, outside the tab stacks and swipe order.
|
|
693
|
+
func updateTabAccessory(_ item: [String: Any]?) {
|
|
694
|
+
guard let item, let path = item["path"] as? String,
|
|
695
|
+
path.hasPrefix("/"), !path.hasPrefix("//"),
|
|
696
|
+
let title = item["title"] as? String else { tabAccessory = nil; return }
|
|
697
|
+
tabAccessory = Tab(id: path, title: title, symbol: item["symbol"] as? String ?? "message",
|
|
698
|
+
badge: (item["badge"] as? String).flatMap { $0.isEmpty ? nil : String($0.prefix(8)) },
|
|
699
|
+
accessibilityLabel: item["accessibilityLabel"] as? String)
|
|
700
|
+
}
|
|
701
|
+
|
|
691
702
|
func setHiddenByScreen(_ hidden: Bool) {
|
|
692
703
|
guard hiddenByScreen != hidden else { return }
|
|
693
704
|
hiddenByScreen = hidden
|
|
@@ -913,7 +924,33 @@ struct CraftTabBarView: View {
|
|
|
913
924
|
|
|
914
925
|
var body: some View {
|
|
915
926
|
if chrome.tabBarVisible && !chrome.tabs.isEmpty {
|
|
916
|
-
|
|
927
|
+
HStack(spacing: 8) {
|
|
928
|
+
bar
|
|
929
|
+
if let accessory = chrome.tabAccessory {
|
|
930
|
+
Button { CraftHybrid.shared.open(accessory.id) } label: {
|
|
931
|
+
surface {
|
|
932
|
+
Image(systemName: accessory.symbol)
|
|
933
|
+
.font(.system(size: iconSize))
|
|
934
|
+
.foregroundStyle(Color.primary)
|
|
935
|
+
.frame(width: 62, height: 62)
|
|
936
|
+
}
|
|
937
|
+
.overlay(alignment: .topTrailing) {
|
|
938
|
+
if let badge = accessory.badge {
|
|
939
|
+
Text(badge)
|
|
940
|
+
.font(.system(size: badgeSize, weight: .bold))
|
|
941
|
+
.foregroundStyle(.white)
|
|
942
|
+
.padding(.horizontal, 4)
|
|
943
|
+
.frame(minWidth: 18, minHeight: 18)
|
|
944
|
+
.background(Capsule().fill(Color.red))
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
.buttonStyle(.plain)
|
|
949
|
+
.accessibilityLabel(accessory.accessibilityLabel ?? accessory.title)
|
|
950
|
+
.accessibilityValue(accessory.badge ?? "")
|
|
951
|
+
.accessibilityIdentifier("craft-tab-accessory")
|
|
952
|
+
}
|
|
953
|
+
}
|
|
917
954
|
.padding(.horizontal, 16)
|
|
918
955
|
.padding(.bottom, 4)
|
|
919
956
|
.background(GeometryReader { proxy in
|
|
@@ -932,24 +969,27 @@ struct CraftTabBarView: View {
|
|
|
932
969
|
chrome.layoutChanged(max(0, screen - proxy.frame(in: .global).minY))
|
|
933
970
|
}
|
|
934
971
|
|
|
935
|
-
|
|
972
|
+
private var bar: some View {
|
|
973
|
+
surface { items.padding(4) }
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/// Both the tab capsule and its circular accessory use the same material,
|
|
977
|
+
/// outline and shadow. A square capsule forms the accessory's circle.
|
|
978
|
+
@ViewBuilder private func surface<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
|
936
979
|
#if compiler(>=6.2)
|
|
937
980
|
if #available(iOS 26.0, *) {
|
|
938
981
|
GlassEffectContainer {
|
|
939
|
-
|
|
940
|
-
.padding(4)
|
|
982
|
+
content()
|
|
941
983
|
.glassEffect(.regular.interactive(), in: .capsule)
|
|
942
984
|
}
|
|
943
985
|
} else {
|
|
944
|
-
|
|
945
|
-
.padding(4)
|
|
986
|
+
content()
|
|
946
987
|
.background(.ultraThinMaterial, in: Capsule())
|
|
947
988
|
.overlay(Capsule().strokeBorder(Color.white.opacity(0.35), lineWidth: 0.5))
|
|
948
989
|
.shadow(color: .black.opacity(0.12), radius: 18, y: 6)
|
|
949
990
|
}
|
|
950
991
|
#else
|
|
951
|
-
|
|
952
|
-
.padding(4)
|
|
992
|
+
content()
|
|
953
993
|
.background(.ultraThinMaterial, in: Capsule())
|
|
954
994
|
.overlay(Capsule().strokeBorder(Color.white.opacity(0.35), lineWidth: 0.5))
|
|
955
995
|
.shadow(color: .black.opacity(0.12), radius: 18, y: 6)
|
|
@@ -3959,11 +3959,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3959
3959
|
// Each screen says whether it wants the tab bar, so one that hid it
|
|
3960
3960
|
// never leaves it hidden for the next.
|
|
3961
3961
|
CraftChrome.shared.setHiddenByScreen(tabBarHidden)
|
|
3962
|
+
CraftChrome.shared.updateTabAccessory(tabAccessory)
|
|
3962
3963
|
refreshTraitDefaults()
|
|
3963
3964
|
}
|
|
3964
3965
|
|
|
3965
3966
|
override func viewWillDisappear(_ animated: Bool) {
|
|
3966
3967
|
super.viewWillDisappear(animated)
|
|
3968
|
+
// The accessory belongs to the visible native screen. Clear the
|
|
3969
|
+
// shared shell state before a hybrid/web route appears, otherwise a
|
|
3970
|
+
// destination from the outgoing screen remains tappable on top of it.
|
|
3971
|
+
CraftChrome.shared.updateTabAccessory(nil)
|
|
3967
3972
|
// Popped (or its stack dismissed) while the bar was away: it comes
|
|
3968
3973
|
// back for whatever shows next, a web page included.
|
|
3969
3974
|
if tabBarHidden, isMovingFromParent || isBeingDismissed || navigationController?.isBeingDismissed == true {
|
|
@@ -4146,6 +4151,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4146
4151
|
let appVersion = Self.jsonString(info?["CFBundleShortVersionString"] as? String ?? "")
|
|
4147
4152
|
let appBuild = Self.jsonString(info?["CFBundleVersion"] as? String ?? "")
|
|
4148
4153
|
jsContext.evaluateScript("""
|
|
4154
|
+
// JavaScriptCore has no DOM window, but browser-safe SDK bridges
|
|
4155
|
+
// address the host through `window.craft`. Keep native screens
|
|
4156
|
+
// compatible without pretending a document exists.
|
|
4157
|
+
if (typeof globalThis.window === "undefined") { globalThis.window = globalThis; }
|
|
4149
4158
|
globalThis.__stxNativeCallback = null;
|
|
4150
4159
|
globalThis.__stxNativeBridge = {
|
|
4151
4160
|
platform: "ios",
|
|
@@ -4274,7 +4283,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4274
4283
|
/// `craft.secureStorage.set` wrote.
|
|
4275
4284
|
/// - `craft.navigation.setOptions({ title, largeTitle, hidden, backTitle,
|
|
4276
4285
|
/// tabBarHidden, swipeBack, contentSwipeBack, tabSwipeEnabled,
|
|
4277
|
-
/// backgroundColor, navigationBarSolid, rightButtons })`;
|
|
4286
|
+
/// backgroundColor, navigationBarSolid, rightButtons, tabAccessory })`;
|
|
4278
4287
|
/// a right button's tap is the event `navButton`.
|
|
4279
4288
|
///
|
|
4280
4289
|
/// A runtime that assigns `craft.storage` (or the others) afterwards keeps
|
|
@@ -4529,6 +4538,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4529
4538
|
private var statusBarHiddenOverride: Bool?
|
|
4530
4539
|
/// The app's tab bar away while this screen shows (`tabBarHidden`).
|
|
4531
4540
|
private var tabBarHidden = false
|
|
4541
|
+
private var tabAccessory: [String: Any]?
|
|
4532
4542
|
/// Whether a back-swipe may leave this screen at all, the edge's
|
|
4533
4543
|
/// included (`swipeBack: false`): a screen that steps through its own
|
|
4534
4544
|
/// pages with a Back of its own (a first-run tour's questions) would
|
|
@@ -4575,6 +4585,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4575
4585
|
}
|
|
4576
4586
|
// The app script has no screen, so no bar and no tab bar to change.
|
|
4577
4587
|
guard !isAppScript else { return }
|
|
4588
|
+
if options.keys.contains("tabAccessory") {
|
|
4589
|
+
tabAccessory = options["tabAccessory"] as? [String: Any]
|
|
4590
|
+
if navigationController?.topViewController === self, view.window != nil {
|
|
4591
|
+
CraftChrome.shared.updateTabAccessory(tabAccessory)
|
|
4592
|
+
}
|
|
4593
|
+
}
|
|
4578
4594
|
if options.keys.contains("title") {
|
|
4579
4595
|
navigationItem.title = options["title"] as? String
|
|
4580
4596
|
titleFromOptions = true
|
|
@@ -5719,7 +5735,6 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5719
5735
|
? nil : number(props["activeOpacity"]) ?? 0.5
|
|
5720
5736
|
pressable.pressBaseOpacity = number(style["opacity"]) ?? 1
|
|
5721
5737
|
pressable.pressUnderlayColor = type == "TouchableHighlight" && !disabled ? color(props["underlayColor"]) : nil
|
|
5722
|
-
pressable.pressBaseBackgroundColor = pressable.backgroundColor
|
|
5723
5738
|
case "Switch":
|
|
5724
5739
|
let toggle = result as! UISwitch
|
|
5725
5740
|
toggle.isOn = props["value"] as? Bool ?? props["checked"] as? Bool ?? false
|
|
@@ -6677,6 +6692,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6677
6692
|
updateDimension(width, constraint: &node.widthConstraint, anchor: view.widthAnchor)
|
|
6678
6693
|
updateDimension(height, constraint: &node.heightConstraint, anchor: view.heightAnchor)
|
|
6679
6694
|
view.backgroundColor = color(style["backgroundColor"]) ?? .clear
|
|
6695
|
+
// Every flow container can receive press feedback, including ordinary
|
|
6696
|
+
// Views. Capture its current authored fill before feedback cleanup or
|
|
6697
|
+
// release restores it; otherwise avatars and button wrappers go clear.
|
|
6698
|
+
(view as? CraftNativeFlowView)?.pressBaseBackgroundColor = view.backgroundColor
|
|
6680
6699
|
// A field the style dresses itself (`border-0`, a border or a
|
|
6681
6700
|
// background, as a search field inside its own pill is) draws no
|
|
6682
6701
|
// system box of its own; a bare one keeps the rounded rectangle.
|
|
@@ -6928,7 +6947,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6928
6947
|
private func textFont(_ style: [String: Any], default fallback: UIFont) -> UIFont {
|
|
6929
6948
|
let size = number(style["fontSize"]) ?? fallback.pointSize
|
|
6930
6949
|
let weight: UIFont.Weight
|
|
6931
|
-
switch style["fontWeight"]
|
|
6950
|
+
switch craftNativeFontWeight(style["fontWeight"]) {
|
|
6932
6951
|
case "100": weight = .ultraLight
|
|
6933
6952
|
case "200": weight = .thin
|
|
6934
6953
|
case "300": weight = .light
|
|
@@ -6939,11 +6958,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6939
6958
|
case "900": weight = .black
|
|
6940
6959
|
default: weight = .regular
|
|
6941
6960
|
}
|
|
6942
|
-
|
|
6943
|
-
|
|
6961
|
+
// Android's TextView uses SP, so its native text scales with the
|
|
6962
|
+
// user's content-size setting. Apply the same Dynamic Type multiplier
|
|
6963
|
+
// here before assigning the font to labels, buttons, and inputs.
|
|
6964
|
+
let scaledSize = UIFontMetrics(forTextStyle: .body).scaledValue(
|
|
6965
|
+
for: size,
|
|
6966
|
+
compatibleWith: traitCollection
|
|
6967
|
+
)
|
|
6968
|
+
var font = (style["fontFamily"] as? String).flatMap { UIFont(name: $0, size: scaledSize) }
|
|
6969
|
+
?? .systemFont(ofSize: scaledSize, weight: weight)
|
|
6944
6970
|
if style["fontStyle"] as? String == "italic",
|
|
6945
6971
|
let descriptor = font.fontDescriptor.withSymbolicTraits(.traitItalic) {
|
|
6946
|
-
font = UIFont(descriptor: descriptor, size:
|
|
6972
|
+
font = UIFont(descriptor: descriptor, size: scaledSize)
|
|
6947
6973
|
}
|
|
6948
6974
|
return font
|
|
6949
6975
|
}
|
|
@@ -7143,7 +7169,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7143
7169
|
|| controlledVideo
|
|
7144
7170
|
view.isAccessibilityElement = !container && role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Link", "Checkbox", "Image", "ImageBackground", "Video", "TextInput", "Picker", "DatePicker", "Switch", "Slider"].contains(type))
|
|
7145
7171
|
view.accessibilityContainerType = container && (view.accessibilityLabel != nil || controlledVideo) ? .semanticGroup : .none
|
|
7172
|
+
// Keep the interaction trait native controls expose. Rebuilding this
|
|
7173
|
+
// set from the STX role alone otherwise turns a Slider, Picker,
|
|
7174
|
+
// DatePicker, or Switch into a static VoiceOver item whenever the
|
|
7175
|
+
// screen omits redundant accessibility metadata.
|
|
7146
7176
|
var traits: UIAccessibilityTraits = []
|
|
7177
|
+
switch type {
|
|
7178
|
+
case "Slider", "Picker", "DatePicker": traits.insert(.adjustable)
|
|
7179
|
+
case "Switch": traits.insert(.button)
|
|
7180
|
+
default: break
|
|
7181
|
+
}
|
|
7147
7182
|
switch role ?? type.lowercased() {
|
|
7148
7183
|
case "tablist": traits.insert(.tabBar)
|
|
7149
7184
|
case "button", "checkbox": traits.insert(.button)
|