@craft-native/android 0.0.204 → 0.0.206

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.
@@ -21,15 +21,18 @@ import android.graphics.BitmapFactory
21
21
  import android.graphics.Canvas
22
22
  import android.graphics.Color
23
23
  import android.graphics.DashPathEffect
24
+ import android.graphics.LinearGradient
24
25
  import android.graphics.Matrix
25
26
  import android.graphics.Outline
26
27
  import android.graphics.Paint
27
28
  import android.graphics.Path
28
29
  import android.graphics.RectF
30
+ import android.graphics.Shader
29
31
  import android.graphics.Typeface
30
32
  import android.graphics.ColorFilter
31
33
  import android.graphics.PixelFormat
32
34
  import android.graphics.drawable.Drawable
35
+ import android.graphics.drawable.ColorDrawable
33
36
  import android.graphics.drawable.GradientDrawable
34
37
  import android.net.Uri
35
38
  import android.os.Build
@@ -145,6 +148,12 @@ private fun craftNativeOptionalString(value: Any?): String? = value
145
148
  ?.takeUnless { it == "null" || it == "undefined" }
146
149
  ?.takeIf { it.isNotBlank() }
147
150
 
151
+ private fun craftNativePickerValue(value: Any?): String? = when (value) {
152
+ is String -> value.takeIf { it.isNotEmpty() }
153
+ is Number -> value.toDouble().takeIf { it.isFinite() }?.toString()
154
+ else -> null
155
+ }
156
+
148
157
  private fun craftNativeEventName(events: JSONObject, vararg names: String): String =
149
158
  names.firstNotNullOfOrNull { name -> craftNativeOptionalString(events.opt(name)) }.orEmpty()
150
159
 
@@ -332,12 +341,26 @@ private class CraftNativeSvgView(context: Context) : View(context) {
332
341
  val strokeWidth: Float = 1f,
333
342
  val color: String = "#000000",
334
343
  val opacity: Float = 1f,
344
+ val fillOpacity: Float = 1f,
345
+ val strokeOpacity: Float = 1f,
346
+ val strokeDashoffset: Float = 0f,
347
+ val strokeMiterlimit: Float = 4f,
348
+ val fillRule: String = "nonzero",
335
349
  val typeface: Typeface = Typeface.DEFAULT,
336
350
  val strokeLinecap: String? = null,
337
351
  val strokeLinejoin: String? = null,
338
352
  val strokeDasharray: List<Float> = emptyList(),
339
353
  val vectorEffect: String? = null,
354
+ val dominantBaseline: String? = null,
340
355
  ) {
356
+ private fun svgTypeface(weight: String?, inherited: Typeface): Typeface = when (weight?.lowercase()) {
357
+ "500" -> Typeface.create("sans-serif-medium", Typeface.NORMAL)
358
+ "600" -> Typeface.create("sans-serif", Typeface.BOLD)
359
+ "bold", "700", "800", "900" -> Typeface.DEFAULT_BOLD
360
+ "normal", "100", "200", "300", "400" -> Typeface.DEFAULT
361
+ else -> inherited
362
+ }
363
+
341
364
  fun merge(node: JSONObject): SvgPaint {
342
365
  val props = node.optJSONObject("props") ?: JSONObject()
343
366
  val style = node.optJSONObject("style") ?: JSONObject()
@@ -361,17 +384,24 @@ private class CraftNativeSvgView(context: Context) : View(context) {
361
384
  // when both provide the same SVG paint value.
362
385
  val weight = value("fontWeight")
363
386
  val ownOpacity = value("opacity")?.toFloatOrNull()?.takeIf { it.isFinite() } ?: 1f
387
+ fun number(name: String): Float? = value(name)?.toFloatOrNull()?.takeIf { it.isFinite() }
364
388
  return copy(
365
389
  fill = value("fill") ?: fill,
366
390
  stroke = value("stroke") ?: stroke,
367
391
  strokeWidth = value("strokeWidth")?.toFloatOrNull()?.takeIf { it.isFinite() } ?: strokeWidth,
368
392
  color = value("color") ?: color,
369
393
  opacity = ownOpacity * opacity,
370
- typeface = if (weight in setOf("bold", "700")) Typeface.DEFAULT_BOLD else typeface,
394
+ fillOpacity = number("fillOpacity") ?: fillOpacity,
395
+ strokeOpacity = number("strokeOpacity") ?: strokeOpacity,
396
+ strokeDashoffset = number("strokeDashoffset") ?: strokeDashoffset,
397
+ strokeMiterlimit = number("strokeMiterlimit")?.takeIf { it > 0 } ?: strokeMiterlimit,
398
+ fillRule = value("fillRule") ?: fillRule,
399
+ typeface = svgTypeface(weight, typeface),
371
400
  strokeLinecap = value("strokeLinecap") ?: strokeLinecap,
372
401
  strokeLinejoin = value("strokeLinejoin") ?: strokeLinejoin,
373
402
  strokeDasharray = dashes(raw("strokeDasharray")) ?: strokeDasharray,
374
403
  vectorEffect = value("vectorEffect") ?: vectorEffect,
404
+ dominantBaseline = value("dominantBaseline") ?: dominantBaseline,
375
405
  )
376
406
  }
377
407
  }
@@ -467,16 +497,18 @@ private class CraftNativeSvgView(context: Context) : View(context) {
467
497
  }
468
498
  val fill = android.graphics.Paint(Paint.ANTI_ALIAS_FLAG).apply {
469
499
  color = resolveColor(style.fill, style.color, Color.TRANSPARENT)
470
- alpha = (style.opacity * 255f).roundToInt().coerceIn(0, 255)
500
+ alpha = (style.opacity * style.fillOpacity * 255f).roundToInt().coerceIn(0, 255)
471
501
  this.style = Paint.Style.FILL
472
502
  }
503
+ path.fillType = if (style.fillRule == "evenodd") Path.FillType.EVEN_ODD else Path.FillType.WINDING
473
504
  if (style.fill != "none" && style.fill != "transparent") canvas.drawPath(path, fill)
474
505
  if (!style.stroke.isNullOrBlank() && style.stroke != "none" && style.stroke != "transparent" && style.strokeWidth > 0) {
475
506
  val stroke = android.graphics.Paint(Paint.ANTI_ALIAS_FLAG).apply {
476
507
  color = resolveColor(style.stroke, style.color, Color.TRANSPARENT)
477
- alpha = (style.opacity * 255f).roundToInt().coerceIn(0, 255)
508
+ alpha = (style.opacity * style.strokeOpacity * 255f).roundToInt().coerceIn(0, 255)
478
509
  this.style = Paint.Style.STROKE
479
510
  strokeWidth = style.strokeWidth
511
+ strokeMiter = style.strokeMiterlimit
480
512
  strokeCap = when (style.strokeLinecap) {
481
513
  "round" -> Paint.Cap.ROUND
482
514
  "square" -> Paint.Cap.SQUARE
@@ -494,7 +526,7 @@ private class CraftNativeSvgView(context: Context) : View(context) {
494
526
  } else {
495
527
  dashPattern
496
528
  }
497
- pathEffect = DashPathEffect(pattern, 0f)
529
+ pathEffect = DashPathEffect(pattern, style.strokeDashoffset)
498
530
  }
499
531
  }
500
532
  // `vector-effect="non-scaling-stroke"`: the stroke is in the view's
@@ -525,7 +557,7 @@ private class CraftNativeSvgView(context: Context) : View(context) {
525
557
  ?: 12f
526
558
  val paint = android.graphics.Paint(Paint.ANTI_ALIAS_FLAG).apply {
527
559
  color = resolveColor(style.fill, style.color, Color.BLACK)
528
- alpha = (style.opacity * 255f).roundToInt().coerceIn(0, 255)
560
+ alpha = (style.opacity * style.fillOpacity * 255f).roundToInt().coerceIn(0, 255)
529
561
  textSize = size
530
562
  typeface = style.typeface
531
563
  }
@@ -535,7 +567,14 @@ private class CraftNativeSvgView(context: Context) : View(context) {
535
567
  "middle" -> x -= paint.measureText(text) / 2f
536
568
  "end" -> x -= paint.measureText(text)
537
569
  }
538
- canvas.drawText(text, x, y, paint)
570
+ val metrics = paint.fontMetrics
571
+ val baseline = when (style.dominantBaseline) {
572
+ "middle", "central", "mathematical" -> y - (metrics.ascent + metrics.descent) / 2f
573
+ "hanging", "text-before-edge", "before-edge" -> y - metrics.ascent
574
+ "text-after-edge", "after-edge" -> y - metrics.descent
575
+ else -> y
576
+ }
577
+ canvas.drawText(text, x, baseline, paint)
539
578
  }
540
579
 
541
580
  private fun resolveColor(value: String?, current: String, fallback: Int): Int = runCatching {
@@ -557,6 +596,7 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
557
596
  private var onMarkerTap: ((String) -> Unit)? = null
558
597
  private var lifecycleStarted = false
559
598
  private var destroyed = false
599
+ private var initialCameraKey: String? = null
560
600
 
561
601
  init {
562
602
  addView(mapView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
@@ -631,7 +671,11 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
631
671
  is Number -> rawWidth.toDouble()
632
672
  else -> 3.0
633
673
  }
634
- val width = authoredWidth.toFloat()
674
+ // MapKit's lineWidth is expressed in points, while the
675
+ // Google Maps SDK consumes screen pixels. Keep the
676
+ // protocol in logical points and convert only at this
677
+ // Android host boundary so dense displays stay in parity.
678
+ val width = (authoredWidth * resources.displayMetrics.density).toFloat()
635
679
  if (!authoredWidth.isFinite() || !width.isFinite() || width <= 0f) continue
636
680
  nextPolylines[id] = Triple(points, color(craftNativeOptionalString(props.opt("strokeColor")).orEmpty()), width)
637
681
  }
@@ -662,24 +706,67 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
662
706
  map.addPolyline(PolylineOptions().addAll(value.first).color(value.second).width(value.third))?.let { polylines[id] = it }
663
707
  }
664
708
  }
665
- if (pendingProps.optBoolean("fitToBounds", false)) fitToBounds()
666
- region(pendingProps.optJSONObject("region") ?: pendingProps.optJSONObject("initialRegion"))?.let { bounds ->
667
- post {
668
- if (width > 0 && height > 0) map.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, 24))
709
+ val explicitRegion = region(pendingProps.optJSONObject("region"))
710
+ val initialRegion = region(pendingProps.optJSONObject("initialRegion"))
711
+ if (explicitRegion != null) {
712
+ moveCamera(explicitRegion)
713
+ initialCameraKey = null
714
+ } else if (initialRegion != null) {
715
+ val key = "region:${pendingProps.optJSONObject("initialRegion")}"
716
+ if (initialCameraKey != key) {
717
+ moveCamera(initialRegion)
718
+ initialCameraKey = key
669
719
  }
720
+ } else if (pendingProps.optBoolean("fitToBounds", false)) {
721
+ val key = "fit:${overlayKey(nextMarkers, nextPolylines)}"
722
+ if (initialCameraKey != key) {
723
+ if (fitToBounds()) initialCameraKey = key
724
+ }
725
+ } else {
726
+ initialCameraKey = null
670
727
  }
671
728
  }
672
729
 
673
- private fun fitToBounds() {
674
- val map = googleMap ?: return
730
+ override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
731
+ super.onSizeChanged(width, height, oldWidth, oldHeight)
732
+ // MapView can become ready before its host has a usable viewport. Do
733
+ // not mark a fit as complete until the camera update can be issued.
734
+ if (width > 0 && height > 0 && pendingProps.optBoolean("fitToBounds", false) && initialCameraKey == null) {
735
+ applyPending()
736
+ }
737
+ }
738
+
739
+ private fun moveCamera(bounds: LatLngBounds) {
740
+ post {
741
+ if (width > 0 && height > 0) googleMap?.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, 24))
742
+ }
743
+ }
744
+
745
+ private fun overlayKey(
746
+ markers: Map<String, Pair<LatLng, String?>>,
747
+ polylines: Map<String, Triple<List<LatLng>, Int, Float>>,
748
+ ): String {
749
+ val markerKey = markers.toSortedMap().entries.joinToString("|") { (id, value) ->
750
+ "$id:${value.first.latitude},${value.first.longitude}"
751
+ }
752
+ val polylineKey = polylines.toSortedMap().entries.joinToString("|") { (id, value) ->
753
+ "$id:" + value.first.joinToString(";") { point -> "${point.latitude},${point.longitude}" }
754
+ }
755
+ return "m[$markerKey]p[$polylineKey]"
756
+ }
757
+
758
+ private fun fitToBounds(): Boolean {
759
+ val map = googleMap ?: return false
760
+ if (width <= 0 || height <= 0) return false
675
761
  val builder = LatLngBounds.Builder()
676
762
  var count = 0
677
763
  markers.values.forEach { builder.include(it.position); count++ }
678
764
  polylines.values.flatMap { it.points }.forEach { builder.include(it); count++ }
679
- if (count == 0) return
765
+ if (count == 0) return false
680
766
  post {
681
767
  if (width > 0 && height > 0) map.moveCamera(CameraUpdateFactory.newLatLngBounds(builder.build(), 24))
682
768
  }
769
+ return true
683
770
  }
684
771
 
685
772
  override fun onAttachedToWindow() {
@@ -728,8 +815,8 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
728
815
  private fun coordinates(geometry: JSONArray?): List<LatLng> {
729
816
  if (geometry == null) return emptyList()
730
817
  return (0 until geometry.length() - 1 step 2).mapNotNull { index ->
731
- val latitude = geometry.optDouble(index, Double.NaN)
732
- val longitude = geometry.optDouble(index + 1, Double.NaN)
818
+ val latitude = finiteNumber(geometry.opt(index)) ?: return@mapNotNull null
819
+ val longitude = finiteNumber(geometry.opt(index + 1)) ?: return@mapNotNull null
733
820
  if (latitude.isFinite() && longitude.isFinite() &&
734
821
  latitude >= -90.0 && latitude <= 90.0 &&
735
822
  longitude >= -180.0 && longitude <= 180.0
@@ -740,10 +827,14 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
740
827
  private fun region(value: JSONObject?): LatLngBounds? {
741
828
  val center = value?.optJSONObject("center") ?: return null
742
829
  val span = value.optJSONObject("span") ?: return null
743
- val latitude = center.optDouble("latitude", Double.NaN)
744
- val longitude = center.optDouble("longitude", Double.NaN)
745
- val latitudeDelta = span.optDouble("latitudeDelta", span.optDouble("latitude_delta", Double.NaN))
746
- val longitudeDelta = span.optDouble("longitudeDelta", span.optDouble("longitude_delta", Double.NaN))
830
+ val latitude = finiteNumber(center.opt("latitude")) ?: return null
831
+ val longitude = finiteNumber(center.opt("longitude")) ?: return null
832
+ val latitudeDelta = finiteNumber(span.opt("latitudeDelta"))
833
+ ?: finiteNumber(span.opt("latitude_delta"))
834
+ ?: return null
835
+ val longitudeDelta = finiteNumber(span.opt("longitudeDelta"))
836
+ ?: finiteNumber(span.opt("longitude_delta"))
837
+ ?: return null
747
838
  if (!latitude.isFinite() || !longitude.isFinite() || !latitudeDelta.isFinite() || !longitudeDelta.isFinite() ||
748
839
  latitude < -90.0 || latitude > 90.0 || longitude < -180.0 || longitude > 180.0 ||
749
840
  latitudeDelta <= 0.0 || latitudeDelta > 180.0 ||
@@ -753,7 +844,10 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
753
844
  return LatLngBounds(LatLng(latitude - latitudeDelta / 2, longitude - longitudeDelta / 2), LatLng(latitude + latitudeDelta / 2, longitude + longitudeDelta / 2))
754
845
  }
755
846
 
756
- private fun color(value: String): Int = runCatching { Color.parseColor(value) }.getOrDefault(Color.BLUE)
847
+ private fun finiteNumber(value: Any?): Double? =
848
+ (value as? Number)?.toDouble()?.takeIf { it.isFinite() }
849
+
850
+ private fun color(value: String): Int = runCatching { craftNativeColor(value) }.getOrDefault(Color.BLUE)
757
851
  }
758
852
 
759
853
  /** A deterministic flex-shaped container shared by normal views and scroll content. */
@@ -2036,6 +2130,9 @@ private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(conte
2036
2130
  private val defaultProgressStart = progressViewStartOffset
2037
2131
  private val defaultProgressEnd = progressViewEndOffset
2038
2132
  private var configuredProgressOffset: Int? = null
2133
+ private var stickyIndices = emptyList<Int>()
2134
+ private var scrollTargetKey: String? = null
2135
+ private var pendingScrollTarget: Pair<String, Boolean>? = null
2039
2136
 
2040
2137
  init {
2041
2138
  vertical.isNestedScrollingEnabled = true
@@ -2121,13 +2218,13 @@ private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(conte
2121
2218
  }
2122
2219
  }
2123
2220
 
2124
- fun setRefreshAppearance(props: JSONObject, density: Float) {
2221
+ fun setRefreshAppearance(props: JSONObject, density: Float, fallback: Int) {
2125
2222
  val tint = craftNativeOptionalString(props.opt("tintColor"))
2126
- if (tint != null) setColorSchemeColors(craftNativeColor(tint))
2127
- else setColorSchemeColors(Color.WHITE)
2223
+ setColorSchemeColors(tint?.let { runCatching { craftNativeColor(it) }.getOrNull() } ?: fallback)
2128
2224
  val background = craftNativeOptionalString(props.opt("progressBackgroundColor"))
2129
- if (background != null) setProgressBackgroundColorSchemeColor(craftNativeColor(background))
2130
- else setProgressBackgroundColorSchemeColor(Color.TRANSPARENT)
2225
+ setProgressBackgroundColorSchemeColor(
2226
+ background?.let { runCatching { craftNativeColor(it) }.getOrNull() } ?: Color.TRANSPARENT,
2227
+ )
2131
2228
  val offset = props.optDouble("progressViewOffset", Double.NaN)
2132
2229
  val requested = if (offset.isFinite()) (offset * density).roundToInt() else null
2133
2230
  if (configuredProgressOffset != requested) {
@@ -2247,6 +2344,133 @@ private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(conte
2247
2344
  horizontal.isHorizontalScrollBarEnabled = horizontalVisible
2248
2345
  }
2249
2346
 
2347
+ /** Keep vertical ScrollView headers pinned while preserving their layout slots. */
2348
+ fun setStickyHeaderIndices(value: JSONArray?) {
2349
+ stickyIndices.forEach { index ->
2350
+ content.getChildAt(index)?.apply {
2351
+ translationY = 0f
2352
+ translationZ = 0f
2353
+ }
2354
+ }
2355
+ stickyIndices = if (value == null) {
2356
+ emptyList()
2357
+ } else {
2358
+ (0 until value.length()).mapNotNull { value.optInt(it, -1).takeIf { index -> index >= 0 } }.distinct().sorted()
2359
+ }
2360
+ post { updateStickyHeaders() }
2361
+ }
2362
+
2363
+ /** Scroll a child identified by its native testID/id after reconciliation. */
2364
+ fun setScrollTarget(value: Any?) {
2365
+ val target: String
2366
+ val animated: Boolean
2367
+ val key: String
2368
+ when (value) {
2369
+ is String -> {
2370
+ target = value.trim()
2371
+ animated = true
2372
+ key = target
2373
+ }
2374
+ is JSONObject -> {
2375
+ target = craftNativeOptionalString(value.opt("id")).orEmpty().trim()
2376
+ animated = value.optBoolean("animated", true)
2377
+ key = value.toString()
2378
+ }
2379
+ else -> {
2380
+ scrollTargetKey = null
2381
+ pendingScrollTarget = null
2382
+ return
2383
+ }
2384
+ }
2385
+ if (target.isBlank()) {
2386
+ scrollTargetKey = null
2387
+ pendingScrollTarget = null
2388
+ return
2389
+ }
2390
+ if (key == scrollTargetKey) return
2391
+ scrollTargetKey = key
2392
+ pendingScrollTarget = target to animated
2393
+ post { scrollToPendingTarget(0) }
2394
+ }
2395
+
2396
+ private fun updateStickyHeaders() {
2397
+ if (horizontalMode == true) {
2398
+ stickyIndices.forEach { index ->
2399
+ content.getChildAt(index)?.apply {
2400
+ translationY = 0f
2401
+ translationZ = 0f
2402
+ }
2403
+ }
2404
+ return
2405
+ }
2406
+ val scrollY = vertical.scrollY
2407
+ var pinnedBottom = 0
2408
+ stickyIndices.forEachIndexed { position, index ->
2409
+ val header = content.getChildAt(index)
2410
+ if (header == null || header.visibility != View.VISIBLE || header.height <= 0) return@forEachIndexed
2411
+ val nextTop = stickyIndices.drop(position + 1)
2412
+ .firstNotNullOfOrNull { next -> content.getChildAt(next)?.takeIf { it.visibility == View.VISIBLE }?.top }
2413
+ ?: Int.MAX_VALUE
2414
+ val desired = max(0, scrollY + pinnedBottom - header.top)
2415
+ val limit = if (nextTop == Int.MAX_VALUE) Int.MAX_VALUE else max(0, nextTop - header.bottom)
2416
+ val translation = min(desired, limit)
2417
+ header.translationY = translation.toFloat()
2418
+ header.translationZ = if (translation > 0) 1f else 0f
2419
+ if (translation > 0) pinnedBottom = header.top - scrollY + translation + header.height
2420
+ }
2421
+ }
2422
+
2423
+ private fun findDescendant(view: View, id: String): View? {
2424
+ if (view.tag?.toString() == id || view.contentDescription?.toString() == id) return view
2425
+ if (view is ViewGroup) {
2426
+ for (index in 0 until view.childCount) findDescendant(view.getChildAt(index), id)?.let { return it }
2427
+ }
2428
+ return null
2429
+ }
2430
+
2431
+ private fun scrollToPendingTarget(attempt: Int) {
2432
+ val target = pendingScrollTarget ?: return
2433
+ val scroller: View = if (horizontalMode == true) horizontal else vertical
2434
+ val view = findDescendant(content, target.first)
2435
+ if (view == null || view.width <= 0 || view.height <= 0 || scroller.width <= 0 || scroller.height <= 0) {
2436
+ if (attempt < 4) postDelayed({ scrollToPendingTarget(attempt + 1) }, 16L)
2437
+ return
2438
+ }
2439
+ val targetLocation = IntArray(2)
2440
+ val scrollerLocation = IntArray(2)
2441
+ view.getLocationOnScreen(targetLocation)
2442
+ scroller.getLocationOnScreen(scrollerLocation)
2443
+ val current = if (horizontalMode == true) horizontal.scrollX else vertical.scrollY
2444
+ val stickyHeight = if (horizontalMode == true) 0 else {
2445
+ val contentLocation = IntArray(2)
2446
+ content.getLocationOnScreen(contentLocation)
2447
+ val targetContentTop = targetLocation[1] - contentLocation[1] + vertical.scrollY
2448
+ stickyIndices.mapNotNull { index -> content.getChildAt(index) }
2449
+ .lastOrNull { it.visibility == View.VISIBLE && it.top <= targetContentTop }
2450
+ ?.height ?: 0
2451
+ }
2452
+ val desired = if (horizontalMode == true) {
2453
+ current + targetLocation[0] - scrollerLocation[0]
2454
+ } else {
2455
+ current + targetLocation[1] - scrollerLocation[1] - stickyHeight
2456
+ }
2457
+ val maximum = if (horizontalMode == true) {
2458
+ max(0, content.measuredWidth - scroller.width)
2459
+ } else {
2460
+ max(0, content.measuredHeight - scroller.height)
2461
+ }
2462
+ val bounded = desired.coerceIn(0, maximum)
2463
+ if (target.second) {
2464
+ if (horizontalMode == true) horizontal.smoothScrollTo(bounded, 0) else vertical.smoothScrollTo(0, bounded)
2465
+ } else if (horizontalMode == true) {
2466
+ horizontal.scrollTo(bounded, 0)
2467
+ } else {
2468
+ vertical.scrollTo(0, bounded)
2469
+ }
2470
+ pendingScrollTarget = null
2471
+ updateStickyHeaders()
2472
+ }
2473
+
2250
2474
  fun setScrollListeners(
2251
2475
  onScroll: ((Int, Int) -> Unit)?,
2252
2476
  onBeginDrag: (() -> Unit)?,
@@ -2277,7 +2501,10 @@ private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(conte
2277
2501
  }
2278
2502
  }
2279
2503
 
2280
- scroller.setOnScrollChangeListener { _, x, y, _, _ -> onScroll?.invoke(x, y) }
2504
+ scroller.setOnScrollChangeListener { _, x, y, _, _ ->
2505
+ if (!horizontal) updateStickyHeaders()
2506
+ onScroll?.invoke(x, y)
2507
+ }
2281
2508
  scroller.setOnTouchListener { _, event ->
2282
2509
  when (event.actionMasked) {
2283
2510
  android.view.MotionEvent.ACTION_DOWN -> {
@@ -2644,7 +2871,8 @@ private class CraftNativeDatePickerView(context: Context) : FrameLayout(context)
2644
2871
  if (text.isBlank() || text == JSONObject.NULL.toString()) return null
2645
2872
  val formats = listOf(
2646
2873
  "HH:mm", "HH:mm:ss", "yyyy-MM-dd", "yyyy-MM-dd'T'HH:mm",
2647
- "yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss.SSSXXX", "yyyy-MM-dd'T'HH:mm:ssX",
2874
+ "yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss.SSS",
2875
+ "yyyy-MM-dd'T'HH:mm:ss.SSSXXX", "yyyy-MM-dd'T'HH:mm:ssX",
2648
2876
  )
2649
2877
  formats.firstNotNullOfOrNull { format ->
2650
2878
  runCatching {
@@ -2722,6 +2950,9 @@ private class CraftNativeVideoView(context: Context) : VideoView(context) {
2722
2950
  private var sourceUri = ""
2723
2951
  private var shouldLoop = false
2724
2952
  private var shouldPlay = false
2953
+ private var playIntent: Boolean? = null
2954
+ private var userPaused = false
2955
+ private var ended = false
2725
2956
  private var muted = false
2726
2957
  private var resizeMode = "contain"
2727
2958
  private var preparedPlayer: MediaPlayer? = null
@@ -2736,6 +2967,16 @@ private class CraftNativeVideoView(context: Context) : VideoView(context) {
2736
2967
  private set
2737
2968
  /** Playback started or stopped, or the choice changed: the activity's PiP params follow. */
2738
2969
  var onPictureInPictureChanged: (() -> Unit)? = null
2970
+ private val progressHandler = Handler(Looper.getMainLooper())
2971
+ private val progressTick = object : Runnable {
2972
+ override fun run() {
2973
+ if (!playing) return
2974
+ fire("onProgress", JSONObject()
2975
+ .put("currentTime", currentPosition.coerceAtLeast(0) / 1000.0)
2976
+ .put("duration", duration.coerceAtLeast(0) / 1000.0))
2977
+ progressHandler.postDelayed(this, 500L)
2978
+ }
2979
+ }
2739
2980
 
2740
2981
  init {
2741
2982
  // Mixed with the music rather than pausing it.
@@ -2749,7 +2990,13 @@ private class CraftNativeVideoView(context: Context) : VideoView(context) {
2749
2990
  else -> ""
2750
2991
  }
2751
2992
  shouldLoop = loop
2752
- shouldPlay = autoplay && !paused
2993
+ val nextShouldPlay = autoplay && !paused
2994
+ if (playIntent != nextShouldPlay) {
2995
+ userPaused = false
2996
+ ended = false
2997
+ }
2998
+ playIntent = nextShouldPlay
2999
+ shouldPlay = nextShouldPlay
2753
3000
  this.muted = muted
2754
3001
  val floating = pictureInPicture && !muted
2755
3002
  if (floating != this.pictureInPicture) {
@@ -2761,6 +3008,8 @@ private class CraftNativeVideoView(context: Context) : VideoView(context) {
2761
3008
  setMediaController(if (controls) MediaController(context).also { it.setAnchorView(this) } else null)
2762
3009
  if (uri != sourceUri) {
2763
3010
  sourceUri = uri
3011
+ userPaused = false
3012
+ ended = false
2764
3013
  stopPlayback()
2765
3014
  setPlaying(false)
2766
3015
  preparedPlayer = null
@@ -2780,7 +3029,10 @@ private class CraftNativeVideoView(context: Context) : VideoView(context) {
2780
3029
  }
2781
3030
  setOnCompletionListener {
2782
3031
  if (shouldLoop && !isPlaying) start()
2783
- else setPlaying(false)
3032
+ else {
3033
+ ended = true
3034
+ setPlaying(false)
3035
+ }
2784
3036
  fire("onEnd")
2785
3037
  }
2786
3038
  setOnErrorListener { _, what, extra ->
@@ -2791,18 +3043,24 @@ private class CraftNativeVideoView(context: Context) : VideoView(context) {
2791
3043
  }
2792
3044
  if (uri.isBlank()) return
2793
3045
  runCatching {
2794
- if (shouldPlay && !isPlaying) start()
3046
+ if (shouldPlay && !isPlaying && !userPaused && !ended) start()
2795
3047
  else if (!shouldPlay && isPlaying) pause()
2796
3048
  }
2797
3049
  }
2798
3050
 
2799
3051
  override fun start() {
2800
3052
  super.start()
2801
- setPlaying(true)
3053
+ userPaused = false
3054
+ ended = false
3055
+ // VideoView accepts start() before its MediaPlayer is prepared. Do
3056
+ // not announce playback until the player reports that it is actually
3057
+ // running; iOS follows the same rule through AVPlayer's time status.
3058
+ if (runCatching { isPlaying }.getOrDefault(false)) setPlaying(true)
2802
3059
  }
2803
3060
 
2804
3061
  override fun pause() {
2805
3062
  super.pause()
3063
+ userPaused = true
2806
3064
  setPlaying(false)
2807
3065
  }
2808
3066
 
@@ -2810,6 +3068,8 @@ private class CraftNativeVideoView(context: Context) : VideoView(context) {
2810
3068
  if (playing == value) return
2811
3069
  playing = value
2812
3070
  fire(if (value) "onPlay" else "onPause")
3071
+ progressHandler.removeCallbacks(progressTick)
3072
+ if (value) progressHandler.post(progressTick)
2813
3073
  onPictureInPictureChanged?.invoke()
2814
3074
  }
2815
3075
 
@@ -2834,12 +3094,20 @@ private class CraftNativeVideoView(context: Context) : VideoView(context) {
2834
3094
  return runCatching { player.setDisplay(holder ?: this.holder) }.isSuccess
2835
3095
  }
2836
3096
 
3097
+ fun notifyPictureInPicture(active: Boolean) {
3098
+ fire(if (active) "onPictureInPictureStart" else "onPictureInPictureStop")
3099
+ }
3100
+
2837
3101
  fun stop() {
2838
3102
  // Released with its screen: nothing more is told to it.
2839
3103
  emit = null
3104
+ progressHandler.removeCallbacks(progressTick)
2840
3105
  stopPlayback()
2841
3106
  setPlaying(false)
2842
3107
  sourceUri = ""
3108
+ playIntent = null
3109
+ userPaused = false
3110
+ ended = false
2843
3111
  preparedPlayer = null
2844
3112
  if (pictureInPicture) {
2845
3113
  pictureInPicture = false
@@ -3249,30 +3517,92 @@ private class CraftTextDecorationSpan(
3249
3517
  }
3250
3518
  }
3251
3519
 
3520
+ private fun craftNativeGradientOrientation(direction: String?): GradientDrawable.Orientation = when (direction) {
3521
+ "to-t" -> GradientDrawable.Orientation.BOTTOM_TOP
3522
+ "to-tr" -> GradientDrawable.Orientation.BL_TR
3523
+ "to-r" -> GradientDrawable.Orientation.LEFT_RIGHT
3524
+ "to-br" -> GradientDrawable.Orientation.TL_BR
3525
+ "to-bl" -> GradientDrawable.Orientation.TR_BL
3526
+ "to-l" -> GradientDrawable.Orientation.RIGHT_LEFT
3527
+ "to-tl" -> GradientDrawable.Orientation.BR_TL
3528
+ else -> GradientDrawable.Orientation.TOP_BOTTOM
3529
+ }
3530
+
3252
3531
  private class CraftNativeSideBorderDrawable(
3253
3532
  private val fillColor: Int,
3254
3533
  private val radii: FloatArray,
3255
3534
  private val widths: IntArray,
3256
3535
  private val colors: IntArray,
3536
+ private val borderStyle: String?,
3537
+ private val gradientDirection: String?,
3538
+ private val gradientColors: IntArray?,
3257
3539
  ) : Drawable() {
3258
3540
  private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
3259
3541
 
3542
+ private fun gradient(bounds: RectF): Shader? {
3543
+ val colors = gradientColors ?: return null
3544
+ val points = when (gradientDirection) {
3545
+ "to-t" -> floatArrayOf(0.5f, 1f, 0.5f, 0f)
3546
+ "to-tr" -> floatArrayOf(0f, 1f, 1f, 0f)
3547
+ "to-r" -> floatArrayOf(0f, 0.5f, 1f, 0.5f)
3548
+ "to-br" -> floatArrayOf(0f, 0f, 1f, 1f)
3549
+ "to-bl" -> floatArrayOf(1f, 0f, 0f, 1f)
3550
+ "to-l" -> floatArrayOf(1f, 0.5f, 0f, 0.5f)
3551
+ "to-tl" -> floatArrayOf(1f, 1f, 0f, 0f)
3552
+ else -> floatArrayOf(0.5f, 0f, 0.5f, 1f)
3553
+ }
3554
+ return LinearGradient(
3555
+ bounds.left + bounds.width() * points[0],
3556
+ bounds.top + bounds.height() * points[1],
3557
+ bounds.left + bounds.width() * points[2],
3558
+ bounds.top + bounds.height() * points[3],
3559
+ colors,
3560
+ null,
3561
+ Shader.TileMode.CLAMP,
3562
+ )
3563
+ }
3564
+
3260
3565
  override fun draw(canvas: Canvas) {
3261
3566
  val bounds = RectF(this.bounds)
3262
3567
  paint.style = Paint.Style.FILL
3263
3568
  paint.color = fillColor
3264
- if (Color.alpha(fillColor) > 0) {
3569
+ paint.shader = gradient(bounds)
3570
+ if (paint.shader != null || Color.alpha(fillColor) > 0) {
3265
3571
  val path = Path().apply { addRoundRect(bounds, radii, Path.Direction.CW) }
3266
3572
  canvas.drawPath(path, paint)
3267
3573
  }
3574
+ paint.shader = null
3268
3575
  val top = widths.getOrElse(0) { 0 }
3269
3576
  val right = widths.getOrElse(1) { 0 }
3270
3577
  val bottom = widths.getOrElse(2) { 0 }
3271
3578
  val left = widths.getOrElse(3) { 0 }
3272
- if (top > 0) { paint.color = colors.getOrElse(0) { Color.TRANSPARENT }; canvas.drawRect(bounds.left, bounds.top, bounds.right, bounds.top + top, paint) }
3273
- if (right > 0) { paint.color = colors.getOrElse(1) { Color.TRANSPARENT }; canvas.drawRect(bounds.right - right, bounds.top, bounds.right, bounds.bottom, paint) }
3274
- if (bottom > 0) { paint.color = colors.getOrElse(2) { Color.TRANSPARENT }; canvas.drawRect(bounds.left, bounds.bottom - bottom, bounds.right, bounds.bottom, paint) }
3275
- if (left > 0) { paint.color = colors.getOrElse(3) { Color.TRANSPARENT }; canvas.drawRect(bounds.left, bounds.top, bounds.left + left, bounds.bottom, paint) }
3579
+ if (borderStyle == "dashed" || borderStyle == "dotted") {
3580
+ paint.style = Paint.Style.STROKE
3581
+ paint.strokeCap = if (borderStyle == "dotted") Paint.Cap.ROUND else Paint.Cap.BUTT
3582
+ paint.pathEffect = null
3583
+ fun drawEdge(index: Int, width: Int, startX: Float, startY: Float, endX: Float, endY: Float) {
3584
+ if (width <= 0) return
3585
+ paint.color = colors.getOrElse(index) { Color.TRANSPARENT }
3586
+ paint.strokeWidth = width.toFloat()
3587
+ paint.pathEffect = if (borderStyle == "dotted") {
3588
+ DashPathEffect(floatArrayOf(width.toFloat(), width * 1.5f), 0f)
3589
+ } else {
3590
+ DashPathEffect(floatArrayOf(width * 3f, width * 2f), 0f)
3591
+ }
3592
+ canvas.drawLine(startX, startY, endX, endY, paint)
3593
+ }
3594
+ drawEdge(0, top, bounds.left, bounds.top + top / 2f, bounds.right, bounds.top + top / 2f)
3595
+ drawEdge(1, right, bounds.right - right / 2f, bounds.top, bounds.right - right / 2f, bounds.bottom)
3596
+ drawEdge(2, bottom, bounds.left, bounds.bottom - bottom / 2f, bounds.right, bounds.bottom - bottom / 2f)
3597
+ drawEdge(3, left, bounds.left + left / 2f, bounds.top, bounds.left + left / 2f, bounds.bottom)
3598
+ paint.pathEffect = null
3599
+ } else {
3600
+ paint.style = Paint.Style.FILL
3601
+ if (top > 0) { paint.color = colors.getOrElse(0) { Color.TRANSPARENT }; canvas.drawRect(bounds.left, bounds.top, bounds.right, bounds.top + top, paint) }
3602
+ if (right > 0) { paint.color = colors.getOrElse(1) { Color.TRANSPARENT }; canvas.drawRect(bounds.right - right, bounds.top, bounds.right, bounds.bottom, paint) }
3603
+ if (bottom > 0) { paint.color = colors.getOrElse(2) { Color.TRANSPARENT }; canvas.drawRect(bounds.left, bounds.bottom - bottom, bounds.right, bounds.bottom, paint) }
3604
+ if (left > 0) { paint.color = colors.getOrElse(3) { Color.TRANSPARENT }; canvas.drawRect(bounds.left, bounds.top, bounds.left + left, bounds.bottom, paint) }
3605
+ }
3276
3606
  }
3277
3607
 
3278
3608
  override fun setAlpha(alpha: Int) { paint.alpha = alpha; invalidateSelf() }
@@ -3326,6 +3656,8 @@ class MainActivity : AppCompatActivity() {
3326
3656
  val linkTargets = mutableMapOf<View, String>()
3327
3657
  val pressInHandlers = mutableMapOf<View, String>()
3328
3658
  val pressOutHandlers = mutableMapOf<View, String>()
3659
+ val pressUnderlayShowHandlers = mutableMapOf<View, String>()
3660
+ val pressUnderlayHideHandlers = mutableMapOf<View, String>()
3329
3661
  val longPressHandlers = mutableMapOf<View, String>()
3330
3662
  val scrollHandlers = mutableMapOf<View, String>()
3331
3663
  val scrollBeginHandlers = mutableMapOf<View, String>()
@@ -3344,6 +3676,8 @@ class MainActivity : AppCompatActivity() {
3344
3676
  val imageErrorHandlers = mutableMapOf<ImageView, String>()
3345
3677
  val pressActiveOpacities = mutableMapOf<View, Float>()
3346
3678
  val pressBaseOpacities = mutableMapOf<View, Float>()
3679
+ val pressUnderlayColors = mutableMapOf<View, Int>()
3680
+ val pressBaseBackgrounds = mutableMapOf<View, Drawable?>()
3347
3681
  val modalVisibility = mutableMapOf<View, Boolean>()
3348
3682
  val modalCloseHandlers = mutableMapOf<View, String>()
3349
3683
  val layoutHandlers = mutableMapOf<View, String>()
@@ -3459,6 +3793,7 @@ class MainActivity : AppCompatActivity() {
3459
3793
  /** Fields that already carry their text watcher. */
3460
3794
  private val watchedInputs: MutableSet<EditText> = java.util.Collections.newSetFromMap(WeakHashMap())
3461
3795
  private val inputHighlightDefaults = mutableMapOf<EditText, Int>()
3796
+ private val inputPaddingDefaults = mutableMapOf<EditText, IntArray>()
3462
3797
  private val defaultBackgrounds = mutableMapOf<View, Drawable?>()
3463
3798
  private var keyboardInset = 0
3464
3799
  private var animationScaleObserverRegistered = false
@@ -3670,6 +4005,7 @@ class MainActivity : AppCompatActivity() {
3670
4005
  val target = ensureTabRoot(targetIndex) ?: return false
3671
4006
  val host = chrome?.content ?: return false
3672
4007
  if (target === current || host.width <= 0) return false
4008
+ updateSafeAreaInsets(target)
3673
4009
  val chromeLocation = IntArray(2)
3674
4010
  chrome?.getLocationOnScreen(chromeLocation)
3675
4011
  if (horizontalScrollCanConsume(current.view, chromeLocation[0] + x, chromeLocation[1] + y, dx)) return false
@@ -4114,6 +4450,7 @@ class MainActivity : AppCompatActivity() {
4114
4450
  visibleScreen = screen
4115
4451
  if (screen.frameRequested) postFrame(screen)
4116
4452
  if (bars != null) applyChrome(screen)
4453
+ applyScreenBackground(screen)
4117
4454
  if (previous !== screen) {
4118
4455
  if (previous != null && previous.active) send(previous, "SCREEN_FOCUS", JSONObject().put("focused", false))
4119
4456
  if (screen.shown) send(screen, "SCREEN_FOCUS", JSONObject().put("focused", true))
@@ -4149,6 +4486,22 @@ class MainActivity : AppCompatActivity() {
4149
4486
  bars.apply(screen.options, depth, fallback)
4150
4487
  }
4151
4488
 
4489
+ /** Applies a screen-specific background, falling back to the dynamic app colour. */
4490
+ private fun applyScreenBackground(screen: Screen) {
4491
+ val configured = screen.options.opt("backgroundColor")
4492
+ ?.takeUnless { it == JSONObject.NULL }
4493
+ ?.let { craftNativeOptionalString(it) }
4494
+ ?.let { runCatching { craftNativeColor(it) }.getOrNull() }
4495
+ screen.view.setBackgroundColor(configured ?: ContextCompat.getColor(this, R.color.background))
4496
+ }
4497
+
4498
+ /** Refresh controls use a contrasting system tint when no color is authored. */
4499
+ private fun refreshTintFallback(screen: Screen): Int {
4500
+ val background = (screen.view.background as? ColorDrawable)?.color
4501
+ ?: ContextCompat.getColor(this, R.color.background)
4502
+ return if (Color.luminance(background) < 0.5f) Color.WHITE else Color.BLACK
4503
+ }
4504
+
4152
4505
  /** The bridge's capability names: Craft's core set and this host's services. */
4153
4506
  private fun capabilityNames(): JSONArray = capabilities.names().apply {
4154
4507
  services.names().forEach { name -> put(name) }
@@ -4214,6 +4567,7 @@ class MainActivity : AppCompatActivity() {
4214
4567
  }
4215
4568
 
4216
4569
  private fun refreshScreenTheme(screen: Screen) {
4570
+ applyScreenBackground(screen)
4217
4571
  screen.mutations.node("root")?.let { renderCommitted(screen, it) }
4218
4572
  screen.controls.values.mapNotNull {
4219
4573
  when (it) {
@@ -4236,7 +4590,20 @@ class MainActivity : AppCompatActivity() {
4236
4590
  if (safeRoot) 0 else windowSafeInsets[2],
4237
4591
  if (safeRoot) 0 else windowSafeInsets[3],
4238
4592
  )
4239
- val tree = active?.view?.getChildAt(0) as? CraftNativeFlexLayout
4593
+ active?.let(::updateSafeAreaInsets)
4594
+ }
4595
+
4596
+ /**
4597
+ * Applies window insets to a screen that is mounted for an interactive
4598
+ * transition but is not visibleScreen yet. The tab swipe keeps both
4599
+ * screens attached while it moves the incoming one across the window, so
4600
+ * updating only the active screen leaves a SafeAreaView briefly measured
4601
+ * against zero insets.
4602
+ */
4603
+ private fun updateSafeAreaInsets(screen: Screen) {
4604
+ val rootNode = screen.mutations.node("root")
4605
+ val safeRoot = rootNode?.let { canonicalType(it.optString("type")) == "SafeAreaView" } == true
4606
+ val tree = screen.view.getChildAt(0) as? CraftNativeFlexLayout
4240
4607
  if (safeRoot) tree?.setWindowSafeInsets(windowSafeInsets[0], windowSafeInsets[1], windowSafeInsets[2], windowSafeInsets[3])
4241
4608
  else tree?.setWindowSafeInsets(0, 0, 0, 0)
4242
4609
  }
@@ -4325,6 +4692,7 @@ class MainActivity : AppCompatActivity() {
4325
4692
  if (screen !== appScreen) {
4326
4693
  payload.keys().forEach { key -> screen.options.put(key, payload.opt(key)) }
4327
4694
  if (visibleScreen === screen) applyChrome(screen)
4695
+ if (payload.has("backgroundColor")) applyScreenBackground(screen)
4328
4696
  }
4329
4697
  }
4330
4698
  "ANIMATE_NEXT" -> if (screen !== appScreen && visibleScreen === screen) animateNextChange(screen, payload)
@@ -4610,7 +4978,9 @@ class MainActivity : AppCompatActivity() {
4610
4978
  val title = craftNativeOptionalString(props.opt("title")) ?: childText(children)
4611
4979
  isAllCaps = false
4612
4980
  configureText(this, title, style)
4613
- craftNativeOptionalString(props.opt("color"))?.let { setTextColor(color(it, defaultTextColor)) }
4981
+ val buttonColor = craftNativeOptionalString(props.opt("color"))
4982
+ ?: craftNativeOptionalString(style.opt("color"))
4983
+ setTextColor(buttonColor?.let { color(it, defaultTextColor) } ?: defaultTextColor)
4614
4984
  isEnabled = !props.optBoolean("disabled", false)
4615
4985
  val handler = craftNativeEventName(events, "onPress", "onClick")
4616
4986
  if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
@@ -4622,11 +4992,11 @@ class MainActivity : AppCompatActivity() {
4622
4992
  "Checkbox" -> (previous as? CheckBox ?: CheckBox(this)).apply {
4623
4993
  text = craftNativeOptionalString(props.opt("label")) ?: childText(children)
4624
4994
  configureText(this, text.toString(), style)
4625
- val requested = props.opt("checked") ?: props.opt("value")
4626
- val checked = when (requested) {
4627
- is Boolean -> requested
4628
- is Number -> requested.toDouble() != 0.0
4629
- is String -> requested.equals("true", ignoreCase = true) || requested == "1"
4995
+ val checkboxChecked = props.opt("checked")
4996
+ val checkboxValue = props.opt("value")
4997
+ val checked = when {
4998
+ checkboxChecked is Boolean -> checkboxChecked
4999
+ checkboxValue is Boolean -> checkboxValue
4630
5000
  else -> false
4631
5001
  }
4632
5002
  setOnCheckedChangeListener(null)
@@ -4634,11 +5004,14 @@ class MainActivity : AppCompatActivity() {
4634
5004
  // an indeterminate control must announce "mixed" and its next
4635
5005
  // user toggle should transition to checked, not unchecked.
4636
5006
  isChecked = checked
4637
- isActivated = props.optBoolean("indeterminate", false)
5007
+ val indeterminate = (props.opt("indeterminate") as? Boolean) == true
5008
+ isActivated = indeterminate
4638
5009
  isEnabled = !props.optBoolean("disabled", false)
4639
5010
  val checkboxColor = craftNativeOptionalString(props.opt("color"))
4640
5011
  ?: craftNativeOptionalString(style.opt("color"))
4641
- buttonTintList = checkboxColor?.let { ColorStateList.valueOf(color(it, defaultTextColor)) }
5012
+ buttonTintList = checkboxColor
5013
+ ?.let { runCatching { craftNativeColor(it) }.getOrNull() }
5014
+ ?.let { ColorStateList.valueOf(it) }
4642
5015
  val handler = craftNativeEventName(events, "onValueChange", "onChange")
4643
5016
  if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
4644
5017
  setOnCheckedChangeListener { _, value ->
@@ -4659,14 +5032,18 @@ class MainActivity : AppCompatActivity() {
4659
5032
  hintTextColors ?: ColorStateList.valueOf(Color.GRAY)
4660
5033
  }
4661
5034
  val defaultHighlightColor = inputHighlightDefaults.getOrPut(this) { highlightColor }
5035
+ val defaultPadding = inputPaddingDefaults.getOrPut(this) {
5036
+ intArrayOf(paddingLeft, paddingTop, paddingRight, paddingBottom)
5037
+ }
4662
5038
  hint = craftNativeOptionalString(props.opt("placeholder")).orEmpty()
4663
5039
  setHintTextColor(
4664
5040
  craftNativeOptionalString(props.opt("placeholderTextColor"))
4665
- ?.let { ColorStateList.valueOf(color(it, Color.GRAY)) }
5041
+ ?.let { runCatching { craftNativeColor(it) }.getOrNull() }
5042
+ ?.let { ColorStateList.valueOf(it) }
4666
5043
  ?: defaultHintColors,
4667
5044
  )
4668
5045
  highlightColor = craftNativeOptionalString(props.opt("selectionColor"))
4669
- ?.let { color(it, defaultHighlightColor) }
5046
+ ?.let { runCatching { craftNativeColor(it) }.getOrNull() }
4670
5047
  ?: defaultHighlightColor
4671
5048
  val maxLength = props.optInt("maxLength", 0)
4672
5049
  val currentLength = (filters.firstOrNull { it is InputFilter.LengthFilter } as? InputFilter.LengthFilter)?.max ?: 0
@@ -4685,7 +5062,8 @@ class MainActivity : AppCompatActivity() {
4685
5062
  configureTextStyle(this, style)
4686
5063
  // The style's padding is where the text starts, as on iOS and
4687
5064
  // the web; without one the field keeps its own.
4688
- if (listOf("padding", "paddingHorizontal", "paddingVertical", "paddingLeft", "paddingRight", "paddingTop", "paddingBottom").any { style.has(it) }) {
5065
+ val paddingKeys = listOf("padding", "paddingHorizontal", "paddingVertical", "paddingLeft", "paddingRight", "paddingTop", "paddingBottom")
5066
+ if (paddingKeys.any(style::has)) {
4689
5067
  val density = resources.displayMetrics.density
4690
5068
  val all = craftNativePadding(style, "padding", 0, density)
4691
5069
  val horizontal = craftNativePadding(style, "paddingHorizontal", all, density)
@@ -4696,6 +5074,11 @@ class MainActivity : AppCompatActivity() {
4696
5074
  craftNativePadding(style, "paddingRight", horizontal, density),
4697
5075
  craftNativePadding(style, "paddingBottom", vertical, density),
4698
5076
  )
5077
+ } else {
5078
+ // A keyed field can lose its authored padding while the
5079
+ // EditText itself is retained. Restore the native baseline
5080
+ // rather than carrying the previous field's spacing.
5081
+ setPadding(defaultPadding[0], defaultPadding[1], defaultPadding[2], defaultPadding[3])
4699
5082
  }
4700
5083
  // Reused EditTexts must forget the previous field's hint as
4701
5084
  // well as accept a new one. Otherwise a password manager can
@@ -4772,7 +5155,14 @@ class MainActivity : AppCompatActivity() {
4772
5155
  })
4773
5156
  }
4774
5157
  "Picker" -> (previous as? Spinner ?: Spinner(this)).apply {
4775
- val options = (0 until children.length()).mapNotNull { index ->
5158
+ val itemOptions = props.optJSONArray("items")?.let { items ->
5159
+ (0 until items.length()).mapNotNull { index ->
5160
+ val item = items.optJSONObject(index) ?: return@mapNotNull null
5161
+ val label = craftNativeOptionalString(item.opt("label")) ?: return@mapNotNull null
5162
+ craftNativePickerValue(item.opt("value"))?.let { it to label } ?: (label to label)
5163
+ }
5164
+ }.orEmpty()
5165
+ val options = if (itemOptions.isNotEmpty()) itemOptions else (0 until children.length()).mapNotNull { index ->
4776
5166
  val option = children.optJSONObject(index) ?: return@mapNotNull null
4777
5167
  if (option.optString("type") != "Text") return@mapNotNull null
4778
5168
  val label = (option.optJSONArray("children") ?: JSONArray()).let { text ->
@@ -4780,7 +5170,7 @@ class MainActivity : AppCompatActivity() {
4780
5170
  }
4781
5171
  if (label.isBlank()) return@mapNotNull null
4782
5172
  val optionProps = option.optJSONObject("props")
4783
- val value = craftNativeOptionalString(optionProps?.opt("value")) ?: label
5173
+ val value = craftNativePickerValue(optionProps?.opt("value")) ?: label
4784
5174
  value to label
4785
5175
  }
4786
5176
  val handler = craftNativeEventName(events, "onValueChange", "onChange")
@@ -4792,8 +5182,8 @@ class MainActivity : AppCompatActivity() {
4792
5182
  adapter = ArrayAdapter(this@MainActivity, android.R.layout.simple_spinner_item, options.map { it.second }).also {
4793
5183
  it.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
4794
5184
  }
4795
- val requested = craftNativeOptionalString(props.opt("selectedValue"))
4796
- ?: craftNativeOptionalString(props.opt("value")).orEmpty()
5185
+ val requested = craftNativePickerValue(props.opt("selectedValue"))
5186
+ ?: craftNativePickerValue(props.opt("value")).orEmpty()
4797
5187
  val selected = options.indexOfFirst { it.first == requested }.takeIf { it >= 0 } ?: 0
4798
5188
  isEnabled = !props.optBoolean("disabled", false)
4799
5189
  // Mounting a controlled picker must not look like a user
@@ -4870,8 +5260,16 @@ class MainActivity : AppCompatActivity() {
4870
5260
  }
4871
5261
  "TouchableOpacity", "TouchableHighlight", "Pressable" -> (previous as? CraftNativeFlexLayout ?: CraftNativeFlexLayout(this)).apply {
4872
5262
  configureContainer(this, style)
5263
+ val disabled = props.optBoolean("disabled", false)
4873
5264
  screen.pressActiveOpacities[this] = craftNativeFiniteFloat(props, "activeOpacity", 0.5f)
4874
5265
  screen.pressBaseOpacities[this] = craftNativeFiniteFloat(style, "opacity", 1f)
5266
+ if (type == "TouchableHighlight" && !disabled) {
5267
+ craftNativeOptionalString(props.opt("underlayColor"))?.let { screen.pressUnderlayColors[this] = color(it, Color.TRANSPARENT) }
5268
+ ?: screen.pressUnderlayColors.remove(this)
5269
+ } else {
5270
+ screen.pressUnderlayColors.remove(this)
5271
+ }
5272
+ screen.pressBaseBackgrounds[this] = this.background
4875
5273
  reconcileChildren(screen, this, children, identity, next, style, previousControls)
4876
5274
  }
4877
5275
  "Switch" -> (previous as? Switch ?: Switch(this)).apply {
@@ -4879,24 +5277,29 @@ class MainActivity : AppCompatActivity() {
4879
5277
  val requestedValue = props.opt("value")
4880
5278
  val requestedChecked = props.opt("checked")
4881
5279
  isChecked = when {
4882
- requestedValue != null && requestedValue != JSONObject.NULL -> props.optBoolean("value")
4883
- requestedChecked == null || requestedChecked == JSONObject.NULL -> false
5280
+ requestedValue != null && requestedValue != JSONObject.NULL -> requestedValue as? Boolean ?: false
4884
5281
  requestedChecked is Boolean -> requestedChecked
4885
- requestedChecked is Number -> requestedChecked.toDouble() != 0.0
4886
- requestedChecked is String -> requestedChecked.isBlank() || requestedChecked.equals("true", ignoreCase = true)
4887
- else -> props.optBoolean("checked", false)
5282
+ else -> false
4888
5283
  }
4889
5284
  isEnabled = !props.optBoolean("disabled", false)
4890
5285
  props.optJSONObject("trackColor")?.let { track ->
4891
- val falseColor = color(track.optString("false"), Color.GRAY)
4892
- val trueColor = color(track.optString("true"), falseColor)
4893
- trackTintList = ColorStateList(
4894
- arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()),
4895
- intArrayOf(trueColor, falseColor),
4896
- )
5286
+ val falseRaw = craftNativeOptionalString(track.opt("false"))
5287
+ val trueRaw = craftNativeOptionalString(track.opt("true"))
5288
+ val falseColor = falseRaw?.let { runCatching { craftNativeColor(it) }.getOrNull() }
5289
+ val trueColor = trueRaw?.let { runCatching { craftNativeColor(it) }.getOrNull() }
5290
+ trackTintList = if ((falseRaw != null && falseColor == null) || (trueRaw != null && trueColor == null)) {
5291
+ null
5292
+ } else {
5293
+ val resolvedFalse = falseColor ?: Color.GRAY
5294
+ ColorStateList(
5295
+ arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()),
5296
+ intArrayOf(trueColor ?: resolvedFalse, resolvedFalse),
5297
+ )
5298
+ }
4897
5299
  } ?: run { trackTintList = null }
4898
5300
  craftNativeOptionalString(props.opt("thumbColor"))
4899
- ?.let { thumbTintList = ColorStateList.valueOf(color(it, Color.WHITE)) }
5301
+ ?.let { runCatching { craftNativeColor(it) }.getOrNull() }
5302
+ ?.let { thumbTintList = ColorStateList.valueOf(it) }
4900
5303
  ?: run { thumbTintList = null }
4901
5304
  val handler = craftNativeEventName(events, "onValueChange", "onChange")
4902
5305
  if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
@@ -4933,15 +5336,15 @@ class MainActivity : AppCompatActivity() {
4933
5336
  (((value - minimum) / (maximum - minimum)) * max).roundToInt().coerceIn(0, max)
4934
5337
  }
4935
5338
  if (step != null) screen.sliderSteps[this] = step else screen.sliderSteps.remove(this)
4936
- craftNativeOptionalString(props.opt("minimumTrackTintColor"))
4937
- ?.let { ColorStateList.valueOf(color(it, Color.WHITE)) }
4938
- ?: run { progressTintList = null }
4939
- craftNativeOptionalString(props.opt("maximumTrackTintColor"))
4940
- ?.let { ColorStateList.valueOf(color(it, Color.GRAY)) }
4941
- ?: run { progressBackgroundTintList = null }
4942
- craftNativeOptionalString(props.opt("thumbTintColor"))
4943
- ?.let { ColorStateList.valueOf(color(it, Color.WHITE)) }
4944
- ?: run { thumbTintList = null }
5339
+ progressTintList = craftNativeOptionalString(props.opt("minimumTrackTintColor"))
5340
+ ?.let { runCatching { craftNativeColor(it) }.getOrNull() }
5341
+ ?.let { ColorStateList.valueOf(it) }
5342
+ progressBackgroundTintList = craftNativeOptionalString(props.opt("maximumTrackTintColor"))
5343
+ ?.let { runCatching { craftNativeColor(it) }.getOrNull() }
5344
+ ?.let { ColorStateList.valueOf(it) }
5345
+ thumbTintList = craftNativeOptionalString(props.opt("thumbTintColor"))
5346
+ ?.let { runCatching { craftNativeColor(it) }.getOrNull() }
5347
+ ?.let { ColorStateList.valueOf(it) }
4945
5348
  isEnabled = !props.optBoolean("disabled", false)
4946
5349
  val handler = craftNativeEventName(events, "onValueChange", "onChange")
4947
5350
  if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
@@ -4979,9 +5382,9 @@ class MainActivity : AppCompatActivity() {
4979
5382
  val animating = props.optBoolean("animating", true)
4980
5383
  isIndeterminate = animating
4981
5384
  visibility = if (!animating && props.optBoolean("hidesWhenStopped", true)) View.GONE else View.VISIBLE
4982
- craftNativeOptionalString(props.opt("color"))
4983
- ?.let { ColorStateList.valueOf(color(it, Color.WHITE)) }
4984
- ?: run { indeterminateTintList = null }
5385
+ indeterminateTintList = craftNativeOptionalString(props.opt("color"))
5386
+ ?.let { runCatching { craftNativeColor(it) }.getOrNull() }
5387
+ ?.let { ColorStateList.valueOf(it) }
4985
5388
  }
4986
5389
  "Icon" -> (previous as? CraftNativeIconView ?: CraftNativeIconView(this)).apply {
4987
5390
  configureIcon(this, props, style)
@@ -5011,6 +5414,9 @@ class MainActivity : AppCompatActivity() {
5011
5414
  "center" -> ImageView.ScaleType.CENTER
5012
5415
  else -> ImageView.ScaleType.CENTER_INSIDE
5013
5416
  }
5417
+ val tint = craftNativeOptionalString(style.opt("tintColor"))
5418
+ ?.let { runCatching { craftNativeColor(it) }.getOrNull() }
5419
+ if (tint == null) image.clearColorFilter() else image.setColorFilter(tint)
5014
5420
  val onLoadStart = craftNativeEventName(events, "onLoadStart")
5015
5421
  if (onLoadStart.isNotBlank()) screen.imageLoadStartHandlers[image] = onLoadStart else screen.imageLoadStartHandlers.remove(image)
5016
5422
  val onLoad = craftNativeEventName(events, "onLoad")
@@ -5041,8 +5447,9 @@ class MainActivity : AppCompatActivity() {
5041
5447
  )
5042
5448
  }
5043
5449
  "Map" -> (previous as? CraftNativeMapView ?: CraftNativeMapView(this)).apply {
5450
+ val mapMarkerHandler = craftNativeOptionalString(events.opt("onMarkerPress"))
5044
5451
  configure(props, children) { markerId ->
5045
- markerHandler(markerId)?.let { handler ->
5452
+ (markerHandler(markerId) ?: mapMarkerHandler)?.let { handler ->
5046
5453
  send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", JSONObject().put("id", markerId)))
5047
5454
  }
5048
5455
  }
@@ -5069,7 +5476,8 @@ class MainActivity : AppCompatActivity() {
5069
5476
  val refreshNode = (0 until children.length()).asSequence()
5070
5477
  .mapNotNull { children.optJSONObject(it) }
5071
5478
  .firstOrNull { canonicalType(it.optString("type")) == "RefreshControl" }
5072
- val refreshProps = refreshNode?.optJSONObject("props") ?: JSONObject()
5479
+ val propRefresh = props.optJSONObject("refreshControl")
5480
+ val refreshProps = refreshNode?.optJSONObject("props") ?: propRefresh ?: JSONObject()
5073
5481
  val refreshEvents = refreshNode?.optJSONObject("events") ?: JSONObject()
5074
5482
  val refreshHandlerName = craftNativeEventName(refreshEvents, "onRefresh")
5075
5483
  .takeIf { it.isNotBlank() }
@@ -5077,8 +5485,8 @@ class MainActivity : AppCompatActivity() {
5077
5485
  val refreshHandler = refreshHandlerName?.let { handler ->
5078
5486
  { send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", JSONObject())) }
5079
5487
  }
5080
- setRefreshHandler(refreshHandler, if (refreshNode == null) props.optBoolean("refreshing", false) else refreshProps.optBoolean("refreshing", false))
5081
- setRefreshAppearance(refreshProps, resources.displayMetrics.density)
5488
+ setRefreshHandler(refreshHandler, refreshProps.optBoolean("refreshing", props.optBoolean("refreshing", false)))
5489
+ setRefreshAppearance(refreshProps, resources.displayMetrics.density, refreshTintFallback(screen))
5082
5490
  val onScroll = craftNativeEventName(events, "onScroll")
5083
5491
  if (onScroll.isNotBlank()) screen.scrollHandlers[this] = onScroll else screen.scrollHandlers.remove(this)
5084
5492
  val onBegin = craftNativeEventName(events, "onScrollBeginDrag")
@@ -5143,19 +5551,26 @@ class MainActivity : AppCompatActivity() {
5143
5551
  if (child !is JSONObject || canonicalType(child.optString("type")) != "RefreshControl") contentChildren.put(child)
5144
5552
  }
5145
5553
  reconcileChildren(screen, content, contentChildren, identity, next, contentStyle, previousControls)
5554
+ setStickyHeaderIndices(props.optJSONArray("stickyHeaderIndices"))
5555
+ setScrollTarget(props.opt("scrollTarget"))
5146
5556
  }
5147
5557
  "FlatList" -> (previous as? CraftNativeRefreshableFlatList ?: CraftNativeRefreshableFlatList(this)).apply {
5148
5558
  val nativeList = list
5149
- configureRefresh(craftNativeEventName(events, "onRefresh").takeIf { it.isNotBlank() }?.let { handler ->
5559
+ val propRefresh = props.optJSONObject("refreshControl") ?: JSONObject()
5560
+ val propRefreshEvents = propRefresh.optJSONObject("events") ?: JSONObject()
5561
+ val refreshHandlerName = craftNativeEventName(propRefreshEvents, "onRefresh")
5562
+ .takeIf { it.isNotBlank() }
5563
+ ?: craftNativeEventName(events, "onRefresh").takeIf { it.isNotBlank() }
5564
+ configureRefresh(refreshHandlerName?.let { handler ->
5150
5565
  { send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", JSONObject())) }
5151
- }, props.optBoolean("refreshing", false))
5566
+ }, propRefresh.optBoolean("refreshing", props.optBoolean("refreshing", false)))
5567
+ setRefreshAppearance(propRefresh, refreshTintFallback(screen))
5152
5568
  nativeList.setKeyboardShouldPersistTaps(props.optString("keyboardShouldPersistTaps", "never"))
5153
5569
  nativeList.setContentContainerStyle(props.optJSONObject("contentContainerStyle"), resources.displayMetrics.density)
5154
5570
  nativeList.setKeyboardDismissMode(props.optString("keyboardDismissMode")) {
5155
5571
  (getSystemService(INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager)
5156
5572
  ?.hideSoftInputFromWindow(windowToken, 0)
5157
5573
  }
5158
- nativeList.overScrollMode = if (props.optBoolean("bounces", true)) View.OVER_SCROLL_ALWAYS else View.OVER_SCROLL_NEVER
5159
5574
  reconcileFlatList(screen, nativeList, children, identity, protocolId ?: identity, props, events)
5160
5575
  }
5161
5576
  "View", "SafeAreaView" -> (previous as? CraftNativeFlexLayout ?: CraftNativeFlexLayout(this)).apply {
@@ -5182,6 +5597,10 @@ class MainActivity : AppCompatActivity() {
5182
5597
  val pressOutHandler = craftNativeEventName(events, "onPressOut").takeIf { it.isNotBlank() }
5183
5598
  if (pressInHandler != null) screen.pressInHandlers[control] = pressInHandler else screen.pressInHandlers.remove(control)
5184
5599
  if (pressOutHandler != null) screen.pressOutHandlers[control] = pressOutHandler else screen.pressOutHandlers.remove(control)
5600
+ val underlayShowHandler = if (type == "TouchableHighlight") craftNativeEventName(events, "onShowUnderlay").takeIf { it.isNotBlank() } else null
5601
+ val underlayHideHandler = if (type == "TouchableHighlight") craftNativeEventName(events, "onHideUnderlay").takeIf { it.isNotBlank() } else null
5602
+ if (underlayShowHandler != null) screen.pressUnderlayShowHandlers[control] = underlayShowHandler else screen.pressUnderlayShowHandlers.remove(control)
5603
+ if (underlayHideHandler != null) screen.pressUnderlayHideHandlers[control] = underlayHideHandler else screen.pressUnderlayHideHandlers.remove(control)
5185
5604
  if (type != "Button" && type != "Link" && type != "Checkbox" && type != "TextInput" && type != "Picker" && type != "DatePicker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" && type != "KeyboardAvoidingView" && type != "StatusBar" && type != "RefreshControl" && type != "Map") {
5186
5605
  val handler = craftNativeEventName(events, "onPress", "onClick")
5187
5606
  val disabled = props.optBoolean("disabled", false)
@@ -5190,7 +5609,8 @@ class MainActivity : AppCompatActivity() {
5190
5609
  ?.trim()
5191
5610
  ?.takeIf { !disabled && type in setOf("TouchableOpacity", "TouchableHighlight", "Pressable") }
5192
5611
  if (target != null) screen.linkTargets[control] = target else screen.linkTargets.remove(control)
5193
- val hasPressLifecycle = pressInHandler != null || pressOutHandler != null
5612
+ val hasUnderlay = type == "TouchableHighlight" && screen.pressUnderlayColors[control] != null
5613
+ val hasPressLifecycle = pressInHandler != null || pressOutHandler != null || hasUnderlay
5194
5614
  if ((handler.isNotBlank() || target != null || (type in setOf("TouchableOpacity", "TouchableHighlight", "Pressable") && hasPressLifecycle)) && !disabled) {
5195
5615
  if (handler.isNotBlank()) screen.handlers[control] = handler else screen.handlers.remove(control)
5196
5616
  control.isClickable = true
@@ -5206,16 +5626,24 @@ class MainActivity : AppCompatActivity() {
5206
5626
  val active = screen.pressActiveOpacities[view] ?: 0.5f
5207
5627
  val base = screen.pressBaseOpacities[view] ?: 1f
5208
5628
  view.alpha = base * active
5629
+ screen.pressUnderlayColors[view]?.let { view.background = ColorDrawable(it) }
5209
5630
  screen.pressInHandlers[view]?.let { current ->
5210
5631
  send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
5211
5632
  }
5633
+ screen.pressUnderlayShowHandlers[view]?.let { current ->
5634
+ send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
5635
+ }
5212
5636
  true
5213
5637
  }
5214
5638
  android.view.MotionEvent.ACTION_UP, android.view.MotionEvent.ACTION_CANCEL -> {
5215
5639
  view.alpha = screen.pressBaseOpacities[view] ?: 1f
5640
+ if (screen.pressBaseBackgrounds.containsKey(view)) view.background = screen.pressBaseBackgrounds[view]
5216
5641
  screen.pressOutHandlers[view]?.let { current ->
5217
5642
  send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
5218
5643
  }
5644
+ screen.pressUnderlayHideHandlers[view]?.let { current ->
5645
+ send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
5646
+ }
5219
5647
  if (event.actionMasked == android.view.MotionEvent.ACTION_UP) {
5220
5648
  screen.linkTargets[view]?.let { target -> navigateOpen(target); return@setOnTouchListener true }
5221
5649
  val current = screen.handlers[view] ?: return@setOnTouchListener true
@@ -5228,6 +5656,7 @@ class MainActivity : AppCompatActivity() {
5228
5656
  }
5229
5657
  } else {
5230
5658
  control.setOnTouchListener(null)
5659
+ if (screen.pressBaseBackgrounds.containsKey(control)) control.background = screen.pressBaseBackgrounds[control]
5231
5660
  control.setOnClickListener {
5232
5661
  screen.linkTargets[control]?.let { target -> navigateOpen(target); return@setOnClickListener }
5233
5662
  val current = screen.handlers[control] ?: return@setOnClickListener
@@ -5405,8 +5834,7 @@ class MainActivity : AppCompatActivity() {
5405
5834
  is JSONObject -> makeView(screen, child, "$path.$index", next, previousControls)
5406
5835
  is String -> TextView(this).apply {
5407
5836
  includeFontPadding = false
5408
- text = child
5409
- setTextColor(defaultTextColor)
5837
+ configureText(this, child, craftNativeTypography(style))
5410
5838
  }
5411
5839
  else -> null
5412
5840
  }
@@ -5464,9 +5892,10 @@ class MainActivity : AppCompatActivity() {
5464
5892
  }
5465
5893
  val threshold = craftNativeNonNegativeDouble(props, "threshold", 0.1)
5466
5894
  val authoredThreshold = craftNativeNonNegativeDouble(props, "onEndReachedThreshold", threshold)
5895
+ val horizontal = props.optBoolean("horizontal", false)
5467
5896
  list.apply(
5468
5897
  nodes = nodes,
5469
- horizontal = props.optBoolean("horizontal", false),
5898
+ horizontal = horizontal,
5470
5899
  columns = props.optInt("numColumns", 1),
5471
5900
  inverted = props.optBoolean("inverted", false),
5472
5901
  endReachedThreshold = authoredThreshold,
@@ -5487,6 +5916,18 @@ class MainActivity : AppCompatActivity() {
5487
5916
  { send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", JSONObject())) }
5488
5917
  },
5489
5918
  )
5919
+ list.setScrollIndicators(
5920
+ props.optBoolean("showsVerticalScrollIndicator", true),
5921
+ props.optBoolean("showsHorizontalScrollIndicator", true),
5922
+ )
5923
+ list.setScrollEnabled(props.optBoolean("scrollEnabled", true))
5924
+ list.setPagingEnabled(props.optBoolean("pagingEnabled", false))
5925
+ list.setScrollTarget(props.opt("scrollTarget"))
5926
+ list.setBounceOptions(
5927
+ props.optBoolean("bounces", true),
5928
+ props.optBoolean("alwaysBounceVertical", !horizontal),
5929
+ props.optBoolean("alwaysBounceHorizontal", horizontal),
5930
+ )
5490
5931
  }
5491
5932
 
5492
5933
  private fun registerFlatListOwnership(screen: Screen, node: JSONObject, owner: String) {
@@ -5519,7 +5960,11 @@ class MainActivity : AppCompatActivity() {
5519
5960
  craftNativePadding(style, "paddingRight", horizontal, density),
5520
5961
  craftNativePadding(style, "paddingBottom", vertical, density),
5521
5962
  )
5522
- } else if (view.javaClass == TextView::class.java) {
5963
+ } else if (view.javaClass == TextView::class.java || view.javaClass == Button::class.java) {
5964
+ // Plain Android buttons start with zero padding in the native
5965
+ // contract. A keyed button may lose a reactive padding style;
5966
+ // clear that old value just as iOS reapplies its padding every
5967
+ // reconciliation.
5523
5968
  view.setPadding(0, 0, 0, 0)
5524
5969
  }
5525
5970
  }
@@ -5537,6 +5982,17 @@ class MainActivity : AppCompatActivity() {
5537
5982
  else color(style.optString("backgroundColor"), Color.TRANSPARENT)
5538
5983
  val border = craftNativeNonNegativeDouble(style, "borderWidth", 0.0)
5539
5984
  val radius = craftNativeNonNegativeDouble(style, "borderRadius", 0.0)
5985
+ val gradientDirection = style.optString("gradientDirection").takeIf { it.isNotBlank() }
5986
+ val gradientFrom = style.optString("gradientFrom").takeIf { it.isNotBlank() }
5987
+ val gradientVia = style.optString("gradientVia").takeIf { it.isNotBlank() }
5988
+ val gradientTo = style.optString("gradientTo").takeIf { it.isNotBlank() }
5989
+ val gradientColors = if (gradientDirection != null && gradientFrom != null && gradientTo != null) {
5990
+ listOfNotNull(
5991
+ color(gradientFrom, Color.TRANSPARENT),
5992
+ gradientVia?.let { color(it, Color.TRANSPARENT) },
5993
+ color(gradientTo, Color.TRANSPARENT),
5994
+ ).toIntArray()
5995
+ } else null
5540
5996
  val sideBorderKeys = listOf("borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth")
5541
5997
  val hasSideBorders = (sideBorderKeys + listOf("borderTopColor", "borderRightColor", "borderBottomColor", "borderLeftColor")).any(style::has)
5542
5998
  val sideWidths = sideBorderKeys.map { dp(craftNativeNonNegativeDouble(style, it, border)) }.toIntArray()
@@ -5554,17 +6010,23 @@ class MainActivity : AppCompatActivity() {
5554
6010
  } else {
5555
6011
  view.background.also { defaultBackgrounds[view] = it }
5556
6012
  }
5557
- if (background || border > 0 || radius > 0 || hasSideBorders) {
6013
+ if (background || border > 0 || radius > 0 || hasSideBorders || gradientColors != null) {
5558
6014
  if (hasSideBorders) {
5559
6015
  view.background = CraftNativeSideBorderDrawable(
5560
6016
  backgroundColor,
5561
6017
  cornerRadii,
5562
6018
  sideWidths,
5563
6019
  sideColors,
6020
+ style.optString("borderStyle").takeIf { it == "dashed" || it == "dotted" },
6021
+ gradientDirection,
6022
+ gradientColors,
5564
6023
  )
5565
6024
  } else {
5566
6025
  view.background = GradientDrawable().apply {
5567
- setColor(backgroundColor)
6026
+ if (gradientColors != null) {
6027
+ orientation = craftNativeGradientOrientation(gradientDirection)
6028
+ colors = gradientColors
6029
+ } else setColor(backgroundColor)
5568
6030
  if (hasCornerRadii) setCornerRadii(cornerRadii) else cornerRadius = dp(radius).toFloat()
5569
6031
  if (border > 0) {
5570
6032
  val borderColor = color(style.optString("borderColor"), Color.TRANSPARENT)
@@ -5605,13 +6067,25 @@ class MainActivity : AppCompatActivity() {
5605
6067
  view.invalidateOutline()
5606
6068
  } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
5607
6069
  view.outlineProvider = ViewOutlineProvider.BACKGROUND
6070
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
6071
+ // A retained view may lose an authored shadow while keeping
6072
+ // its native object. Restore Android's default outline colors
6073
+ // too; otherwise a later elevation-only style inherits the
6074
+ // previous shadow's custom ambient/spot tint.
6075
+ view.outlineAmbientShadowColor = Color.BLACK
6076
+ view.outlineSpotShadowColor = Color.BLACK
6077
+ view.invalidateOutline()
6078
+ }
5608
6079
  }
5609
6080
  if (view is ImageView) {
5610
6081
  val tint = style.opt("tintColor") as? String
5611
- if (tint.isNullOrBlank() || tint == "null" || tint == "undefined") {
6082
+ val resolvedTint = tint
6083
+ ?.takeUnless { it.isBlank() || it == "null" || it == "undefined" }
6084
+ ?.let { runCatching { craftNativeColor(it) }.getOrNull() }
6085
+ if (resolvedTint == null) {
5612
6086
  view.clearColorFilter()
5613
6087
  } else {
5614
- view.setColorFilter(color(tint, Color.TRANSPARENT))
6088
+ view.setColorFilter(resolvedTint)
5615
6089
  }
5616
6090
  }
5617
6091
  }
@@ -5682,6 +6156,22 @@ class MainActivity : AppCompatActivity() {
5682
6156
  configureTextStyle(view, style)
5683
6157
  }
5684
6158
 
6159
+ /**
6160
+ * Raw strings inside a native container are implicit Text nodes. Keep
6161
+ * their typography, but not the parent's padding or layout, just like
6162
+ * the iOS reconciler does for the same child shape.
6163
+ */
6164
+ private fun craftNativeTypography(style: JSONObject): JSONObject = JSONObject().also { typography ->
6165
+ listOf(
6166
+ "color", "letterSpacing", "lineHeight", "fontSize", "fontFamily",
6167
+ "fontWeight", "fontStyle", "textAlign", "textTransform",
6168
+ "textDecorationLine", "textDecorationStyle", "textDecorationColor",
6169
+ "textShadowColor", "textShadowRadius", "textShadowOffset",
6170
+ ).forEach { key ->
6171
+ if (style.has(key) && !style.isNull(key)) typography.put(key, style.opt(key))
6172
+ }
6173
+ }
6174
+
5685
6175
  private fun configureIcon(view: CraftNativeIconView, props: JSONObject, style: JSONObject) {
5686
6176
  val symbol = craftNativeOptionalString(props.opt("symbol"))
5687
6177
  ?: craftNativeOptionalString(props.opt("name")).orEmpty()
@@ -5706,10 +6196,18 @@ class MainActivity : AppCompatActivity() {
5706
6196
  else -> Typeface.NORMAL
5707
6197
  }
5708
6198
  view.typeface = Typeface.create("sans-serif", weight)
5709
- if (!style.has("width") && !style.has("height")) {
6199
+ val hasExplicitWidth = style.has("width") && !style.isNull("width")
6200
+ val hasExplicitHeight = style.has("height") && !style.isNull("height")
6201
+ if (!hasExplicitWidth && !hasExplicitHeight) {
5710
6202
  val box = dp(size.toDouble())
5711
6203
  view.minimumWidth = box
5712
6204
  view.minimumHeight = box
6205
+ } else {
6206
+ // A retained icon may move from the implicit square to an
6207
+ // explicitly sized layout. Do not let the old minimum constrain
6208
+ // the authored dimensions.
6209
+ view.minimumWidth = 0
6210
+ view.minimumHeight = 0
5713
6211
  }
5714
6212
  }
5715
6213
 
@@ -5859,6 +6357,17 @@ class MainActivity : AppCompatActivity() {
5859
6357
  else -> ""
5860
6358
  }
5861
6359
 
6360
+ private fun imageHeaders(source: Any?): Map<String, String> {
6361
+ val objectSource = source as? JSONObject ?: return emptyMap()
6362
+ val raw = objectSource.optJSONObject("headers") ?: return emptyMap()
6363
+ return linkedMapOf<String, String>().apply {
6364
+ raw.keys().forEach { name -> craftNativeOptionalString(raw.opt(name))?.let { put(name, it) } }
6365
+ }
6366
+ }
6367
+
6368
+ private fun imageRequestKey(uri: String, headers: Map<String, String>): String =
6369
+ uri + "\u0000" + headers.toSortedMap().entries.joinToString("\u0000") { "${it.key}=${it.value}" }
6370
+
5862
6371
  private fun immediateImage(uri: String): Bitmap? {
5863
6372
  if (uri.startsWith("data:image/") && uri.contains(',')) {
5864
6373
  val bytes = runCatching { Base64.decode(uri.substringAfter(','), Base64.DEFAULT) }.getOrNull()
@@ -5870,6 +6379,8 @@ class MainActivity : AppCompatActivity() {
5870
6379
 
5871
6380
  private fun updateImage(screen: Screen, view: ImageView, source: Any?, defaultSource: Any? = null) {
5872
6381
  val uri = imageUri(source)
6382
+ val headers = imageHeaders(source)
6383
+ val requestKey = imageRequestKey(uri, headers)
5873
6384
  if (uri.isBlank()) {
5874
6385
  if (imageSources[view] == "") return
5875
6386
  imageJobs.remove(view)?.cancel(true)
@@ -5880,9 +6391,9 @@ class MainActivity : AppCompatActivity() {
5880
6391
  imageFailure(screen, view, uri, "Image source is missing")
5881
6392
  return
5882
6393
  }
5883
- if (imageSources[view] == uri) return
6394
+ if (imageSources[view] == requestKey) return
5884
6395
  imageJobs.remove(view)?.cancel(true)
5885
- imageSources[view] = uri
6396
+ imageSources[view] = requestKey
5886
6397
  imageErrors.remove(view)
5887
6398
  view.setImageDrawable(null)
5888
6399
  imageDidChange(view)
@@ -5920,11 +6431,12 @@ class MainActivity : AppCompatActivity() {
5920
6431
  val connection = URL(uri).openConnection().apply {
5921
6432
  connectTimeout = 10_000
5922
6433
  readTimeout = 10_000
6434
+ headers.forEach { (name, value) -> setRequestProperty(name, value) }
5923
6435
  }
5924
6436
  connection.getInputStream().use(BitmapFactory::decodeStream)
5925
6437
  }.getOrNull()
5926
6438
  runOnUiThread {
5927
- if (imageSources[view] != uri) return@runOnUiThread
6439
+ if (imageSources[view] != requestKey) return@runOnUiThread
5928
6440
  imageJobs.remove(view)
5929
6441
  if (bitmap != null) {
5930
6442
  view.setImageBitmap(bitmap)
@@ -6006,7 +6518,7 @@ class MainActivity : AppCompatActivity() {
6006
6518
  }
6007
6519
  ViewCompat.setStateDescription(
6008
6520
  view,
6009
- if (type == "Checkbox" && props.optBoolean("indeterminate", false)) "mixed" else null,
6521
+ if (type == "Checkbox" && (props.opt("indeterminate") as? Boolean) == true) "mixed" else null,
6010
6522
  )
6011
6523
  ViewCompat.setAccessibilityDelegate(view, object : AccessibilityDelegateCompat() {
6012
6524
  override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfoCompat) {
@@ -6508,7 +7020,13 @@ class MainActivity : AppCompatActivity() {
6508
7020
 
6509
7021
  override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
6510
7022
  super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
6511
- if (isInPictureInPictureMode) showVideoAlone() else putVideoBack()
7023
+ if (isInPictureInPictureMode) {
7024
+ floatingVideo()?.notifyPictureInPicture(true)
7025
+ showVideoAlone()
7026
+ } else {
7027
+ pictureInPictureVideo?.notifyPictureInPicture(false)
7028
+ putVideoBack()
7029
+ }
6512
7030
  }
6513
7031
 
6514
7032
  /**
@@ -6584,6 +7102,7 @@ class MainActivity : AppCompatActivity() {
6584
7102
  released.filterIsInstance<EditText>().forEach {
6585
7103
  inputHintDefaults.remove(it)
6586
7104
  inputHighlightDefaults.remove(it)
7105
+ inputPaddingDefaults.remove(it)
6587
7106
  screen.suppressedTextChanges.remove(it)
6588
7107
  screen.inputEchoes.remove(it)
6589
7108
  val identity = screen.inputIdentities.remove(it)
@@ -6594,6 +7113,10 @@ class MainActivity : AppCompatActivity() {
6594
7113
  screen.handlers.keys.removeAll(released)
6595
7114
  screen.pressInHandlers.keys.removeAll(released)
6596
7115
  screen.pressOutHandlers.keys.removeAll(released)
7116
+ screen.pressUnderlayShowHandlers.keys.removeAll(released)
7117
+ screen.pressUnderlayHideHandlers.keys.removeAll(released)
7118
+ screen.pressUnderlayColors.keys.removeAll(released)
7119
+ screen.pressBaseBackgrounds.keys.removeAll(released)
6597
7120
  screen.longPressHandlers.keys.removeAll(released)
6598
7121
  screen.layoutHandlers.keys.removeAll(released)
6599
7122
  screen.layoutListeners.forEach { (view, listener) ->
@@ -6618,6 +7141,8 @@ class MainActivity : AppCompatActivity() {
6618
7141
  screen.imageErrorHandlers.keys.removeAll(releasedImages)
6619
7142
  screen.pressActiveOpacities.keys.removeAll(released)
6620
7143
  screen.pressBaseOpacities.keys.removeAll(released)
7144
+ screen.pressUnderlayColors.keys.removeAll(released)
7145
+ screen.pressBaseBackgrounds.keys.removeAll(released)
6621
7146
  screen.modalVisibility.keys.removeAll(released)
6622
7147
  screen.modalCloseHandlers.keys.removeAll(released)
6623
7148
  }
@@ -6648,6 +7173,10 @@ class MainActivity : AppCompatActivity() {
6648
7173
  screen.handlers.clear()
6649
7174
  screen.pressInHandlers.clear()
6650
7175
  screen.pressOutHandlers.clear()
7176
+ screen.pressUnderlayShowHandlers.clear()
7177
+ screen.pressUnderlayHideHandlers.clear()
7178
+ screen.pressUnderlayColors.clear()
7179
+ screen.pressBaseBackgrounds.clear()
6651
7180
  screen.layoutHandlers.clear()
6652
7181
  screen.lastLayoutBounds.clear()
6653
7182
  screen.scrollHandlers.clear()