@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.296",
3
+ "version": "0.0.299",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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 CraftNativeModalView: UIView, UIGestureRecognizerDelegate {
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 = nil
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
- /// Text a field sent its script that the script has not echoed back yet,
9027
- /// while the script runs on its own thread: a value that matches an older
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
- private func noteSentText(_ text: String, for view: UIView) {
9033
- guard CraftNativeScriptThread.isBackground else { return }
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 updateField(_ field: UITextField, value: String?) {
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 isStaleEcho(value, for: field) || field.text == value { return }
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 isStaleEcho(value, for: textView) || textView.text == value { return }
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