craft-native 0.0.394 → 0.0.396

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.
@@ -366,6 +366,9 @@ private object CraftNativeSkewTransforms {
366
366
 
367
367
  /** A DOM-free SVG surface for the geometry emitted by the shared STX native protocol. */
368
368
  private class CraftNativeSvgView(context: Context) : View(context) {
369
+ override fun dispatchTouchEvent(event: MotionEvent): Boolean =
370
+ CraftNativePointers.dispatch(this, event) { super.dispatchTouchEvent(it) }
371
+
369
372
  var drawing: JSONObject = JSONObject()
370
373
  set(value) {
371
374
  field = value
@@ -972,8 +975,184 @@ internal class CraftNativeCelebration(private val view: View) {
972
975
  }
973
976
  }
974
977
 
978
+ /** Authored nodes are boundaries; native control implementation views are not. */
979
+ internal object CraftNativePointers {
980
+ private data class State(var mode: String? = null, var container: Boolean = false,
981
+ var active: Boolean = false, var cancelled: Boolean = false, var route: List<String> = emptyList())
982
+ private val states = java.util.WeakHashMap<View, State>()
983
+
984
+ fun configure(view: View, props: JSONObject, style: JSONObject, container: Boolean) {
985
+ val raw = if (props.has("pointerEvents") && !props.isNull("pointerEvents")) props.opt("pointerEvents") else style.opt("pointerEvents")
986
+ val state = states.getOrPut(view) { State() }
987
+ state.mode = (raw as? String)?.takeIf { it in setOf("auto", "none", "box-none", "box-only") } ?: "auto"
988
+ state.container = container
989
+ }
990
+
991
+ private fun route(view: View): List<String> {
992
+ val result = mutableListOf<String>()
993
+ var candidate: View? = view
994
+ while (candidate != null) {
995
+ states[candidate]?.mode?.let { result.add(it) }
996
+ candidate = candidate.parent as? View
997
+ }
998
+ return result
999
+ }
1000
+
1001
+ fun ownsTouch(view: View): Boolean = route(view).firstOrNull() !in setOf("none", "box-none")
1002
+
1003
+ fun dispatch(view: View, event: MotionEvent, native: (MotionEvent) -> Boolean): Boolean {
1004
+ val state = states.getOrPut(view) { State() }
1005
+ val route = route(view)
1006
+ if (event.actionMasked == MotionEvent.ACTION_CANCEL) {
1007
+ state.active = false
1008
+ return native(event)
1009
+ }
1010
+ if (event.actionMasked == MotionEvent.ACTION_DOWN) {
1011
+ state.active = false
1012
+ state.cancelled = false
1013
+ state.route = route
1014
+ } else if (state.active && state.route != route) {
1015
+ val cancel = MotionEvent.obtain(event)
1016
+ cancel.action = MotionEvent.ACTION_CANCEL
1017
+ try { native(cancel) } finally { cancel.recycle() }
1018
+ state.active = false
1019
+ state.cancelled = true
1020
+ }
1021
+ if (state.cancelled) return false
1022
+ if (state.mode == "box-only" && event.actionMasked == MotionEvent.ACTION_DOWN &&
1023
+ (event.x < 0 || event.x >= view.width || event.y < 0 || event.y >= view.height)) return false
1024
+ val ancestors = route.drop(1)
1025
+ if ("none" in route || "box-only" in ancestors || (state.mode == "box-none" && !state.container)) return false
1026
+ val handled = native(event) || (state.mode == "box-only" && state.container && view.isEnabled)
1027
+ if (event.actionMasked == MotionEvent.ACTION_DOWN) state.active = handled
1028
+ if (event.actionMasked == MotionEvent.ACTION_UP) state.active = false
1029
+ return handled
1030
+ }
1031
+ }
1032
+
1033
+ internal class CraftNativePointerTextView(context: Context) : TextView(context) {
1034
+ override fun dispatchTouchEvent(event: MotionEvent): Boolean =
1035
+ CraftNativePointers.dispatch(this, event) { super.dispatchTouchEvent(it) }
1036
+
1037
+ }
1038
+
1039
+ internal class CraftNativePointerButton(context: Context) : Button(context) {
1040
+ override fun dispatchTouchEvent(event: MotionEvent): Boolean =
1041
+ CraftNativePointers.dispatch(this, event) { super.dispatchTouchEvent(it) }
1042
+
1043
+ }
1044
+
1045
+ internal class CraftNativePointerCheckBox(context: Context) : CheckBox(context) {
1046
+ override fun dispatchTouchEvent(event: MotionEvent): Boolean =
1047
+ CraftNativePointers.dispatch(this, event) { super.dispatchTouchEvent(it) }
1048
+
1049
+ }
1050
+
1051
+ internal class CraftNativePointerRadioGroup(context: Context) : android.widget.RadioGroup(context) {
1052
+ override fun dispatchTouchEvent(event: MotionEvent): Boolean =
1053
+ CraftNativePointers.dispatch(this, event) { super.dispatchTouchEvent(it) }
1054
+ }
1055
+
1056
+ internal class CraftNativePointerSpinner(context: Context) : Spinner(context) {
1057
+ override fun dispatchTouchEvent(event: MotionEvent): Boolean =
1058
+ CraftNativePointers.dispatch(this, event) { super.dispatchTouchEvent(it) }
1059
+
1060
+ }
1061
+
1062
+ internal class CraftNativePointerSwitch(context: Context) : Switch(context) {
1063
+ override fun dispatchTouchEvent(event: MotionEvent): Boolean =
1064
+ CraftNativePointers.dispatch(this, event) { super.dispatchTouchEvent(it) }
1065
+
1066
+ }
1067
+
1068
+ internal class CraftNativePointerSeekBar(context: Context) : SeekBar(context) {
1069
+ override fun dispatchTouchEvent(event: MotionEvent): Boolean =
1070
+ CraftNativePointers.dispatch(this, event) { super.dispatchTouchEvent(it) }
1071
+
1072
+ }
1073
+
1074
+ internal class CraftNativePointerImageView(context: Context) : ImageView(context) {
1075
+ private var objectFit: String? = null
1076
+ private var objectX = 0.5f
1077
+ private var objectY = 0.5f
1078
+
1079
+ fun configureObjectGeometry(props: JSONObject, style: JSONObject) {
1080
+ fun value(name: String): Any? = props.opt(name)?.takeUnless { it == JSONObject.NULL }
1081
+ ?: style.opt(name)?.takeUnless { it == JSONObject.NULL }
1082
+ val fit = value("objectFit")
1083
+ val position = value("objectPosition")
1084
+ objectFit = if (fit != null || position != null) (fit as? String)
1085
+ ?.takeIf { it in setOf("fill", "contain", "cover", "none", "scale-down") } ?: "fill" else null
1086
+ val point = parsePosition(position)
1087
+ objectX = point.first; objectY = point.second
1088
+ cropToPadding = objectFit != null
1089
+ updateObjectGeometry()
1090
+ }
1091
+
1092
+ private fun parsePosition(raw: Any?): Pair<Float, Float> {
1093
+ val center = 0.5f to 0.5f
1094
+ val text = (raw as? String)?.trim()?.lowercase() ?: return center
1095
+ var parts = text.split(Regex("\\s+"))
1096
+ if (parts.size !in 1..2) return center
1097
+ if (parts.size == 1) parts = if (parts[0] in setOf("top", "bottom")) listOf("center", parts[0]) else listOf(parts[0], "center")
1098
+ else if (parts[0] in setOf("top", "bottom") || parts[1] in setOf("left", "right")) parts = parts.reversed()
1099
+ fun fraction(part: String, low: String, high: String): Float? = when (part) {
1100
+ low -> 0f; high -> 1f; "center" -> 0.5f
1101
+ else -> if (part.endsWith("%")) part.dropLast(1).toFloatOrNull()?.takeIf { it.isFinite() }?.div(100f) else null
1102
+ }
1103
+ return (fraction(parts[0], "left", "right") ?: return center) to (fraction(parts[1], "top", "bottom") ?: return center)
1104
+ }
1105
+
1106
+ override fun setImageDrawable(drawable: android.graphics.drawable.Drawable?) {
1107
+ super.setImageDrawable(drawable)
1108
+ updateObjectGeometry()
1109
+ }
1110
+
1111
+ override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
1112
+ super.onSizeChanged(w, h, oldw, oldh)
1113
+ updateObjectGeometry()
1114
+ }
1115
+
1116
+ override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
1117
+ super.onLayout(changed, left, top, right, bottom)
1118
+ updateObjectGeometry()
1119
+ }
1120
+
1121
+ private fun updateObjectGeometry() {
1122
+ val fit = objectFit ?: return
1123
+ val source = drawable ?: return
1124
+ val sw = source.intrinsicWidth.toFloat(); val sh = source.intrinsicHeight.toFloat()
1125
+ if (sw <= 0 || sh <= 0) return
1126
+ val w = (width - paddingLeft - paddingRight).coerceAtLeast(0).toFloat()
1127
+ val h = (height - paddingTop - paddingBottom).coerceAtLeast(0).toFloat()
1128
+ val contain = minOf(w / sw, h / sh)
1129
+ val scale = when (fit) {
1130
+ "cover" -> maxOf(w / sw, h / sh)
1131
+ "none" -> 1f
1132
+ "scale-down" -> minOf(1f, contain)
1133
+ else -> contain
1134
+ }
1135
+ val sx = if (fit == "fill") w / sw else scale
1136
+ val sy = if (fit == "fill") h / sh else scale
1137
+ scaleType = ScaleType.MATRIX
1138
+ imageMatrix = Matrix().apply {
1139
+ setScale(sx, sy)
1140
+ postTranslate((w - sw * sx) * objectX, (h - sh * sy) * objectY)
1141
+ }
1142
+ }
1143
+
1144
+ override fun dispatchTouchEvent(event: MotionEvent): Boolean =
1145
+ CraftNativePointers.dispatch(this, event) { super.dispatchTouchEvent(it) }
1146
+
1147
+ }
1148
+
975
1149
  /** A deterministic flex-shaped container shared by normal views and scroll content. */
976
1150
  internal class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1151
+ override fun onInterceptTouchEvent(event: MotionEvent): Boolean =
1152
+ CraftNativePointers.ownsTouch(this) && super.onInterceptTouchEvent(event)
1153
+ override fun onTouchEvent(event: MotionEvent): Boolean =
1154
+ (event.actionMasked == MotionEvent.ACTION_CANCEL || CraftNativePointers.ownsTouch(this)) && super.onTouchEvent(event)
1155
+
977
1156
  private var celebration: CraftNativeCelebration? = null
978
1157
  fun configureCelebration(raw: JSONObject?, radius: Float) {
979
1158
  if (raw != null && celebration == null) celebration = CraftNativeCelebration(this)
@@ -1012,10 +1191,76 @@ internal class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1012
1191
  private var safeAreaPadding = intArrayOf(0, 0, 0, 0)
1013
1192
  private var applyingPadding = false
1014
1193
  private var safeAreaEdges = setOf("top", "right", "bottom", "left")
1194
+ private var overflowTouchTarget: View? = null
1195
+
1196
+ private fun childEvent(child: View, event: MotionEvent): MotionEvent = MotionEvent.obtain(event).also {
1197
+ it.offsetLocation((scrollX - child.left).toFloat(), (scrollY - child.top).toFloat())
1198
+ if (!child.matrix.isIdentity) {
1199
+ val inverse = android.graphics.Matrix()
1200
+ if (child.matrix.invert(inverse)) it.transform(inverse)
1201
+ }
1202
+ }
1203
+
1204
+ private fun overflowContains(group: ViewGroup, x: Float, y: Float): Boolean {
1205
+ if (group.clipChildren) return false
1206
+ for (index in group.childCount - 1 downTo 0) {
1207
+ val child = group.getChildAt(index)
1208
+ if (child.visibility != View.VISIBLE || child.alpha <= 0.01f) continue
1209
+ val point = floatArrayOf(x + group.scrollX - child.left, y + group.scrollY - child.top)
1210
+ val inverse = android.graphics.Matrix()
1211
+ if (!child.matrix.isIdentity && child.matrix.invert(inverse)) inverse.mapPoints(point)
1212
+ if (point[0] >= 0 && point[0] < child.width && point[1] >= 0 && point[1] < child.height) return true
1213
+ if (child is ViewGroup && overflowContains(child, point[0], point[1])) return true
1214
+ }
1215
+ return false
1216
+ }
1217
+
1218
+ // Android normally rejects a zero-height child's entire subtree before
1219
+ // dispatch, even when it draws outside that box. Only that overflow path
1220
+ // needs routing here; ordinary gestures stay in ViewGroup's dispatcher.
1221
+ private fun dispatchOverflow(event: MotionEvent): Boolean? {
1222
+ if (event.actionMasked == MotionEvent.ACTION_DOWN) {
1223
+ overflowTouchTarget = null
1224
+ val children = (0 until childCount).map { getChildAt(it) }
1225
+ .filter { it.visibility == View.VISIBLE && it.alpha > 0.01f }
1226
+ .sortedBy { it.z }.reversed()
1227
+ val reachesOverflow = children.any { child ->
1228
+ val local = childEvent(child, event)
1229
+ try {
1230
+ child is ViewGroup && (local.x < 0 || local.x >= child.width || local.y < 0 || local.y >= child.height) &&
1231
+ overflowContains(child, local.x, local.y)
1232
+ } finally { local.recycle() }
1233
+ }
1234
+ if (!reachesOverflow) return null
1235
+ for (child in children) {
1236
+ val local = childEvent(child, event)
1237
+ try {
1238
+ val inside = local.x >= 0 && local.x < child.width && local.y >= 0 && local.y < child.height
1239
+ if ((inside || (child is ViewGroup && overflowContains(child, local.x, local.y))) && child.dispatchTouchEvent(local)) {
1240
+ overflowTouchTarget = child
1241
+ return true
1242
+ }
1243
+ } finally { local.recycle() }
1244
+ }
1245
+ return null
1246
+ }
1247
+ val target = overflowTouchTarget ?: return null
1248
+ val local = childEvent(target, event)
1249
+ try {
1250
+ if (target.parent !== this) local.action = MotionEvent.ACTION_CANCEL
1251
+ target.dispatchTouchEvent(local)
1252
+ if (local.actionMasked == MotionEvent.ACTION_UP || local.actionMasked == MotionEvent.ACTION_CANCEL) overflowTouchTarget = null
1253
+ return true
1254
+ } finally { local.recycle() }
1255
+ }
1256
+
1015
1257
  private var shieldedModalTouch = false
1016
1258
  private var dismissibleModalTouch: CraftNativeModalView? = null
1017
1259
 
1018
- override fun dispatchTouchEvent(event: MotionEvent): Boolean {
1260
+ override fun dispatchTouchEvent(event: MotionEvent): Boolean =
1261
+ CraftNativePointers.dispatch(this, event) { dispatchNativeTouchEvent(it) }
1262
+
1263
+ private fun dispatchNativeTouchEvent(event: MotionEvent): Boolean {
1019
1264
  if (event.actionMasked == MotionEvent.ACTION_DOWN) {
1020
1265
  val x = event.x.roundToInt()
1021
1266
  val y = event.y.roundToInt()
@@ -1023,12 +1268,12 @@ internal class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1023
1268
  .map { getChildAt(it) }
1024
1269
  .filterIsInstance<CraftNativeModalView>()
1025
1270
  .firstOrNull { child ->
1026
- child.dismissOnOutsideTap && child.visibility == View.VISIBLE && child.alpha > 0.01f
1271
+ CraftNativePointers.ownsTouch(child) && child.dismissOnOutsideTap && child.visibility == View.VISIBLE && child.alpha > 0.01f
1027
1272
  && (x < child.left || x >= child.right || y < child.top || y >= child.bottom)
1028
1273
  }
1029
1274
  shieldedModalTouch = (childCount - 1 downTo 0).any { index ->
1030
1275
  val child = getChildAt(index)
1031
- child is CraftNativeModalView && !child.blocksBackground && child.visibility == View.VISIBLE
1276
+ child is CraftNativeModalView && CraftNativePointers.ownsTouch(child) && !child.blocksBackground && child.visibility == View.VISIBLE
1032
1277
  && (x < child.left || x >= child.right || y < child.top || y >= child.bottom)
1033
1278
  && child.hasBlockingModalBelow()
1034
1279
  }
@@ -1046,6 +1291,7 @@ internal class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1046
1291
  if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) shieldedModalTouch = false
1047
1292
  return true
1048
1293
  }
1294
+ dispatchOverflow(event)?.let { return it }
1049
1295
  return super.dispatchTouchEvent(event)
1050
1296
  }
1051
1297
 
@@ -2328,7 +2574,15 @@ internal class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
2328
2574
 
2329
2575
  /** An image-backed flex container with ordinary native children over the image. */
2330
2576
  private class CraftNativeImageBackgroundView(context: Context) : FrameLayout(context) {
2331
- val image = ImageView(context)
2577
+ override fun onInterceptTouchEvent(event: MotionEvent): Boolean =
2578
+ CraftNativePointers.ownsTouch(this) && super.onInterceptTouchEvent(event)
2579
+ override fun onTouchEvent(event: MotionEvent): Boolean =
2580
+ (event.actionMasked == MotionEvent.ACTION_CANCEL || CraftNativePointers.ownsTouch(this)) && super.onTouchEvent(event)
2581
+
2582
+ override fun dispatchTouchEvent(event: MotionEvent): Boolean =
2583
+ CraftNativePointers.dispatch(this, event) { super.dispatchTouchEvent(it) }
2584
+
2585
+ val image = CraftNativePointerImageView(context)
2332
2586
  val content = CraftNativeFlexLayout(context)
2333
2587
 
2334
2588
  init {
@@ -2342,6 +2596,14 @@ private class CraftNativeImageBackgroundView(context: Context) : FrameLayout(con
2342
2596
 
2343
2597
  /** A flex container that responds to the activity's IME inset. */
2344
2598
  private class CraftNativeKeyboardAvoidingView(context: Context) : FrameLayout(context) {
2599
+ override fun onInterceptTouchEvent(event: MotionEvent): Boolean =
2600
+ CraftNativePointers.ownsTouch(this) && super.onInterceptTouchEvent(event)
2601
+ override fun onTouchEvent(event: MotionEvent): Boolean =
2602
+ (event.actionMasked == MotionEvent.ACTION_CANCEL || CraftNativePointers.ownsTouch(this)) && super.onTouchEvent(event)
2603
+
2604
+ override fun dispatchTouchEvent(event: MotionEvent): Boolean =
2605
+ CraftNativePointers.dispatch(this, event) { super.dispatchTouchEvent(it) }
2606
+
2345
2607
  val content = CraftNativeFlexLayout(context)
2346
2608
  private var behavior = "padding"
2347
2609
  private var keyboardVerticalOffset = 0
@@ -2389,6 +2651,31 @@ private class CraftNativeKeyboardAvoidingView(context: Context) : FrameLayout(co
2389
2651
  }
2390
2652
  }
2391
2653
 
2654
+ /**
2655
+ * The `fontFamily` a node passes to its children: its own, which already
2656
+ * carries any family it inherited (see [craftNativeInheritFontFamily]).
2657
+ */
2658
+ internal fun craftNativeInheritableFontFamily(style: JSONObject): String? =
2659
+ craftNativeOptionalString(style.opt("fontFamily"))?.takeUnless { it == "inherit" }
2660
+
2661
+ /**
2662
+ * CSS-style `fontFamily` inheritance: a node that sets no family (or
2663
+ * `inherit`) takes its nearest ancestor's, so a brand face set on a screen's
2664
+ * root reaches every label, button and input below it. Returns [style]
2665
+ * itself when nothing is inherited or the node sets its own family, else a
2666
+ * shallow copy carrying the inherited one; the protocol document's style is
2667
+ * never written to, so an ancestor's later change still reaches the node.
2668
+ */
2669
+ internal fun craftNativeInheritFontFamily(style: JSONObject, inherited: String?): JSONObject {
2670
+ if (inherited == null) return style
2671
+ val own = craftNativeOptionalString(style.opt("fontFamily"))
2672
+ if (own != null && own != "inherit") return style
2673
+ return JSONObject().also { copy ->
2674
+ style.keys().forEach { key -> copy.put(key, style.opt(key)) }
2675
+ copy.put("fontFamily", inherited)
2676
+ }
2677
+ }
2678
+
2392
2679
  /** CSS family fallback order, using bundled app fonts before platform families. */
2393
2680
  internal fun craftNativeFontFamily(family: String?, bundledFonts: Map<String, Typeface> = emptyMap()): Typeface =
2394
2681
  family?.split(',')?.asSequence()?.map { it.trim().trim('"', '\'') }?.mapNotNull { name ->
@@ -2401,17 +2688,23 @@ internal fun craftNativeFontFamily(family: String?, bundledFonts: Map<String, Ty
2401
2688
  }?.firstOrNull() ?: Typeface.DEFAULT
2402
2689
 
2403
2690
  internal class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(context) {
2691
+ override fun dispatchTouchEvent(event: MotionEvent): Boolean =
2692
+ CraftNativePointers.dispatch(this, event) { super.dispatchTouchEvent(it) }
2693
+
2694
+ override fun onTouchEvent(event: MotionEvent): Boolean =
2695
+ (event.actionMasked == MotionEvent.ACTION_CANCEL || CraftNativePointers.ownsTouch(this)) && super.onTouchEvent(event)
2696
+
2404
2697
  val content = CraftNativeFlexLayout(context)
2405
2698
  // Keep one refresh target across axis changes. Replacing all children of
2406
2699
  // SwipeRefreshLayout would also detach its own animated indicator.
2407
2700
  private val scrollContainer = FrameLayout(context)
2408
2701
  private val vertical = object : ScrollView(context) {
2409
- override fun onInterceptTouchEvent(event: MotionEvent): Boolean = isEnabled && super.onInterceptTouchEvent(event)
2410
- override fun onTouchEvent(event: MotionEvent): Boolean = isEnabled && super.onTouchEvent(event)
2702
+ override fun onInterceptTouchEvent(event: MotionEvent): Boolean = isEnabled && CraftNativePointers.ownsTouch(this) && super.onInterceptTouchEvent(event)
2703
+ override fun onTouchEvent(event: MotionEvent): Boolean = isEnabled && (event.actionMasked == MotionEvent.ACTION_CANCEL || CraftNativePointers.ownsTouch(this)) && super.onTouchEvent(event)
2411
2704
  }
2412
2705
  private val horizontal = object : HorizontalScrollView(context) {
2413
- override fun onInterceptTouchEvent(event: MotionEvent): Boolean = isEnabled && super.onInterceptTouchEvent(event)
2414
- override fun onTouchEvent(event: MotionEvent): Boolean = isEnabled && super.onTouchEvent(event)
2706
+ override fun onInterceptTouchEvent(event: MotionEvent): Boolean = isEnabled && CraftNativePointers.ownsTouch(this) && super.onInterceptTouchEvent(event)
2707
+ override fun onTouchEvent(event: MotionEvent): Boolean = isEnabled && (event.actionMasked == MotionEvent.ACTION_CANCEL || CraftNativePointers.ownsTouch(this)) && super.onTouchEvent(event)
2415
2708
  }
2416
2709
  private var horizontalMode: Boolean? = null
2417
2710
  private var pagingEnabled = false
@@ -2509,7 +2802,7 @@ internal class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(cont
2509
2802
  }
2510
2803
 
2511
2804
  override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
2512
- if (refreshHandler == null) return false
2805
+ if (refreshHandler == null || !CraftNativePointers.ownsTouch(this)) return false
2513
2806
  return super.onInterceptTouchEvent(event)
2514
2807
  }
2515
2808
 
@@ -2889,6 +3182,7 @@ internal class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(cont
2889
3182
  onScroll?.invoke(x, y)
2890
3183
  }
2891
3184
  scroller.setOnTouchListener { _, event ->
3185
+ if (!CraftNativePointers.ownsTouch(scroller) && event.actionMasked != MotionEvent.ACTION_CANCEL) return@setOnTouchListener false
2892
3186
  when (event.actionMasked) {
2893
3187
  android.view.MotionEvent.ACTION_DOWN -> {
2894
3188
  pageDragging = true
@@ -2959,6 +3253,9 @@ private class CraftNativeFlexSpacer(context: Context) : View(context)
2959
3253
  * the other native leaf controls without adding an icon-font dependency.
2960
3254
  */
2961
3255
  private class CraftNativeIconView(context: Context) : TextView(context) {
3256
+ override fun dispatchTouchEvent(event: MotionEvent): Boolean =
3257
+ CraftNativePointers.dispatch(this, event) { super.dispatchTouchEvent(it) }
3258
+
2962
3259
  /** The app's vector for the icon (CraftNativeIcons), drawn in place of the glyph. */
2963
3260
  var vector: Drawable? = null
2964
3261
  set(value) {
@@ -2985,6 +3282,9 @@ private class CraftNativeIconView(context: Context) : TextView(context) {
2985
3282
 
2986
3283
  /** A platform EditText with the native end clear affordance and TalkBack action. */
2987
3284
  private class CraftNativeTextField(context: Context) : EditText(context) {
3285
+ override fun dispatchTouchEvent(event: MotionEvent): Boolean =
3286
+ CraftNativePointers.dispatch(this, event) { super.dispatchTouchEvent(it) }
3287
+
2988
3288
  companion object { val CLEAR_ACTION_ID = View.generateViewId() }
2989
3289
  var clearButtonMode = "never"
2990
3290
  var onClearRequest: (() -> Unit)? = null
@@ -3029,8 +3329,14 @@ private class CraftNativeTextField(context: Context) : EditText(context) {
3029
3329
  }
3030
3330
 
3031
3331
  private class CraftNativeModalView(context: Context) : FrameLayout(context) {
3332
+ override fun dispatchTouchEvent(event: MotionEvent): Boolean =
3333
+ CraftNativePointers.dispatch(this, event) { super.dispatchTouchEvent(it) }
3334
+
3032
3335
  val content = CraftNativeFlexLayout(context)
3033
- private val blocker = View(context)
3336
+ private val blocker = object : View(context) {
3337
+ override fun onTouchEvent(event: MotionEvent): Boolean =
3338
+ (event.actionMasked == MotionEvent.ACTION_CANCEL || CraftNativePointers.ownsTouch(this)) && super.onTouchEvent(event)
3339
+ }
3034
3340
  private var renderedVisible = false
3035
3341
  private var animationGeneration = 0
3036
3342
  var focusToRestore: View? = null
@@ -3070,6 +3376,7 @@ private class CraftNativeModalView(context: Context) : FrameLayout(context) {
3070
3376
  isClickable = true
3071
3377
  blocker.isClickable = true
3072
3378
  blocker.setOnTouchListener { _, event ->
3379
+ if (!CraftNativePointers.ownsTouch(this)) return@setOnTouchListener false
3073
3380
  if (event.action == MotionEvent.ACTION_UP) requestClose(reason = "backdrop")
3074
3381
  true
3075
3382
  }
@@ -3150,6 +3457,7 @@ private class CraftNativeModalView(context: Context) : FrameLayout(context) {
3150
3457
  }
3151
3458
 
3152
3459
  override fun onTouchEvent(event: MotionEvent): Boolean {
3460
+ if (event.actionMasked != MotionEvent.ACTION_CANCEL && !CraftNativePointers.ownsTouch(this)) return false
3153
3461
  if (!blocksBackground) {
3154
3462
  if (dismissOnOutsideTap && event.action == MotionEvent.ACTION_UP) {
3155
3463
  requestClose(reason = "backdrop")
@@ -3167,7 +3475,7 @@ private class CraftNativeModalView(context: Context) : FrameLayout(context) {
3167
3475
  val index = group.indexOfChild(this)
3168
3476
  return index > 0 && (0 until index).any { siblingIndex ->
3169
3477
  val sibling = group.getChildAt(siblingIndex)
3170
- sibling is CraftNativeModalView && sibling.blocksBackground && sibling.visibility == View.VISIBLE && sibling.alpha > 0.01f
3478
+ sibling is CraftNativeModalView && CraftNativePointers.ownsTouch(sibling) && sibling.blocksBackground && sibling.visibility == View.VISIBLE && sibling.alpha > 0.01f
3171
3479
  }
3172
3480
  }
3173
3481
  }
@@ -3188,6 +3496,9 @@ private data class LayoutBounds(
3188
3496
  */
3189
3497
  private class CraftNativeActivityIndicator(context: Context) :
3190
3498
  android.widget.ProgressBar(context, null, android.R.attr.progressBarStyleHorizontal) {
3499
+ override fun dispatchTouchEvent(event: MotionEvent): Boolean =
3500
+ CraftNativePointers.dispatch(this, event) { super.dispatchTouchEvent(it) }
3501
+
3191
3502
  var indicatorScale = 1f
3192
3503
 
3193
3504
  init {
@@ -3199,6 +3510,9 @@ private class CraftNativeActivityIndicator(context: Context) :
3199
3510
 
3200
3511
  /** Android counterpart to iOS's UIDatePicker-backed native DatePicker. */
3201
3512
  private class CraftNativeDatePickerView(context: Context) : FrameLayout(context) {
3513
+ override fun dispatchTouchEvent(event: MotionEvent): Boolean =
3514
+ CraftNativePointers.dispatch(this, event) { super.dispatchTouchEvent(it) }
3515
+
3202
3516
  private enum class PickerMode { DATE, TIME, DATE_TIME, COUNTDOWN }
3203
3517
  private val maxCountdownSeconds = 23 * 3600 + 59 * 60 + 59
3204
3518
 
@@ -3445,6 +3759,9 @@ internal class CraftNativeInputEcho {
3445
3759
  * while this view keeps its place.
3446
3760
  */
3447
3761
  private class CraftNativeVideoView(context: Context) : VideoView(context) {
3762
+ override fun dispatchTouchEvent(event: MotionEvent): Boolean =
3763
+ CraftNativePointers.dispatch(this, event) { super.dispatchTouchEvent(it) }
3764
+
3448
3765
  private var sourceUri = ""
3449
3766
  private var shouldLoop = false
3450
3767
  private var shouldPlay = false
@@ -4210,6 +4527,12 @@ class MainActivity : AppCompatActivity() {
4210
4527
  // the original tree path without manufacturing a detached view.
4211
4528
  val nodeViews = mutableMapOf<String, View>()
4212
4529
  val nodePaths = mutableMapOf<String, String>()
4530
+ /**
4531
+ * The `fontFamily` a protocol node inherited from its ancestors when
4532
+ * drawn, so a targeted update of its subtree keeps it. Only nodes that
4533
+ * inherit one are recorded: a screen with no family keeps it empty.
4534
+ */
4535
+ val inheritedFontFamilies = mutableMapOf<String, String>()
4213
4536
  val protocolIds = mutableMapOf<View, String>()
4214
4537
  val handlers = mutableMapOf<View, String>()
4215
4538
  val linkTargets = mutableMapOf<View, String>()
@@ -5497,7 +5820,7 @@ class MainActivity : AppCompatActivity() {
5497
5820
  val document = screen.mutations.node(target) ?: return false
5498
5821
  val path = screen.nodePaths[target] ?: return false
5499
5822
  if (screen.nodeViews[target] == null) return false
5500
- makeView(screen, document, path, screen.controls)
5823
+ makeView(screen, document, path, screen.controls, inheritedFontFamily = screen.inheritedFontFamilies[target])
5501
5824
  val updated = screen.nodeViews[target] ?: return false
5502
5825
  screen.lastLayoutBounds.remove(updated)
5503
5826
  if (!updateParentLayoutStyle(updated, document.optJSONObject("style") ?: JSONObject())) return false
@@ -5584,6 +5907,7 @@ class MainActivity : AppCompatActivity() {
5584
5907
  val next = mutableMapOf<String, View>()
5585
5908
  screen.nodeViews.clear()
5586
5909
  screen.nodePaths.clear()
5910
+ screen.inheritedFontFamilies.clear()
5587
5911
  val tree = makeView(screen, document, "root", next)
5588
5912
  if (screen.view.getChildAt(0) !== tree) {
5589
5913
  (tree.parent as? ViewGroup)?.removeView(tree)
@@ -5673,6 +5997,7 @@ class MainActivity : AppCompatActivity() {
5673
5997
  screen.controls.clear()
5674
5998
  screen.nodeViews.clear()
5675
5999
  screen.nodePaths.clear()
6000
+ screen.inheritedFontFamilies.clear()
5676
6001
  screen.protocolIds.clear()
5677
6002
  screen.handlers.clear()
5678
6003
  screen.linkTargets.clear()
@@ -5681,16 +6006,22 @@ class MainActivity : AppCompatActivity() {
5681
6006
  screen.flatListOwnerByView.clear()
5682
6007
  }
5683
6008
 
6009
+ /**
6010
+ * [inheritedFontFamily]: the nearest ancestor's `fontFamily`, which the
6011
+ * node takes when it sets none ([craftNativeInheritFontFamily]) and
6012
+ * passes on to its children and list rows.
6013
+ */
5684
6014
  private fun makeView(
5685
6015
  screen: Screen,
5686
6016
  node: JSONObject,
5687
6017
  path: String,
5688
6018
  next: MutableMap<String, View>,
5689
6019
  previousControls: Map<String, View> = screen.controls,
6020
+ inheritedFontFamily: String? = null,
5690
6021
  ): View {
5691
6022
  val type = canonicalType(node.optString("type", "View"))
5692
6023
  val props = node.optJSONObject("props") ?: JSONObject()
5693
- val style = node.optJSONObject("style") ?: JSONObject()
6024
+ val style = craftNativeInheritFontFamily(node.optJSONObject("style") ?: JSONObject(), inheritedFontFamily)
5694
6025
  val events = node.optJSONObject("events") ?: JSONObject()
5695
6026
  val children = node.optJSONArray("children") ?: JSONArray()
5696
6027
  val protocolId = node.optString("id").takeIf { it.isNotBlank() }
@@ -5709,7 +6040,7 @@ class MainActivity : AppCompatActivity() {
5709
6040
  val previous = protocolId?.let { previousControls["id:$it"] } ?: keyedPrevious
5710
6041
  val reusableInput = previous as? CraftNativeTextField
5711
6042
  val control = when (type) {
5712
- "Text" -> ((previous as? TextView)?.takeUnless { it is Button || it is EditText } ?: TextView(this)).apply {
6043
+ "Text" -> ((previous as? TextView)?.takeUnless { it is Button || it is EditText } ?: CraftNativePointerTextView(this)).apply {
5713
6044
  includeFontPadding = false
5714
6045
  maxLines = craftNativeInt(props.opt("numberOfLines"), 0).takeIf { it > 0 } ?: Int.MAX_VALUE
5715
6046
  ellipsize = when (craftNativeOptionalString(props.opt("ellipsizeMode"))) {
@@ -5732,7 +6063,7 @@ class MainActivity : AppCompatActivity() {
5732
6063
  "RefreshControl" -> (previous as? CraftNativeFlexLayout ?: CraftNativeFlexLayout(this)).apply {
5733
6064
  visibility = View.GONE
5734
6065
  }
5735
- "Link" -> ((previous as? TextView)?.takeUnless { it is Button } ?: TextView(this)).apply {
6066
+ "Link" -> ((previous as? TextView)?.takeUnless { it is Button } ?: CraftNativePointerTextView(this)).apply {
5736
6067
  includeFontPadding = false
5737
6068
  configureText(this, (props.opt("title") as? String) ?: childText(children), style)
5738
6069
  val linkColor = craftNativeOptionalString(props.opt("color")) ?: craftNativeOptionalString(style.opt("color"))
@@ -5755,7 +6086,7 @@ class MainActivity : AppCompatActivity() {
5755
6086
  send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
5756
6087
  }
5757
6088
  }
5758
- "Button" -> (previous as? Button ?: Button(this).apply {
6089
+ "Button" -> (previous as? Button ?: CraftNativePointerButton(this).apply {
5759
6090
  // A plain button, as on iOS (UIButton) and the web: its
5760
6091
  // style draws any fill, not Material's grey raised box.
5761
6092
  background = null
@@ -5780,7 +6111,7 @@ class MainActivity : AppCompatActivity() {
5780
6111
  send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
5781
6112
  }
5782
6113
  }
5783
- "Checkbox" -> (previous as? CheckBox ?: CheckBox(this)).apply {
6114
+ "Checkbox" -> (previous as? CheckBox ?: CraftNativePointerCheckBox(this)).apply {
5784
6115
  text = (props.opt("label") as? String) ?: childText(children)
5785
6116
  configureText(this, text.toString(), style)
5786
6117
  val checkboxChecked = props.opt("checked")
@@ -5965,7 +6296,7 @@ class MainActivity : AppCompatActivity() {
5965
6296
  addTextChangedListener(watcher)
5966
6297
  }
5967
6298
  }
5968
- "SegmentedControl" -> (previous as? android.widget.RadioGroup ?: android.widget.RadioGroup(this)).apply {
6299
+ "SegmentedControl" -> (previous as? android.widget.RadioGroup ?: CraftNativePointerRadioGroup(this)).apply {
5969
6300
  orientation = android.widget.LinearLayout.HORIZONTAL
5970
6301
  // Reconciliation is silent. Only the platform's user selection
5971
6302
  // listener below emits a value, with the stable option value.
@@ -6016,7 +6347,7 @@ class MainActivity : AppCompatActivity() {
6016
6347
  send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject().put("value", value).put("index", index)))
6017
6348
  }
6018
6349
  }
6019
- "Picker" -> (previous as? Spinner ?: Spinner(this)).apply {
6350
+ "Picker" -> (previous as? Spinner ?: CraftNativePointerSpinner(this)).apply {
6020
6351
  val itemOptions = props.optJSONArray("items")?.let { items ->
6021
6352
  (0 until items.length()).mapNotNull { index ->
6022
6353
  val item = items.optJSONObject(index) ?: return@mapNotNull null
@@ -6160,7 +6491,7 @@ class MainActivity : AppCompatActivity() {
6160
6491
  screen.pressBaseBackgrounds[this] = this.background
6161
6492
  reconcileChildren(screen, this, children, identity, next, style, previousControls)
6162
6493
  }
6163
- "Switch" -> (previous as? Switch ?: Switch(this)).apply {
6494
+ "Switch" -> (previous as? Switch ?: CraftNativePointerSwitch(this)).apply {
6164
6495
  setOnCheckedChangeListener(null)
6165
6496
  val requestedValue = props.opt("value")
6166
6497
  val requestedChecked = props.opt("checked")
@@ -6199,7 +6530,7 @@ class MainActivity : AppCompatActivity() {
6199
6530
  send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject().put("value", checked)))
6200
6531
  }
6201
6532
  }
6202
- "Slider" -> (previous as? SeekBar ?: SeekBar(this)).apply {
6533
+ "Slider" -> (previous as? SeekBar ?: CraftNativePointerSeekBar(this)).apply {
6203
6534
  val minimum = craftNativeFiniteNumber(props, "minimumValue", 0.0)
6204
6535
  val maximumCandidate = craftNativeFiniteNumber(props, "maximumValue", 1.0)
6205
6536
  val maximum = if (maximumCandidate > minimum) maximumCandidate else minimum + Double.MIN_VALUE
@@ -6280,13 +6611,14 @@ class MainActivity : AppCompatActivity() {
6280
6611
  "Icon" -> (previous as? CraftNativeIconView ?: CraftNativeIconView(this)).apply {
6281
6612
  configureIcon(this, props, style)
6282
6613
  }
6283
- "Image" -> (previous as? ImageView ?: ImageView(this)).apply {
6614
+ "Image" -> (previous as? CraftNativePointerImageView ?: CraftNativePointerImageView(this)).apply {
6284
6615
  scaleType = when (craftNativeResizeMode(style, props)) {
6285
6616
  "cover" -> ImageView.ScaleType.CENTER_CROP
6286
6617
  "stretch" -> ImageView.ScaleType.FIT_XY
6287
6618
  "center" -> ImageView.ScaleType.CENTER
6288
6619
  else -> ImageView.ScaleType.CENTER_INSIDE
6289
6620
  }
6621
+ configureObjectGeometry(props, style)
6290
6622
  adjustViewBounds = false
6291
6623
  val onLoadStart = craftNativeEventName(events, "onLoadStart")
6292
6624
  if (onLoadStart.isNotBlank()) screen.imageLoadStartHandlers[this] = onLoadStart else screen.imageLoadStartHandlers.remove(this)
@@ -6305,6 +6637,7 @@ class MainActivity : AppCompatActivity() {
6305
6637
  "center" -> ImageView.ScaleType.CENTER
6306
6638
  else -> ImageView.ScaleType.CENTER_INSIDE
6307
6639
  }
6640
+ image.configureObjectGeometry(props, style)
6308
6641
  val tint = craftNativeOptionalString(style.opt("tintColor"))
6309
6642
  ?.let { runCatching { craftNativeColor(it) }.getOrNull() }
6310
6643
  if (tint == null) image.clearColorFilter() else image.setColorFilter(tint)
@@ -6477,7 +6810,7 @@ class MainActivity : AppCompatActivity() {
6477
6810
  (getSystemService(INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager)
6478
6811
  ?.hideSoftInputFromWindow(windowToken, 0)
6479
6812
  }
6480
- reconcileFlatList(screen, nativeList, children, identity, protocolId ?: identity, props, events)
6813
+ reconcileFlatList(screen, nativeList, children, identity, protocolId ?: identity, props, events, craftNativeInheritableFontFamily(style))
6481
6814
  }
6482
6815
  "View", "SafeAreaView" -> (previous as? CraftNativeFlexLayout ?: CraftNativeFlexLayout(this)).apply {
6483
6816
  configureContainer(this, style)
@@ -6529,6 +6862,7 @@ class MainActivity : AppCompatActivity() {
6529
6862
  send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
6530
6863
  }
6531
6864
  control.setOnTouchListener { view, event ->
6865
+ if (!CraftNativePointers.ownsTouch(view) && event.actionMasked != MotionEvent.ACTION_CANCEL) return@setOnTouchListener false
6532
6866
  when (event.actionMasked) {
6533
6867
  android.view.MotionEvent.ACTION_DOWN -> {
6534
6868
  val active = screen.pressActiveOpacities[view] ?: 0.5f
@@ -6597,6 +6931,8 @@ class MainActivity : AppCompatActivity() {
6597
6931
  control.setOnLongClickListener(null)
6598
6932
  control.isLongClickable = false
6599
6933
  }
6934
+ CraftNativePointers.configure(control, props, style, control is CraftNativeFlexLayout || control is CraftNativeImageBackgroundView ||
6935
+ control is CraftNativeKeyboardAvoidingView || control is CraftNativeScrollView || control is CraftNativeRefreshableFlatList || control is CraftNativeModalView)
6600
6936
  applyAccessibility(control, props, type, explicitKey ?: protocolId ?: path)
6601
6937
  // One element across an animated change (craft.animation.next).
6602
6938
  control.transitionName = craftNativeOptionalString(props.opt("transitionID"))?.takeIf { it.isNotBlank() }
@@ -6622,6 +6958,11 @@ class MainActivity : AppCompatActivity() {
6622
6958
  }
6623
6959
  }
6624
6960
  protocolId?.let {
6961
+ if (inheritedFontFamily != null) {
6962
+ screen.inheritedFontFamilies[it] = inheritedFontFamily
6963
+ } else if (screen.inheritedFontFamilies.isNotEmpty()) {
6964
+ screen.inheritedFontFamilies.remove(it)
6965
+ }
6625
6966
  screen.nodeViews[it] = control
6626
6967
  screen.nodePaths[it] = path
6627
6968
  }
@@ -6742,10 +7083,12 @@ class MainActivity : AppCompatActivity() {
6742
7083
  }
6743
7084
  val desired = mutableListOf<View>()
6744
7085
  val desiredOrder = mutableMapOf<View, Int>()
7086
+ // `style` is the parent's effective style, its inherited family included.
7087
+ val fontFamily = craftNativeInheritableFontFamily(style)
6745
7088
  for (index in 0 until children.length()) {
6746
7089
  val child = children.opt(index)
6747
7090
  val childView = when (child) {
6748
- is JSONObject -> makeView(screen, child, "$path.$index", next, previousControls)
7091
+ is JSONObject -> makeView(screen, child, "$path.$index", next, previousControls, fontFamily)
6749
7092
  is String -> TextView(this).apply {
6750
7093
  includeFontPadding = false
6751
7094
  configureText(this, child, craftNativeTypography(style))
@@ -6790,6 +7133,7 @@ class MainActivity : AppCompatActivity() {
6790
7133
  owner: String,
6791
7134
  props: JSONObject,
6792
7135
  events: JSONObject,
7136
+ fontFamily: String?,
6793
7137
  ) {
6794
7138
  screen.flatListOwners.entries.removeAll { it.value == owner }
6795
7139
  screen.flatListOwnerByView[list] = owner
@@ -6819,6 +7163,10 @@ class MainActivity : AppCompatActivity() {
6819
7163
  val threshold = craftNativeNonNegativeDouble(props, "threshold", 0.1)
6820
7164
  val authoredThreshold = craftNativeNonNegativeDouble(props, "onEndReachedThreshold", threshold)
6821
7165
  val horizontal = craftNativeBoolean(props.opt("horizontal"))
7166
+ // A row redraws when its own node changes; an ancestor's new family
7167
+ // leaves every node as it was, so rows drawn before are redrawn below.
7168
+ val familyChanged = list.inheritedFontFamily != fontFamily
7169
+ val rowsDrawnBefore = familyChanged && screen.flatListRows[list]?.isNotEmpty() == true
6822
7170
  list.apply(
6823
7171
  nodes = nodes,
6824
7172
  horizontal = horizontal,
@@ -6829,7 +7177,9 @@ class MainActivity : AppCompatActivity() {
6829
7177
  val rows = screen.flatListRows.getOrPut(list) { mutableMapOf() }
6830
7178
  val previous = rows[itemIdentity].orEmpty()
6831
7179
  val next = mutableMapOf<String, View>()
6832
- val view = makeView(screen, node, "$path.$itemIdentity", next, previous)
7180
+ // Rows are drawn later, as they scroll in: each takes the
7181
+ // list's family from this render's closure.
7182
+ val view = makeView(screen, node, "$path.$itemIdentity", next, previous, fontFamily)
6833
7183
  releaseViewState(screen, previous.values.filter { it !in next.values })
6834
7184
  rows[itemIdentity] = next
6835
7185
  view
@@ -6858,6 +7208,10 @@ class MainActivity : AppCompatActivity() {
6858
7208
  craftNativeBoolean(props.opt("alwaysBounceVertical"), !horizontal),
6859
7209
  craftNativeBoolean(props.opt("alwaysBounceHorizontal"), horizontal),
6860
7210
  )
7211
+ if (familyChanged) {
7212
+ list.inheritedFontFamily = fontFamily
7213
+ if (rowsDrawnBefore) list.refreshThemeDefaults()
7214
+ }
6861
7215
  }
6862
7216
 
6863
7217
  private fun registerFlatListOwnership(screen: Screen, node: JSONObject, owner: String) {
@@ -8358,6 +8712,7 @@ class MainActivity : AppCompatActivity() {
8358
8712
  screen.controls.clear()
8359
8713
  screen.nodeViews.clear()
8360
8714
  screen.nodePaths.clear()
8715
+ screen.inheritedFontFamilies.clear()
8361
8716
  screen.protocolIds.clear()
8362
8717
  screen.handlers.clear()
8363
8718
  screen.pressInHandlers.clear()