craft-native 0.0.296 → 0.0.301
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/dist/android/templates/MainActivityNative.kt.template +152 -11
- package/dist/cli.js +1 -1
- package/dist/components/index.d.ts +21 -0
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/templates/CraftNativeActions.swift +1 -1
- package/dist/ios/templates/CraftNativeScreen.swift +238 -24
- package/package.json +1 -1
|
@@ -2736,6 +2736,51 @@ private class CraftNativeIconView(context: Context) : TextView(context) {
|
|
|
2736
2736
|
}
|
|
2737
2737
|
}
|
|
2738
2738
|
|
|
2739
|
+
/** A platform EditText with the native end clear affordance and TalkBack action. */
|
|
2740
|
+
private class CraftNativeTextField(context: Context) : EditText(context) {
|
|
2741
|
+
companion object { val CLEAR_ACTION_ID = View.generateViewId() }
|
|
2742
|
+
var clearButtonMode = "never"
|
|
2743
|
+
var onClearRequest: (() -> Unit)? = null
|
|
2744
|
+
private var clearPressed = false
|
|
2745
|
+
private fun showsClear() = isEnabled && keyListener != null && text?.isNotEmpty() == true && when (clearButtonMode) {
|
|
2746
|
+
"always" -> true
|
|
2747
|
+
"while-editing" -> isFocused
|
|
2748
|
+
"unless-editing" -> !isFocused
|
|
2749
|
+
else -> false
|
|
2750
|
+
}
|
|
2751
|
+
fun refreshClearButton() {
|
|
2752
|
+
val icon = if (showsClear()) context.getDrawable(android.R.drawable.ic_menu_close_clear_cancel)?.mutate() else null
|
|
2753
|
+
icon?.setTint(currentHintTextColor)
|
|
2754
|
+
setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, icon, null)
|
|
2755
|
+
}
|
|
2756
|
+
override fun onTextChanged(text: CharSequence?, start: Int, lengthBefore: Int, lengthAfter: Int) {
|
|
2757
|
+
super.onTextChanged(text, start, lengthBefore, lengthAfter)
|
|
2758
|
+
refreshClearButton()
|
|
2759
|
+
}
|
|
2760
|
+
override fun onFocusChanged(focused: Boolean, direction: Int, previouslyFocusedRect: android.graphics.Rect?) {
|
|
2761
|
+
super.onFocusChanged(focused, direction, previouslyFocusedRect)
|
|
2762
|
+
refreshClearButton()
|
|
2763
|
+
}
|
|
2764
|
+
override fun onTouchEvent(event: MotionEvent): Boolean {
|
|
2765
|
+
val inClear = showsClear() && if (layoutDirection == View.LAYOUT_DIRECTION_RTL) event.x <= compoundPaddingLeft else event.x >= width - compoundPaddingRight
|
|
2766
|
+
if (event.actionMasked == MotionEvent.ACTION_DOWN && inClear) { clearPressed = true; return true }
|
|
2767
|
+
if (clearPressed) {
|
|
2768
|
+
if (event.actionMasked == MotionEvent.ACTION_UP) { clearPressed = false; if (inClear) onClearRequest?.invoke(); return true }
|
|
2769
|
+
if (event.actionMasked == MotionEvent.ACTION_CANCEL) clearPressed = false
|
|
2770
|
+
return true
|
|
2771
|
+
}
|
|
2772
|
+
return super.onTouchEvent(event)
|
|
2773
|
+
}
|
|
2774
|
+
override fun onInitializeAccessibilityNodeInfo(info: android.view.accessibility.AccessibilityNodeInfo) {
|
|
2775
|
+
super.onInitializeAccessibilityNodeInfo(info)
|
|
2776
|
+
if (showsClear()) info.addAction(android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction(CLEAR_ACTION_ID, "Clear text"))
|
|
2777
|
+
}
|
|
2778
|
+
override fun performAccessibilityAction(action: Int, arguments: Bundle?): Boolean {
|
|
2779
|
+
if (action == CLEAR_ACTION_ID && showsClear()) { onClearRequest?.invoke(); return true }
|
|
2780
|
+
return super.performAccessibilityAction(action, arguments)
|
|
2781
|
+
}
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2739
2784
|
private class CraftNativeModalView(context: Context) : FrameLayout(context) {
|
|
2740
2785
|
val content = CraftNativeFlexLayout(context)
|
|
2741
2786
|
private val blocker = View(context)
|
|
@@ -2745,6 +2790,16 @@ private class CraftNativeModalView(context: Context) : FrameLayout(context) {
|
|
|
2745
2790
|
var onRequestClose: ((String) -> Unit)? = null
|
|
2746
2791
|
var viewportStyle = JSONObject()
|
|
2747
2792
|
var declarationPath = ""
|
|
2793
|
+
var sheetFraction: Float? = null
|
|
2794
|
+
|
|
2795
|
+
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
|
2796
|
+
val fraction = sheetFraction
|
|
2797
|
+
val params = content.layoutParams as LayoutParams
|
|
2798
|
+
params.height = if (fraction == null) LayoutParams.MATCH_PARENT else (View.MeasureSpec.getSize(heightMeasureSpec) * fraction).toInt()
|
|
2799
|
+
params.gravity = Gravity.BOTTOM
|
|
2800
|
+
content.layoutParams = params
|
|
2801
|
+
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
|
2802
|
+
}
|
|
2748
2803
|
|
|
2749
2804
|
var transparent: Boolean = false
|
|
2750
2805
|
set(value) {
|
|
@@ -3095,18 +3150,28 @@ internal class CraftNativeInputEcho {
|
|
|
3095
3150
|
private var rendered: String? = null
|
|
3096
3151
|
private var initialized = false
|
|
3097
3152
|
|
|
3098
|
-
|
|
3153
|
+
private var eventCount = 0
|
|
3154
|
+
|
|
3155
|
+
fun record(value: String): Int {
|
|
3099
3156
|
pending.addLast(value)
|
|
3100
3157
|
while (pending.size > 64) pending.removeFirst()
|
|
3158
|
+
return ++eventCount
|
|
3101
3159
|
}
|
|
3102
3160
|
|
|
3103
|
-
fun accept(value: String?, nativeValue: String): Boolean {
|
|
3161
|
+
fun accept(value: String?, nativeValue: String, acknowledged: Int? = null): Boolean {
|
|
3104
3162
|
if (value == null) {
|
|
3105
3163
|
initialized = false
|
|
3106
3164
|
rendered = null
|
|
3107
3165
|
pending.clear()
|
|
3108
3166
|
return false
|
|
3109
3167
|
}
|
|
3168
|
+
if (acknowledged != null && acknowledged >= 0) {
|
|
3169
|
+
if (acknowledged < eventCount) return false
|
|
3170
|
+
pending.clear()
|
|
3171
|
+
initialized = true
|
|
3172
|
+
rendered = value
|
|
3173
|
+
return nativeValue != value
|
|
3174
|
+
}
|
|
3110
3175
|
// A retained field can render the same prop more than once, after
|
|
3111
3176
|
// its echo was consumed. That repeated prop must not overwrite a
|
|
3112
3177
|
// keystroke that has not made the native/JS round trip yet.
|
|
@@ -4866,6 +4931,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
4866
4931
|
put("viewScroll")
|
|
4867
4932
|
put("viewReparent")
|
|
4868
4933
|
put("viewNonModalPresentation")
|
|
4934
|
+
put("nativePlatformControls")
|
|
4869
4935
|
}
|
|
4870
4936
|
|
|
4871
4937
|
private fun appVersion(): Pair<String, String> = runCatching {
|
|
@@ -5360,7 +5426,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
5360
5426
|
previousId == null || previousId == protocolId || !screen.mutations.containsNode(previousId)
|
|
5361
5427
|
}
|
|
5362
5428
|
val previous = protocolId?.let { previousControls["id:$it"] } ?: keyedPrevious
|
|
5363
|
-
val reusableInput = previous as?
|
|
5429
|
+
val reusableInput = previous as? CraftNativeTextField
|
|
5364
5430
|
val control = when (type) {
|
|
5365
5431
|
"Text" -> ((previous as? TextView)?.takeUnless { it is Button || it is EditText } ?: TextView(this)).apply {
|
|
5366
5432
|
includeFontPadding = false
|
|
@@ -5465,7 +5531,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
5465
5531
|
}
|
|
5466
5532
|
}
|
|
5467
5533
|
}
|
|
5468
|
-
"TextInput" -> (reusableInput ?:
|
|
5534
|
+
"TextInput" -> (reusableInput ?: CraftNativeTextField(this)).apply {
|
|
5469
5535
|
// Setting filters, the input type or single-line mode makes
|
|
5470
5536
|
// TextView set its text again, which puts the caret back at
|
|
5471
5537
|
// the start: typing then came out backwards. Each is set only
|
|
@@ -5531,9 +5597,21 @@ class MainActivity : AppCompatActivity() {
|
|
|
5531
5597
|
// them after keyed reconciliation.
|
|
5532
5598
|
val autofillHint = autofillHintFor(props)
|
|
5533
5599
|
if (autofillHint == null) setAutofillHints(*emptyArray()) else setAutofillHints(autofillHint)
|
|
5600
|
+
clearButtonMode = craftNativeOptionalString(props.opt("clearButtonMode")) ?: "never"
|
|
5601
|
+
onClearRequest = {
|
|
5602
|
+
if (isEnabled && keyListener != null) {
|
|
5603
|
+
screen.suppressedTextChanges.add(this)
|
|
5604
|
+
setText("")
|
|
5605
|
+
val eventCount = screen.inputEchoes.getOrPut(this) { CraftNativeInputEcho() }.record("")
|
|
5606
|
+
screen.handlers[this]?.let { current ->
|
|
5607
|
+
send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject().put("text", "").put("clear", true).put("eventCount", eventCount)))
|
|
5608
|
+
}
|
|
5609
|
+
}
|
|
5610
|
+
}
|
|
5611
|
+
refreshClearButton()
|
|
5534
5612
|
val controlledValue = props.opt("value") as? String
|
|
5535
5613
|
val echoes = screen.inputEchoes.getOrPut(this) { CraftNativeInputEcho() }
|
|
5536
|
-
if (echoes.accept(controlledValue, text.toString()) && controlledValue != null) {
|
|
5614
|
+
if (echoes.accept(controlledValue, text.toString(), craftNativeInt(props.opt("mostRecentEventCount"), -1).takeIf { it >= 0 }) && controlledValue != null) {
|
|
5537
5615
|
screen.suppressedTextChanges.add(this)
|
|
5538
5616
|
setTextPreservingSelection(this, controlledValue)
|
|
5539
5617
|
}
|
|
@@ -5596,10 +5674,10 @@ class MainActivity : AppCompatActivity() {
|
|
|
5596
5674
|
(screen.inputIdentities[this@apply] ?: identity).let { screen.drafts[it] = updated }
|
|
5597
5675
|
if (screen.suppressedTextChanges.remove(this@apply)) return
|
|
5598
5676
|
val current = screen.handlers[this@apply] ?: return
|
|
5599
|
-
screen.inputEchoes.getOrPut(this@apply) { CraftNativeInputEcho() }.record(updated)
|
|
5677
|
+
val eventCount = screen.inputEchoes.getOrPut(this@apply) { CraftNativeInputEcho() }.record(updated)
|
|
5600
5678
|
send(screen, "EVENT", JSONObject()
|
|
5601
5679
|
.put("handlerName", current)
|
|
5602
|
-
.put("nativeEvent", JSONObject().put("text", updated)))
|
|
5680
|
+
.put("nativeEvent", JSONObject().put("text", updated).put("eventCount", eventCount)))
|
|
5603
5681
|
}
|
|
5604
5682
|
override fun afterTextChanged(s: Editable?) {}
|
|
5605
5683
|
}
|
|
@@ -5607,6 +5685,57 @@ class MainActivity : AppCompatActivity() {
|
|
|
5607
5685
|
addTextChangedListener(watcher)
|
|
5608
5686
|
}
|
|
5609
5687
|
}
|
|
5688
|
+
"SegmentedControl" -> (previous as? android.widget.RadioGroup ?: android.widget.RadioGroup(this)).apply {
|
|
5689
|
+
orientation = android.widget.LinearLayout.HORIZONTAL
|
|
5690
|
+
// Reconciliation is silent. Only the platform's user selection
|
|
5691
|
+
// listener below emits a value, with the stable option value.
|
|
5692
|
+
setOnCheckedChangeListener(null)
|
|
5693
|
+
val items = props.optJSONArray("items") ?: JSONArray()
|
|
5694
|
+
val valuesChanged = childCount != items.length() || (0 until items.length()).any { index ->
|
|
5695
|
+
getChildAt(index)?.tag != craftNativePickerValue(items.optJSONObject(index)?.opt("value")).orEmpty()
|
|
5696
|
+
}
|
|
5697
|
+
if (valuesChanged) removeAllViews()
|
|
5698
|
+
val requested = props.opt("value") as? String ?: craftNativePickerValue(props.opt("value")) ?: props.opt("selectedValue") as? String ?: craftNativePickerValue(props.opt("selectedValue"))
|
|
5699
|
+
val disabled = craftNativeBoolean(props.opt("disabled"))
|
|
5700
|
+
val foreground = color(craftNativeOptionalString(style.opt("color")).orEmpty(), Color.BLACK)
|
|
5701
|
+
val values = mutableMapOf<Int, Pair<String, Int>>()
|
|
5702
|
+
for (index in 0 until items.length()) {
|
|
5703
|
+
val item = items.optJSONObject(index) ?: continue
|
|
5704
|
+
val value = craftNativePickerValue(item.opt("value")) ?: ""
|
|
5705
|
+
val segment = (getChildAt(index) as? android.widget.RadioButton ?: android.widget.RadioButton(this@MainActivity)).apply {
|
|
5706
|
+
if (id == View.NO_ID) id = View.generateViewId()
|
|
5707
|
+
tag = value
|
|
5708
|
+
text = craftNativeOptionalString(item.opt("label")) ?: value
|
|
5709
|
+
contentDescription = craftNativeOptionalString(item.opt("spoken")) ?: text
|
|
5710
|
+
isEnabled = !disabled && !craftNativeBoolean(item.opt("disabled"))
|
|
5711
|
+
buttonDrawable = null
|
|
5712
|
+
gravity = Gravity.CENTER
|
|
5713
|
+
setTextColor(foreground)
|
|
5714
|
+
textSize = 13f
|
|
5715
|
+
minHeight = (32 * resources.displayMetrics.density).toInt()
|
|
5716
|
+
setPadding((8 * resources.displayMetrics.density).toInt(), 0, (8 * resources.displayMetrics.density).toInt(), 0)
|
|
5717
|
+
val selected = GradientDrawable().apply {
|
|
5718
|
+
setColor(color(craftNativeOptionalString(props.opt("tintColor")).orEmpty(), 0x33767680))
|
|
5719
|
+
cornerRadius = 7 * resources.displayMetrics.density
|
|
5720
|
+
}
|
|
5721
|
+
background = android.graphics.drawable.StateListDrawable().apply {
|
|
5722
|
+
addState(intArrayOf(android.R.attr.state_checked), selected)
|
|
5723
|
+
addState(intArrayOf(), ColorDrawable(Color.TRANSPARENT))
|
|
5724
|
+
}
|
|
5725
|
+
isChecked = value == requested
|
|
5726
|
+
}
|
|
5727
|
+
values[segment.id] = value to index
|
|
5728
|
+
if (segment.parent == null) addView(segment, android.widget.RadioGroup.LayoutParams(0, android.view.ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
|
|
5729
|
+
}
|
|
5730
|
+
isEnabled = !disabled
|
|
5731
|
+
val handler = craftNativeEventName(events, "onValueChange", "onChange")
|
|
5732
|
+
if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
|
|
5733
|
+
setOnCheckedChangeListener { _, checkedId ->
|
|
5734
|
+
val current = screen.handlers[this] ?: return@setOnCheckedChangeListener
|
|
5735
|
+
val (value, index) = values[checkedId] ?: return@setOnCheckedChangeListener
|
|
5736
|
+
send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject().put("value", value).put("index", index)))
|
|
5737
|
+
}
|
|
5738
|
+
}
|
|
5610
5739
|
"Picker" -> (previous as? Spinner ?: Spinner(this)).apply {
|
|
5611
5740
|
val itemOptions = props.optJSONArray("items")?.let { items ->
|
|
5612
5741
|
(0 until items.length()).mapNotNull { index ->
|
|
@@ -5676,6 +5805,14 @@ class MainActivity : AppCompatActivity() {
|
|
|
5676
5805
|
// the screen root. Keep the authored layout style with that
|
|
5677
5806
|
// promoted child so non-modal absolute coordinates remain in
|
|
5678
5807
|
// the screen viewport rather than the old clipped parent.
|
|
5808
|
+
val sheetStyle = craftNativeOptionalString(props.opt("presentationStyle"))
|
|
5809
|
+
val detents = props.optJSONArray("sheetDetents")
|
|
5810
|
+
val firstDetent = detents?.optString(0)?.takeIf { it == "medium" || it == "large" } ?: "medium"
|
|
5811
|
+
val selectedDetent = craftNativeOptionalString(props.opt("selectedDetent")) ?: firstDetent
|
|
5812
|
+
val fraction = if (blocksBackground && sheetStyle in setOf("pageSheet", "formSheet")) {
|
|
5813
|
+
if (selectedDetent == "large") 0.92f else 0.5f
|
|
5814
|
+
} else null
|
|
5815
|
+
if (sheetFraction != fraction) { sheetFraction = fraction; requestLayout() }
|
|
5679
5816
|
viewportStyle = style
|
|
5680
5817
|
declarationPath = path
|
|
5681
5818
|
screen.view.setChildStyle(this, style, resources.displayMetrics.density)
|
|
@@ -5767,7 +5904,10 @@ class MainActivity : AppCompatActivity() {
|
|
|
5767
5904
|
intArrayOf(trueColor ?: resolvedFalse, resolvedFalse),
|
|
5768
5905
|
)
|
|
5769
5906
|
}
|
|
5770
|
-
} ?: run {
|
|
5907
|
+
} ?: run {
|
|
5908
|
+
val tint = craftNativeOptionalString(style.opt("color"))?.let { runCatching { craftNativeColor(it) }.getOrNull() }
|
|
5909
|
+
trackTintList = tint?.let { ColorStateList(arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()), intArrayOf(it, Color.GRAY)) }
|
|
5910
|
+
}
|
|
5771
5911
|
craftNativeOptionalString(props.opt("thumbColor"))
|
|
5772
5912
|
?.let { runCatching { craftNativeColor(it) }.getOrNull() }
|
|
5773
5913
|
?.let { thumbTintList = ColorStateList.valueOf(it) }
|
|
@@ -5853,7 +5993,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
5853
5993
|
val animating = craftNativeBoolean(props.opt("animating"), true)
|
|
5854
5994
|
isIndeterminate = animating
|
|
5855
5995
|
visibility = if (!animating && craftNativeBoolean(props.opt("hidesWhenStopped"), true)) View.GONE else View.VISIBLE
|
|
5856
|
-
indeterminateTintList = craftNativeOptionalString(props.opt("color"))
|
|
5996
|
+
indeterminateTintList = (craftNativeOptionalString(props.opt("color")) ?: craftNativeOptionalString(style.opt("color")))
|
|
5857
5997
|
?.let { runCatching { craftNativeColor(it) }.getOrNull() }
|
|
5858
5998
|
?.let { ColorStateList.valueOf(it) }
|
|
5859
5999
|
}
|
|
@@ -6085,7 +6225,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
6085
6225
|
val underlayHideHandler = if (type == "TouchableHighlight") craftNativeEventName(events, "onHideUnderlay").takeIf { it.isNotBlank() } else null
|
|
6086
6226
|
if (underlayShowHandler != null) screen.pressUnderlayShowHandlers[control] = underlayShowHandler else screen.pressUnderlayShowHandlers.remove(control)
|
|
6087
6227
|
if (underlayHideHandler != null) screen.pressUnderlayHideHandlers[control] = underlayHideHandler else screen.pressUnderlayHideHandlers.remove(control)
|
|
6088
|
-
if (type != "Button" && type != "Link" && type != "Checkbox" && type != "TextInput" && type != "Picker" && type != "DatePicker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" && type != "KeyboardAvoidingView" && type != "StatusBar" && type != "RefreshControl" && type != "Map") {
|
|
6228
|
+
if (type != "Button" && type != "Link" && type != "Checkbox" && type != "TextInput" && type != "Picker" && type != "SegmentedControl" && type != "DatePicker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" && type != "KeyboardAvoidingView" && type != "StatusBar" && type != "RefreshControl" && type != "Map") {
|
|
6089
6229
|
val handler = craftNativeEventName(events, "onPress", "onClick")
|
|
6090
6230
|
val disabled = craftNativeBoolean(props.opt("disabled"))
|
|
6091
6231
|
val target = listOf(props.opt("href"), props.opt("to"))
|
|
@@ -6250,6 +6390,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
6250
6390
|
"link" -> "Link"
|
|
6251
6391
|
"checkbox" -> "Checkbox"
|
|
6252
6392
|
"textinput", "input" -> "TextInput"
|
|
6393
|
+
"segmentedcontrol", "segmented-control" -> "SegmentedControl"
|
|
6253
6394
|
"picker", "select" -> "Picker"
|
|
6254
6395
|
"datepicker", "date-picker", "date" -> "DatePicker"
|
|
6255
6396
|
"modal" -> "Modal"
|
|
@@ -7079,7 +7220,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
7079
7220
|
}
|
|
7080
7221
|
val state = props.optJSONObject("accessibilityState")
|
|
7081
7222
|
val disabled = craftNativeBoolean(props.opt("disabled")) || craftNativeBoolean(state?.opt("disabled"))
|
|
7082
|
-
if (type !in setOf("Button", "Link", "Checkbox", "TextInput", "Picker", "DatePicker", "Modal", "Switch", "Slider", "ActivityIndicator")) {
|
|
7223
|
+
if (type !in setOf("Button", "Link", "Checkbox", "TextInput", "Picker", "SegmentedControl", "DatePicker", "Modal", "Switch", "Slider", "ActivityIndicator")) {
|
|
7083
7224
|
view.isEnabled = !disabled
|
|
7084
7225
|
} else if (disabled) {
|
|
7085
7226
|
view.isEnabled = false
|
package/dist/cli.js
CHANGED
|
@@ -2945,7 +2945,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
|
|
|
2945
2945
|
`);
|
|
2946
2946
|
}
|
|
2947
2947
|
// package.json
|
|
2948
|
-
var version = "0.0.
|
|
2948
|
+
var version = "0.0.301";
|
|
2949
2949
|
|
|
2950
2950
|
// bin/cli.ts
|
|
2951
2951
|
var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
|
|
@@ -721,6 +721,9 @@ export interface PressableProps extends ViewProps {
|
|
|
721
721
|
* TextInput component props.
|
|
722
722
|
*/
|
|
723
723
|
export interface TextInputProps extends BaseProps {
|
|
724
|
+
/** Runtime acknowledgement of the latest native edit; stale controlled echoes are ignored. */
|
|
725
|
+
mostRecentEventCount?: number;
|
|
726
|
+
clearButtonMode?: 'never' | 'while-editing' | 'unless-editing' | 'always';
|
|
724
727
|
/** Style object or array */
|
|
725
728
|
style?: StyleValue<TextStyle>;
|
|
726
729
|
/** Current value */
|
|
@@ -831,6 +834,20 @@ export interface PickerProps extends BaseProps {
|
|
|
831
834
|
onChange?: (value: string | number, index: number) => void;
|
|
832
835
|
disabled?: boolean;
|
|
833
836
|
}
|
|
837
|
+
/** Platform-owned segmented selection (UISegmentedControl / Android RadioGroup). */
|
|
838
|
+
export interface SegmentedControlProps extends BaseProps {
|
|
839
|
+
items: Array<PickerOption & {
|
|
840
|
+
disabled?: boolean;
|
|
841
|
+
spoken?: string;
|
|
842
|
+
}>;
|
|
843
|
+
value?: string | number;
|
|
844
|
+
disabled?: boolean;
|
|
845
|
+
tintColor?: string;
|
|
846
|
+
onValueChange?: (event: {
|
|
847
|
+
value: string;
|
|
848
|
+
index: number;
|
|
849
|
+
}) => void;
|
|
850
|
+
}
|
|
834
851
|
export interface DatePickerChange {
|
|
835
852
|
value: string;
|
|
836
853
|
iso: string;
|
|
@@ -890,6 +907,10 @@ export interface ModalProps extends BaseProps {
|
|
|
890
907
|
nonModal?: boolean;
|
|
891
908
|
/** When non-modal, consume an outside tap and call onRequestClose. */
|
|
892
909
|
dismissOnOutsideTap?: boolean;
|
|
910
|
+
presentationStyle?: 'fullScreen' | 'pageSheet' | 'formSheet' | 'overFullScreen';
|
|
911
|
+
sheetDetents?: Array<'medium' | 'large'>;
|
|
912
|
+
selectedDetent?: 'medium' | 'large';
|
|
913
|
+
grabberVisible?: boolean;
|
|
893
914
|
animationType?: 'none' | 'fade' | 'slide';
|
|
894
915
|
onShow?: () => void;
|
|
895
916
|
onDismiss?: () => void;
|
package/dist/index.cjs
CHANGED
package/dist/index.js
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
|
|
@@ -7937,15 +8077,17 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7937
8077
|
break
|
|
7938
8078
|
}
|
|
7939
8079
|
let field = result as! UITextField
|
|
7940
|
-
|
|
7941
|
-
if let placeholder = field.placeholder,
|
|
8080
|
+
if let placeholder = props["placeholder"] as? String,
|
|
7942
8081
|
let placeholderColor = color(props["placeholderTextColor"]) {
|
|
7943
8082
|
field.attributedPlaceholder = NSAttributedString(
|
|
7944
8083
|
string: placeholder,
|
|
7945
8084
|
attributes: [.foregroundColor: placeholderColor]
|
|
7946
8085
|
)
|
|
7947
8086
|
} else {
|
|
8087
|
+
// UIKit shares these properties: resetting the attributed
|
|
8088
|
+
// value after the plain one also erases the visible hint.
|
|
7948
8089
|
field.attributedPlaceholder = nil
|
|
8090
|
+
field.placeholder = props["placeholder"] as? String
|
|
7949
8091
|
}
|
|
7950
8092
|
field.tintColor = color(props["selectionColor"])
|
|
7951
8093
|
let fieldId = ObjectIdentifier(field)
|
|
@@ -7957,6 +8099,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7957
8099
|
textMaxLengths.removeValue(forKey: fieldId)
|
|
7958
8100
|
}
|
|
7959
8101
|
field.delegate = self
|
|
8102
|
+
switch props["clearButtonMode"] as? String {
|
|
8103
|
+
case "while-editing": field.clearButtonMode = .whileEditing
|
|
8104
|
+
case "unless-editing": field.clearButtonMode = .unlessEditing
|
|
8105
|
+
case "always": field.clearButtonMode = .always
|
|
8106
|
+
default: field.clearButtonMode = .never
|
|
8107
|
+
}
|
|
7960
8108
|
(field as? CraftNativeTextField)?.padding = paddingInsets(style)
|
|
7961
8109
|
field.textColor = color(style["color"]) ?? .label
|
|
7962
8110
|
field.font = textFont(style, default: .systemFont(ofSize: 16))
|
|
@@ -7977,7 +8125,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7977
8125
|
if field.isEnabled != desiredEnabled { field.isEnabled = desiredEnabled }
|
|
7978
8126
|
applyAutofill(to: field, props: props)
|
|
7979
8127
|
if let value = props["value"] as? String {
|
|
7980
|
-
updateField(field, value: value)
|
|
8128
|
+
updateField(field, value: value, acknowledged: (props["mostRecentEventCount"] as? NSNumber)?.intValue)
|
|
7981
8129
|
} else if previous?.view !== field {
|
|
7982
8130
|
updateField(field, value: inputDrafts[current.identity] ?? props["defaultValue"] as? String)
|
|
7983
8131
|
}
|
|
@@ -7989,6 +8137,27 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7989
8137
|
if props["autoFocus"] as? Bool == true, !field.isFirstResponder {
|
|
7990
8138
|
DispatchQueue.main.async { _ = field.becomeFirstResponder() }
|
|
7991
8139
|
}
|
|
8140
|
+
case "SegmentedControl":
|
|
8141
|
+
let control = result as! CraftNativeSegmentedControl
|
|
8142
|
+
let items = props["items"] as? [[String: Any]] ?? []
|
|
8143
|
+
let labels = items.map { $0["label"] as? String ?? craftNativePickerValue($0["value"]) ?? "" }
|
|
8144
|
+
// UIKit owns the segments; only rebuild when the choices change.
|
|
8145
|
+
// Setting a controlled value never dispatches valueChanged.
|
|
8146
|
+
if control.numberOfSegments != labels.count || labels.enumerated().contains(where: { control.titleForSegment(at: $0.offset) != $0.element }) {
|
|
8147
|
+
control.removeAllSegments()
|
|
8148
|
+
for (index, label) in labels.enumerated() { control.insertSegment(withTitle: label, at: index, animated: false) }
|
|
8149
|
+
}
|
|
8150
|
+
control.values = items.map { $0["value"] as? String ?? craftNativePickerValue($0["value"]) ?? "" }
|
|
8151
|
+
for (index, item) in items.enumerated() { control.setEnabled(item["disabled"] as? Bool != true, forSegmentAt: index) }
|
|
8152
|
+
let selected = props["value"] as? String ?? craftNativePickerValue(props["value"]) ?? props["selectedValue"] as? String ?? craftNativePickerValue(props["selectedValue"])
|
|
8153
|
+
control.selectedSegmentIndex = control.values.firstIndex(where: { $0 == selected }) ?? UISegmentedControl.noSegment
|
|
8154
|
+
control.isEnabled = props["disabled"] as? Bool != true
|
|
8155
|
+
control.selectedSegmentTintColor = color(props["tintColor"])
|
|
8156
|
+
let titleAttributes: [NSAttributedString.Key: Any]? = color(style["color"]).map { [.foregroundColor: $0] }
|
|
8157
|
+
control.setTitleTextAttributes(titleAttributes, for: .normal)
|
|
8158
|
+
control.setTitleTextAttributes(titleAttributes, for: .selected)
|
|
8159
|
+
control.configureAccessibility(items: items)
|
|
8160
|
+
updateHandler(nonEmptyHandler(events["onValueChange"]) ?? nonEmptyHandler(events["onChange"]), for: control)
|
|
7992
8161
|
case "Picker":
|
|
7993
8162
|
let picker = result as! UIPickerView
|
|
7994
8163
|
let itemOptions = (props["items"] as? [[String: Any]])?.compactMap { item -> (value: String, label: String)? in
|
|
@@ -8051,6 +8220,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8051
8220
|
case "Modal":
|
|
8052
8221
|
let modal = result as! CraftNativeModalView
|
|
8053
8222
|
let focusBeforeContent = firstResponder(in: rootStack) ?? lastFocusedInput
|
|
8223
|
+
modal.sheetPresenter = self
|
|
8224
|
+
modal.presentationStyle = props["presentationStyle"] as? String ?? "overFullScreen"
|
|
8225
|
+
modal.sheetDetents = props["sheetDetents"] as? [String] ?? ["medium", "large"]
|
|
8226
|
+
modal.selectedDetent = props["selectedDetent"] as? String
|
|
8227
|
+
modal.grabberVisible = props["grabberVisible"] as? Bool != false
|
|
8228
|
+
modal.onRequestClose = nonEmptyHandler(events["onRequestClose"]).map { handler in
|
|
8229
|
+
{ [weak self] reason in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["reason": reason]]) }
|
|
8230
|
+
}
|
|
8054
8231
|
modal.transparent = props["transparent"] as? Bool == true
|
|
8055
8232
|
modal.blocksBackground = props["nonModal"] as? Bool != true
|
|
8056
8233
|
modal.dismissOnOutsideTap = props["dismissOnOutsideTap"] as? Bool == true
|
|
@@ -8124,7 +8301,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8124
8301
|
toggle.onTintColor = color(track["true"])
|
|
8125
8302
|
toggle.tintColor = color(track["false"])
|
|
8126
8303
|
} else {
|
|
8127
|
-
toggle.onTintColor =
|
|
8304
|
+
toggle.onTintColor = color(style["color"])
|
|
8128
8305
|
toggle.tintColor = nil
|
|
8129
8306
|
}
|
|
8130
8307
|
toggle.thumbTintColor = color(props["thumbColor"])
|
|
@@ -8162,7 +8339,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8162
8339
|
}
|
|
8163
8340
|
indicator.indicatorScale = scale
|
|
8164
8341
|
applyTransform(style["transform"], to: indicator)
|
|
8165
|
-
indicator.color = color(props["color"]) ?? .tintColor
|
|
8342
|
+
indicator.color = color(props["color"]) ?? color(style["color"]) ?? .tintColor
|
|
8166
8343
|
indicator.hidesWhenStopped = props["hidesWhenStopped"] as? Bool ?? true
|
|
8167
8344
|
if props["animating"] as? Bool == false {
|
|
8168
8345
|
indicator.stopAnimating()
|
|
@@ -8335,7 +8512,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8335
8512
|
reconcileChildren(children, in: stack, parent: current, path: path, style: style)
|
|
8336
8513
|
}
|
|
8337
8514
|
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" {
|
|
8515
|
+
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
8516
|
let disabled = props["disabled"] as? Bool == true
|
|
8340
8517
|
updatePressHandler(disabled ? nil : nonEmptyHandler(events["onPress"]) ?? nonEmptyHandler(events["onClick"]), for: result)
|
|
8341
8518
|
updatePressFeedback(
|
|
@@ -8391,6 +8568,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8391
8568
|
field.addTarget(self, action: #selector(textBlurred(_:)), for: .editingDidEnd)
|
|
8392
8569
|
field.addTarget(self, action: #selector(textSubmitted(_:)), for: .editingDidEndOnExit)
|
|
8393
8570
|
return field
|
|
8571
|
+
case "SegmentedControl":
|
|
8572
|
+
let control = CraftNativeSegmentedControl()
|
|
8573
|
+
control.addTarget(self, action: #selector(segmentChanged(_:)), for: .valueChanged)
|
|
8574
|
+
return control
|
|
8394
8575
|
case "Picker":
|
|
8395
8576
|
let picker = UIPickerView()
|
|
8396
8577
|
picker.dataSource = self
|
|
@@ -8457,6 +8638,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8457
8638
|
case "link": return "Link"
|
|
8458
8639
|
case "checkbox": return "Checkbox"
|
|
8459
8640
|
case "textinput", "input": return "TextInput"
|
|
8641
|
+
case "segmentedcontrol", "segmented-control": return "SegmentedControl"
|
|
8460
8642
|
case "picker", "select": return "Picker"
|
|
8461
8643
|
case "datepicker", "date-picker", "date": return "DatePicker"
|
|
8462
8644
|
case "modal": return "Modal"
|
|
@@ -8806,6 +8988,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8806
8988
|
submitHandlers.removeValue(forKey: id)
|
|
8807
8989
|
inputIdentities.removeValue(forKey: id)
|
|
8808
8990
|
unechoedText.removeValue(forKey: id)
|
|
8991
|
+
inputEventCounts.removeValue(forKey: id)
|
|
8809
8992
|
if node.type == "TextInput" && !preservingInputDrafts { inputDrafts.removeValue(forKey: node.identity) }
|
|
8810
8993
|
longPressHandlers.removeValue(forKey: id)
|
|
8811
8994
|
textMaxLengths.removeValue(forKey: id)
|
|
@@ -9023,19 +9206,21 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9023
9206
|
}
|
|
9024
9207
|
}
|
|
9025
9208
|
|
|
9026
|
-
///
|
|
9027
|
-
///
|
|
9028
|
-
/// one is the script catching up, not a new value, and applying it would
|
|
9029
|
-
/// undo what was typed since.
|
|
9209
|
+
/// Legacy runtimes echo values without a revision. Keep their bounded
|
|
9210
|
+
/// history on either script thread; bridge delivery may still be delayed.
|
|
9030
9211
|
private var unechoedText: [ObjectIdentifier: [String]] = [:]
|
|
9212
|
+
private var inputEventCounts: [ObjectIdentifier: Int] = [:]
|
|
9031
9213
|
|
|
9032
|
-
|
|
9033
|
-
|
|
9214
|
+
@discardableResult
|
|
9215
|
+
private func noteSentText(_ text: String, for view: UIView) -> Int {
|
|
9034
9216
|
let id = ObjectIdentifier(view)
|
|
9217
|
+
let count = (inputEventCounts[id] ?? 0) + 1
|
|
9218
|
+
inputEventCounts[id] = count
|
|
9035
9219
|
var sent = unechoedText[id] ?? []
|
|
9036
9220
|
sent.append(text)
|
|
9037
9221
|
if sent.count > 32 { sent.removeFirst(sent.count - 32) }
|
|
9038
9222
|
unechoedText[id] = sent
|
|
9223
|
+
return count
|
|
9039
9224
|
}
|
|
9040
9225
|
|
|
9041
9226
|
/// Whether `value` is a stale echo of text the view has moved past: it
|
|
@@ -9053,9 +9238,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9053
9238
|
return later > 0
|
|
9054
9239
|
}
|
|
9055
9240
|
|
|
9056
|
-
private func
|
|
9241
|
+
private func acceptsText(_ value: String, for view: UIView, acknowledged: Int?) -> Bool {
|
|
9242
|
+
if let acknowledged, acknowledged >= 0 {
|
|
9243
|
+
guard acknowledged >= (inputEventCounts[ObjectIdentifier(view)] ?? 0) else { return false }
|
|
9244
|
+
unechoedText.removeValue(forKey: ObjectIdentifier(view))
|
|
9245
|
+
return true // A current explicit reset may equal an older edit.
|
|
9246
|
+
}
|
|
9247
|
+
return !isStaleEcho(value, for: view)
|
|
9248
|
+
}
|
|
9249
|
+
|
|
9250
|
+
private func updateField(_ field: UITextField, value: String?, acknowledged: Int? = nil) {
|
|
9057
9251
|
guard let value = value, field.markedTextRange == nil else { return }
|
|
9058
|
-
if
|
|
9252
|
+
if !acceptsText(value, for: field, acknowledged: acknowledged) || field.text == value { return }
|
|
9059
9253
|
let selection = field.selectedTextRange
|
|
9060
9254
|
let start = selection.map { field.offset(from: field.beginningOfDocument, to: $0.start) }
|
|
9061
9255
|
let end = selection.map { field.offset(from: field.beginningOfDocument, to: $0.end) }
|
|
@@ -9067,9 +9261,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9067
9261
|
}
|
|
9068
9262
|
}
|
|
9069
9263
|
|
|
9070
|
-
private func updateTextView(_ textView: UITextView, value: String?) {
|
|
9264
|
+
private func updateTextView(_ textView: UITextView, value: String?, acknowledged: Int? = nil) {
|
|
9071
9265
|
guard let value = value, textView.markedTextRange == nil else { return }
|
|
9072
|
-
if
|
|
9266
|
+
if !acceptsText(value, for: textView, acknowledged: acknowledged) || textView.text == value { return }
|
|
9073
9267
|
let selection = textView.selectedTextRange
|
|
9074
9268
|
let start = selection.map { textView.offset(from: textView.beginningOfDocument, to: $0.start) }
|
|
9075
9269
|
let end = selection.map { textView.offset(from: textView.beginningOfDocument, to: $0.end) }
|
|
@@ -9132,7 +9326,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9132
9326
|
craftTextView.placeholderColor = color(props["placeholderTextColor"])
|
|
9133
9327
|
craftTextView.placeholderFont = textView.font
|
|
9134
9328
|
}
|
|
9135
|
-
updateTextView(textView, value: (props["value"] as? String) ?? defaultValue)
|
|
9329
|
+
updateTextView(textView, value: (props["value"] as? String) ?? defaultValue, acknowledged: (props["mostRecentEventCount"] as? NSNumber)?.intValue)
|
|
9136
9330
|
if textView.markedTextRange == nil {
|
|
9137
9331
|
let attributes = inputTextAttributes(style, font: textView.font ?? .systemFont(ofSize: 16), alignment: textView.textAlignment)
|
|
9138
9332
|
let styled = NSAttributedString(string: textView.text ?? "", attributes: attributes)
|
|
@@ -9836,7 +10030,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9836
10030
|
let controlledVideo = type == "Video" && (props["controls"] as? Bool) == true
|
|
9837
10031
|
// UIPickerView exposes adjustable component wheels. Making the
|
|
9838
10032
|
// picker itself a leaf hides those wheels and their selected values.
|
|
9839
|
-
let container = ["ScrollView", "FlatList", "SectionList", "Picker"].contains(type)
|
|
10033
|
+
let container = ["ScrollView", "FlatList", "SectionList", "Picker", "SegmentedControl"].contains(type)
|
|
9840
10034
|
|| ["tablist", "radiogroup", "list", "menu", "menubar", "toolbar"].contains(role ?? "")
|
|
9841
10035
|
|| controlledVideo
|
|
9842
10036
|
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 +10133,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9939
10133
|
/// Return on a `returnKeyType="next"` field the screen does not handle
|
|
9940
10134
|
/// itself (no `@submit`) goes to the next field down the screen, keeping
|
|
9941
10135
|
/// the keyboard up. Every other Return ends editing and fires `@submit`.
|
|
10136
|
+
func textFieldShouldClear(_ textField: UITextField) -> Bool {
|
|
10137
|
+
guard textField.isEnabled else { return false }
|
|
10138
|
+
textField.text = ""
|
|
10139
|
+
let id = ObjectIdentifier(textField)
|
|
10140
|
+
inputIdentities[id].map { inputDrafts[$0] = "" }
|
|
10141
|
+
let eventCount = noteSentText("", for: textField)
|
|
10142
|
+
if let handler = handlers[id] {
|
|
10143
|
+
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": "", "clear": true, "eventCount": eventCount]])
|
|
10144
|
+
}
|
|
10145
|
+
return false // The model receives exactly one edit, including lazy models.
|
|
10146
|
+
}
|
|
10147
|
+
|
|
9942
10148
|
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
|
9943
10149
|
guard textField.returnKeyType == .next, submitHandlers[ObjectIdentifier(textField)] == nil,
|
|
9944
10150
|
let next = inputAfter(textField) else { return true }
|
|
@@ -9985,8 +10191,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9985
10191
|
(textView as? CraftNativeTextView)?.refreshPlaceholder()
|
|
9986
10192
|
keepCaretVisible(in: textView)
|
|
9987
10193
|
guard let handler = handlers[id] else { return }
|
|
9988
|
-
noteSentText(textView.text ?? "", for: textView)
|
|
9989
|
-
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": textView.text ?? ""]])
|
|
10194
|
+
let eventCount = noteSentText(textView.text ?? "", for: textView)
|
|
10195
|
+
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": textView.text ?? "", "eventCount": eventCount]])
|
|
9990
10196
|
}
|
|
9991
10197
|
|
|
9992
10198
|
func textViewDidChangeSelection(_ textView: UITextView) {
|
|
@@ -10042,14 +10248,22 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
10042
10248
|
inputIdentities[id].map { inputDrafts[$0] = sender.text ?? "" }
|
|
10043
10249
|
let focused = sender.isFirstResponder ? sender : firstResponder(in: rootStack)
|
|
10044
10250
|
guard let handler = handlers[id] else { return }
|
|
10045
|
-
noteSentText(sender.text ?? "", for: sender)
|
|
10046
|
-
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": sender.text ?? ""]])
|
|
10251
|
+
let eventCount = noteSentText(sender.text ?? "", for: sender)
|
|
10252
|
+
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": sender.text ?? "", "eventCount": eventCount]])
|
|
10047
10253
|
restoreFocus(focused)
|
|
10048
10254
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self, weak focused] in
|
|
10049
10255
|
self?.restoreFocus(focused)
|
|
10050
10256
|
}
|
|
10051
10257
|
}
|
|
10052
10258
|
|
|
10259
|
+
@objc private func segmentChanged(_ sender: CraftNativeSegmentedControl) {
|
|
10260
|
+
let index = sender.selectedSegmentIndex
|
|
10261
|
+
guard sender.isEnabled, sender.values.indices.contains(index), sender.isEnabledForSegment(at: index),
|
|
10262
|
+
let handler = handlers[ObjectIdentifier(sender)] else { return }
|
|
10263
|
+
sender.refreshAccessibilityState()
|
|
10264
|
+
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": sender.values[index], "index": index]])
|
|
10265
|
+
}
|
|
10266
|
+
|
|
10053
10267
|
@objc private func switchChanged(_ sender: UISwitch) {
|
|
10054
10268
|
let id = ObjectIdentifier(sender)
|
|
10055
10269
|
switchUserValues[id] = sender.isOn
|