@craft-native/ios 0.0.486 → 0.0.487
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/package.json +1 -1
- package/templates/CraftNativeScreen.swift +140 -67
package/package.json
CHANGED
|
@@ -168,8 +168,8 @@ private func craftNativeFontWeight(_ value: Any?) -> String? {
|
|
|
168
168
|
}
|
|
169
169
|
|
|
170
170
|
private func craftNativePickerValue(_ value: Any?) -> String? {
|
|
171
|
-
if let value = value as? String
|
|
172
|
-
if let value = craftNativeNumber(value), value.doubleValue.isFinite { return
|
|
171
|
+
if let value = value as? String { return value }
|
|
172
|
+
if let value = craftNativeNumber(value), value.doubleValue.isFinite { return value.stringValue }
|
|
173
173
|
return nil
|
|
174
174
|
}
|
|
175
175
|
|
|
@@ -6595,11 +6595,108 @@ enum CraftNativeBundle {
|
|
|
6595
6595
|
}
|
|
6596
6596
|
}
|
|
6597
6597
|
|
|
6598
|
-
|
|
6598
|
+
/// One shared single-choice control, compact like a form field. UIKit owns
|
|
6599
|
+
/// presentation, dismissal, checkmarks, keyboard navigation and VoiceOver.
|
|
6600
|
+
final class CraftNativePickerView: UIButton {
|
|
6601
|
+
struct Option: Equatable {
|
|
6602
|
+
let value: String
|
|
6603
|
+
let label: String
|
|
6604
|
+
let disabled: Bool
|
|
6605
|
+
}
|
|
6606
|
+
private var options: [Option] = []
|
|
6607
|
+
private var selectedValue: String?
|
|
6608
|
+
private var handlerIdentity: String?
|
|
6609
|
+
private var dispatch: ((String, Int) -> Void)?
|
|
6610
|
+
private var revision = 0
|
|
6611
|
+
private(set) var selectedLabel = ""
|
|
6612
|
+
|
|
6613
|
+
override init(frame: CGRect) {
|
|
6614
|
+
super.init(frame: frame)
|
|
6615
|
+
showsMenuAsPrimaryAction = true
|
|
6616
|
+
contentHorizontalAlignment = .fill
|
|
6617
|
+
titleLabel?.adjustsFontForContentSizeCategory = true
|
|
6618
|
+
layer.cornerRadius = 10
|
|
6619
|
+
}
|
|
6620
|
+
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
6621
|
+
|
|
6622
|
+
func update(options: [Option], selected: String?, enabled: Bool, handler: String?,
|
|
6623
|
+
font: UIFont, foreground: UIColor, dispatch: @escaping (String, Int) -> Void) {
|
|
6624
|
+
let candidate = selected ?? self.selectedValue
|
|
6625
|
+
let resolved = options.contains(where: { $0.value == candidate }) ? candidate : options.first(where: { !$0.disabled })?.value
|
|
6626
|
+
if self.options != options || self.selectedValue != resolved || self.isEnabled != enabled || self.handlerIdentity != handler {
|
|
6627
|
+
revision += 1
|
|
6628
|
+
interactions.compactMap { $0 as? UIContextMenuInteraction }.forEach { $0.dismissMenu() }
|
|
6629
|
+
}
|
|
6630
|
+
self.options = options
|
|
6631
|
+
self.selectedValue = resolved
|
|
6632
|
+
self.handlerIdentity = handler
|
|
6633
|
+
self.dispatch = dispatch
|
|
6634
|
+
isEnabled = enabled && options.contains(where: { !$0.disabled })
|
|
6635
|
+
selectedLabel = options.first(where: { $0.value == resolved })?.label ?? ""
|
|
6636
|
+
var configuration = UIButton.Configuration.plain()
|
|
6637
|
+
configuration.title = selectedLabel
|
|
6638
|
+
configuration.image = UIImage(systemName: "chevron.down")
|
|
6639
|
+
configuration.imagePlacement = .trailing
|
|
6640
|
+
configuration.imagePadding = 12
|
|
6641
|
+
configuration.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 12, bottom: 10, trailing: 12)
|
|
6642
|
+
configuration.titleAlignment = .leading
|
|
6643
|
+
configuration.baseForegroundColor = foreground
|
|
6644
|
+
configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
|
|
6645
|
+
var result = attributes
|
|
6646
|
+
result.font = font
|
|
6647
|
+
return result
|
|
6648
|
+
}
|
|
6649
|
+
configuration.imageColorTransformer = UIConfigurationColorTransformer { _ in .secondaryLabel }
|
|
6650
|
+
self.configuration = configuration
|
|
6651
|
+
refreshMenu()
|
|
6652
|
+
invalidateIntrinsicContentSize()
|
|
6653
|
+
}
|
|
6654
|
+
|
|
6655
|
+
private func refreshMenu() {
|
|
6656
|
+
menu = UIMenu(children: options.enumerated().map { index, option in
|
|
6657
|
+
let choose = selection(index)
|
|
6658
|
+
return UIAction(title: option.label, attributes: option.disabled ? [.disabled] : [],
|
|
6659
|
+
state: option.value == selectedValue ? .on : .off) { _ in choose() }
|
|
6660
|
+
})
|
|
6661
|
+
}
|
|
6662
|
+
|
|
6663
|
+
/// An open menu must not apply an old option to a rebound or disabled field.
|
|
6664
|
+
func selection(_ index: Int) -> () -> Void {
|
|
6665
|
+
let expected = revision
|
|
6666
|
+
return { [weak self] in
|
|
6667
|
+
guard let self, self.revision == expected, self.isEnabled,
|
|
6668
|
+
self.options.indices.contains(index), !self.options[index].disabled else { return }
|
|
6669
|
+
let value = self.options[index].value
|
|
6670
|
+
guard value != self.selectedValue else { return }
|
|
6671
|
+
self.selectedValue = value
|
|
6672
|
+
self.selectedLabel = self.options[index].label
|
|
6673
|
+
self.configuration?.title = self.selectedLabel
|
|
6674
|
+
self.accessibilityValue = self.selectedLabel
|
|
6675
|
+
self.revision += 1
|
|
6676
|
+
self.refreshMenu()
|
|
6677
|
+
self.invalidateIntrinsicContentSize()
|
|
6678
|
+
self.dispatch?(value, index)
|
|
6679
|
+
}
|
|
6680
|
+
}
|
|
6681
|
+
|
|
6682
|
+
func detach() {
|
|
6683
|
+
revision += 1
|
|
6684
|
+
dispatch = nil
|
|
6685
|
+
interactions.compactMap { $0 as? UIContextMenuInteraction }.forEach { $0.dismissMenu() }
|
|
6686
|
+
menu = nil
|
|
6687
|
+
}
|
|
6688
|
+
|
|
6689
|
+
override var intrinsicContentSize: CGSize {
|
|
6690
|
+
let size = super.intrinsicContentSize
|
|
6691
|
+
return CGSize(width: max(44, size.width), height: max(44, size.height))
|
|
6692
|
+
}
|
|
6693
|
+
override func sizeThatFits(_ size: CGSize) -> CGSize {
|
|
6694
|
+
let fitted = super.sizeThatFits(size)
|
|
6695
|
+
return CGSize(width: fitted.width, height: max(44, fitted.height))
|
|
6696
|
+
}
|
|
6599
6697
|
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
|
6600
6698
|
craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
|
|
6601
6699
|
}
|
|
6602
|
-
|
|
6603
6700
|
}
|
|
6604
6701
|
|
|
6605
6702
|
private final class CraftNativeDatePickerView: UIDatePicker {
|
|
@@ -6650,7 +6747,7 @@ private final class CraftNativeScreenRootView: UIView {
|
|
|
6650
6747
|
}
|
|
6651
6748
|
}
|
|
6652
6749
|
|
|
6653
|
-
final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate, UITextFieldDelegate, UITextViewDelegate
|
|
6750
|
+
final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate, UITextFieldDelegate, UITextViewDelegate {
|
|
6654
6751
|
private final class RenderedNode {
|
|
6655
6752
|
/// A recycled list row's root takes the identity of the row it shows next.
|
|
6656
6753
|
var identity: String
|
|
@@ -6714,7 +6811,6 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6714
6811
|
/// the user's tap. Remember that native value briefly so the echo does not
|
|
6715
6812
|
/// interrupt the animation and leave the thumb painted at its old edge.
|
|
6716
6813
|
private var switchUserValues: [ObjectIdentifier: Bool] = [:]
|
|
6717
|
-
private var pickerOptions: [ObjectIdentifier: [(value: String, label: String)]] = [:]
|
|
6718
6814
|
private var modalVisibility: [ObjectIdentifier: Bool] = [:]
|
|
6719
6815
|
private var modalFocusRestore: [ObjectIdentifier: UIView] = [:]
|
|
6720
6816
|
private var layoutHandlers: [ObjectIdentifier: String] = [:]
|
|
@@ -9594,31 +9690,33 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9594
9690
|
control.configureAccessibility(items: items)
|
|
9595
9691
|
updateHandler(nonEmptyHandler(events["onValueChange"]) ?? nonEmptyHandler(events["onChange"]), for: control)
|
|
9596
9692
|
case "Picker":
|
|
9597
|
-
let picker = result as!
|
|
9598
|
-
let
|
|
9599
|
-
|
|
9600
|
-
|
|
9601
|
-
|
|
9602
|
-
|
|
9603
|
-
|
|
9604
|
-
guard let node = child as? [String: Any], node["type"] as? String == "Text" else { return nil }
|
|
9605
|
-
let optionProps = node["props"] as? [String: Any] ?? [:]
|
|
9606
|
-
let label = (node["children"] as? [Any] ?? []).compactMap { $0 as? String }.joined()
|
|
9607
|
-
guard !label.isEmpty else { return nil }
|
|
9608
|
-
return (value: craftNativePickerValue(optionProps["value"]) ?? label, label: label)
|
|
9693
|
+
let picker = result as! CraftNativePickerView
|
|
9694
|
+
let rawItems = props["items"] as? [[String: Any]]
|
|
9695
|
+
let options: [CraftNativePickerView.Option] = rawItems.map { items in
|
|
9696
|
+
items.compactMap { item in
|
|
9697
|
+
guard let label = item["label"] as? String, !label.isEmpty else { return nil }
|
|
9698
|
+
return CraftNativePickerView.Option(value: craftNativePickerValue(item["value"]) ?? label,
|
|
9699
|
+
label: label, disabled: item["disabled"] as? Bool == true)
|
|
9609
9700
|
}
|
|
9610
|
-
|
|
9611
|
-
|
|
9612
|
-
|
|
9613
|
-
|
|
9614
|
-
|
|
9615
|
-
|
|
9616
|
-
|
|
9617
|
-
}
|
|
9618
|
-
|
|
9619
|
-
|
|
9620
|
-
picker.
|
|
9621
|
-
|
|
9701
|
+
} ?? children.compactMap { child in
|
|
9702
|
+
guard let node = child as? [String: Any], node["type"] as? String == "Text" else { return nil }
|
|
9703
|
+
let optionProps = node["props"] as? [String: Any] ?? [:]
|
|
9704
|
+
let label = (node["children"] as? [Any] ?? []).compactMap { $0 as? String }.joined()
|
|
9705
|
+
guard !label.isEmpty else { return nil }
|
|
9706
|
+
return CraftNativePickerView.Option(value: craftNativePickerValue(optionProps["value"]) ?? label,
|
|
9707
|
+
label: label, disabled: optionProps["disabled"] as? Bool == true)
|
|
9708
|
+
}
|
|
9709
|
+
let handler = nonEmptyHandler(events["onValueChange"]) ?? nonEmptyHandler(events["onChange"])
|
|
9710
|
+
updateHandler(handler, for: picker)
|
|
9711
|
+
picker.update(options: options,
|
|
9712
|
+
selected: craftNativePickerValue(props["selectedValue"]) ?? craftNativePickerValue(props["value"]),
|
|
9713
|
+
enabled: props["disabled"] as? Bool != true,
|
|
9714
|
+
handler: handler, font: textFont(style, default: .preferredFont(forTextStyle: .body)),
|
|
9715
|
+
foreground: color(style["color"]) ?? .label) { [weak self, weak picker] value, index in
|
|
9716
|
+
guard let self, let picker, let handler = self.handlers[ObjectIdentifier(picker)] else { return }
|
|
9717
|
+
self.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": value, "index": index]])
|
|
9718
|
+
}
|
|
9719
|
+
if style["backgroundColor"] == nil { picker.backgroundColor = .tertiarySystemFill }
|
|
9622
9720
|
case "DatePicker":
|
|
9623
9721
|
let picker = result as! UIDatePicker
|
|
9624
9722
|
let mode = Self.datePickerMode(props["mode"])
|
|
@@ -9994,6 +10092,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9994
10092
|
result.craftPointerEvents = CraftNativePointerEvents.resolve(props: props, style: style)
|
|
9995
10093
|
applyAccessibility(props, type: type, to: result)
|
|
9996
10094
|
(result as? CraftNativeSvgView)?.inspection.configureAccessibility()
|
|
10095
|
+
if let picker = result as? CraftNativePickerView, props["accessibilityValue"] == nil {
|
|
10096
|
+
picker.accessibilityValue = picker.selectedLabel
|
|
10097
|
+
}
|
|
9997
10098
|
// After applyAccessibility: it settles whether the view is an element
|
|
9998
10099
|
// at all, and an action on a non-element is unreachable.
|
|
9999
10100
|
updateAccessibilityActions(props["accessibilityActions"], handler: menuDisabled ? nil : events["onAccessibilityAction"], for: result)
|
|
@@ -10053,10 +10154,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
10053
10154
|
control.addTarget(self, action: #selector(segmentChanged(_:)), for: .valueChanged)
|
|
10054
10155
|
return control
|
|
10055
10156
|
case "Picker":
|
|
10056
|
-
|
|
10057
|
-
picker.dataSource = self
|
|
10058
|
-
picker.delegate = self
|
|
10059
|
-
return picker
|
|
10157
|
+
return CraftNativePickerView(frame: .zero)
|
|
10060
10158
|
case "DatePicker":
|
|
10061
10159
|
let picker = CraftNativeDatePickerView()
|
|
10062
10160
|
picker.preferredDatePickerStyle = .compact
|
|
@@ -10444,10 +10542,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
10444
10542
|
// its subtree is gone.
|
|
10445
10543
|
if !preservingInputDrafts {
|
|
10446
10544
|
if let textView = node.view as? UITextView { textView.delegate = nil }
|
|
10447
|
-
|
|
10448
|
-
picker.delegate = nil
|
|
10449
|
-
picker.dataSource = nil
|
|
10450
|
-
}
|
|
10545
|
+
(node.view as? CraftNativePickerView)?.detach()
|
|
10451
10546
|
if let scroll = node.view as? UIScrollView { scroll.delegate = nil }
|
|
10452
10547
|
(node.view as? UIControl)?.removeTarget(self, action: nil, for: .allEvents)
|
|
10453
10548
|
}
|
|
@@ -10502,7 +10597,6 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
10502
10597
|
scrollMomentumEndHandlers.removeValue(forKey: id)
|
|
10503
10598
|
sliderCompleteHandlers.removeValue(forKey: id)
|
|
10504
10599
|
sliderSteps.removeValue(forKey: id)
|
|
10505
|
-
pickerOptions.removeValue(forKey: id)
|
|
10506
10600
|
modalVisibility.removeValue(forKey: id)
|
|
10507
10601
|
if let recognizer = tapRecognizers.removeValue(forKey: id) {
|
|
10508
10602
|
node.view.removeGestureRecognizer(recognizer)
|
|
@@ -10636,12 +10730,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
10636
10730
|
/// Views that take their own touches whatever handlers the screen gives
|
|
10637
10731
|
/// them: native controls, a scroll view, a modal, and a video's controls (with
|
|
10638
10732
|
/// interaction off, its play, scrubber and full screen never answered).
|
|
10639
|
-
///
|
|
10640
|
-
///
|
|
10641
|
-
/// not be panned, zoomed, or have a pin tapped; a picker could not be spun.
|
|
10733
|
+
/// Maps too: a map without a long-press handler must still pan, zoom,
|
|
10734
|
+
/// and take taps on its pins.
|
|
10642
10735
|
private func ownsTouches(_ view: UIView) -> Bool {
|
|
10643
10736
|
view is UIControl || view is UIScrollView || view is CraftNativeModalView || view is CraftNativeVideoView
|
|
10644
|
-
|| view is MKMapView ||
|
|
10737
|
+
|| view is MKMapView || ((view as? CraftNativeSvgView)?.inspection.readings.isEmpty == false)
|
|
10645
10738
|
}
|
|
10646
10739
|
|
|
10647
10740
|
private func updatePressHandler(_ handler: String?, for view: UIView) {
|
|
@@ -11630,9 +11723,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
11630
11723
|
// A video with its system controls is a group too: VoiceOver reaches
|
|
11631
11724
|
// play, the scrubber, skipping and full screen inside it.
|
|
11632
11725
|
let controlledVideo = type == "Video" && (props["controls"] as? Bool) == true
|
|
11633
|
-
//
|
|
11634
|
-
//
|
|
11635
|
-
let container = ["ScrollView", "FlatList", "SectionList", "
|
|
11726
|
+
// Compact single-choice pickers are accessible menu buttons. Controls
|
|
11727
|
+
// containing several independent elements remain semantic groups.
|
|
11728
|
+
let container = ["ScrollView", "FlatList", "SectionList", "SegmentedControl", "Modal", "Drawer"].contains(type)
|
|
11636
11729
|
|| ["tablist", "radiogroup", "list", "menu", "menubar", "toolbar"].contains(role ?? "")
|
|
11637
11730
|
|| controlledVideo
|
|
11638
11731
|
view.isAccessibilityElement = !container && role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Link", "Checkbox", "Image", "ImageBackground", "Video", "TextInput", "Picker", "DatePicker", "Switch", "Slider"].contains(type))
|
|
@@ -11644,6 +11737,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
11644
11737
|
var traits: UIAccessibilityTraits = []
|
|
11645
11738
|
switch type {
|
|
11646
11739
|
case "Slider", "DatePicker": traits.insert(.adjustable)
|
|
11740
|
+
case "Picker": traits.insert(.button)
|
|
11647
11741
|
default: break
|
|
11648
11742
|
}
|
|
11649
11743
|
switch role ?? type.lowercased() {
|
|
@@ -12056,27 +12150,6 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
12056
12150
|
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": Self.sliderEventValue(snappedSliderValue(sender.value, for: sender), step: sliderSteps[ObjectIdentifier(sender)])]])
|
|
12057
12151
|
}
|
|
12058
12152
|
|
|
12059
|
-
func numberOfComponents(in pickerView: UIPickerView) -> Int { 1 }
|
|
12060
|
-
|
|
12061
|
-
func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
|
|
12062
|
-
pickerOptions[ObjectIdentifier(pickerView)]?.count ?? 0
|
|
12063
|
-
}
|
|
12064
|
-
|
|
12065
|
-
func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
|
|
12066
|
-
guard let options = pickerOptions[ObjectIdentifier(pickerView)], options.indices.contains(row) else { return nil }
|
|
12067
|
-
return options[row].label
|
|
12068
|
-
}
|
|
12069
|
-
|
|
12070
|
-
func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
|
|
12071
|
-
guard let options = pickerOptions[ObjectIdentifier(pickerView)], options.indices.contains(row),
|
|
12072
|
-
let handler = handlers[ObjectIdentifier(pickerView)] else { return }
|
|
12073
|
-
let option = options[row]
|
|
12074
|
-
send(type: "EVENT", payload: [
|
|
12075
|
-
"handlerName": handler,
|
|
12076
|
-
"nativeEvent": ["value": option.value, "index": row],
|
|
12077
|
-
])
|
|
12078
|
-
}
|
|
12079
|
-
|
|
12080
12153
|
@objc private func textFocused(_ sender: UITextField) {
|
|
12081
12154
|
lastFocusedInput = sender
|
|
12082
12155
|
// Moving between fields does not move the keyboard, so nothing else
|