craft-native 0.0.128 → 0.0.132

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.
@@ -48,6 +48,7 @@ import android.widget.Switch
48
48
  import android.widget.TextView
49
49
  import androidx.appcompat.app.AppCompatActivity
50
50
  import androidx.appcompat.app.AppCompatDelegate
51
+ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
51
52
  import androidx.core.content.ContextCompat
52
53
  import androidx.core.view.AccessibilityDelegateCompat
53
54
  import androidx.core.view.WindowInsetsCompat
@@ -72,13 +73,31 @@ import kotlin.math.roundToInt
72
73
 
73
74
  private const val CRAFT_NATIVE_CAPABILITY_TIMEOUT_MS = 30_000L
74
75
 
76
+ private data class CraftNativeLength(
77
+ val points: Int? = null,
78
+ val fraction: Double? = null,
79
+ ) {
80
+ fun resolve(base: Int?): Int? = points ?: fraction?.let { value -> base?.let { max(0, (it * value).roundToInt()) } }
81
+
82
+ companion object {
83
+ fun from(value: Any?, density: Float): CraftNativeLength? {
84
+ if (value == null || value == JSONObject.NULL) return null
85
+ if (value is Number) return CraftNativeLength(points = (value.toDouble() * density).roundToInt())
86
+ val text = value.toString().trim()
87
+ if (text.endsWith('%')) return text.dropLast(1).toDoubleOrNull()?.let { CraftNativeLength(fraction = it / 100) }
88
+ if (text.endsWith("px")) return text.dropLast(2).toDoubleOrNull()?.let { CraftNativeLength(points = (it * density).roundToInt()) }
89
+ return text.toDoubleOrNull()?.let { CraftNativeLength(points = (it * density).roundToInt()) }
90
+ }
91
+ }
92
+ }
93
+
75
94
  private data class CraftNativeLayoutStyle(
76
- val width: Int? = null,
77
- val height: Int? = null,
78
- val minWidth: Int? = null,
79
- val maxWidth: Int? = null,
80
- val minHeight: Int? = null,
81
- val maxHeight: Int? = null,
95
+ val width: CraftNativeLength? = null,
96
+ val height: CraftNativeLength? = null,
97
+ val minWidth: CraftNativeLength? = null,
98
+ val maxWidth: CraftNativeLength? = null,
99
+ val minHeight: CraftNativeLength? = null,
100
+ val maxHeight: CraftNativeLength? = null,
82
101
  val position: String = "relative",
83
102
  val top: Int? = null,
84
103
  val right: Int? = null,
@@ -86,19 +105,25 @@ private data class CraftNativeLayoutStyle(
86
105
  val left: Int? = null,
87
106
  val alignSelf: String? = null,
88
107
  val flexGrow: Double = 0.0,
89
- val flexShrink: Double = 0.0,
90
- val flexBasis: Int? = null,
108
+ val flexShrink: Double? = null,
109
+ val flexBasis: CraftNativeLength? = null,
91
110
  ) {
92
111
  companion object {
93
112
  fun from(raw: JSONObject, density: Float): CraftNativeLayoutStyle {
113
+ fun length(name: String): CraftNativeLength? = CraftNativeLength.from(raw.opt(name), density)
94
114
  fun number(name: String): Int? {
95
115
  if (!raw.has(name) || raw.isNull(name)) return null
96
- return raw.optDouble(name, Double.NaN).takeUnless { it.isNaN() }?.let { (it * density).toInt() }
116
+ return raw.optDouble(name, Double.NaN).takeUnless { it.isNaN() }?.let { (it * density).roundToInt() }
97
117
  }
118
+ fun decimal(name: String): Double? {
119
+ if (!raw.has(name) || raw.isNull(name)) return null
120
+ return raw.optDouble(name, Double.NaN).takeUnless { it.isNaN() }
121
+ }
122
+ val flex = decimal("flex")
98
123
  return CraftNativeLayoutStyle(
99
- width = number("width"), height = number("height"),
100
- minWidth = number("minWidth"), maxWidth = number("maxWidth"),
101
- minHeight = number("minHeight"), maxHeight = number("maxHeight"),
124
+ width = length("width"), height = length("height"),
125
+ minWidth = length("minWidth"), maxWidth = length("maxWidth"),
126
+ minHeight = length("minHeight"), maxHeight = length("maxHeight"),
102
127
  position = raw.opt("position")
103
128
  ?.takeUnless { it == JSONObject.NULL }
104
129
  ?.toString()
@@ -108,10 +133,11 @@ private data class CraftNativeLayoutStyle(
108
133
  alignSelf = raw.opt("alignSelf")
109
134
  ?.takeUnless { it == JSONObject.NULL }
110
135
  ?.toString()
111
- ?.takeIf { it.isNotBlank() },
112
- flexGrow = raw.optDouble("flexGrow", raw.optDouble("flex", 0.0)),
113
- flexShrink = raw.optDouble("flexShrink", 0.0),
114
- flexBasis = number("flexBasis"),
136
+ ?.takeIf { it.isNotBlank() && it != "auto" },
137
+ flexGrow = decimal("flexGrow") ?: flex?.coerceAtLeast(0.0) ?: 0.0,
138
+ flexShrink = decimal("flexShrink") ?: flex?.let { if (it != 0.0) 1.0 else 0.0 },
139
+ flexBasis = length("flexBasis")
140
+ ?: flex?.takeIf { it > 0.0 }?.let { CraftNativeLength(points = 0) },
115
141
  )
116
142
  }
117
143
  }
@@ -134,6 +160,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
134
160
  private val styles = mutableMapOf<View, CraftNativeLayoutStyle>()
135
161
  private var wrap = false
136
162
  private var alignItems = "stretch"
163
+ private var alignContent = "flex-start"
137
164
  private var justifyContent = "flex-start"
138
165
  private var rowGap = 0
139
166
  private var columnGap = 0
@@ -145,6 +172,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
145
172
  orientation = if (style.optString("flexDirection") in setOf("row", "row-reverse")) HORIZONTAL else VERTICAL
146
173
  wrap = style.optString("flexWrap") == "wrap" || style.optBoolean("flexWrap", false)
147
174
  alignItems = style.optString("alignItems", "stretch")
175
+ alignContent = style.optString("alignContent", "flex-start")
148
176
  justifyContent = style.optString("justifyContent", "flex-start")
149
177
  val gap = (style.optDouble("gap", 0.0) * density).toInt()
150
178
  rowGap = (style.optDouble("rowGap", gap.toDouble() / density.toDouble()) * density).toInt()
@@ -294,25 +322,30 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
294
322
  }
295
323
 
296
324
  private fun measureChildWidth(child: View, style: CraftNativeLayoutStyle, available: Int, margins: Int): Int {
297
- val flexBasis = if (orientation == HORIZONTAL) style.flexBasis else null
298
- val explicit = style.width ?: flexBasis
299
- ?: (child.layoutParams?.width ?: ViewGroup.LayoutParams.WRAP_CONTENT).takeIf { it >= 0 }
300
- val target = explicit ?: max(0, available - margins)
325
+ val base = max(0, available - margins)
326
+ val flexBasis = if (orientation == HORIZONTAL) style.flexBasis?.resolve(base) else null
327
+ // The STX style is authoritative. A reused View may still carry a
328
+ // previous fixed LayoutParams width after a reactive width is removed;
329
+ // using that value here prevents intrinsic sizing from being restored.
330
+ val explicit = style.width?.resolve(base) ?: flexBasis
331
+ val target = explicit ?: base
301
332
  val spec = if (explicit != null) View.MeasureSpec.makeMeasureSpec(target, View.MeasureSpec.EXACTLY)
302
- else View.MeasureSpec.makeMeasureSpec(max(0, available - margins), View.MeasureSpec.AT_MOST)
333
+ else View.MeasureSpec.makeMeasureSpec(base, View.MeasureSpec.AT_MOST)
303
334
  child.measure(spec, View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED))
304
- return clamp(if (explicit != null) target else child.measuredWidth, style.minWidth, style.maxWidth)
335
+ return clamp(if (explicit != null) target else child.measuredWidth, style.minWidth?.resolve(base), style.maxWidth?.resolve(base))
305
336
  }
306
337
 
307
338
  private fun measureChildHeight(child: View, style: CraftNativeLayoutStyle, available: Int, margins: Int): Int {
308
- val flexBasis = if (orientation == VERTICAL) style.flexBasis else null
309
- val explicit = style.height ?: flexBasis
310
- ?: (child.layoutParams?.height ?: ViewGroup.LayoutParams.WRAP_CONTENT).takeIf { it >= 0 }
311
- val target = explicit ?: max(0, available - margins)
339
+ val base = max(0, available - margins)
340
+ val flexBasis = if (orientation == VERTICAL) style.flexBasis?.resolve(base) else null
341
+ // Match width measurement: do not let stale native LayoutParams keep
342
+ // a removed reactive height fixed.
343
+ val explicit = style.height?.resolve(base) ?: flexBasis
344
+ val target = explicit ?: base
312
345
  val spec = if (explicit != null) View.MeasureSpec.makeMeasureSpec(target, View.MeasureSpec.EXACTLY)
313
- else View.MeasureSpec.makeMeasureSpec(max(0, available - margins), View.MeasureSpec.AT_MOST)
346
+ else View.MeasureSpec.makeMeasureSpec(base, View.MeasureSpec.AT_MOST)
314
347
  child.measure(child.measuredWidth.let { View.MeasureSpec.makeMeasureSpec(max(0, it), View.MeasureSpec.EXACTLY) }, spec)
315
- return clamp(if (explicit != null) target else child.measuredHeight, style.minHeight, style.maxHeight)
348
+ return clamp(if (explicit != null) target else child.measuredHeight, style.minHeight?.resolve(base), style.maxHeight?.resolve(base))
316
349
  }
317
350
 
318
351
  private fun clamp(value: Int, lower: Int?, upper: Int?): Int {
@@ -322,6 +355,12 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
322
355
  return result
323
356
  }
324
357
 
358
+ private fun defaultFlexShrink(view: View): Double = when {
359
+ view is ScrollView || view is HorizontalScrollView -> 1.0
360
+ view is TextView && view !is Button && view !is EditText && orientation == HORIZONTAL -> 1.0
361
+ else -> 0.0
362
+ }
363
+
325
364
  override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
326
365
  val widthMode = View.MeasureSpec.getMode(widthMeasureSpec)
327
366
  val width = View.MeasureSpec.getSize(widthMeasureSpec).takeUnless { widthMode == View.MeasureSpec.UNSPECIFIED } ?: 1_000_000
@@ -404,18 +443,38 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
404
443
  val gap = if (orientation == HORIZONTAL) columnGap else rowGap
405
444
  val crossGap = if (orientation == HORIZONTAL) rowGap else columnGap
406
445
  val availableMain = if (orientation == HORIZONTAL) contentWidth else contentHeight
407
- var crossOffset = if (orientation == HORIZONTAL) paddingTop else paddingLeft
446
+ val availableCross = if (orientation == HORIZONTAL) contentHeight else contentWidth
447
+ val totalCross = lines.sumOf { it.cross } + max(0, lines.size - 1) * crossGap
448
+ val crossFree = max(0, availableCross - totalCross)
449
+ var crossLeading = 0
450
+ var crossBetween = crossGap
451
+ if (wrap && lines.size > 1) {
452
+ when (alignContent) {
453
+ "center" -> crossLeading = crossFree / 2
454
+ "flex-end" -> crossLeading = crossFree
455
+ "space-between" -> crossBetween = crossFree / (lines.size - 1) + crossGap
456
+ "space-around" -> {
457
+ crossBetween = crossFree / lines.size + crossGap
458
+ crossLeading = crossFree / (lines.size * 2)
459
+ }
460
+ "stretch" -> {
461
+ val extra = crossFree / lines.size
462
+ lines.forEach { it.cross += extra }
463
+ }
464
+ }
465
+ }
466
+ var crossOffset = (if (orientation == HORIZONTAL) paddingTop else paddingLeft) + crossLeading
408
467
  for (line in lines) {
409
468
  val rawFree = availableMain - line.main
410
- val factors = line.entries.map { if (rawFree >= 0) it.style.flexGrow else it.style.flexShrink }
469
+ val factors = line.entries.map { if (rawFree >= 0) it.style.flexGrow else it.style.flexShrink ?: defaultFlexShrink(it.view) }
411
470
  val totalFactor = factors.sum()
412
471
  val distributedMain = line.entries.indices.sumOf { index ->
413
472
  val base = if (orientation == HORIZONTAL) line.entries[index].width else line.entries[index].height
414
473
  val primary = if (totalFactor > 0) max(0, (base + rawFree * factors[index] / totalFactor).toInt()) else base
415
474
  val constrained = if (orientation == HORIZONTAL) {
416
- clamp(primary, line.entries[index].style.minWidth, line.entries[index].style.maxWidth)
475
+ clamp(primary, line.entries[index].style.minWidth?.resolve(contentWidth), line.entries[index].style.maxWidth?.resolve(contentWidth))
417
476
  } else {
418
- clamp(primary, line.entries[index].style.minHeight, line.entries[index].style.maxHeight)
477
+ clamp(primary, line.entries[index].style.minHeight?.resolve(contentHeight), line.entries[index].style.maxHeight?.resolve(contentHeight))
419
478
  }
420
479
  val lp = line.entries[index].view.layoutParams as? MarginLayoutParams
421
480
  val margins = if (orientation == HORIZONTAL) (lp?.leftMargin ?: 0) + (lp?.rightMargin ?: 0)
@@ -447,16 +506,16 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
447
506
  val primary = if (orientation == HORIZONTAL) entry.width else entry.height
448
507
  val distributed = if (totalFactor > 0) max(0, (primary + rawFree * factors[index] / totalFactor).toInt()) else primary
449
508
  val distributedPrimary = if (orientation == HORIZONTAL) {
450
- clamp(distributed, entry.style.minWidth, entry.style.maxWidth)
509
+ clamp(distributed, entry.style.minWidth?.resolve(contentWidth), entry.style.maxWidth?.resolve(contentWidth))
451
510
  } else {
452
- clamp(distributed, entry.style.minHeight, entry.style.maxHeight)
511
+ clamp(distributed, entry.style.minHeight?.resolve(contentHeight), entry.style.maxHeight?.resolve(contentHeight))
453
512
  }
454
513
  val w = if (orientation == HORIZONTAL) distributedPrimary else crossSize
455
514
  val h = if (orientation == HORIZONTAL) crossSize else distributedPrimary
456
515
  entry.view.layout(x, y, x + w, y + h)
457
516
  mainOffset += distributedPrimary + marginStart + marginEnd + gap + between
458
517
  }
459
- crossOffset += line.cross + crossGap
518
+ crossOffset += line.cross + crossBetween
460
519
  }
461
520
  for (entry in absolute) {
462
521
  if (entry.view is CraftNativeModalView && entry.view.visibility == View.VISIBLE) entry.view.bringToFront()
@@ -479,7 +538,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
479
538
  }
480
539
  }
481
540
 
482
- private class CraftNativeScrollView(context: Context) : FrameLayout(context) {
541
+ private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(context) {
483
542
  val content = CraftNativeFlexLayout(context)
484
543
  private val vertical = ScrollView(context)
485
544
  private val horizontal = HorizontalScrollView(context)
@@ -489,15 +548,31 @@ private class CraftNativeScrollView(context: Context) : FrameLayout(context) {
489
548
  private var alwaysBounceVertical = true
490
549
  private var alwaysBounceHorizontal = true
491
550
  private var keyboardShouldPersistTaps = "never"
551
+ private var refreshHandler: (() -> Unit)? = null
492
552
 
493
553
  init {
494
554
  vertical.isNestedScrollingEnabled = true
495
555
  horizontal.isNestedScrollingEnabled = true
496
556
  vertical.isFillViewport = true
497
557
  horizontal.isFillViewport = true
558
+ setOnRefreshListener { refreshHandler?.invoke() }
559
+ isEnabled = false
498
560
  setHorizontal(false)
499
561
  }
500
562
 
563
+ fun setRefreshHandler(handler: (() -> Unit)?, refreshing: Boolean) {
564
+ refreshHandler = handler
565
+ isEnabled = handler != null
566
+ isRefreshing = handler != null && refreshing
567
+ if (handler == null) isRefreshing = false
568
+ }
569
+
570
+ override fun onDetachedFromWindow() {
571
+ refreshHandler = null
572
+ isRefreshing = false
573
+ super.onDetachedFromWindow()
574
+ }
575
+
501
576
  fun setHorizontal(value: Boolean) {
502
577
  if (horizontalMode == value) return
503
578
  horizontalMode = value
@@ -661,6 +736,7 @@ private class CraftNativeModalView(context: Context) : FrameLayout(context) {
661
736
  val content = CraftNativeFlexLayout(context)
662
737
  private val blocker = View(context)
663
738
  private var renderedVisible = false
739
+ private var animationGeneration = 0
664
740
  var onRequestClose: (() -> Unit)? = null
665
741
 
666
742
  var transparent: Boolean = false
@@ -679,22 +755,59 @@ private class CraftNativeModalView(context: Context) : FrameLayout(context) {
679
755
  }
680
756
 
681
757
  fun setVisible(visible: Boolean, opacity: Float, animationType: String?) {
682
- if (renderedVisible == visible && visibility == if (visible) View.VISIBLE else View.GONE) return
758
+ if (renderedVisible == visible && visibility == if (visible) View.VISIBLE else View.GONE) {
759
+ if (visible && alpha != opacity) alpha = opacity
760
+ return
761
+ }
762
+ val wasVisible = renderedVisible && visibility == View.VISIBLE
683
763
  renderedVisible = visible
764
+ animationGeneration += 1
765
+ val generation = animationGeneration
766
+ animate().cancel()
684
767
  if (!visible) {
685
- animate().cancel()
686
- alpha = 0f
687
- visibility = View.GONE
768
+ if (animationType == "slide" && wasVisible) {
769
+ val distance = max(height, (parent as? View)?.height ?: resources.displayMetrics.heightPixels).toFloat()
770
+ animate().translationY(distance).alpha(0f).setDuration(240L).withEndAction {
771
+ if (animationGeneration != generation) return@withEndAction
772
+ visibility = View.GONE
773
+ translationY = 0f
774
+ alpha = 0f
775
+ }.start()
776
+ } else {
777
+ alpha = 0f
778
+ translationY = 0f
779
+ visibility = View.GONE
780
+ }
688
781
  return
689
782
  }
690
783
  visibility = View.VISIBLE
691
784
  if (animationType == "none") {
785
+ translationY = 0f
786
+ alpha = opacity
787
+ } else if (animationType == "slide") {
692
788
  alpha = opacity
789
+ translationY = max(height, (parent as? View)?.height ?: resources.displayMetrics.heightPixels).toFloat()
790
+ post {
791
+ if (animationGeneration != generation || visibility != View.VISIBLE) return@post
792
+ val distance = max(height, (parent as? View)?.height ?: resources.displayMetrics.heightPixels).toFloat()
793
+ translationY = distance
794
+ animate().translationY(0f).setDuration(240L).start()
795
+ }
693
796
  } else {
797
+ translationY = 0f
694
798
  alpha = 0f
695
- animate().alpha(opacity).setDuration(if (animationType == "slide") 240L else 180L).start()
799
+ animate().alpha(opacity).setDuration(180L).start()
696
800
  }
697
801
  }
802
+
803
+ fun resetForTeardown() {
804
+ animationGeneration += 1
805
+ animate().cancel()
806
+ visibility = View.GONE
807
+ translationY = 0f
808
+ alpha = 0f
809
+ onRequestClose = null
810
+ }
698
811
  }
699
812
 
700
813
  private data class LayoutBounds(
@@ -752,9 +865,25 @@ class MainActivity : AppCompatActivity() {
752
865
  var active = true
753
866
 
754
867
  fun close() {
868
+ if (!active) return
755
869
  active = false
756
- port?.close()
757
- isolate.close()
870
+ val finish = {
871
+ port?.close()
872
+ isolate.close()
873
+ }
874
+ val teardown = runCatching {
875
+ isolate.evaluateJavaScriptAsync(
876
+ "globalThis.__stxNativeUnmount && globalThis.__stxNativeUnmount()"
877
+ )
878
+ }.getOrNull()
879
+ if (teardown == null) {
880
+ finish()
881
+ return
882
+ }
883
+ teardown.addListener({
884
+ runCatching { teardown.get() }
885
+ finish()
886
+ }, java.util.concurrent.Executor { command -> command.run() })
758
887
  }
759
888
  }
760
889
 
@@ -922,6 +1051,7 @@ class MainActivity : AppCompatActivity() {
922
1051
  capabilityTimeoutMs: $CRAFT_NATIVE_CAPABILITY_TIMEOUT_MS,
923
1052
  capabilities: ${capabilities.names()},
924
1053
  initialAppState: ${JSONObject.quote(nativeAppState)},
1054
+ colorScheme: ${JSONObject.quote(colorScheme())},
925
1055
  postMessage: function(value) { port.postMessage(value); },
926
1056
  onMessage: function(callback) { port.onmessage = function(event) { callback(event.data); }; }
927
1057
  };
@@ -935,6 +1065,7 @@ class MainActivity : AppCompatActivity() {
935
1065
  capabilityTimeoutMs: $CRAFT_NATIVE_CAPABILITY_TIMEOUT_MS,
936
1066
  capabilities: ${capabilities.names()},
937
1067
  initialAppState: ${JSONObject.quote(nativeAppState)},
1068
+ colorScheme: ${JSONObject.quote(colorScheme())},
938
1069
  postMessage: function(value) { console.log(${JSONObject.quote(prefix)} + value); },
939
1070
  onMessage: function(callback) { globalThis.__stxNativeCallback = callback; }
940
1071
  };
@@ -991,6 +1122,9 @@ class MainActivity : AppCompatActivity() {
991
1122
  refreshInputDefaultsForTraitChange()
992
1123
  screens.lastOrNull()?.let { screen ->
993
1124
  refreshScreenTheme(screen)
1125
+ if (screen.active) {
1126
+ send(screen, "APPEARANCE", JSONObject().put("colorScheme", colorScheme()))
1127
+ }
994
1128
  }
995
1129
  }
996
1130
 
@@ -1011,6 +1145,10 @@ class MainActivity : AppCompatActivity() {
1011
1145
  defaultTextColor = if (Color.luminance(background) < 0.5) Color.WHITE else Color.BLACK
1012
1146
  }
1013
1147
 
1148
+ private fun colorScheme(): String = if (
1149
+ resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
1150
+ ) "dark" else "light"
1151
+
1014
1152
  private fun refreshInputDefaultsForTraitChange() {
1015
1153
  if (inputHintDefaults.isEmpty() && inputHighlightDefaults.isEmpty()) return
1016
1154
  val probe = EditText(this)
@@ -1102,10 +1240,30 @@ class MainActivity : AppCompatActivity() {
1102
1240
  val document = screen.mutations.node(target) ?: return false
1103
1241
  if (!screen.controls.containsKey("id:$target")) return false
1104
1242
  makeView(screen, document, target, screen.controls)
1243
+ val updated = screen.controls["id:$target"] ?: return false
1244
+ if (!updateParentLayoutStyle(updated, document.optJSONObject("style") ?: JSONObject())) return false
1105
1245
  }
1106
1246
  return true
1107
1247
  }
1108
1248
 
1249
+ /**
1250
+ * Targeted mutations bypass the parent's child reconciliation. Rebind the
1251
+ * updated node's layout style explicitly so removing a width/height (or
1252
+ * changing flex participation) invalidates the native parent's metrics.
1253
+ */
1254
+ private fun updateParentLayoutStyle(view: View, style: JSONObject): Boolean {
1255
+ var parent = view.parent
1256
+ while (parent != null) {
1257
+ if (parent is CraftNativeFlexLayout) {
1258
+ parent.setChildStyle(view, style, resources.displayMetrics.density)
1259
+ parent.requestLayout()
1260
+ return true
1261
+ }
1262
+ parent = parent.parent
1263
+ }
1264
+ return false
1265
+ }
1266
+
1109
1267
  private fun renderCommitted(screen: Screen, document: JSONObject) {
1110
1268
  // Keep live controls (especially the focused EditText) across renders.
1111
1269
  val next = mutableMapOf<String, View>()
@@ -1154,6 +1312,7 @@ class MainActivity : AppCompatActivity() {
1154
1312
  val reusableInput = previous as? EditText
1155
1313
  val control = when (type) {
1156
1314
  "Text" -> ((previous as? TextView)?.takeUnless { it is Button || it is EditText } ?: TextView(this)).apply {
1315
+ includeFontPadding = false
1157
1316
  maxLines = props.optInt("numberOfLines", 0).takeIf { it > 0 } ?: Int.MAX_VALUE
1158
1317
  ellipsize = when (props.optString("ellipsizeMode")) {
1159
1318
  "head" -> TextUtils.TruncateAt.START
@@ -1164,6 +1323,7 @@ class MainActivity : AppCompatActivity() {
1164
1323
  configureText(this, childText(children), style)
1165
1324
  }
1166
1325
  "Link" -> ((previous as? TextView)?.takeUnless { it is Button } ?: TextView(this)).apply {
1326
+ includeFontPadding = false
1167
1327
  configureText(this, props.optString("title").ifBlank { childText(children) }, style)
1168
1328
  val linkColor = props.optString("color").takeIf { it.isNotBlank() } ?: style.optString("color")
1169
1329
  setTextColor(color(linkColor, Color.rgb(33, 150, 243)))
@@ -1180,6 +1340,7 @@ class MainActivity : AppCompatActivity() {
1180
1340
  }
1181
1341
  "Button" -> (previous as? Button ?: Button(this)).apply {
1182
1342
  val title = props.optString("title").ifBlank { childText(children) }
1343
+ isAllCaps = false
1183
1344
  configureText(this, title, style)
1184
1345
  props.optString("color").takeIf { it.isNotBlank() }?.let { setTextColor(color(it, defaultTextColor)) }
1185
1346
  isEnabled = !props.optBoolean("disabled", false)
@@ -1287,17 +1448,27 @@ class MainActivity : AppCompatActivity() {
1287
1448
  val value = optionProps?.optString("value").takeIf { !it.isNullOrBlank() } ?: label
1288
1449
  value to label
1289
1450
  }
1451
+ val handler = events.optString("onValueChange").ifBlank { events.optString("onChange") }
1452
+ if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
1453
+ // Detach the old listener before replacing the adapter or selection.
1454
+ // Both operations can synchronously dispatch an Android selection
1455
+ // callback during reconciliation, but neither is user input.
1456
+ onItemSelectedListener = null
1290
1457
  adapter = ArrayAdapter(this@MainActivity, android.R.layout.simple_spinner_item, options.map { it.second }).also {
1291
1458
  it.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
1292
1459
  }
1293
1460
  val requested = props.optString("selectedValue").ifBlank { props.optString("value") }
1294
1461
  val selected = options.indexOfFirst { it.first == requested }.takeIf { it >= 0 } ?: 0
1295
1462
  isEnabled = !props.optBoolean("disabled", false)
1296
- val handler = events.optString("onValueChange").ifBlank { events.optString("onChange") }
1297
- if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
1463
+ // Mounting a controlled picker must not look like a user
1464
+ // change. Android may dispatch the initial adapter selection
1465
+ // during the first layout pass; iOS only reports a choice
1466
+ // after the picker is interacted with.
1467
+ var suppressInitialSelection = true
1298
1468
  onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
1299
1469
  override fun onNothingSelected(parent: AdapterView<*>?) = Unit
1300
1470
  override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
1471
+ if (suppressInitialSelection) return
1301
1472
  val current = screen.handlers[this@apply] ?: return
1302
1473
  val option = options.getOrNull(position) ?: return
1303
1474
  send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()
@@ -1305,12 +1476,13 @@ class MainActivity : AppCompatActivity() {
1305
1476
  }
1306
1477
  }
1307
1478
  setSelection(selected, false)
1479
+ post { suppressInitialSelection = false }
1308
1480
  }
1309
1481
  "Modal" -> (previous as? CraftNativeModalView ?: CraftNativeModalView(this)).apply {
1310
1482
  transparent = props.optBoolean("transparent", false)
1311
1483
  content.configure(style, resources.displayMetrics.density)
1312
1484
  reconcileChildren(screen, content, children, path, next, style, previousControls)
1313
- val visible = props.optBoolean("visible", false)
1485
+ val visible = props.optBoolean("visible", false) && style.optString("display") != "none"
1314
1486
  val wasVisible = screen.modalVisibility[this]
1315
1487
  screen.modalVisibility[this] = visible
1316
1488
  val requestClose = events.optString("onRequestClose")
@@ -1332,10 +1504,14 @@ class MainActivity : AppCompatActivity() {
1332
1504
  "Switch" -> (previous as? Switch ?: Switch(this)).apply {
1333
1505
  setOnCheckedChangeListener(null)
1334
1506
  val requestedValue = props.opt("value")
1335
- isChecked = if (requestedValue != null && requestedValue != JSONObject.NULL) {
1336
- props.optBoolean("value")
1337
- } else {
1338
- props.optBoolean("checked", false)
1507
+ val requestedChecked = props.opt("checked")
1508
+ isChecked = when {
1509
+ requestedValue != null && requestedValue != JSONObject.NULL -> props.optBoolean("value")
1510
+ requestedChecked == null || requestedChecked == JSONObject.NULL -> false
1511
+ requestedChecked is Boolean -> requestedChecked
1512
+ requestedChecked is Number -> requestedChecked.toDouble() != 0.0
1513
+ requestedChecked is String -> requestedChecked.isBlank() || requestedChecked.equals("true", ignoreCase = true)
1514
+ else -> props.optBoolean("checked", false)
1339
1515
  }
1340
1516
  isEnabled = !props.optBoolean("disabled", false)
1341
1517
  props.optJSONObject("trackColor")?.let { track ->
@@ -1453,6 +1629,10 @@ class MainActivity : AppCompatActivity() {
1453
1629
  props.optBoolean("alwaysBounceHorizontal", horizontal),
1454
1630
  )
1455
1631
  setPagingEnabled(props.optBoolean("pagingEnabled", false))
1632
+ val refreshHandler = events.optString("onRefresh").takeIf { it.isNotBlank() }?.let { handler ->
1633
+ { send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", JSONObject())) }
1634
+ }
1635
+ setRefreshHandler(refreshHandler, props.optBoolean("refreshing", false))
1456
1636
  val onScroll = events.optString("onScroll")
1457
1637
  if (onScroll.isNotBlank()) screen.scrollHandlers[this] = onScroll else screen.scrollHandlers.remove(this)
1458
1638
  val onBegin = events.optString("onScrollBeginDrag")
@@ -1543,7 +1723,10 @@ class MainActivity : AppCompatActivity() {
1543
1723
  if (handler.isNotBlank()) screen.handlers[control] = handler else screen.handlers.remove(control)
1544
1724
  control.isClickable = true
1545
1725
  if (type in setOf("TouchableOpacity", "TouchableHighlight", "Pressable")) {
1546
- control.setOnClickListener(null)
1726
+ control.setOnClickListener {
1727
+ val current = screen.handlers[it] ?: return@setOnClickListener
1728
+ send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
1729
+ }
1547
1730
  control.setOnTouchListener { view, event ->
1548
1731
  when (event.actionMasked) {
1549
1732
  android.view.MotionEvent.ACTION_DOWN -> {
@@ -1584,7 +1767,9 @@ class MainActivity : AppCompatActivity() {
1584
1767
  }
1585
1768
  control.isEnabled = !disabled
1586
1769
  }
1587
- val longPressHandler = events.optString("onLongPress").takeUnless { props.optBoolean("disabled", false) } ?: ""
1770
+ val longPressDisabled = props.optBoolean("disabled", false) ||
1771
+ props.optJSONObject("accessibilityState")?.optBoolean("disabled", false) == true
1772
+ val longPressHandler = events.optString("onLongPress").takeUnless { longPressDisabled } ?: ""
1588
1773
  if (longPressHandler.isNotBlank()) {
1589
1774
  screen.longPressHandlers[control] = longPressHandler
1590
1775
  control.setOnLongClickListener {
@@ -1613,6 +1798,15 @@ class MainActivity : AppCompatActivity() {
1613
1798
  }
1614
1799
  next[identity] = control
1615
1800
  applyViewStyle(control, style)
1801
+ if (type == "ActivityIndicator") {
1802
+ val animating = props.optBoolean("animating", true)
1803
+ val hidesWhenStopped = props.optBoolean("hidesWhenStopped", true)
1804
+ control.visibility = if (style.optString("display") == "none" || (!animating && hidesWhenStopped)) {
1805
+ View.GONE
1806
+ } else {
1807
+ View.VISIBLE
1808
+ }
1809
+ }
1616
1810
  if (type == "Modal" && !props.optBoolean("visible", false)) control.visibility = View.GONE
1617
1811
  val width = style.optDouble("width", Double.NaN).takeUnless { it.isNaN() }?.let(::dp)
1618
1812
  val height = style.optDouble("height", Double.NaN).takeUnless { it.isNaN() }?.let(::dp)
@@ -1672,7 +1866,11 @@ class MainActivity : AppCompatActivity() {
1672
1866
  val child = children.opt(index)
1673
1867
  val childView = when (child) {
1674
1868
  is JSONObject -> makeView(screen, child, "$path.$index", next, previousControls)
1675
- is String -> TextView(this).apply { text = child; setTextColor(defaultTextColor) }
1869
+ is String -> TextView(this).apply {
1870
+ includeFontPadding = false
1871
+ text = child
1872
+ setTextColor(defaultTextColor)
1873
+ }
1676
1874
  else -> null
1677
1875
  }
1678
1876
  if (childView != null) desired.add(childView)
@@ -1790,8 +1988,12 @@ class MainActivity : AppCompatActivity() {
1790
1988
  view.clipToOutline = style.optString("overflow") == "hidden"
1791
1989
  if (view is ViewGroup) view.clipChildren = style.optString("overflow") == "hidden"
1792
1990
  if (view is ImageView) {
1793
- style.optString("tintColor").takeIf { it.isNotBlank() }?.let { view.setColorFilter(color(it, Color.TRANSPARENT)) }
1794
- ?: view.clearColorFilter()
1991
+ val tint = style.opt("tintColor") as? String
1992
+ if (tint.isNullOrBlank() || tint == "null" || tint == "undefined") {
1993
+ view.clearColorFilter()
1994
+ } else {
1995
+ view.setColorFilter(color(tint, Color.TRANSPARENT))
1996
+ }
1795
1997
  }
1796
1998
  }
1797
1999
 
@@ -1857,8 +2059,9 @@ class MainActivity : AppCompatActivity() {
1857
2059
  else -> ""
1858
2060
  }
1859
2061
  if (uri.isBlank()) {
2062
+ if (imageSources[view] == "") return
1860
2063
  imageJobs.remove(view)?.cancel(true)
1861
- imageSources.remove(view)
2064
+ imageSources[view] = ""
1862
2065
  imageErrors[view] = "Image source is missing"
1863
2066
  view.setImageDrawable(null)
1864
2067
  imageFailure(screen, view, uri, "Image source is missing")
@@ -1961,7 +2164,7 @@ class MainActivity : AppCompatActivity() {
1961
2164
  view.tooltipText = props.optString("accessibilityHint").takeIf { it.isNotBlank() }
1962
2165
  val role = props.optString("accessibilityRole").ifBlank { type.lowercase() }
1963
2166
  val state = props.optJSONObject("accessibilityState")
1964
- val disabled = state?.optBoolean("disabled", false) == true
2167
+ val disabled = props.optBoolean("disabled", false) || state?.optBoolean("disabled", false) == true
1965
2168
  if (type !in setOf("Button", "Link", "TextInput", "Picker", "Modal", "Switch", "Slider", "ActivityIndicator")) {
1966
2169
  view.isEnabled = !disabled
1967
2170
  } else if (disabled) {
@@ -2236,6 +2439,8 @@ class MainActivity : AppCompatActivity() {
2236
2439
  released.filterIsInstance<CraftNativeFlatList>().filterNot { nestedLists.contains(it) }.forEach {
2237
2440
  releaseFlatList(screen, it, preservingInputDrafts)
2238
2441
  }
2442
+ released.filterIsInstance<CraftNativeModalView>().forEach { it.resetForTeardown() }
2443
+ released.filterIsInstance<CraftNativeScrollView>().forEach { it.setRefreshHandler(null, false) }
2239
2444
  releaseImages(released)
2240
2445
  released.filterIsInstance<EditText>().forEach {
2241
2446
  inputHintDefaults.remove(it)
package/dist/cli.js CHANGED
@@ -3303,7 +3303,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3303
3303
  `);
3304
3304
  }
3305
3305
  // package.json
3306
- var version = "0.0.128";
3306
+ var version = "0.0.132";
3307
3307
 
3308
3308
  // bin/cli.ts
3309
3309
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -310,7 +310,7 @@ function craftBinaryNotFoundMessage(triedPath) {
310
310
  `);
311
311
  }
312
312
  // package.json
313
- var version = "0.0.128";
313
+ var version = "0.0.132";
314
314
 
315
315
  // src/sdk-version.ts
316
316
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -58,7 +58,7 @@ function craftBinaryNotFoundMessage(triedPath) {
58
58
  `);
59
59
  }
60
60
  // package.json
61
- var version = "0.0.128";
61
+ var version = "0.0.132";
62
62
 
63
63
  // src/sdk-version.ts
64
64
  var SDK_VERSION = version;