@craft-native/ios 0.0.296 → 0.0.299
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 +20 -1
- package/package.json +1 -1
- package/templates/CraftNativeActions.swift +1 -1
- package/templates/CraftNativeScreen.swift +234 -22
package/README.md
CHANGED
|
@@ -157,7 +157,7 @@ and linear gradients are applied by UIKit with the same style keys as Android;
|
|
|
157
157
|
`elevation` remains Android-specific. Gradient layers follow the native view's
|
|
158
158
|
bounds and corners through layout changes.
|
|
159
159
|
|
|
160
|
-
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).
|
|
160
|
+
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`, `SegmentedControl`, `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).
|
|
161
161
|
|
|
162
162
|
`FlatList` rows are authored as keyed `:for` child rows in native STX; the
|
|
163
163
|
maintained protocol does not serialize `data`/`renderItem` callbacks.
|
|
@@ -1305,3 +1305,22 @@ nativeFonts: {
|
|
|
1305
1305
|
|
|
1306
1306
|
The iOS project registers the files through `UIAppFonts`. Android loads bundled
|
|
1307
1307
|
typefaces from assets. Both work offline; a missing file fails the build.
|
|
1308
|
+
|
|
1309
|
+
### Platform selection, clear fields and sheets
|
|
1310
|
+
|
|
1311
|
+
`SegmentedControl` takes `items: [{value, label, disabled?}]` and a controlled
|
|
1312
|
+
`value`, and emits `onValueChange` with `{value, index}`. iOS uses UIKit's
|
|
1313
|
+
`UISegmentedControl`; Android uses a horizontal native `RadioGroup` with
|
|
1314
|
+
selected segments. Reconciliation does not emit a user selection.
|
|
1315
|
+
|
|
1316
|
+
`TextInput.clearButtonMode` is `never`, `while-editing`, `unless-editing` or
|
|
1317
|
+
`always`. The native clear affordance emits a single text edit carrying
|
|
1318
|
+
`{text: '', clear: true}`; Android also exposes a TalkBack clear action.
|
|
1319
|
+
|
|
1320
|
+
`Modal` accepts `presentationStyle: 'pageSheet' | 'formSheet'` with
|
|
1321
|
+
`sheetDetents: ['medium', 'large']`, `selectedDetent` and `grabberVisible`.
|
|
1322
|
+
iOS presents a real `UISheetPresentationController`, including system pan
|
|
1323
|
+
and detents; Android sizes its native bottom panel to the selected detent
|
|
1324
|
+
without an extra dependency. Omitting presentationStyle keeps the existing
|
|
1325
|
+
full-screen overlay. A native sheet's close request still belongs to its
|
|
1326
|
+
owner, which sets visible to false.
|
package/package.json
CHANGED
|
@@ -42,7 +42,7 @@ enum CraftNativeActions {
|
|
|
42
42
|
static func capabilities(config: CraftConfig) -> [String] {
|
|
43
43
|
// `speech`, `audio` (background sound) and `eventStream` (a fetch read
|
|
44
44
|
// as it arrives) need nothing the app has to declare.
|
|
45
|
-
var values = ["device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "dialog", "speech", "audio", "eventStream", "linking", "badge", "animation", "viewFocus", "viewMeasure", "viewReparent", "viewNonModalPresentation"]
|
|
45
|
+
var values = ["device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "dialog", "speech", "audio", "eventStream", "linking", "badge", "animation", "viewFocus", "viewMeasure", "viewReparent", "viewNonModalPresentation", "nativePlatformControls"]
|
|
46
46
|
values.append("storageCompareAndSet")
|
|
47
47
|
values.append("storageSetIfAbsent")
|
|
48
48
|
values.append("viewScroll")
|
|
@@ -2578,6 +2578,7 @@ extension UIView {
|
|
|
2578
2578
|
var responder: UIResponder? = next
|
|
2579
2579
|
while let current = responder {
|
|
2580
2580
|
if let screen = current as? CraftNativeScreenController { return screen }
|
|
2581
|
+
if let sheet = current as? CraftNativeSheetController { return sheet.screen }
|
|
2581
2582
|
responder = current.next
|
|
2582
2583
|
}
|
|
2583
2584
|
return nil
|
|
@@ -3976,13 +3977,141 @@ private final class CraftNativeImageBackgroundView: UIImageView {
|
|
|
3976
3977
|
}
|
|
3977
3978
|
}
|
|
3978
3979
|
|
|
3979
|
-
private final class
|
|
3980
|
+
private final class CraftNativeSegmentElement: UIAccessibilityElement {
|
|
3981
|
+
weak var control: CraftNativeSegmentedControl?
|
|
3982
|
+
let index: Int
|
|
3983
|
+
init(control: CraftNativeSegmentedControl, index: Int) {
|
|
3984
|
+
self.control = control
|
|
3985
|
+
self.index = index
|
|
3986
|
+
super.init(accessibilityContainer: control)
|
|
3987
|
+
}
|
|
3988
|
+
override func accessibilityActivate() -> Bool {
|
|
3989
|
+
guard let control, control.isEnabled, control.values.indices.contains(index), control.isEnabledForSegment(at: index) else { return false }
|
|
3990
|
+
if control.selectedSegmentIndex != index {
|
|
3991
|
+
control.selectedSegmentIndex = index
|
|
3992
|
+
control.sendActions(for: .valueChanged)
|
|
3993
|
+
}
|
|
3994
|
+
return true
|
|
3995
|
+
}
|
|
3996
|
+
}
|
|
3997
|
+
|
|
3998
|
+
private final class CraftNativeSegmentedControl: UISegmentedControl {
|
|
3999
|
+
var values: [String] = []
|
|
4000
|
+
private var segments: [CraftNativeSegmentElement] = []
|
|
4001
|
+
override var accessibilityElements: [Any]? {
|
|
4002
|
+
get { segments }
|
|
4003
|
+
set { /* The native segments, not the labelled group, own activation. */ }
|
|
4004
|
+
}
|
|
4005
|
+
func configureAccessibility(items: [[String: Any]]) {
|
|
4006
|
+
if segments.count != items.count { segments = items.indices.map { CraftNativeSegmentElement(control: self, index: $0) } }
|
|
4007
|
+
for (index, item) in items.enumerated() {
|
|
4008
|
+
segments[index].accessibilityLabel = item["spoken"] as? String ?? item["label"] as? String ?? values[index]
|
|
4009
|
+
}
|
|
4010
|
+
refreshAccessibilityState()
|
|
4011
|
+
setNeedsLayout()
|
|
4012
|
+
}
|
|
4013
|
+
func refreshAccessibilityState() {
|
|
4014
|
+
for (index, segment) in segments.enumerated() {
|
|
4015
|
+
segment.accessibilityTraits = [.button]
|
|
4016
|
+
if selectedSegmentIndex == index { segment.accessibilityTraits.insert(.selected) }
|
|
4017
|
+
if !isEnabled || !isEnabledForSegment(at: index) { segment.accessibilityTraits.insert(.notEnabled) }
|
|
4018
|
+
}
|
|
4019
|
+
}
|
|
4020
|
+
override func layoutSubviews() {
|
|
4021
|
+
super.layoutSubviews()
|
|
4022
|
+
guard !segments.isEmpty else { return }
|
|
4023
|
+
let width = bounds.width / CGFloat(segments.count)
|
|
4024
|
+
for (index, segment) in segments.enumerated() {
|
|
4025
|
+
let visualIndex = effectiveUserInterfaceLayoutDirection == .rightToLeft ? segments.count - index - 1 : index
|
|
4026
|
+
segment.accessibilityFrameInContainerSpace = CGRect(x: CGFloat(visualIndex) * width, y: 0, width: width, height: bounds.height)
|
|
4027
|
+
}
|
|
4028
|
+
}
|
|
4029
|
+
}
|
|
4030
|
+
|
|
4031
|
+
private final class CraftNativeSheetController: UIViewController {
|
|
4032
|
+
weak var screen: CraftNativeScreenController?
|
|
4033
|
+
weak var content: CraftNativeFlowView?
|
|
4034
|
+
override func viewDidLayoutSubviews() {
|
|
4035
|
+
super.viewDidLayoutSubviews()
|
|
4036
|
+
content?.frame = view.bounds.inset(by: view.safeAreaInsets)
|
|
4037
|
+
}
|
|
4038
|
+
}
|
|
4039
|
+
|
|
4040
|
+
private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate, UISheetPresentationControllerDelegate {
|
|
3980
4041
|
let contentStack = CraftNativeFlowView()
|
|
3981
4042
|
private let blocker = UIView()
|
|
3982
4043
|
private let backdropTap = UITapGestureRecognizer()
|
|
3983
4044
|
private var renderedVisible = false
|
|
3984
4045
|
private var animationGeneration = 0
|
|
3985
4046
|
var onRequestClose: ((String) -> Void)?
|
|
4047
|
+
weak var sheetPresenter: CraftNativeScreenController?
|
|
4048
|
+
var presentationStyle = "overFullScreen"
|
|
4049
|
+
var sheetDetents: [String] = ["medium", "large"]
|
|
4050
|
+
var selectedDetent: String?
|
|
4051
|
+
var grabberVisible = true
|
|
4052
|
+
private var sheetController: CraftNativeSheetController?
|
|
4053
|
+
var isPresentedAsSheet: Bool { sheetController != nil }
|
|
4054
|
+
|
|
4055
|
+
private func configureSheet(_ controller: CraftNativeSheetController) {
|
|
4056
|
+
controller.isModalInPresentation = onRequestClose == nil
|
|
4057
|
+
guard let sheet = controller.sheetPresentationController else { return }
|
|
4058
|
+
let choices = sheetDetents.filter { $0 == "medium" || $0 == "large" }
|
|
4059
|
+
let values = choices.isEmpty ? ["large"] : Array(Set(choices)).sorted { $0 == "medium" && $1 == "large" }
|
|
4060
|
+
sheet.detents = values.map { $0 == "medium" ? .medium() : .large() }
|
|
4061
|
+
sheet.prefersGrabberVisible = grabberVisible
|
|
4062
|
+
if let selectedDetent, values.contains(selectedDetent) {
|
|
4063
|
+
sheet.selectedDetentIdentifier = selectedDetent == "medium" ? .medium : .large
|
|
4064
|
+
}
|
|
4065
|
+
sheet.delegate = self
|
|
4066
|
+
}
|
|
4067
|
+
|
|
4068
|
+
private func restoreSheetContent(_ controller: CraftNativeSheetController) {
|
|
4069
|
+
guard sheetController === controller else { return }
|
|
4070
|
+
contentStack.removeFromSuperview()
|
|
4071
|
+
addSubview(contentStack)
|
|
4072
|
+
sheetController = nil
|
|
4073
|
+
setNeedsLayout()
|
|
4074
|
+
}
|
|
4075
|
+
|
|
4076
|
+
private func setSheetVisible(_ visible: Bool, animated: Bool) {
|
|
4077
|
+
renderedVisible = visible
|
|
4078
|
+
isHidden = true // UIKit owns the scrim, safe area, grabber and pan.
|
|
4079
|
+
guard visible else {
|
|
4080
|
+
guard let controller = sheetController else { return }
|
|
4081
|
+
controller.view.endEditing(true)
|
|
4082
|
+
controller.dismiss(animated: animated) { [weak self, weak controller] in
|
|
4083
|
+
guard let self, let controller else { return }
|
|
4084
|
+
self.restoreSheetContent(controller)
|
|
4085
|
+
if self.renderedVisible { self.setSheetVisible(true, animated: animated) }
|
|
4086
|
+
}
|
|
4087
|
+
return
|
|
4088
|
+
}
|
|
4089
|
+
if let controller = sheetController { configureSheet(controller); return }
|
|
4090
|
+
// A newly rendered modal is attached to its screen after this pass.
|
|
4091
|
+
DispatchQueue.main.async { [weak self] in
|
|
4092
|
+
guard let self, self.renderedVisible, self.sheetController == nil,
|
|
4093
|
+
let presenter = self.sheetPresenter, presenter.view.window != nil else { return }
|
|
4094
|
+
let controller = CraftNativeSheetController()
|
|
4095
|
+
controller.screen = presenter
|
|
4096
|
+
controller.modalPresentationStyle = self.presentationStyle == "formSheet" ? .formSheet : .pageSheet
|
|
4097
|
+
controller.view.backgroundColor = .systemBackground
|
|
4098
|
+
self.contentStack.removeFromSuperview()
|
|
4099
|
+
controller.view.addSubview(self.contentStack)
|
|
4100
|
+
controller.content = self.contentStack
|
|
4101
|
+
self.sheetController = controller
|
|
4102
|
+
self.configureSheet(controller)
|
|
4103
|
+
var top: UIViewController = presenter
|
|
4104
|
+
while let presented = top.presentedViewController { top = presented }
|
|
4105
|
+
top.present(controller, animated: animated)
|
|
4106
|
+
}
|
|
4107
|
+
}
|
|
4108
|
+
|
|
4109
|
+
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
|
|
4110
|
+
guard let controller = presentationController.presentedViewController as? CraftNativeSheetController else { return }
|
|
4111
|
+
restoreSheetContent(controller)
|
|
4112
|
+
renderedVisible = false
|
|
4113
|
+
onRequestClose?("swipe")
|
|
4114
|
+
}
|
|
3986
4115
|
|
|
3987
4116
|
var transparent = false {
|
|
3988
4117
|
didSet { blocker.backgroundColor = transparent ? .clear : UIColor.black.withAlphaComponent(0.32) }
|
|
@@ -4020,6 +4149,10 @@ private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate {
|
|
|
4020
4149
|
/// press bookkeeping once turned that off for a view with no handler of
|
|
4021
4150
|
/// its own, and a shown modal let every tap through to the screen behind.
|
|
4022
4151
|
func setVisible(_ visible: Bool, opacity: CGFloat, animationType: String?) {
|
|
4152
|
+
if blocksBackground && (presentationStyle == "pageSheet" || presentationStyle == "formSheet") {
|
|
4153
|
+
setSheetVisible(visible, animated: animationType != "none")
|
|
4154
|
+
return
|
|
4155
|
+
}
|
|
4023
4156
|
if visible { isUserInteractionEnabled = true }
|
|
4024
4157
|
if renderedVisible == visible && isHidden == !visible {
|
|
4025
4158
|
if visible, alpha != opacity { alpha = opacity }
|
|
@@ -4072,6 +4205,12 @@ private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate {
|
|
|
4072
4205
|
}
|
|
4073
4206
|
|
|
4074
4207
|
func resetForTeardown() {
|
|
4208
|
+
renderedVisible = false
|
|
4209
|
+
sheetPresenter = nil
|
|
4210
|
+
if let controller = sheetController {
|
|
4211
|
+
controller.dismiss(animated: false)
|
|
4212
|
+
restoreSheetContent(controller)
|
|
4213
|
+
}
|
|
4075
4214
|
craftScreenController?.forgetFocusedInput(in: self)
|
|
4076
4215
|
endEditing(true)
|
|
4077
4216
|
animationGeneration += 1
|
|
@@ -4133,6 +4272,7 @@ private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate {
|
|
|
4133
4272
|
override func layoutSubviews() {
|
|
4134
4273
|
super.layoutSubviews()
|
|
4135
4274
|
blocker.frame = bounds
|
|
4275
|
+
guard sheetController == nil else { return }
|
|
4136
4276
|
// The scrim covers the whole screen; what the modal holds stays
|
|
4137
4277
|
// clear of the bars and the home indicator it is drawn over.
|
|
4138
4278
|
contentStack.frame = bounds.inset(by: UIEdgeInsets(top: contentInsets.top, left: 0, bottom: contentInsets.bottom, right: 0))
|
|
@@ -5895,7 +6035,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5895
6035
|
@discardableResult
|
|
5896
6036
|
func handleBackForModal() -> Bool {
|
|
5897
6037
|
let modal = view.subviews.reversed().compactMap { $0 as? CraftNativeModalView }.first {
|
|
5898
|
-
!$0.isHidden && $0.alpha > 0.01 && ($0.blocksBackground || $0.dismissOnOutsideTap)
|
|
6038
|
+
($0.isPresentedAsSheet || (!$0.isHidden && $0.alpha > 0.01)) && ($0.blocksBackground || $0.dismissOnOutsideTap)
|
|
5899
6039
|
}
|
|
5900
6040
|
guard let modal else { return false }
|
|
5901
6041
|
let hasHandler = modal.onRequestClose != nil
|
|
@@ -7957,6 +8097,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7957
8097
|
textMaxLengths.removeValue(forKey: fieldId)
|
|
7958
8098
|
}
|
|
7959
8099
|
field.delegate = self
|
|
8100
|
+
switch props["clearButtonMode"] as? String {
|
|
8101
|
+
case "while-editing": field.clearButtonMode = .whileEditing
|
|
8102
|
+
case "unless-editing": field.clearButtonMode = .unlessEditing
|
|
8103
|
+
case "always": field.clearButtonMode = .always
|
|
8104
|
+
default: field.clearButtonMode = .never
|
|
8105
|
+
}
|
|
7960
8106
|
(field as? CraftNativeTextField)?.padding = paddingInsets(style)
|
|
7961
8107
|
field.textColor = color(style["color"]) ?? .label
|
|
7962
8108
|
field.font = textFont(style, default: .systemFont(ofSize: 16))
|
|
@@ -7977,7 +8123,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7977
8123
|
if field.isEnabled != desiredEnabled { field.isEnabled = desiredEnabled }
|
|
7978
8124
|
applyAutofill(to: field, props: props)
|
|
7979
8125
|
if let value = props["value"] as? String {
|
|
7980
|
-
updateField(field, value: value)
|
|
8126
|
+
updateField(field, value: value, acknowledged: (props["mostRecentEventCount"] as? NSNumber)?.intValue)
|
|
7981
8127
|
} else if previous?.view !== field {
|
|
7982
8128
|
updateField(field, value: inputDrafts[current.identity] ?? props["defaultValue"] as? String)
|
|
7983
8129
|
}
|
|
@@ -7989,6 +8135,27 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7989
8135
|
if props["autoFocus"] as? Bool == true, !field.isFirstResponder {
|
|
7990
8136
|
DispatchQueue.main.async { _ = field.becomeFirstResponder() }
|
|
7991
8137
|
}
|
|
8138
|
+
case "SegmentedControl":
|
|
8139
|
+
let control = result as! CraftNativeSegmentedControl
|
|
8140
|
+
let items = props["items"] as? [[String: Any]] ?? []
|
|
8141
|
+
let labels = items.map { $0["label"] as? String ?? craftNativePickerValue($0["value"]) ?? "" }
|
|
8142
|
+
// UIKit owns the segments; only rebuild when the choices change.
|
|
8143
|
+
// Setting a controlled value never dispatches valueChanged.
|
|
8144
|
+
if control.numberOfSegments != labels.count || labels.enumerated().contains(where: { control.titleForSegment(at: $0.offset) != $0.element }) {
|
|
8145
|
+
control.removeAllSegments()
|
|
8146
|
+
for (index, label) in labels.enumerated() { control.insertSegment(withTitle: label, at: index, animated: false) }
|
|
8147
|
+
}
|
|
8148
|
+
control.values = items.map { $0["value"] as? String ?? craftNativePickerValue($0["value"]) ?? "" }
|
|
8149
|
+
for (index, item) in items.enumerated() { control.setEnabled(item["disabled"] as? Bool != true, forSegmentAt: index) }
|
|
8150
|
+
let selected = props["value"] as? String ?? craftNativePickerValue(props["value"]) ?? props["selectedValue"] as? String ?? craftNativePickerValue(props["selectedValue"])
|
|
8151
|
+
control.selectedSegmentIndex = control.values.firstIndex(where: { $0 == selected }) ?? UISegmentedControl.noSegment
|
|
8152
|
+
control.isEnabled = props["disabled"] as? Bool != true
|
|
8153
|
+
control.selectedSegmentTintColor = color(props["tintColor"])
|
|
8154
|
+
let titleAttributes: [NSAttributedString.Key: Any]? = color(style["color"]).map { [.foregroundColor: $0] }
|
|
8155
|
+
control.setTitleTextAttributes(titleAttributes, for: .normal)
|
|
8156
|
+
control.setTitleTextAttributes(titleAttributes, for: .selected)
|
|
8157
|
+
control.configureAccessibility(items: items)
|
|
8158
|
+
updateHandler(nonEmptyHandler(events["onValueChange"]) ?? nonEmptyHandler(events["onChange"]), for: control)
|
|
7992
8159
|
case "Picker":
|
|
7993
8160
|
let picker = result as! UIPickerView
|
|
7994
8161
|
let itemOptions = (props["items"] as? [[String: Any]])?.compactMap { item -> (value: String, label: String)? in
|
|
@@ -8051,6 +8218,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8051
8218
|
case "Modal":
|
|
8052
8219
|
let modal = result as! CraftNativeModalView
|
|
8053
8220
|
let focusBeforeContent = firstResponder(in: rootStack) ?? lastFocusedInput
|
|
8221
|
+
modal.sheetPresenter = self
|
|
8222
|
+
modal.presentationStyle = props["presentationStyle"] as? String ?? "overFullScreen"
|
|
8223
|
+
modal.sheetDetents = props["sheetDetents"] as? [String] ?? ["medium", "large"]
|
|
8224
|
+
modal.selectedDetent = props["selectedDetent"] as? String
|
|
8225
|
+
modal.grabberVisible = props["grabberVisible"] as? Bool != false
|
|
8226
|
+
modal.onRequestClose = nonEmptyHandler(events["onRequestClose"]).map { handler in
|
|
8227
|
+
{ [weak self] reason in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["reason": reason]]) }
|
|
8228
|
+
}
|
|
8054
8229
|
modal.transparent = props["transparent"] as? Bool == true
|
|
8055
8230
|
modal.blocksBackground = props["nonModal"] as? Bool != true
|
|
8056
8231
|
modal.dismissOnOutsideTap = props["dismissOnOutsideTap"] as? Bool == true
|
|
@@ -8124,7 +8299,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8124
8299
|
toggle.onTintColor = color(track["true"])
|
|
8125
8300
|
toggle.tintColor = color(track["false"])
|
|
8126
8301
|
} else {
|
|
8127
|
-
toggle.onTintColor =
|
|
8302
|
+
toggle.onTintColor = color(style["color"])
|
|
8128
8303
|
toggle.tintColor = nil
|
|
8129
8304
|
}
|
|
8130
8305
|
toggle.thumbTintColor = color(props["thumbColor"])
|
|
@@ -8162,7 +8337,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8162
8337
|
}
|
|
8163
8338
|
indicator.indicatorScale = scale
|
|
8164
8339
|
applyTransform(style["transform"], to: indicator)
|
|
8165
|
-
indicator.color = color(props["color"]) ?? .tintColor
|
|
8340
|
+
indicator.color = color(props["color"]) ?? color(style["color"]) ?? .tintColor
|
|
8166
8341
|
indicator.hidesWhenStopped = props["hidesWhenStopped"] as? Bool ?? true
|
|
8167
8342
|
if props["animating"] as? Bool == false {
|
|
8168
8343
|
indicator.stopAnimating()
|
|
@@ -8335,7 +8510,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8335
8510
|
reconcileChildren(children, in: stack, parent: current, path: path, style: style)
|
|
8336
8511
|
}
|
|
8337
8512
|
updateLongPressHandler(props["disabled"] as? Bool == true ? nil : events["onLongPress"], for: result)
|
|
8338
|
-
if type != "Button" && type != "Link" && type != "Checkbox" && type != "TextInput" && type != "Picker" && type != "DatePicker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" && type != "StatusBar" && type != "RefreshControl" && type != "Map" {
|
|
8513
|
+
if type != "Button" && type != "Link" && type != "Checkbox" && type != "TextInput" && type != "Picker" && type != "SegmentedControl" && type != "DatePicker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" && type != "StatusBar" && type != "RefreshControl" && type != "Map" {
|
|
8339
8514
|
let disabled = props["disabled"] as? Bool == true
|
|
8340
8515
|
updatePressHandler(disabled ? nil : nonEmptyHandler(events["onPress"]) ?? nonEmptyHandler(events["onClick"]), for: result)
|
|
8341
8516
|
updatePressFeedback(
|
|
@@ -8391,6 +8566,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8391
8566
|
field.addTarget(self, action: #selector(textBlurred(_:)), for: .editingDidEnd)
|
|
8392
8567
|
field.addTarget(self, action: #selector(textSubmitted(_:)), for: .editingDidEndOnExit)
|
|
8393
8568
|
return field
|
|
8569
|
+
case "SegmentedControl":
|
|
8570
|
+
let control = CraftNativeSegmentedControl()
|
|
8571
|
+
control.addTarget(self, action: #selector(segmentChanged(_:)), for: .valueChanged)
|
|
8572
|
+
return control
|
|
8394
8573
|
case "Picker":
|
|
8395
8574
|
let picker = UIPickerView()
|
|
8396
8575
|
picker.dataSource = self
|
|
@@ -8457,6 +8636,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8457
8636
|
case "link": return "Link"
|
|
8458
8637
|
case "checkbox": return "Checkbox"
|
|
8459
8638
|
case "textinput", "input": return "TextInput"
|
|
8639
|
+
case "segmentedcontrol", "segmented-control": return "SegmentedControl"
|
|
8460
8640
|
case "picker", "select": return "Picker"
|
|
8461
8641
|
case "datepicker", "date-picker", "date": return "DatePicker"
|
|
8462
8642
|
case "modal": return "Modal"
|
|
@@ -8806,6 +8986,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8806
8986
|
submitHandlers.removeValue(forKey: id)
|
|
8807
8987
|
inputIdentities.removeValue(forKey: id)
|
|
8808
8988
|
unechoedText.removeValue(forKey: id)
|
|
8989
|
+
inputEventCounts.removeValue(forKey: id)
|
|
8809
8990
|
if node.type == "TextInput" && !preservingInputDrafts { inputDrafts.removeValue(forKey: node.identity) }
|
|
8810
8991
|
longPressHandlers.removeValue(forKey: id)
|
|
8811
8992
|
textMaxLengths.removeValue(forKey: id)
|
|
@@ -9023,19 +9204,21 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9023
9204
|
}
|
|
9024
9205
|
}
|
|
9025
9206
|
|
|
9026
|
-
///
|
|
9027
|
-
///
|
|
9028
|
-
/// one is the script catching up, not a new value, and applying it would
|
|
9029
|
-
/// undo what was typed since.
|
|
9207
|
+
/// Legacy runtimes echo values without a revision. Keep their bounded
|
|
9208
|
+
/// history on either script thread; bridge delivery may still be delayed.
|
|
9030
9209
|
private var unechoedText: [ObjectIdentifier: [String]] = [:]
|
|
9210
|
+
private var inputEventCounts: [ObjectIdentifier: Int] = [:]
|
|
9031
9211
|
|
|
9032
|
-
|
|
9033
|
-
|
|
9212
|
+
@discardableResult
|
|
9213
|
+
private func noteSentText(_ text: String, for view: UIView) -> Int {
|
|
9034
9214
|
let id = ObjectIdentifier(view)
|
|
9215
|
+
let count = (inputEventCounts[id] ?? 0) + 1
|
|
9216
|
+
inputEventCounts[id] = count
|
|
9035
9217
|
var sent = unechoedText[id] ?? []
|
|
9036
9218
|
sent.append(text)
|
|
9037
9219
|
if sent.count > 32 { sent.removeFirst(sent.count - 32) }
|
|
9038
9220
|
unechoedText[id] = sent
|
|
9221
|
+
return count
|
|
9039
9222
|
}
|
|
9040
9223
|
|
|
9041
9224
|
/// Whether `value` is a stale echo of text the view has moved past: it
|
|
@@ -9053,9 +9236,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9053
9236
|
return later > 0
|
|
9054
9237
|
}
|
|
9055
9238
|
|
|
9056
|
-
private func
|
|
9239
|
+
private func acceptsText(_ value: String, for view: UIView, acknowledged: Int?) -> Bool {
|
|
9240
|
+
if let acknowledged, acknowledged >= 0 {
|
|
9241
|
+
guard acknowledged >= (inputEventCounts[ObjectIdentifier(view)] ?? 0) else { return false }
|
|
9242
|
+
unechoedText.removeValue(forKey: ObjectIdentifier(view))
|
|
9243
|
+
return true // A current explicit reset may equal an older edit.
|
|
9244
|
+
}
|
|
9245
|
+
return !isStaleEcho(value, for: view)
|
|
9246
|
+
}
|
|
9247
|
+
|
|
9248
|
+
private func updateField(_ field: UITextField, value: String?, acknowledged: Int? = nil) {
|
|
9057
9249
|
guard let value = value, field.markedTextRange == nil else { return }
|
|
9058
|
-
if
|
|
9250
|
+
if !acceptsText(value, for: field, acknowledged: acknowledged) || field.text == value { return }
|
|
9059
9251
|
let selection = field.selectedTextRange
|
|
9060
9252
|
let start = selection.map { field.offset(from: field.beginningOfDocument, to: $0.start) }
|
|
9061
9253
|
let end = selection.map { field.offset(from: field.beginningOfDocument, to: $0.end) }
|
|
@@ -9067,9 +9259,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9067
9259
|
}
|
|
9068
9260
|
}
|
|
9069
9261
|
|
|
9070
|
-
private func updateTextView(_ textView: UITextView, value: String?) {
|
|
9262
|
+
private func updateTextView(_ textView: UITextView, value: String?, acknowledged: Int? = nil) {
|
|
9071
9263
|
guard let value = value, textView.markedTextRange == nil else { return }
|
|
9072
|
-
if
|
|
9264
|
+
if !acceptsText(value, for: textView, acknowledged: acknowledged) || textView.text == value { return }
|
|
9073
9265
|
let selection = textView.selectedTextRange
|
|
9074
9266
|
let start = selection.map { textView.offset(from: textView.beginningOfDocument, to: $0.start) }
|
|
9075
9267
|
let end = selection.map { textView.offset(from: textView.beginningOfDocument, to: $0.end) }
|
|
@@ -9132,7 +9324,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9132
9324
|
craftTextView.placeholderColor = color(props["placeholderTextColor"])
|
|
9133
9325
|
craftTextView.placeholderFont = textView.font
|
|
9134
9326
|
}
|
|
9135
|
-
updateTextView(textView, value: (props["value"] as? String) ?? defaultValue)
|
|
9327
|
+
updateTextView(textView, value: (props["value"] as? String) ?? defaultValue, acknowledged: (props["mostRecentEventCount"] as? NSNumber)?.intValue)
|
|
9136
9328
|
if textView.markedTextRange == nil {
|
|
9137
9329
|
let attributes = inputTextAttributes(style, font: textView.font ?? .systemFont(ofSize: 16), alignment: textView.textAlignment)
|
|
9138
9330
|
let styled = NSAttributedString(string: textView.text ?? "", attributes: attributes)
|
|
@@ -9836,7 +10028,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9836
10028
|
let controlledVideo = type == "Video" && (props["controls"] as? Bool) == true
|
|
9837
10029
|
// UIPickerView exposes adjustable component wheels. Making the
|
|
9838
10030
|
// picker itself a leaf hides those wheels and their selected values.
|
|
9839
|
-
let container = ["ScrollView", "FlatList", "SectionList", "Picker"].contains(type)
|
|
10031
|
+
let container = ["ScrollView", "FlatList", "SectionList", "Picker", "SegmentedControl"].contains(type)
|
|
9840
10032
|
|| ["tablist", "radiogroup", "list", "menu", "menubar", "toolbar"].contains(role ?? "")
|
|
9841
10033
|
|| controlledVideo
|
|
9842
10034
|
view.isAccessibilityElement = !container && role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Link", "Checkbox", "Image", "ImageBackground", "Video", "TextInput", "Picker", "DatePicker", "Switch", "Slider"].contains(type))
|
|
@@ -9939,6 +10131,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9939
10131
|
/// Return on a `returnKeyType="next"` field the screen does not handle
|
|
9940
10132
|
/// itself (no `@submit`) goes to the next field down the screen, keeping
|
|
9941
10133
|
/// the keyboard up. Every other Return ends editing and fires `@submit`.
|
|
10134
|
+
func textFieldShouldClear(_ textField: UITextField) -> Bool {
|
|
10135
|
+
guard textField.isEnabled else { return false }
|
|
10136
|
+
textField.text = ""
|
|
10137
|
+
let id = ObjectIdentifier(textField)
|
|
10138
|
+
inputIdentities[id].map { inputDrafts[$0] = "" }
|
|
10139
|
+
let eventCount = noteSentText("", for: textField)
|
|
10140
|
+
if let handler = handlers[id] {
|
|
10141
|
+
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": "", "clear": true, "eventCount": eventCount]])
|
|
10142
|
+
}
|
|
10143
|
+
return false // The model receives exactly one edit, including lazy models.
|
|
10144
|
+
}
|
|
10145
|
+
|
|
9942
10146
|
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
|
9943
10147
|
guard textField.returnKeyType == .next, submitHandlers[ObjectIdentifier(textField)] == nil,
|
|
9944
10148
|
let next = inputAfter(textField) else { return true }
|
|
@@ -9985,8 +10189,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9985
10189
|
(textView as? CraftNativeTextView)?.refreshPlaceholder()
|
|
9986
10190
|
keepCaretVisible(in: textView)
|
|
9987
10191
|
guard let handler = handlers[id] else { return }
|
|
9988
|
-
noteSentText(textView.text ?? "", for: textView)
|
|
9989
|
-
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": textView.text ?? ""]])
|
|
10192
|
+
let eventCount = noteSentText(textView.text ?? "", for: textView)
|
|
10193
|
+
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": textView.text ?? "", "eventCount": eventCount]])
|
|
9990
10194
|
}
|
|
9991
10195
|
|
|
9992
10196
|
func textViewDidChangeSelection(_ textView: UITextView) {
|
|
@@ -10042,14 +10246,22 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
10042
10246
|
inputIdentities[id].map { inputDrafts[$0] = sender.text ?? "" }
|
|
10043
10247
|
let focused = sender.isFirstResponder ? sender : firstResponder(in: rootStack)
|
|
10044
10248
|
guard let handler = handlers[id] else { return }
|
|
10045
|
-
noteSentText(sender.text ?? "", for: sender)
|
|
10046
|
-
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": sender.text ?? ""]])
|
|
10249
|
+
let eventCount = noteSentText(sender.text ?? "", for: sender)
|
|
10250
|
+
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": sender.text ?? "", "eventCount": eventCount]])
|
|
10047
10251
|
restoreFocus(focused)
|
|
10048
10252
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self, weak focused] in
|
|
10049
10253
|
self?.restoreFocus(focused)
|
|
10050
10254
|
}
|
|
10051
10255
|
}
|
|
10052
10256
|
|
|
10257
|
+
@objc private func segmentChanged(_ sender: CraftNativeSegmentedControl) {
|
|
10258
|
+
let index = sender.selectedSegmentIndex
|
|
10259
|
+
guard sender.isEnabled, sender.values.indices.contains(index), sender.isEnabledForSegment(at: index),
|
|
10260
|
+
let handler = handlers[ObjectIdentifier(sender)] else { return }
|
|
10261
|
+
sender.refreshAccessibilityState()
|
|
10262
|
+
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": sender.values[index], "index": index]])
|
|
10263
|
+
}
|
|
10264
|
+
|
|
10053
10265
|
@objc private func switchChanged(_ sender: UISwitch) {
|
|
10054
10266
|
let id = ObjectIdentifier(sender)
|
|
10055
10267
|
switchUserValues[id] = sender.isOn
|