craft-native 0.0.158 → 0.0.161

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.
@@ -45,11 +45,14 @@ import android.view.ViewTreeObserver
45
45
  import android.widget.AdapterView
46
46
  import android.widget.ArrayAdapter
47
47
  import android.widget.Button
48
+ import android.widget.CheckBox
49
+ import android.widget.DatePicker
48
50
  import android.widget.EditText
49
51
  import android.widget.FrameLayout
50
52
  import android.widget.HorizontalScrollView
51
53
  import android.widget.ImageView
52
54
  import android.widget.LinearLayout
55
+ import android.widget.TimePicker
53
56
  import android.widget.ScrollView
54
57
  import android.widget.SeekBar
55
58
  import android.widget.Spinner
@@ -76,6 +79,9 @@ import com.craft.runtime.CraftNative
76
79
  import org.json.JSONArray
77
80
  import org.json.JSONObject
78
81
  import java.net.URL
82
+ import java.text.SimpleDateFormat
83
+ import java.util.Calendar
84
+ import java.util.Locale
79
85
  import java.util.concurrent.Executors
80
86
  import java.util.concurrent.Future
81
87
  import java.util.UUID
@@ -109,6 +115,7 @@ private data class CraftNativeLength(
109
115
  private data class CraftNativeLayoutStyle(
110
116
  val width: CraftNativeLength? = null,
111
117
  val height: CraftNativeLength? = null,
118
+ val aspectRatio: Double? = null,
112
119
  val minWidth: CraftNativeLength? = null,
113
120
  val maxWidth: CraftNativeLength? = null,
114
121
  val minHeight: CraftNativeLength? = null,
@@ -137,6 +144,7 @@ private data class CraftNativeLayoutStyle(
137
144
  val flex = decimal("flex")
138
145
  return CraftNativeLayoutStyle(
139
146
  width = length("width"), height = length("height"),
147
+ aspectRatio = decimal("aspectRatio")?.takeIf { it.isFinite() && it > 0.0 },
140
148
  minWidth = length("minWidth"), maxWidth = length("maxWidth"),
141
149
  minHeight = length("minHeight"), maxHeight = length("maxHeight"),
142
150
  position = raw.opt("position")
@@ -389,7 +397,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
389
397
  private var columnGap = 0
390
398
  private var grid = false
391
399
  private var gridColumns = 1
392
- private var gridAutoRows: Int? = null
400
+ private var gridAutoRows: CraftNativeLength? = null
393
401
  private var reverse = false
394
402
 
395
403
  fun configure(style: JSONObject, density: Float) {
@@ -406,7 +414,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
406
414
  gridColumns = craftNativeGridColumnCount(
407
415
  if (style.has("gridTemplateColumns")) style.opt("gridTemplateColumns") else style.opt("gridColumns")
408
416
  )
409
- gridAutoRows = style.optDouble("gridAutoRows", Double.NaN).takeUnless { it.isNaN() }?.let { (it * density).toInt() }
417
+ gridAutoRows = CraftNativeLength.from(style.opt("gridAutoRows"), density)
410
418
  val padding = (style.optDouble("padding", 0.0) * density).toInt()
411
419
  val horizontal = (style.optDouble("paddingHorizontal", padding.toDouble() / density.toDouble()) * density).toInt()
412
420
  val vertical = (style.optDouble("paddingVertical", padding.toDouble() / density.toDouble()) * density).toInt()
@@ -534,7 +542,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
534
542
  }
535
543
  }
536
544
  val rowHeights = rows.map { row ->
537
- max(gridAutoRows ?: 0, row.maxOfOrNull { entry ->
545
+ max(gridAutoRows?.resolve(height - paddingTop - paddingBottom) ?: 0, row.maxOfOrNull { entry ->
538
546
  val lp = entry.view.layoutParams as? MarginLayoutParams
539
547
  entry.height + (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0)
540
548
  } ?: 0)
@@ -559,7 +567,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
559
567
  val childHeight = measureChildHeight(child, style, height, verticalMargins)
560
568
  val column = flowIndex % columns
561
569
  val row = flowIndex / columns
562
- while (rowHeights.size <= row) rowHeights += gridAutoRows ?: 0
570
+ while (rowHeights.size <= row) rowHeights += gridAutoRows?.resolve(null) ?: 0
563
571
  columnWidths[column] = max(columnWidths[column], childWidth + horizontalMargins)
564
572
  rowHeights[row] = max(rowHeights[row], childHeight + verticalMargins)
565
573
  flowIndex += 1
@@ -577,12 +585,30 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
577
585
  // The STX style is authoritative. A reused View may still carry a
578
586
  // previous fixed LayoutParams width after a reactive width is removed;
579
587
  // using that value here prevents intrinsic sizing from being restored.
580
- val explicit = style.width?.resolve(base) ?: flexBasis
581
- val target = explicit ?: base
582
- val spec = if (explicit != null) View.MeasureSpec.makeMeasureSpec(target, View.MeasureSpec.EXACTLY)
583
- else View.MeasureSpec.makeMeasureSpec(base, View.MeasureSpec.AT_MOST)
584
- child.measure(spec, View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED))
585
- return clamp(if (explicit != null) target else child.measuredWidth, style.minWidth?.resolve(base), style.maxWidth?.resolve(base))
588
+ val authored = style.width?.resolve(base)
589
+ val explicit = authored ?: style.aspectRatio?.let { ratio -> style.height?.resolve(base)?.times(ratio)?.roundToInt() } ?: flexBasis
590
+ if (explicit != null) {
591
+ child.measure(
592
+ View.MeasureSpec.makeMeasureSpec(explicit, View.MeasureSpec.EXACTLY),
593
+ View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
594
+ )
595
+ return clamp(explicit, style.minWidth?.resolve(base), style.maxWidth?.resolve(base))
596
+ }
597
+ // Measure text and nested flex containers at max-content first. An
598
+ // AT_MOST spec equal to the available room can make Android report the
599
+ // longest-word (min-content) width for a wrapping TextView, collapsing
600
+ // shrink-to-fit bubbles and pills before the parent can clamp them.
601
+ val intrinsicSpec = if (child is TextView && child !is EditText) 1_000_000 else base
602
+ child.measure(
603
+ View.MeasureSpec.makeMeasureSpec(intrinsicSpec, View.MeasureSpec.AT_MOST),
604
+ View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
605
+ )
606
+ val target = clamp(min(base, child.measuredWidth), style.minWidth?.resolve(base), style.maxWidth?.resolve(base))
607
+ child.measure(
608
+ View.MeasureSpec.makeMeasureSpec(target, View.MeasureSpec.AT_MOST),
609
+ View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
610
+ )
611
+ return target
586
612
  }
587
613
 
588
614
  private fun measureChildHeight(child: View, style: CraftNativeLayoutStyle, available: Int, margins: Int): Int {
@@ -590,7 +616,8 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
590
616
  val flexBasis = if (orientation == VERTICAL) style.flexBasis?.resolve(base) else null
591
617
  // Match width measurement: do not let stale native LayoutParams keep
592
618
  // a removed reactive height fixed.
593
- val explicit = style.height?.resolve(base) ?: flexBasis
619
+ val authored = style.height?.resolve(base)
620
+ val explicit = authored ?: style.aspectRatio?.let { ratio -> style.width?.resolve(base)?.div(ratio)?.roundToInt() } ?: flexBasis
594
621
  val target = explicit ?: base
595
622
  val spec = if (explicit != null) View.MeasureSpec.makeMeasureSpec(target, View.MeasureSpec.EXACTLY)
596
623
  else View.MeasureSpec.makeMeasureSpec(base, View.MeasureSpec.AT_MOST)
@@ -629,6 +656,68 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
629
656
  else -> 0
630
657
  }
631
658
 
659
+ /** Keep the old-runtime shim on the same bounded-flex water-fill rule as the Zig bridge. */
660
+ private fun fallbackFlexWidths(line: Line, available: Int, gap: Int): IntArray {
661
+ val horizontal = orientation == HORIZONTAL
662
+ val hypothetical = line.entries.sumOf { entry ->
663
+ val lp = entry.view.layoutParams as? MarginLayoutParams
664
+ val margins = if (horizontal) (lp?.leftMargin ?: 0) + (lp?.rightMargin ?: 0)
665
+ else (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0)
666
+ (if (horizontal) entry.width else entry.height) + margins
667
+ } + max(0, line.entries.size - 1) * gap
668
+ val free = available - hypothetical
669
+ val growTotal = line.entries.sumOf { it.style.flexGrow }
670
+ val shrinkTotal = line.entries.sumOf {
671
+ (it.style.flexShrink ?: defaultFlexShrink(it.view)) * (if (horizontal) it.width else it.height)
672
+ }
673
+ val growing = free > 0 && growTotal > 0
674
+ val shrinking = free < 0 && shrinkTotal > 0
675
+ val widths = IntArray(line.entries.size) { index -> if (horizontal) line.entries[index].width else line.entries[index].height }
676
+ if (!growing && !shrinking) return widths
677
+
678
+ val frozen = BooleanArray(widths.size)
679
+ repeat(widths.size + 1) {
680
+ var fixed = max(0, widths.size - 1) * gap.toDouble()
681
+ var activeBasis = 0.0
682
+ var factor = 0.0
683
+ for ((index, entry) in line.entries.withIndex()) {
684
+ val lp = entry.view.layoutParams as? MarginLayoutParams
685
+ val margins = if (horizontal) (lp?.leftMargin ?: 0) + (lp?.rightMargin ?: 0)
686
+ else (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0)
687
+ fixed += margins
688
+ if (frozen[index]) fixed += widths[index]
689
+ else {
690
+ val basis = if (horizontal) entry.width else entry.height
691
+ activeBasis += basis
692
+ factor += if (growing) entry.style.flexGrow
693
+ else (entry.style.flexShrink ?: defaultFlexShrink(entry.view)) * basis
694
+ }
695
+ }
696
+ if (factor <= 0) return@repeat
697
+ val remaining = available - fixed - activeBasis
698
+ var froze = false
699
+ for ((index, entry) in line.entries.withIndex()) {
700
+ if (frozen[index]) continue
701
+ val basis = (if (horizontal) entry.width else entry.height).toDouble()
702
+ val share = if (growing) remaining * entry.style.flexGrow / factor
703
+ else -remaining * (entry.style.flexShrink ?: defaultFlexShrink(entry.view)) * basis / factor
704
+ val raw = if (growing) basis + share else basis - share
705
+ val lower = (if (horizontal) entry.style.minWidth else entry.style.minHeight)?.resolve(if (horizontal) width - paddingLeft - paddingRight else height - paddingTop - paddingBottom)
706
+ val upper = (if (horizontal) entry.style.maxWidth else entry.style.maxHeight)?.resolve(if (horizontal) width - paddingLeft - paddingRight else height - paddingTop - paddingBottom)
707
+ var bounded = raw
708
+ if (lower != null) bounded = max(bounded, lower.toDouble())
709
+ if (upper != null) bounded = min(bounded, upper.toDouble())
710
+ widths[index] = bounded.roundToInt().coerceAtLeast(0)
711
+ if (bounded != raw) {
712
+ frozen[index] = true
713
+ froze = true
714
+ }
715
+ }
716
+ if (!froze) return@repeat
717
+ }
718
+ return widths
719
+ }
720
+
632
721
  override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
633
722
  val widthMode = View.MeasureSpec.getMode(widthMeasureSpec)
634
723
  val width = View.MeasureSpec.getSize(widthMeasureSpec).takeUnless { widthMode == View.MeasureSpec.UNSPECIFIED } ?: 1_000_000
@@ -667,6 +756,8 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
667
756
  val contentWidth = width - paddingLeft - paddingRight
668
757
  val contentHeight = height - paddingTop - paddingBottom
669
758
  val flow = layout.rows.flatten()
759
+ // Older runtimes return null for the optional symbol; the local
760
+ // layout below remains the compatibility path in that case.
670
761
  val sharedFrames = CraftNative.grid(
671
762
  contentWidth.toFloat(), contentHeight.toFloat(), gridColumns, columnGap.toFloat(), rowGap.toFloat(),
672
763
  flow.map { it.width.toFloat() }.toFloatArray(), flow.map { it.height.toFloat() }.toFloatArray(),
@@ -684,6 +775,8 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
684
775
  val align = it.style.alignSelf ?: alignItems
685
776
  listOf(if (align == "stretch") 1f else 0f, if (align == "center") 1f else if (align == "flex-end") 2f else 0f)
686
777
  }.toFloatArray(),
778
+ gridAutoRows?.resolve(contentHeight)?.toFloat(),
779
+ alignContentCode(),
687
780
  )
688
781
  if (sharedFrames != null) {
689
782
  for ((index, entry) in flow.withIndex()) {
@@ -695,9 +788,26 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
695
788
  entry.view.layout(x, y, x + childWidth, y + childHeight)
696
789
  }
697
790
  } else {
698
- var rowTop = paddingTop
791
+ val totalRows = layout.rowHeights.sum()
792
+ val rowGaps = max(0, layout.rowHeights.size - 1) * rowGap
793
+ val freeRows = max(0, contentHeight - totalRows - rowGaps)
794
+ val rowCount = max(1, layout.rowHeights.size)
795
+ var rowLeading = 0
796
+ var rowBetween = rowGap
797
+ var rowExtra = 0
798
+ when (alignContentCode()) {
799
+ 1 -> rowLeading = freeRows / 2
800
+ 2 -> rowLeading = freeRows
801
+ 3 -> if (rowCount > 1) rowBetween += freeRows / (rowCount - 1)
802
+ 4 -> {
803
+ rowLeading = freeRows / (rowCount * 2)
804
+ rowBetween += freeRows / rowCount
805
+ }
806
+ 5 -> rowExtra = freeRows / rowCount
807
+ }
808
+ var rowTop = paddingTop + rowLeading
699
809
  for ((rowIndex, row) in layout.rows.withIndex()) {
700
- val rowHeight = layout.rowHeights[rowIndex]
810
+ val rowHeight = layout.rowHeights[rowIndex] + rowExtra
701
811
  for ((column, entry) in row.withIndex()) {
702
812
  val align = entry.style.alignSelf ?: alignItems
703
813
  val lp = entry.view.layoutParams as? MarginLayoutParams
@@ -721,7 +831,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
721
831
  }
722
832
  entry.view.layout(x, y, x + childWidth, y + childHeight)
723
833
  }
724
- rowTop += rowHeight + rowGap
834
+ rowTop += rowHeight + rowBetween
725
835
  }
726
836
  }
727
837
  for (entry in layout.absolute) {
@@ -770,7 +880,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
770
880
  var crossOffset = (if (orientation == HORIZONTAL) paddingTop else paddingLeft) + crossLeading
771
881
  for ((lineIndex, line) in lines.withIndex()) {
772
882
  val lineOffset = sharedCross?.first?.get(lineIndex)?.roundToInt()?.let { (if (orientation == HORIZONTAL) paddingTop else paddingLeft) + it } ?: crossOffset
773
- val rawFree = availableMain - line.main
883
+ val fallbackWidths = fallbackFlexWidths(line, availableMain, gap)
774
884
  val sharedPrimary = CraftNative.flexRow(
775
885
  availableMain.toFloat(),
776
886
  gap.toFloat(),
@@ -801,16 +911,8 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
801
911
  sharedPrimary[index].roundToInt() + margins
802
912
  } + max(0, line.entries.size - 1) * gap
803
913
  } else {
804
- val factors = line.entries.map { if (rawFree >= 0) it.style.flexGrow else it.style.flexShrink ?: defaultFlexShrink(it.view) }
805
- val totalFactor = factors.sum()
806
914
  line.entries.indices.sumOf { index ->
807
- val base = if (orientation == HORIZONTAL) line.entries[index].width else line.entries[index].height
808
- val primary = if (totalFactor > 0) max(0, (base + rawFree * factors[index] / totalFactor).toInt()) else base
809
- val constrained = if (orientation == HORIZONTAL) {
810
- clamp(primary, line.entries[index].style.minWidth?.resolve(contentWidth), line.entries[index].style.maxWidth?.resolve(contentWidth))
811
- } else {
812
- clamp(primary, line.entries[index].style.minHeight?.resolve(contentHeight), line.entries[index].style.maxHeight?.resolve(contentHeight))
813
- }
915
+ val constrained = fallbackWidths[index]
814
916
  val lp = line.entries[index].view.layoutParams as? MarginLayoutParams
815
917
  val margins = if (orientation == HORIZONTAL) (lp?.leftMargin ?: 0) + (lp?.rightMargin ?: 0)
816
918
  else (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0)
@@ -851,17 +953,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
851
953
  ?: mainOffset + marginStart
852
954
  val x = if (orientation == HORIZONTAL) positionedMain else lineOffset + crossPosition
853
955
  val y = if (orientation == HORIZONTAL) lineOffset + crossPosition else positionedMain
854
- val distributedPrimary = sharedPrimary?.get(index)?.roundToInt() ?: run {
855
- val primary = if (orientation == HORIZONTAL) entry.width else entry.height
856
- val factor = if (rawFree >= 0) entry.style.flexGrow else entry.style.flexShrink ?: defaultFlexShrink(entry.view)
857
- val totalFactor = line.entries.sumOf { if (rawFree >= 0) it.style.flexGrow else it.style.flexShrink ?: defaultFlexShrink(it.view) }
858
- val distributed = if (totalFactor > 0) max(0, (primary + rawFree * factor / totalFactor).toInt()) else primary
859
- if (orientation == HORIZONTAL) {
860
- clamp(distributed, entry.style.minWidth?.resolve(contentWidth), entry.style.maxWidth?.resolve(contentWidth))
861
- } else {
862
- clamp(distributed, entry.style.minHeight?.resolve(contentHeight), entry.style.maxHeight?.resolve(contentHeight))
863
- }
864
- }
956
+ val distributedPrimary = sharedPrimary?.get(index)?.roundToInt() ?: fallbackWidths[index]
865
957
  val w = if (orientation == HORIZONTAL) distributedPrimary else crossSize
866
958
  val h = if (orientation == HORIZONTAL) crossSize else distributedPrimary
867
959
  entry.view.layout(x, y, x + w, y + h)
@@ -890,6 +982,67 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
890
982
  }
891
983
  }
892
984
 
985
+ /** An image-backed flex container with ordinary native children over the image. */
986
+ private class CraftNativeImageBackgroundView(context: Context) : FrameLayout(context) {
987
+ val image = ImageView(context)
988
+ val content = CraftNativeFlexLayout(context)
989
+
990
+ init {
991
+ clipChildren = true
992
+ image.scaleType = ImageView.ScaleType.CENTER_CROP
993
+ image.isClickable = false
994
+ addView(image, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
995
+ addView(content, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
996
+ }
997
+ }
998
+
999
+ /** A flex container that responds to the activity's IME inset. */
1000
+ private class CraftNativeKeyboardAvoidingView(context: Context) : FrameLayout(context) {
1001
+ val content = CraftNativeFlexLayout(context)
1002
+ private var behavior = "padding"
1003
+ private var keyboardVerticalOffset = 0
1004
+ private var keyboardInset = 0
1005
+ private var basePadding = intArrayOf(0, 0, 0, 0)
1006
+
1007
+ init {
1008
+ addView(content, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
1009
+ }
1010
+
1011
+ fun configure(props: JSONObject, style: JSONObject, density: Float) {
1012
+ behavior = props.optString("behavior", "padding").takeIf { it in setOf("height", "position", "padding") } ?: "padding"
1013
+ keyboardVerticalOffset = (props.optDouble("keyboardVerticalOffset", 0.0) * density).roundToInt()
1014
+ content.configure(style, density)
1015
+ basePadding = intArrayOf(content.paddingLeft, content.paddingTop, content.paddingRight, content.paddingBottom)
1016
+ applyKeyboardInset()
1017
+ }
1018
+
1019
+ fun setKeyboardInset(inset: Int) {
1020
+ if (keyboardInset == inset) return
1021
+ keyboardInset = max(0, inset)
1022
+ applyKeyboardInset()
1023
+ }
1024
+
1025
+ private fun effectiveInset(): Int = max(0, keyboardInset - keyboardVerticalOffset)
1026
+
1027
+ private fun applyKeyboardInset() {
1028
+ val inset = effectiveInset()
1029
+ translationY = if (behavior == "position") -inset.toFloat() else 0f
1030
+ val bottom = if (behavior == "padding") basePadding[3] + inset else basePadding[3]
1031
+ content.setPadding(basePadding[0], basePadding[1], basePadding[2], bottom)
1032
+ requestLayout()
1033
+ }
1034
+
1035
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
1036
+ val inset = effectiveInset()
1037
+ val adjustedHeight = if (behavior == "height" && inset > 0 && MeasureSpec.getMode(heightMeasureSpec) != MeasureSpec.UNSPECIFIED) {
1038
+ MeasureSpec.makeMeasureSpec(max(0, MeasureSpec.getSize(heightMeasureSpec) - inset), MeasureSpec.getMode(heightMeasureSpec))
1039
+ } else {
1040
+ heightMeasureSpec
1041
+ }
1042
+ super.onMeasure(widthMeasureSpec, adjustedHeight)
1043
+ }
1044
+ }
1045
+
893
1046
  private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(context) {
894
1047
  val content = CraftNativeFlexLayout(context)
895
1048
  private val vertical = ScrollView(context)
@@ -939,6 +1092,21 @@ private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(conte
939
1092
  addView(scroller, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
940
1093
  }
941
1094
 
1095
+ /**
1096
+ * A targeted mutation can change a child's size without reconciling the
1097
+ * scroll content tree. Invalidate both the custom content layout and the
1098
+ * platform scroller so its measured content range is recomputed too.
1099
+ */
1100
+ fun invalidateContentLayout() {
1101
+ content.forceLayout()
1102
+ content.requestLayout()
1103
+ val scroller = if (horizontalMode == true) horizontal else vertical
1104
+ scroller.forceLayout()
1105
+ scroller.requestLayout()
1106
+ forceLayout()
1107
+ requestLayout()
1108
+ }
1109
+
942
1110
  fun setNativeNestedScrollingEnabled(value: Boolean) {
943
1111
  vertical.isNestedScrollingEnabled = value
944
1112
  horizontal.isNestedScrollingEnabled = value
@@ -1084,6 +1252,25 @@ private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(conte
1084
1252
 
1085
1253
  private class CraftNativeFlexSpacer(context: Context) : View(context)
1086
1254
 
1255
+ /**
1256
+ * A small, dependency-free native icon surface for Android.
1257
+ *
1258
+ * STX resolves Iconify names to the SF Symbol name that iOS draws. Android
1259
+ * does not ship SF Symbols, so the maintained host uses the corresponding
1260
+ * Unicode glyph for the common cross-platform symbols and a hollow circle for
1261
+ * an unknown symbol. Keeping this as a TextView gives the glyph the same
1262
+ * intrinsic measurement, tint, accessibility, and reconciliation behavior as
1263
+ * the other native leaf controls without adding an icon-font dependency.
1264
+ */
1265
+ private class CraftNativeIconView(context: Context) : TextView(context) {
1266
+ init {
1267
+ includeFontPadding = false
1268
+ gravity = Gravity.CENTER
1269
+ setPadding(0, 0, 0, 0)
1270
+ isSingleLine = true
1271
+ }
1272
+ }
1273
+
1087
1274
  private class CraftNativeModalView(context: Context) : FrameLayout(context) {
1088
1275
  val content = CraftNativeFlexLayout(context)
1089
1276
  private val blocker = View(context)
@@ -1214,6 +1401,176 @@ private class CraftNativeActivityIndicator(context: Context) :
1214
1401
  }
1215
1402
  }
1216
1403
 
1404
+ /** Android counterpart to iOS's UIDatePicker-backed native DatePicker. */
1405
+ private class CraftNativeDatePickerView(context: Context) : FrameLayout(context) {
1406
+ private enum class PickerMode { DATE, TIME, DATE_TIME, COUNTDOWN }
1407
+ private val maxCountdownSeconds = 23 * 3600 + 59 * 60 + 59
1408
+
1409
+ private var pickerMode: PickerMode? = null
1410
+ private var datePicker: DatePicker? = null
1411
+ private var timePicker: TimePicker? = null
1412
+ private var minuteInterval = 1
1413
+ private var suppressChanges = true
1414
+ var onValueChanged: (() -> Unit)? = null
1415
+
1416
+ init {
1417
+ isFocusable = true
1418
+ }
1419
+
1420
+ fun configure(props: JSONObject) {
1421
+ val requestedMode = when (props.optString("mode")) {
1422
+ "time" -> PickerMode.TIME
1423
+ "datetime", "dateAndTime", "datetime-local" -> PickerMode.DATE_TIME
1424
+ "countdown" -> PickerMode.COUNTDOWN
1425
+ else -> PickerMode.DATE
1426
+ }
1427
+ if (pickerMode != requestedMode) {
1428
+ pickerMode = requestedMode
1429
+ datePicker = null
1430
+ timePicker = null
1431
+ removeAllViews()
1432
+ val content = LinearLayout(context).apply {
1433
+ orientation = LinearLayout.VERTICAL
1434
+ gravity = Gravity.CENTER
1435
+ }
1436
+ if (requestedMode == PickerMode.DATE || requestedMode == PickerMode.DATE_TIME) {
1437
+ datePicker = DatePicker(context).also { picker ->
1438
+ val today = Calendar.getInstance()
1439
+ picker.init(today.get(Calendar.YEAR), today.get(Calendar.MONTH), today.get(Calendar.DAY_OF_MONTH)) { _, _, _, _ -> changed() }
1440
+ content.addView(picker, LinearLayout.LayoutParams(
1441
+ ViewGroup.LayoutParams.WRAP_CONTENT,
1442
+ ViewGroup.LayoutParams.WRAP_CONTENT,
1443
+ ))
1444
+ }
1445
+ }
1446
+ if (requestedMode != PickerMode.DATE) {
1447
+ timePicker = TimePicker(context).also { picker ->
1448
+ picker.setIs24HourView(true)
1449
+ picker.setOnTimeChangedListener { _, _, _ -> changed() }
1450
+ content.addView(picker, LinearLayout.LayoutParams(
1451
+ ViewGroup.LayoutParams.WRAP_CONTENT,
1452
+ ViewGroup.LayoutParams.WRAP_CONTENT,
1453
+ ))
1454
+ }
1455
+ }
1456
+ addView(content, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
1457
+ }
1458
+
1459
+ suppressChanges = true
1460
+ minuteInterval = props.optInt("minuteInterval", 1).takeIf { it in 1..30 && 60 % it == 0 } ?: 1
1461
+ pickerDate(props.opt("minimumDate"))?.let { datePicker?.minDate = it.timeInMillis }
1462
+ pickerDate(props.opt("maximumDate"))?.let { datePicker?.maxDate = it.timeInMillis }
1463
+ if (requestedMode == PickerMode.COUNTDOWN) {
1464
+ countdownSeconds(props.opt("value"))?.let { applyCountdown(it) }
1465
+ } else {
1466
+ pickerDate(props.opt("value"))?.let { applyDate(it) }
1467
+ }
1468
+ val enabled = !props.optBoolean("disabled", false)
1469
+ isEnabled = enabled
1470
+ datePicker?.isEnabled = enabled
1471
+ timePicker?.isEnabled = enabled
1472
+ for (index in 0 until childCount) getChildAt(index).isEnabled = enabled
1473
+ suppressChanges = false
1474
+ }
1475
+
1476
+ fun event(): JSONObject {
1477
+ if (pickerMode == PickerMode.COUNTDOWN) {
1478
+ val seconds = currentCountdownSeconds()
1479
+ return JSONObject()
1480
+ .put("value", formatCountdown(seconds))
1481
+ .put("iso", "PT${seconds}S")
1482
+ .put("timestamp", seconds * 1000L)
1483
+ }
1484
+ val current = currentDate()
1485
+ val valueFormat = when (pickerMode) {
1486
+ PickerMode.DATE -> "yyyy-MM-dd"
1487
+ PickerMode.TIME -> "HH:mm"
1488
+ else -> "yyyy-MM-dd'T'HH:mm"
1489
+ }
1490
+ val value = SimpleDateFormat(valueFormat, Locale.US).apply { isLenient = false }.format(current.time)
1491
+ val iso = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).format(current.time)
1492
+ return JSONObject().put("value", value).put("iso", iso).put("timestamp", current.timeInMillis)
1493
+ }
1494
+
1495
+ private fun changed() {
1496
+ if (suppressChanges) return
1497
+ val picker = timePicker ?: return
1498
+ val snapped = ((picker.minute + minuteInterval / 2) / minuteInterval * minuteInterval).coerceAtMost(59)
1499
+ if (snapped != picker.minute) {
1500
+ suppressChanges = true
1501
+ picker.minute = snapped
1502
+ suppressChanges = false
1503
+ }
1504
+ onValueChanged?.invoke()
1505
+ }
1506
+
1507
+ private fun applyDate(value: Calendar) {
1508
+ datePicker?.updateDate(value.get(Calendar.YEAR), value.get(Calendar.MONTH), value.get(Calendar.DAY_OF_MONTH))
1509
+ timePicker?.hour = value.get(Calendar.HOUR_OF_DAY)
1510
+ timePicker?.minute = value.get(Calendar.MINUTE)
1511
+ }
1512
+
1513
+ private fun applyCountdown(seconds: Int) {
1514
+ timePicker?.hour = (seconds / 3600).coerceIn(0, 23)
1515
+ timePicker?.minute = (seconds % 3600 / 60).coerceIn(0, 59)
1516
+ }
1517
+
1518
+ private fun currentCountdownSeconds(): Int = timePicker?.let { it.hour * 3600 + it.minute * 60 } ?: 0
1519
+
1520
+ private fun formatCountdown(seconds: Int): String = "%02d:%02d:%02d".format(
1521
+ Locale.US,
1522
+ seconds / 3600,
1523
+ seconds % 3600 / 60,
1524
+ seconds % 60,
1525
+ )
1526
+
1527
+ private fun currentDate(): Calendar = Calendar.getInstance().also { current ->
1528
+ datePicker?.let {
1529
+ current.set(Calendar.YEAR, it.year)
1530
+ current.set(Calendar.MONTH, it.month)
1531
+ current.set(Calendar.DAY_OF_MONTH, it.dayOfMonth)
1532
+ }
1533
+ timePicker?.let {
1534
+ current.set(Calendar.HOUR_OF_DAY, it.hour)
1535
+ current.set(Calendar.MINUTE, it.minute)
1536
+ current.set(Calendar.SECOND, 0)
1537
+ current.set(Calendar.MILLISECOND, 0)
1538
+ }
1539
+ }
1540
+
1541
+ private fun pickerDate(value: Any?): Calendar? {
1542
+ if (value is Number && value !is Boolean) return Calendar.getInstance().apply { timeInMillis = value.toLong() }
1543
+ val text = value?.toString()?.trim().orEmpty()
1544
+ if (text.isBlank() || text == JSONObject.NULL.toString()) return null
1545
+ val formats = listOf(
1546
+ "HH:mm", "HH:mm:ss", "yyyy-MM-dd", "yyyy-MM-dd'T'HH:mm",
1547
+ "yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss.SSSXXX", "yyyy-MM-dd'T'HH:mm:ssX",
1548
+ )
1549
+ formats.firstNotNullOfOrNull { format ->
1550
+ runCatching {
1551
+ SimpleDateFormat(format, Locale.US).apply { isLenient = false }.parse(text)
1552
+ }.getOrNull()
1553
+ }?.let { parsed ->
1554
+ return Calendar.getInstance().apply { time = parsed }
1555
+ }
1556
+ return null
1557
+ }
1558
+
1559
+ private fun countdownSeconds(value: Any?): Int? {
1560
+ if (value is Number && value !is Boolean) return value.toDouble().coerceIn(0.0, maxCountdownSeconds.toDouble()).roundToInt()
1561
+ val text = value?.toString()?.trim().orEmpty()
1562
+ if (text.isBlank() || text == JSONObject.NULL.toString()) return null
1563
+ text.toDoubleOrNull()?.takeIf { it.isFinite() }?.let { return it.coerceIn(0.0, maxCountdownSeconds.toDouble()).roundToInt() }
1564
+ val parts = text.split(':').mapNotNull { it.toDoubleOrNull() }
1565
+ if (parts.size != 2 && parts.size != 3) return null
1566
+ val hours = parts[0]
1567
+ val minutes = parts[1]
1568
+ val seconds = if (parts.size == 3) parts[2] else 0.0
1569
+ if (hours < 0 || minutes !in 0.0..<60.0 || seconds !in 0.0..<60.0) return null
1570
+ return (hours * 3600 + minutes * 60 + seconds).coerceIn(0.0, maxCountdownSeconds.toDouble()).roundToInt()
1571
+ }
1572
+ }
1573
+
1217
1574
  private class CraftNativeVideoView(context: Context) : VideoView(context) {
1218
1575
  private var sourceUri = ""
1219
1576
  private var shouldLoop = false
@@ -1437,6 +1794,11 @@ class MainActivity : AppCompatActivity() {
1437
1794
  val inputIdentities = mutableMapOf<EditText, String>()
1438
1795
  val suppressedTextChanges = mutableSetOf<EditText>()
1439
1796
  val controls = mutableMapOf<String, View>()
1797
+ // Protocol ids identify mutation targets; authored keys identify the
1798
+ // retained view. Keep both mappings so a targeted update can recover
1799
+ // the original tree path without manufacturing a detached view.
1800
+ val nodeViews = mutableMapOf<String, View>()
1801
+ val nodePaths = mutableMapOf<String, String>()
1440
1802
  val handlers = mutableMapOf<View, String>()
1441
1803
  val pressInHandlers = mutableMapOf<View, String>()
1442
1804
  val pressOutHandlers = mutableMapOf<View, String>()
@@ -1520,6 +1882,7 @@ class MainActivity : AppCompatActivity() {
1520
1882
  private val inputHintDefaults = mutableMapOf<EditText, ColorStateList>()
1521
1883
  private val inputHighlightDefaults = mutableMapOf<EditText, Int>()
1522
1884
  private val defaultBackgrounds = mutableMapOf<View, Drawable?>()
1885
+ private var keyboardInset = 0
1523
1886
 
1524
1887
  override fun onCreate(savedInstanceState: Bundle?) {
1525
1888
  applyConfiguredAppearance()
@@ -1531,7 +1894,10 @@ class MainActivity : AppCompatActivity() {
1531
1894
  root = findViewById(R.id.native_root)
1532
1895
  ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets ->
1533
1896
  val safe = insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout())
1897
+ val ime = insets.getInsets(WindowInsetsCompat.Type.ime())
1898
+ keyboardInset = max(0, ime.bottom - safe.bottom)
1534
1899
  view.setPadding(safe.left, safe.top, safe.right, safe.bottom)
1900
+ updateKeyboardAvoidingViews()
1535
1901
  view.requestLayout()
1536
1902
  view.post { screens.lastOrNull()?.takeIf { it.active }?.let(::emitLayoutEvents) }
1537
1903
  insets
@@ -1926,9 +2292,10 @@ class MainActivity : AppCompatActivity() {
1926
2292
  val target = screen.flatListOwners[id] ?: id
1927
2293
  if (!applied.add(target)) continue
1928
2294
  val document = screen.mutations.node(target) ?: return false
1929
- if (!screen.controls.containsKey("id:$target")) return false
1930
- makeView(screen, document, target, screen.controls)
1931
- val updated = screen.controls["id:$target"] ?: return false
2295
+ val path = screen.nodePaths[target] ?: return false
2296
+ if (screen.nodeViews[target] == null) return false
2297
+ makeView(screen, document, path, screen.controls)
2298
+ val updated = screen.nodeViews[target] ?: return false
1932
2299
  screen.lastLayoutBounds.remove(updated)
1933
2300
  if (!updateParentLayoutStyle(updated, document.optJSONObject("style") ?: JSONObject())) return false
1934
2301
  }
@@ -1966,6 +2333,12 @@ class MainActivity : AppCompatActivity() {
1966
2333
  private fun updateParentLayoutStyle(view: View, style: JSONObject): Boolean {
1967
2334
  var parent = view.parent
1968
2335
  var found = false
2336
+ // A targeted style update can arrive while Android considers the
2337
+ // existing traversal settled. Invalidate the node itself as well as
2338
+ // every ancestor, including the platform ScrollView between the
2339
+ // native content layout and CraftNativeScrollView.
2340
+ view.forceLayout()
2341
+ view.requestLayout()
1969
2342
  while (parent != null) {
1970
2343
  when (parent) {
1971
2344
  is CraftNativeFlexLayout -> {
@@ -1975,10 +2348,13 @@ class MainActivity : AppCompatActivity() {
1975
2348
  found = true
1976
2349
  }
1977
2350
  is CraftNativeScrollView -> {
1978
- parent.forceLayout()
1979
- parent.requestLayout()
2351
+ parent.invalidateContentLayout()
1980
2352
  }
1981
2353
  }
2354
+ (parent as? View)?.let {
2355
+ it.forceLayout()
2356
+ it.requestLayout()
2357
+ }
1982
2358
  parent = parent.parent
1983
2359
  }
1984
2360
  return found
@@ -1987,6 +2363,8 @@ class MainActivity : AppCompatActivity() {
1987
2363
  private fun renderCommitted(screen: Screen, document: JSONObject) {
1988
2364
  // Keep live controls (especially the focused EditText) across renders.
1989
2365
  val next = mutableMapOf<String, View>()
2366
+ screen.nodeViews.clear()
2367
+ screen.nodePaths.clear()
1990
2368
  val tree = makeView(screen, document, "root", next)
1991
2369
  if (screen.view.getChildAt(0) !== tree) {
1992
2370
  (tree.parent as? ViewGroup)?.removeView(tree)
@@ -1998,12 +2376,18 @@ class MainActivity : AppCompatActivity() {
1998
2376
  screen.controls.putAll(next)
1999
2377
  screen.handlers.keys.retainAll(retainedViews(screen))
2000
2378
  screen.view.setChildStyle(tree, document.optJSONObject("style") ?: JSONObject(), resources.displayMetrics.density)
2379
+ // A full render can change a view's custom layout style without
2380
+ // changing the root bounds. Ensure native @layout observers still see
2381
+ // the settled child frame after the next traversal.
2382
+ scheduleLayoutEvents(screen)
2001
2383
  }
2002
2384
 
2003
2385
  private fun clearRenderedTree(screen: Screen) {
2004
2386
  releaseViewState(screen, retainedViews(screen).toList())
2005
2387
  screen.view.removeAllViews()
2006
2388
  screen.controls.clear()
2389
+ screen.nodeViews.clear()
2390
+ screen.nodePaths.clear()
2007
2391
  screen.handlers.clear()
2008
2392
  screen.flatListRows.clear()
2009
2393
  screen.flatListOwners.clear()
@@ -2017,16 +2401,15 @@ class MainActivity : AppCompatActivity() {
2017
2401
  next: MutableMap<String, View>,
2018
2402
  previousControls: Map<String, View> = screen.controls,
2019
2403
  ): View {
2020
- val type = node.optString("type", "View")
2404
+ val type = canonicalType(node.optString("type", "View"))
2021
2405
  val props = node.optJSONObject("props") ?: JSONObject()
2022
2406
  val style = node.optJSONObject("style") ?: JSONObject()
2023
2407
  val events = node.optJSONObject("events") ?: JSONObject()
2024
2408
  val children = node.optJSONArray("children") ?: JSONArray()
2025
2409
  val protocolId = node.optString("id").takeIf { it.isNotBlank() }
2026
- val explicitKey = listOf(node.optString("key"), props.optString("key"), props.optString("testID"))
2027
- .firstOrNull { it.isNotBlank() }
2028
- val identity = protocolId?.let { "id:$it" }
2029
- ?: explicitKey?.let { "${path.substringBeforeLast('.', "")}/$it" }
2410
+ val explicitKey = authoredKey(node, props)
2411
+ val identity = explicitKey?.let { "${path.substringBeforeLast('.', "")}/$it" }
2412
+ ?: protocolId?.let { "id:$it" }
2030
2413
  ?: path
2031
2414
  val previous = previousControls[identity]
2032
2415
  val reusableInput = previous as? EditText
@@ -2071,6 +2454,32 @@ class MainActivity : AppCompatActivity() {
2071
2454
  send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
2072
2455
  }
2073
2456
  }
2457
+ "Checkbox" -> (previous as? CheckBox ?: CheckBox(this)).apply {
2458
+ text = props.optString("label").ifBlank { childText(children) }
2459
+ configureText(this, text.toString(), style)
2460
+ val requested = props.opt("checked") ?: props.opt("value")
2461
+ val checked = when (requested) {
2462
+ is Boolean -> requested
2463
+ is Number -> requested.toDouble() != 0.0
2464
+ is String -> requested.equals("true", ignoreCase = true) || requested == "1"
2465
+ else -> false
2466
+ }
2467
+ setOnCheckedChangeListener(null)
2468
+ // Keep the visual checked state separate from indeterminate:
2469
+ // an indeterminate control must announce "mixed" and its next
2470
+ // user toggle should transition to checked, not unchecked.
2471
+ isChecked = checked
2472
+ isActivated = props.optBoolean("indeterminate", false)
2473
+ isEnabled = !props.optBoolean("disabled", false)
2474
+ props.optString("color").takeIf { it.isNotBlank() }?.let { buttonTintList = ColorStateList.valueOf(color(it, defaultTextColor)) }
2475
+ val handler = events.optString("onValueChange").ifBlank { events.optString("onChange") }
2476
+ if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
2477
+ setOnCheckedChangeListener { _, value ->
2478
+ screen.handlers[this]?.let { current ->
2479
+ send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject().put("value", value)))
2480
+ }
2481
+ }
2482
+ }
2074
2483
  "TextInput" -> (reusableInput?.takeIf { it.isSingleLine == !props.optBoolean("multiline", false) }
2075
2484
  ?: EditText(this)).apply {
2076
2485
  val defaultHintColors = inputHintDefaults.getOrPut(this) {
@@ -2213,6 +2622,16 @@ class MainActivity : AppCompatActivity() {
2213
2622
  setSelection(selected, false)
2214
2623
  post { suppressInitialSelection = false }
2215
2624
  }
2625
+ "DatePicker" -> (previous as? CraftNativeDatePickerView ?: CraftNativeDatePickerView(this)).apply {
2626
+ configure(props)
2627
+ val handler = events.optString("onValueChange").ifBlank { events.optString("onChange") }
2628
+ if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
2629
+ onValueChanged = {
2630
+ screen.handlers[this]?.let { current ->
2631
+ send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", event()))
2632
+ }
2633
+ }
2634
+ }
2216
2635
  "Modal" -> (previous as? CraftNativeModalView ?: CraftNativeModalView(this)).apply {
2217
2636
  transparent = props.optBoolean("transparent", false)
2218
2637
  content.configure(style, resources.displayMetrics.density)
@@ -2353,6 +2772,9 @@ class MainActivity : AppCompatActivity() {
2353
2772
  indeterminateTintList = props.optString("color").takeIf { it.isNotBlank() }
2354
2773
  ?.let { ColorStateList.valueOf(color(it, Color.WHITE)) }
2355
2774
  }
2775
+ "Icon" -> (previous as? CraftNativeIconView ?: CraftNativeIconView(this)).apply {
2776
+ configureIcon(this, props, style)
2777
+ }
2356
2778
  "Image" -> (previous as? ImageView ?: ImageView(this)).apply {
2357
2779
  scaleType = when (style.optString("resizeMode").ifBlank { props.optString("resizeMode") }) {
2358
2780
  "cover" -> ImageView.ScaleType.CENTER_CROP
@@ -2371,6 +2793,25 @@ class MainActivity : AppCompatActivity() {
2371
2793
  if (onError.isNotBlank()) screen.imageErrorHandlers[this] = onError else screen.imageErrorHandlers.remove(this)
2372
2794
  updateImage(screen, this, props.opt("source"))
2373
2795
  }
2796
+ "ImageBackground" -> (previous as? CraftNativeImageBackgroundView ?: CraftNativeImageBackgroundView(this)).apply {
2797
+ image.scaleType = when (style.optString("resizeMode").ifBlank { props.optString("resizeMode") }) {
2798
+ "cover" -> ImageView.ScaleType.CENTER_CROP
2799
+ "stretch" -> ImageView.ScaleType.FIT_XY
2800
+ "center" -> ImageView.ScaleType.CENTER
2801
+ else -> ImageView.ScaleType.CENTER_INSIDE
2802
+ }
2803
+ val onLoadStart = events.optString("onLoadStart")
2804
+ if (onLoadStart.isNotBlank()) screen.imageLoadStartHandlers[image] = onLoadStart else screen.imageLoadStartHandlers.remove(image)
2805
+ val onLoad = events.optString("onLoad")
2806
+ if (onLoad.isNotBlank()) screen.imageLoadHandlers[image] = onLoad else screen.imageLoadHandlers.remove(image)
2807
+ val onLoadEnd = events.optString("onLoadEnd")
2808
+ if (onLoadEnd.isNotBlank()) screen.imageLoadEndHandlers[image] = onLoadEnd else screen.imageLoadEndHandlers.remove(image)
2809
+ val onError = events.optString("onError")
2810
+ if (onError.isNotBlank()) screen.imageErrorHandlers[image] = onError else screen.imageErrorHandlers.remove(image)
2811
+ updateImage(screen, image, props.opt("source"))
2812
+ configureContainer(content, style)
2813
+ reconcileChildren(screen, content, children, path, next, style, previousControls)
2814
+ }
2374
2815
  "Video" -> (previous as? CraftNativeVideoView ?: CraftNativeVideoView(this)).apply {
2375
2816
  configure(
2376
2817
  source = props.opt("source"),
@@ -2384,7 +2825,7 @@ class MainActivity : AppCompatActivity() {
2384
2825
  "Svg" -> (previous as? CraftNativeSvgView ?: CraftNativeSvgView(this)).apply {
2385
2826
  drawing = node
2386
2827
  }
2387
- "ScrollView" -> (previous as? CraftNativeScrollView ?: CraftNativeScrollView(this)).apply {
2828
+ "ScrollView", "SectionList" -> (previous as? CraftNativeScrollView ?: CraftNativeScrollView(this)).apply {
2388
2829
  val horizontal = props.optBoolean("horizontal", false) || style.optString("flexDirection") in setOf("row", "row-reverse")
2389
2830
  setHorizontal(horizontal)
2390
2831
  setNativeNestedScrollingEnabled(true)
@@ -2477,6 +2918,11 @@ class MainActivity : AppCompatActivity() {
2477
2918
  configureContainer(this, style)
2478
2919
  reconcileChildren(screen, this, children, path, next, style, previousControls)
2479
2920
  }
2921
+ "KeyboardAvoidingView" -> (previous as? CraftNativeKeyboardAvoidingView ?: CraftNativeKeyboardAvoidingView(this)).apply {
2922
+ configure(props, style, resources.displayMetrics.density)
2923
+ setKeyboardInset(keyboardInset)
2924
+ reconcileChildren(screen, content, children, path, next, style, previousControls)
2925
+ }
2480
2926
  else -> (previous as? CraftNativeFlexLayout ?: CraftNativeFlexLayout(this)).apply {
2481
2927
  configureContainer(this, style)
2482
2928
  reconcileChildren(screen, this, children, path, next, style, previousControls)
@@ -2484,12 +2930,14 @@ class MainActivity : AppCompatActivity() {
2484
2930
  }
2485
2931
  if (control is CraftNativeFlexLayout) {
2486
2932
  control.onLayoutChanged = { if (screen.active) emitLayoutEvents(screen) }
2933
+ } else if (control is CraftNativeKeyboardAvoidingView) {
2934
+ control.content.onLayoutChanged = { if (screen.active) emitLayoutEvents(screen) }
2487
2935
  }
2488
2936
  val pressInHandler = events.optString("onPressIn").takeIf { it.isNotBlank() }
2489
2937
  val pressOutHandler = events.optString("onPressOut").takeIf { it.isNotBlank() }
2490
2938
  if (pressInHandler != null) screen.pressInHandlers[control] = pressInHandler else screen.pressInHandlers.remove(control)
2491
2939
  if (pressOutHandler != null) screen.pressOutHandlers[control] = pressOutHandler else screen.pressOutHandlers.remove(control)
2492
- if (type != "Button" && type != "Link" && type != "TextInput" && type != "Picker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator") {
2940
+ if (type != "Button" && type != "Link" && type != "TextInput" && type != "Picker" && type != "DatePicker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" && type != "KeyboardAvoidingView") {
2493
2941
  val handler = events.optString("onPress").ifBlank { events.optString("onClick") }
2494
2942
  val disabled = props.optBoolean("disabled", false)
2495
2943
  val hasPressLifecycle = pressInHandler != null || pressOutHandler != null
@@ -2571,6 +3019,15 @@ class MainActivity : AppCompatActivity() {
2571
3019
  if (!current.contains(message)) {
2572
3020
  control.contentDescription = listOf(current, message).filter { it.isNotBlank() }.joinToString(", ")
2573
3021
  }
3022
+ } else if (control is CraftNativeImageBackgroundView) imageErrors[control.image]?.let { message ->
3023
+ val current = control.contentDescription?.toString().orEmpty()
3024
+ if (!current.contains(message)) {
3025
+ control.contentDescription = listOf(current, message).filter { it.isNotBlank() }.joinToString(", ")
3026
+ }
3027
+ }
3028
+ protocolId?.let {
3029
+ screen.nodeViews[it] = control
3030
+ screen.nodePaths[it] = path
2574
3031
  }
2575
3032
  next[identity] = control
2576
3033
  applyViewStyle(control, style)
@@ -2609,6 +3066,48 @@ class MainActivity : AppCompatActivity() {
2609
3066
  }
2610
3067
  }
2611
3068
 
3069
+ private fun canonicalType(raw: String): String = when (raw.lowercase()) {
3070
+ "view" -> "View"
3071
+ "safeareaview" -> "SafeAreaView"
3072
+ "text" -> "Text"
3073
+ "button" -> "Button"
3074
+ "link" -> "Link"
3075
+ "checkbox" -> "Checkbox"
3076
+ "textinput", "input" -> "TextInput"
3077
+ "picker", "select" -> "Picker"
3078
+ "datepicker", "date-picker", "date" -> "DatePicker"
3079
+ "modal" -> "Modal"
3080
+ "switch" -> "Switch"
3081
+ "slider" -> "Slider"
3082
+ "activityindicator" -> "ActivityIndicator"
3083
+ "image" -> "Image"
3084
+ "imagebackground" -> "ImageBackground"
3085
+ "keyboardavoidingview" -> "KeyboardAvoidingView"
3086
+ "video" -> "Video"
3087
+ "icon" -> "Icon"
3088
+ "scrollview" -> "ScrollView"
3089
+ "sectionlist" -> "SectionList"
3090
+ "flatlist" -> "FlatList"
3091
+ "svg" -> "Svg"
3092
+ "touchableopacity" -> "TouchableOpacity"
3093
+ "touchablehighlight" -> "TouchableHighlight"
3094
+ "pressable" -> "Pressable"
3095
+ else -> raw
3096
+ }
3097
+
3098
+ private fun authoredKey(node: JSONObject, props: JSONObject): String? {
3099
+ val direct = listOf(node.optString("key"), props.optString("key"), props.optString("testID"))
3100
+ .firstOrNull { it.isNotBlank() }
3101
+ if (direct != null) return direct
3102
+ val children = node.optJSONArray("children") ?: return null
3103
+ for (index in 0 until children.length()) {
3104
+ val child = children.optJSONObject(index) ?: continue
3105
+ val childProps = child.optJSONObject("props") ?: JSONObject()
3106
+ authoredKey(child, childProps)?.let { return it }
3107
+ }
3108
+ return node.optString("id").takeIf { it.isNotBlank() }
3109
+ }
3110
+
2612
3111
  private fun configureContainer(layout: CraftNativeFlexLayout, style: JSONObject) {
2613
3112
  layout.configure(style, resources.displayMetrics.density)
2614
3113
  }
@@ -2793,6 +3292,59 @@ class MainActivity : AppCompatActivity() {
2793
3292
  configureTextStyle(view, style)
2794
3293
  }
2795
3294
 
3295
+ private fun configureIcon(view: CraftNativeIconView, props: JSONObject, style: JSONObject) {
3296
+ val symbol = props.optString("symbol").ifBlank { props.optString("name") }
3297
+ view.text = androidIconGlyph(symbol)
3298
+ val size = style.optDouble("fontSize", 20.0).toFloat().coerceAtLeast(1f)
3299
+ view.setTextSize(TypedValue.COMPLEX_UNIT_SP, size)
3300
+ val foreground = style.optString("color").ifBlank { props.optString("color") }
3301
+ view.setTextColor(color(foreground, defaultTextColor))
3302
+ val weight = when (style.optString("fontWeight")) {
3303
+ "bold", "700", "800", "900" -> Typeface.BOLD
3304
+ else -> Typeface.NORMAL
3305
+ }
3306
+ view.typeface = Typeface.create("sans-serif", weight)
3307
+ if (!style.has("width") && !style.has("height")) {
3308
+ val box = dp(size.toDouble())
3309
+ view.minimumWidth = box
3310
+ view.minimumHeight = box
3311
+ }
3312
+ }
3313
+
3314
+ private fun androidIconGlyph(symbol: String): String = when (symbol) {
3315
+ "arrow.left" -> "←"
3316
+ "arrow.right" -> "→"
3317
+ "arrow.up" -> "↑"
3318
+ "arrow.down" -> "↓"
3319
+ "chevron.left" -> "‹"
3320
+ "chevron.right" -> "›"
3321
+ "chevron.up" -> "⌃"
3322
+ "chevron.down" -> "⌄"
3323
+ "plus" -> "+"
3324
+ "minus" -> "−"
3325
+ "xmark" -> "×"
3326
+ "checkmark" -> "✓"
3327
+ "house", "house.fill" -> "⌂"
3328
+ "magnifyingglass" -> "⌕"
3329
+ "gear", "gearshape" -> "⚙"
3330
+ "heart" -> "♡"
3331
+ "heart.fill" -> "♥"
3332
+ "star" -> "☆"
3333
+ "star.fill" -> "★"
3334
+ "sun.max" -> "☼"
3335
+ "moon" -> "☾"
3336
+ "info.circle", "info.circle.fill" -> "ⓘ"
3337
+ "questionmark.circle", "questionmark.circle.fill" -> "?"
3338
+ "exclamationmark.triangle", "exclamationmark.triangle.fill" -> "⚠"
3339
+ "play", "play.fill" -> "▶"
3340
+ "pause", "pause.fill" -> "Ⅱ"
3341
+ "stop", "stop.fill" -> "■"
3342
+ "camera" -> "▣"
3343
+ "circle" -> "○"
3344
+ "circle.fill" -> "●"
3345
+ else -> "○"
3346
+ }
3347
+
2796
3348
  private fun configureTextStyle(view: TextView, style: JSONObject) {
2797
3349
  view.setTextColor(color(style.optString("color"), defaultTextColor))
2798
3350
  view.setTextSize(TypedValue.COMPLEX_UNIT_SP, style.optDouble("fontSize", 16.0).toFloat())
@@ -2939,7 +3491,7 @@ class MainActivity : AppCompatActivity() {
2939
3491
  private fun applyAccessibility(view: View, props: JSONObject, type: String, fallbackLabel: String) {
2940
3492
  view.tag = fallbackLabel
2941
3493
  val label = props.optString("accessibilityLabel").takeIf { it.isNotBlank() }
2942
- ?: props.optString("alt").takeIf { type == "Image" && it.isNotBlank() }
3494
+ ?: props.optString("alt").takeIf { (type == "Image" || type == "ImageBackground") && it.isNotBlank() }
2943
3495
  val value = props.opt("accessibilityValue")?.takeUnless { it == JSONObject.NULL }?.toString()
2944
3496
  view.contentDescription = when {
2945
3497
  label != null && value != null -> "$label, $value"
@@ -2948,14 +3500,20 @@ class MainActivity : AppCompatActivity() {
2948
3500
  else -> null
2949
3501
  }
2950
3502
  view.tooltipText = props.optString("accessibilityHint").takeIf { it.isNotBlank() }
2951
- val role = props.optString("accessibilityRole").ifBlank { type.lowercase() }
3503
+ val role = props.optString("accessibilityRole").ifBlank {
3504
+ if (type == "Icon") "image" else type.lowercase()
3505
+ }
2952
3506
  val state = props.optJSONObject("accessibilityState")
2953
3507
  val disabled = props.optBoolean("disabled", false) || state?.optBoolean("disabled", false) == true
2954
- if (type !in setOf("Button", "Link", "TextInput", "Picker", "Modal", "Switch", "Slider", "ActivityIndicator")) {
3508
+ if (type !in setOf("Button", "Link", "Checkbox", "TextInput", "Picker", "DatePicker", "Modal", "Switch", "Slider", "ActivityIndicator")) {
2955
3509
  view.isEnabled = !disabled
2956
3510
  } else if (disabled) {
2957
3511
  view.isEnabled = false
2958
3512
  }
3513
+ ViewCompat.setStateDescription(
3514
+ view,
3515
+ if (type == "Checkbox" && props.optBoolean("indeterminate", false)) "mixed" else null,
3516
+ )
2959
3517
  ViewCompat.setAccessibilityDelegate(view, object : AccessibilityDelegateCompat() {
2960
3518
  override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfoCompat) {
2961
3519
  super.onInitializeAccessibilityNodeInfo(host, info)
@@ -3156,6 +3714,14 @@ class MainActivity : AppCompatActivity() {
3156
3714
  emitLayoutEvents(screen, screen.view)
3157
3715
  }
3158
3716
 
3717
+ private fun updateKeyboardAvoidingViews() {
3718
+ fun visit(view: View) {
3719
+ (view as? CraftNativeKeyboardAvoidingView)?.setKeyboardInset(keyboardInset)
3720
+ if (view is ViewGroup) for (index in 0 until view.childCount) visit(view.getChildAt(index))
3721
+ }
3722
+ if (::root.isInitialized) visit(root)
3723
+ }
3724
+
3159
3725
  private fun emitLayoutEvents(screen: Screen, view: View) {
3160
3726
  emitLayoutEvent(screen, view, view.left, view.top, view.right, view.bottom)
3161
3727
  if (view is ViewGroup) {
@@ -3213,8 +3779,16 @@ class MainActivity : AppCompatActivity() {
3213
3779
  }
3214
3780
  }
3215
3781
 
3782
+ private fun imageViews(views: Collection<View>): Set<ImageView> = buildSet {
3783
+ fun collect(view: View) {
3784
+ if (view is ImageView) add(view)
3785
+ if (view is ViewGroup) for (index in 0 until view.childCount) collect(view.getChildAt(index))
3786
+ }
3787
+ views.forEach(::collect)
3788
+ }
3789
+
3216
3790
  private fun releaseImages(views: Collection<View>) {
3217
- views.filterIsInstance<ImageView>().forEach { image ->
3791
+ imageViews(views).forEach { image ->
3218
3792
  imageJobs.remove(image)?.cancel(true)
3219
3793
  imageSources.remove(image)
3220
3794
  imageErrors.remove(image)
@@ -3244,6 +3818,7 @@ class MainActivity : AppCompatActivity() {
3244
3818
  preservingInputDrafts: Boolean = false,
3245
3819
  ) {
3246
3820
  val released = views.toMutableSet()
3821
+ val releasedImages = imageViews(released)
3247
3822
  val refreshableLists = released.filterIsInstance<CraftNativeRefreshableFlatList>()
3248
3823
  val nestedLists = refreshableLists.map { it.list }.toSet()
3249
3824
  released.addAll(nestedLists)
@@ -3256,6 +3831,7 @@ class MainActivity : AppCompatActivity() {
3256
3831
  released.filterIsInstance<CraftNativeModalView>().forEach { it.resetForTeardown() }
3257
3832
  released.filterIsInstance<CraftNativeVideoView>().forEach { it.stop() }
3258
3833
  released.filterIsInstance<CraftNativeFlexLayout>().forEach { it.onLayoutChanged = null }
3834
+ released.filterIsInstance<CraftNativeKeyboardAvoidingView>().forEach { it.content.onLayoutChanged = null }
3259
3835
  released.filterIsInstance<CraftNativeScrollView>().forEach { it.setRefreshHandler(null, false) }
3260
3836
  releaseImages(released)
3261
3837
  released.filterIsInstance<EditText>().forEach {
@@ -3287,10 +3863,10 @@ class MainActivity : AppCompatActivity() {
3287
3863
  screen.blurHandlers.keys.removeAll(released)
3288
3864
  screen.endEditingHandlers.keys.removeAll(released)
3289
3865
  screen.submitHandlers.keys.removeAll(released)
3290
- screen.imageLoadStartHandlers.keys.removeAll(released)
3291
- screen.imageLoadHandlers.keys.removeAll(released)
3292
- screen.imageLoadEndHandlers.keys.removeAll(released)
3293
- screen.imageErrorHandlers.keys.removeAll(released)
3866
+ screen.imageLoadStartHandlers.keys.removeAll(releasedImages)
3867
+ screen.imageLoadHandlers.keys.removeAll(releasedImages)
3868
+ screen.imageLoadEndHandlers.keys.removeAll(releasedImages)
3869
+ screen.imageErrorHandlers.keys.removeAll(releasedImages)
3294
3870
  screen.pressActiveOpacities.keys.removeAll(released)
3295
3871
  screen.pressBaseOpacities.keys.removeAll(released)
3296
3872
  screen.modalVisibility.keys.removeAll(released)
@@ -3304,6 +3880,8 @@ class MainActivity : AppCompatActivity() {
3304
3880
  screen.pendingCapabilities.clear()
3305
3881
  releaseViewState(screen, retainedViews(screen).toList())
3306
3882
  screen.controls.clear()
3883
+ screen.nodeViews.clear()
3884
+ screen.nodePaths.clear()
3307
3885
  screen.handlers.clear()
3308
3886
  screen.pressInHandlers.clear()
3309
3887
  screen.pressOutHandlers.clear()
@@ -3341,6 +3919,11 @@ class MainActivity : AppCompatActivity() {
3341
3919
  back()
3342
3920
  }
3343
3921
 
3922
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
3923
+ if (::capabilities.isInitialized && capabilities.onActivityResult(requestCode, resultCode, data)) return
3924
+ super.onActivityResult(requestCode, resultCode, data)
3925
+ }
3926
+
3344
3927
  private fun showError(message: String) {
3345
3928
  root.removeAllViews()
3346
3929
  root.addView(TextView(this).apply {