craft-native 0.0.203 → 0.0.205

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,11 @@ 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 Number -> value.toDouble().takeIf { it.isFinite() }?.toString()
153
+ else -> craftNativeOptionalString(value)
154
+ }
155
+
148
156
  private fun craftNativeEventName(events: JSONObject, vararg names: String): String =
149
157
  names.firstNotNullOfOrNull { name -> craftNativeOptionalString(events.opt(name)) }.orEmpty()
150
158
 
@@ -332,11 +340,17 @@ private class CraftNativeSvgView(context: Context) : View(context) {
332
340
  val strokeWidth: Float = 1f,
333
341
  val color: String = "#000000",
334
342
  val opacity: Float = 1f,
343
+ val fillOpacity: Float = 1f,
344
+ val strokeOpacity: Float = 1f,
345
+ val strokeDashoffset: Float = 0f,
346
+ val strokeMiterlimit: Float = 4f,
347
+ val fillRule: String = "nonzero",
335
348
  val typeface: Typeface = Typeface.DEFAULT,
336
349
  val strokeLinecap: String? = null,
337
350
  val strokeLinejoin: String? = null,
338
351
  val strokeDasharray: List<Float> = emptyList(),
339
352
  val vectorEffect: String? = null,
353
+ val dominantBaseline: String? = null,
340
354
  ) {
341
355
  fun merge(node: JSONObject): SvgPaint {
342
356
  val props = node.optJSONObject("props") ?: JSONObject()
@@ -361,17 +375,24 @@ private class CraftNativeSvgView(context: Context) : View(context) {
361
375
  // when both provide the same SVG paint value.
362
376
  val weight = value("fontWeight")
363
377
  val ownOpacity = value("opacity")?.toFloatOrNull()?.takeIf { it.isFinite() } ?: 1f
378
+ fun number(name: String): Float? = value(name)?.toFloatOrNull()?.takeIf { it.isFinite() }
364
379
  return copy(
365
380
  fill = value("fill") ?: fill,
366
381
  stroke = value("stroke") ?: stroke,
367
382
  strokeWidth = value("strokeWidth")?.toFloatOrNull()?.takeIf { it.isFinite() } ?: strokeWidth,
368
383
  color = value("color") ?: color,
369
384
  opacity = ownOpacity * opacity,
385
+ fillOpacity = number("fillOpacity") ?: fillOpacity,
386
+ strokeOpacity = number("strokeOpacity") ?: strokeOpacity,
387
+ strokeDashoffset = number("strokeDashoffset") ?: strokeDashoffset,
388
+ strokeMiterlimit = number("strokeMiterlimit")?.takeIf { it > 0 } ?: strokeMiterlimit,
389
+ fillRule = value("fillRule") ?: fillRule,
370
390
  typeface = if (weight in setOf("bold", "700")) Typeface.DEFAULT_BOLD else typeface,
371
391
  strokeLinecap = value("strokeLinecap") ?: strokeLinecap,
372
392
  strokeLinejoin = value("strokeLinejoin") ?: strokeLinejoin,
373
393
  strokeDasharray = dashes(raw("strokeDasharray")) ?: strokeDasharray,
374
394
  vectorEffect = value("vectorEffect") ?: vectorEffect,
395
+ dominantBaseline = value("dominantBaseline") ?: dominantBaseline,
375
396
  )
376
397
  }
377
398
  }
@@ -467,16 +488,18 @@ private class CraftNativeSvgView(context: Context) : View(context) {
467
488
  }
468
489
  val fill = android.graphics.Paint(Paint.ANTI_ALIAS_FLAG).apply {
469
490
  color = resolveColor(style.fill, style.color, Color.TRANSPARENT)
470
- alpha = (style.opacity * 255f).roundToInt().coerceIn(0, 255)
491
+ alpha = (style.opacity * style.fillOpacity * 255f).roundToInt().coerceIn(0, 255)
471
492
  this.style = Paint.Style.FILL
472
493
  }
494
+ path.fillType = if (style.fillRule == "evenodd") Path.FillType.EVEN_ODD else Path.FillType.WINDING
473
495
  if (style.fill != "none" && style.fill != "transparent") canvas.drawPath(path, fill)
474
496
  if (!style.stroke.isNullOrBlank() && style.stroke != "none" && style.stroke != "transparent" && style.strokeWidth > 0) {
475
497
  val stroke = android.graphics.Paint(Paint.ANTI_ALIAS_FLAG).apply {
476
498
  color = resolveColor(style.stroke, style.color, Color.TRANSPARENT)
477
- alpha = (style.opacity * 255f).roundToInt().coerceIn(0, 255)
499
+ alpha = (style.opacity * style.strokeOpacity * 255f).roundToInt().coerceIn(0, 255)
478
500
  this.style = Paint.Style.STROKE
479
501
  strokeWidth = style.strokeWidth
502
+ strokeMiter = style.strokeMiterlimit
480
503
  strokeCap = when (style.strokeLinecap) {
481
504
  "round" -> Paint.Cap.ROUND
482
505
  "square" -> Paint.Cap.SQUARE
@@ -494,7 +517,7 @@ private class CraftNativeSvgView(context: Context) : View(context) {
494
517
  } else {
495
518
  dashPattern
496
519
  }
497
- pathEffect = DashPathEffect(pattern, 0f)
520
+ pathEffect = DashPathEffect(pattern, style.strokeDashoffset)
498
521
  }
499
522
  }
500
523
  // `vector-effect="non-scaling-stroke"`: the stroke is in the view's
@@ -525,7 +548,7 @@ private class CraftNativeSvgView(context: Context) : View(context) {
525
548
  ?: 12f
526
549
  val paint = android.graphics.Paint(Paint.ANTI_ALIAS_FLAG).apply {
527
550
  color = resolveColor(style.fill, style.color, Color.BLACK)
528
- alpha = (style.opacity * 255f).roundToInt().coerceIn(0, 255)
551
+ alpha = (style.opacity * style.fillOpacity * 255f).roundToInt().coerceIn(0, 255)
529
552
  textSize = size
530
553
  typeface = style.typeface
531
554
  }
@@ -535,7 +558,14 @@ private class CraftNativeSvgView(context: Context) : View(context) {
535
558
  "middle" -> x -= paint.measureText(text) / 2f
536
559
  "end" -> x -= paint.measureText(text)
537
560
  }
538
- canvas.drawText(text, x, y, paint)
561
+ val metrics = paint.fontMetrics
562
+ val baseline = when (style.dominantBaseline) {
563
+ "middle", "central", "mathematical" -> y - (metrics.ascent + metrics.descent) / 2f
564
+ "hanging", "text-before-edge", "before-edge" -> y - metrics.ascent
565
+ "text-after-edge", "after-edge" -> y - metrics.descent
566
+ else -> y
567
+ }
568
+ canvas.drawText(text, x, baseline, paint)
539
569
  }
540
570
 
541
571
  private fun resolveColor(value: String?, current: String, fallback: Int): Int = runCatching {
@@ -557,6 +587,7 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
557
587
  private var onMarkerTap: ((String) -> Unit)? = null
558
588
  private var lifecycleStarted = false
559
589
  private var destroyed = false
590
+ private var initialCameraKey: String? = null
560
591
 
561
592
  init {
562
593
  addView(mapView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
@@ -631,7 +662,11 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
631
662
  is Number -> rawWidth.toDouble()
632
663
  else -> 3.0
633
664
  }
634
- val width = authoredWidth.toFloat()
665
+ // MapKit's lineWidth is expressed in points, while the
666
+ // Google Maps SDK consumes screen pixels. Keep the
667
+ // protocol in logical points and convert only at this
668
+ // Android host boundary so dense displays stay in parity.
669
+ val width = (authoredWidth * resources.displayMetrics.density).toFloat()
635
670
  if (!authoredWidth.isFinite() || !width.isFinite() || width <= 0f) continue
636
671
  nextPolylines[id] = Triple(points, color(craftNativeOptionalString(props.opt("strokeColor")).orEmpty()), width)
637
672
  }
@@ -662,24 +697,67 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
662
697
  map.addPolyline(PolylineOptions().addAll(value.first).color(value.second).width(value.third))?.let { polylines[id] = it }
663
698
  }
664
699
  }
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))
700
+ val explicitRegion = region(pendingProps.optJSONObject("region"))
701
+ val initialRegion = region(pendingProps.optJSONObject("initialRegion"))
702
+ if (explicitRegion != null) {
703
+ moveCamera(explicitRegion)
704
+ initialCameraKey = null
705
+ } else if (initialRegion != null) {
706
+ val key = "region:${pendingProps.optJSONObject("initialRegion")}"
707
+ if (initialCameraKey != key) {
708
+ moveCamera(initialRegion)
709
+ initialCameraKey = key
669
710
  }
711
+ } else if (pendingProps.optBoolean("fitToBounds", false)) {
712
+ val key = "fit:${overlayKey(nextMarkers, nextPolylines)}"
713
+ if (initialCameraKey != key) {
714
+ if (fitToBounds()) initialCameraKey = key
715
+ }
716
+ } else {
717
+ initialCameraKey = null
670
718
  }
671
719
  }
672
720
 
673
- private fun fitToBounds() {
674
- val map = googleMap ?: return
721
+ override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
722
+ super.onSizeChanged(width, height, oldWidth, oldHeight)
723
+ // MapView can become ready before its host has a usable viewport. Do
724
+ // not mark a fit as complete until the camera update can be issued.
725
+ if (width > 0 && height > 0 && pendingProps.optBoolean("fitToBounds", false) && initialCameraKey == null) {
726
+ applyPending()
727
+ }
728
+ }
729
+
730
+ private fun moveCamera(bounds: LatLngBounds) {
731
+ post {
732
+ if (width > 0 && height > 0) googleMap?.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, 24))
733
+ }
734
+ }
735
+
736
+ private fun overlayKey(
737
+ markers: Map<String, Pair<LatLng, String?>>,
738
+ polylines: Map<String, Triple<List<LatLng>, Int, Float>>,
739
+ ): String {
740
+ val markerKey = markers.toSortedMap().entries.joinToString("|") { (id, value) ->
741
+ "$id:${value.first.latitude},${value.first.longitude}"
742
+ }
743
+ val polylineKey = polylines.toSortedMap().entries.joinToString("|") { (id, value) ->
744
+ "$id:" + value.first.joinToString(";") { point -> "${point.latitude},${point.longitude}" }
745
+ }
746
+ return "m[$markerKey]p[$polylineKey]"
747
+ }
748
+
749
+ private fun fitToBounds(): Boolean {
750
+ val map = googleMap ?: return false
751
+ if (width <= 0 || height <= 0) return false
675
752
  val builder = LatLngBounds.Builder()
676
753
  var count = 0
677
754
  markers.values.forEach { builder.include(it.position); count++ }
678
755
  polylines.values.flatMap { it.points }.forEach { builder.include(it); count++ }
679
- if (count == 0) return
756
+ if (count == 0) return false
680
757
  post {
681
758
  if (width > 0 && height > 0) map.moveCamera(CameraUpdateFactory.newLatLngBounds(builder.build(), 24))
682
759
  }
760
+ return true
683
761
  }
684
762
 
685
763
  override fun onAttachedToWindow() {
@@ -728,8 +806,8 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
728
806
  private fun coordinates(geometry: JSONArray?): List<LatLng> {
729
807
  if (geometry == null) return emptyList()
730
808
  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)
809
+ val latitude = finiteNumber(geometry.opt(index)) ?: return@mapNotNull null
810
+ val longitude = finiteNumber(geometry.opt(index + 1)) ?: return@mapNotNull null
733
811
  if (latitude.isFinite() && longitude.isFinite() &&
734
812
  latitude >= -90.0 && latitude <= 90.0 &&
735
813
  longitude >= -180.0 && longitude <= 180.0
@@ -740,10 +818,14 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
740
818
  private fun region(value: JSONObject?): LatLngBounds? {
741
819
  val center = value?.optJSONObject("center") ?: return null
742
820
  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))
821
+ val latitude = finiteNumber(center.opt("latitude")) ?: return null
822
+ val longitude = finiteNumber(center.opt("longitude")) ?: return null
823
+ val latitudeDelta = finiteNumber(span.opt("latitudeDelta"))
824
+ ?: finiteNumber(span.opt("latitude_delta"))
825
+ ?: return null
826
+ val longitudeDelta = finiteNumber(span.opt("longitudeDelta"))
827
+ ?: finiteNumber(span.opt("longitude_delta"))
828
+ ?: return null
747
829
  if (!latitude.isFinite() || !longitude.isFinite() || !latitudeDelta.isFinite() || !longitudeDelta.isFinite() ||
748
830
  latitude < -90.0 || latitude > 90.0 || longitude < -180.0 || longitude > 180.0 ||
749
831
  latitudeDelta <= 0.0 || latitudeDelta > 180.0 ||
@@ -753,7 +835,10 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
753
835
  return LatLngBounds(LatLng(latitude - latitudeDelta / 2, longitude - longitudeDelta / 2), LatLng(latitude + latitudeDelta / 2, longitude + longitudeDelta / 2))
754
836
  }
755
837
 
756
- private fun color(value: String): Int = runCatching { Color.parseColor(value) }.getOrDefault(Color.BLUE)
838
+ private fun finiteNumber(value: Any?): Double? =
839
+ (value as? Number)?.toDouble()?.takeIf { it.isFinite() }
840
+
841
+ private fun color(value: String): Int = runCatching { craftNativeColor(value) }.getOrDefault(Color.BLUE)
757
842
  }
758
843
 
759
844
  /** A deterministic flex-shaped container shared by normal views and scroll content. */
@@ -2036,6 +2121,9 @@ private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(conte
2036
2121
  private val defaultProgressStart = progressViewStartOffset
2037
2122
  private val defaultProgressEnd = progressViewEndOffset
2038
2123
  private var configuredProgressOffset: Int? = null
2124
+ private var stickyIndices = emptyList<Int>()
2125
+ private var scrollTargetKey: String? = null
2126
+ private var pendingScrollTarget: Pair<String, Boolean>? = null
2039
2127
 
2040
2128
  init {
2041
2129
  vertical.isNestedScrollingEnabled = true
@@ -2247,6 +2335,133 @@ private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(conte
2247
2335
  horizontal.isHorizontalScrollBarEnabled = horizontalVisible
2248
2336
  }
2249
2337
 
2338
+ /** Keep vertical ScrollView headers pinned while preserving their layout slots. */
2339
+ fun setStickyHeaderIndices(value: JSONArray?) {
2340
+ stickyIndices.forEach { index ->
2341
+ content.getChildAt(index)?.apply {
2342
+ translationY = 0f
2343
+ translationZ = 0f
2344
+ }
2345
+ }
2346
+ stickyIndices = if (value == null) {
2347
+ emptyList()
2348
+ } else {
2349
+ (0 until value.length()).mapNotNull { value.optInt(it, -1).takeIf { index -> index >= 0 } }.distinct().sorted()
2350
+ }
2351
+ post { updateStickyHeaders() }
2352
+ }
2353
+
2354
+ /** Scroll a child identified by its native testID/id after reconciliation. */
2355
+ fun setScrollTarget(value: Any?) {
2356
+ val target: String
2357
+ val animated: Boolean
2358
+ val key: String
2359
+ when (value) {
2360
+ is String -> {
2361
+ target = value.trim()
2362
+ animated = true
2363
+ key = target
2364
+ }
2365
+ is JSONObject -> {
2366
+ target = craftNativeOptionalString(value.opt("id")).orEmpty().trim()
2367
+ animated = value.optBoolean("animated", true)
2368
+ key = value.toString()
2369
+ }
2370
+ else -> {
2371
+ scrollTargetKey = null
2372
+ pendingScrollTarget = null
2373
+ return
2374
+ }
2375
+ }
2376
+ if (target.isBlank()) {
2377
+ scrollTargetKey = null
2378
+ pendingScrollTarget = null
2379
+ return
2380
+ }
2381
+ if (key == scrollTargetKey) return
2382
+ scrollTargetKey = key
2383
+ pendingScrollTarget = target to animated
2384
+ post { scrollToPendingTarget(0) }
2385
+ }
2386
+
2387
+ private fun updateStickyHeaders() {
2388
+ if (horizontalMode == true) {
2389
+ stickyIndices.forEach { index ->
2390
+ content.getChildAt(index)?.apply {
2391
+ translationY = 0f
2392
+ translationZ = 0f
2393
+ }
2394
+ }
2395
+ return
2396
+ }
2397
+ val scrollY = vertical.scrollY
2398
+ var pinnedBottom = 0
2399
+ stickyIndices.forEachIndexed { position, index ->
2400
+ val header = content.getChildAt(index)
2401
+ if (header == null || header.visibility != View.VISIBLE || header.height <= 0) return@forEachIndexed
2402
+ val nextTop = stickyIndices.drop(position + 1)
2403
+ .firstNotNullOfOrNull { next -> content.getChildAt(next)?.takeIf { it.visibility == View.VISIBLE }?.top }
2404
+ ?: Int.MAX_VALUE
2405
+ val desired = max(0, scrollY + pinnedBottom - header.top)
2406
+ val limit = if (nextTop == Int.MAX_VALUE) Int.MAX_VALUE else max(0, nextTop - header.bottom)
2407
+ val translation = min(desired, limit)
2408
+ header.translationY = translation.toFloat()
2409
+ header.translationZ = if (translation > 0) 1f else 0f
2410
+ if (translation > 0) pinnedBottom = header.top - scrollY + translation + header.height
2411
+ }
2412
+ }
2413
+
2414
+ private fun findDescendant(view: View, id: String): View? {
2415
+ if (view.tag?.toString() == id || view.contentDescription?.toString() == id) return view
2416
+ if (view is ViewGroup) {
2417
+ for (index in 0 until view.childCount) findDescendant(view.getChildAt(index), id)?.let { return it }
2418
+ }
2419
+ return null
2420
+ }
2421
+
2422
+ private fun scrollToPendingTarget(attempt: Int) {
2423
+ val target = pendingScrollTarget ?: return
2424
+ val scroller: View = if (horizontalMode == true) horizontal else vertical
2425
+ val view = findDescendant(content, target.first)
2426
+ if (view == null || view.width <= 0 || view.height <= 0 || scroller.width <= 0 || scroller.height <= 0) {
2427
+ if (attempt < 4) postDelayed({ scrollToPendingTarget(attempt + 1) }, 16L)
2428
+ return
2429
+ }
2430
+ val targetLocation = IntArray(2)
2431
+ val scrollerLocation = IntArray(2)
2432
+ view.getLocationOnScreen(targetLocation)
2433
+ scroller.getLocationOnScreen(scrollerLocation)
2434
+ val current = if (horizontalMode == true) horizontal.scrollX else vertical.scrollY
2435
+ val stickyHeight = if (horizontalMode == true) 0 else {
2436
+ val contentLocation = IntArray(2)
2437
+ content.getLocationOnScreen(contentLocation)
2438
+ val targetContentTop = targetLocation[1] - contentLocation[1] + vertical.scrollY
2439
+ stickyIndices.mapNotNull { index -> content.getChildAt(index) }
2440
+ .lastOrNull { it.visibility == View.VISIBLE && it.top <= targetContentTop }
2441
+ ?.height ?: 0
2442
+ }
2443
+ val desired = if (horizontalMode == true) {
2444
+ current + targetLocation[0] - scrollerLocation[0]
2445
+ } else {
2446
+ current + targetLocation[1] - scrollerLocation[1] - stickyHeight
2447
+ }
2448
+ val maximum = if (horizontalMode == true) {
2449
+ max(0, content.measuredWidth - scroller.width)
2450
+ } else {
2451
+ max(0, content.measuredHeight - scroller.height)
2452
+ }
2453
+ val bounded = desired.coerceIn(0, maximum)
2454
+ if (target.second) {
2455
+ if (horizontalMode == true) horizontal.smoothScrollTo(bounded, 0) else vertical.smoothScrollTo(0, bounded)
2456
+ } else if (horizontalMode == true) {
2457
+ horizontal.scrollTo(bounded, 0)
2458
+ } else {
2459
+ vertical.scrollTo(0, bounded)
2460
+ }
2461
+ pendingScrollTarget = null
2462
+ updateStickyHeaders()
2463
+ }
2464
+
2250
2465
  fun setScrollListeners(
2251
2466
  onScroll: ((Int, Int) -> Unit)?,
2252
2467
  onBeginDrag: (() -> Unit)?,
@@ -2277,7 +2492,10 @@ private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(conte
2277
2492
  }
2278
2493
  }
2279
2494
 
2280
- scroller.setOnScrollChangeListener { _, x, y, _, _ -> onScroll?.invoke(x, y) }
2495
+ scroller.setOnScrollChangeListener { _, x, y, _, _ ->
2496
+ if (!horizontal) updateStickyHeaders()
2497
+ onScroll?.invoke(x, y)
2498
+ }
2281
2499
  scroller.setOnTouchListener { _, event ->
2282
2500
  when (event.actionMasked) {
2283
2501
  android.view.MotionEvent.ACTION_DOWN -> {
@@ -2722,6 +2940,9 @@ private class CraftNativeVideoView(context: Context) : VideoView(context) {
2722
2940
  private var sourceUri = ""
2723
2941
  private var shouldLoop = false
2724
2942
  private var shouldPlay = false
2943
+ private var playIntent: Boolean? = null
2944
+ private var userPaused = false
2945
+ private var ended = false
2725
2946
  private var muted = false
2726
2947
  private var resizeMode = "contain"
2727
2948
  private var preparedPlayer: MediaPlayer? = null
@@ -2736,6 +2957,16 @@ private class CraftNativeVideoView(context: Context) : VideoView(context) {
2736
2957
  private set
2737
2958
  /** Playback started or stopped, or the choice changed: the activity's PiP params follow. */
2738
2959
  var onPictureInPictureChanged: (() -> Unit)? = null
2960
+ private val progressHandler = Handler(Looper.getMainLooper())
2961
+ private val progressTick = object : Runnable {
2962
+ override fun run() {
2963
+ if (!playing) return
2964
+ fire("onProgress", JSONObject()
2965
+ .put("currentTime", currentPosition.coerceAtLeast(0) / 1000.0)
2966
+ .put("duration", duration.coerceAtLeast(0) / 1000.0))
2967
+ progressHandler.postDelayed(this, 500L)
2968
+ }
2969
+ }
2739
2970
 
2740
2971
  init {
2741
2972
  // Mixed with the music rather than pausing it.
@@ -2749,7 +2980,13 @@ private class CraftNativeVideoView(context: Context) : VideoView(context) {
2749
2980
  else -> ""
2750
2981
  }
2751
2982
  shouldLoop = loop
2752
- shouldPlay = autoplay && !paused
2983
+ val nextShouldPlay = autoplay && !paused
2984
+ if (playIntent != nextShouldPlay) {
2985
+ userPaused = false
2986
+ ended = false
2987
+ }
2988
+ playIntent = nextShouldPlay
2989
+ shouldPlay = nextShouldPlay
2753
2990
  this.muted = muted
2754
2991
  val floating = pictureInPicture && !muted
2755
2992
  if (floating != this.pictureInPicture) {
@@ -2761,6 +2998,8 @@ private class CraftNativeVideoView(context: Context) : VideoView(context) {
2761
2998
  setMediaController(if (controls) MediaController(context).also { it.setAnchorView(this) } else null)
2762
2999
  if (uri != sourceUri) {
2763
3000
  sourceUri = uri
3001
+ userPaused = false
3002
+ ended = false
2764
3003
  stopPlayback()
2765
3004
  setPlaying(false)
2766
3005
  preparedPlayer = null
@@ -2780,7 +3019,10 @@ private class CraftNativeVideoView(context: Context) : VideoView(context) {
2780
3019
  }
2781
3020
  setOnCompletionListener {
2782
3021
  if (shouldLoop && !isPlaying) start()
2783
- else setPlaying(false)
3022
+ else {
3023
+ ended = true
3024
+ setPlaying(false)
3025
+ }
2784
3026
  fire("onEnd")
2785
3027
  }
2786
3028
  setOnErrorListener { _, what, extra ->
@@ -2791,18 +3033,24 @@ private class CraftNativeVideoView(context: Context) : VideoView(context) {
2791
3033
  }
2792
3034
  if (uri.isBlank()) return
2793
3035
  runCatching {
2794
- if (shouldPlay && !isPlaying) start()
3036
+ if (shouldPlay && !isPlaying && !userPaused && !ended) start()
2795
3037
  else if (!shouldPlay && isPlaying) pause()
2796
3038
  }
2797
3039
  }
2798
3040
 
2799
3041
  override fun start() {
2800
3042
  super.start()
2801
- setPlaying(true)
3043
+ userPaused = false
3044
+ ended = false
3045
+ // VideoView accepts start() before its MediaPlayer is prepared. Do
3046
+ // not announce playback until the player reports that it is actually
3047
+ // running; iOS follows the same rule through AVPlayer's time status.
3048
+ if (runCatching { isPlaying }.getOrDefault(false)) setPlaying(true)
2802
3049
  }
2803
3050
 
2804
3051
  override fun pause() {
2805
3052
  super.pause()
3053
+ userPaused = true
2806
3054
  setPlaying(false)
2807
3055
  }
2808
3056
 
@@ -2810,6 +3058,8 @@ private class CraftNativeVideoView(context: Context) : VideoView(context) {
2810
3058
  if (playing == value) return
2811
3059
  playing = value
2812
3060
  fire(if (value) "onPlay" else "onPause")
3061
+ progressHandler.removeCallbacks(progressTick)
3062
+ if (value) progressHandler.post(progressTick)
2813
3063
  onPictureInPictureChanged?.invoke()
2814
3064
  }
2815
3065
 
@@ -2834,12 +3084,20 @@ private class CraftNativeVideoView(context: Context) : VideoView(context) {
2834
3084
  return runCatching { player.setDisplay(holder ?: this.holder) }.isSuccess
2835
3085
  }
2836
3086
 
3087
+ fun notifyPictureInPicture(active: Boolean) {
3088
+ fire(if (active) "onPictureInPictureStart" else "onPictureInPictureStop")
3089
+ }
3090
+
2837
3091
  fun stop() {
2838
3092
  // Released with its screen: nothing more is told to it.
2839
3093
  emit = null
3094
+ progressHandler.removeCallbacks(progressTick)
2840
3095
  stopPlayback()
2841
3096
  setPlaying(false)
2842
3097
  sourceUri = ""
3098
+ playIntent = null
3099
+ userPaused = false
3100
+ ended = false
2843
3101
  preparedPlayer = null
2844
3102
  if (pictureInPicture) {
2845
3103
  pictureInPicture = false
@@ -3249,30 +3507,92 @@ private class CraftTextDecorationSpan(
3249
3507
  }
3250
3508
  }
3251
3509
 
3510
+ private fun craftNativeGradientOrientation(direction: String?): GradientDrawable.Orientation = when (direction) {
3511
+ "to-t" -> GradientDrawable.Orientation.BOTTOM_TOP
3512
+ "to-tr" -> GradientDrawable.Orientation.BL_TR
3513
+ "to-r" -> GradientDrawable.Orientation.LEFT_RIGHT
3514
+ "to-br" -> GradientDrawable.Orientation.TL_BR
3515
+ "to-bl" -> GradientDrawable.Orientation.TR_BL
3516
+ "to-l" -> GradientDrawable.Orientation.RIGHT_LEFT
3517
+ "to-tl" -> GradientDrawable.Orientation.BR_TL
3518
+ else -> GradientDrawable.Orientation.TOP_BOTTOM
3519
+ }
3520
+
3252
3521
  private class CraftNativeSideBorderDrawable(
3253
3522
  private val fillColor: Int,
3254
3523
  private val radii: FloatArray,
3255
3524
  private val widths: IntArray,
3256
3525
  private val colors: IntArray,
3526
+ private val borderStyle: String?,
3527
+ private val gradientDirection: String?,
3528
+ private val gradientColors: IntArray?,
3257
3529
  ) : Drawable() {
3258
3530
  private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
3259
3531
 
3532
+ private fun gradient(bounds: RectF): Shader? {
3533
+ val colors = gradientColors ?: return null
3534
+ val points = when (gradientDirection) {
3535
+ "to-t" -> floatArrayOf(0.5f, 1f, 0.5f, 0f)
3536
+ "to-tr" -> floatArrayOf(0f, 1f, 1f, 0f)
3537
+ "to-r" -> floatArrayOf(0f, 0.5f, 1f, 0.5f)
3538
+ "to-br" -> floatArrayOf(0f, 0f, 1f, 1f)
3539
+ "to-bl" -> floatArrayOf(1f, 0f, 0f, 1f)
3540
+ "to-l" -> floatArrayOf(1f, 0.5f, 0f, 0.5f)
3541
+ "to-tl" -> floatArrayOf(1f, 1f, 0f, 0f)
3542
+ else -> floatArrayOf(0.5f, 0f, 0.5f, 1f)
3543
+ }
3544
+ return LinearGradient(
3545
+ bounds.left + bounds.width() * points[0],
3546
+ bounds.top + bounds.height() * points[1],
3547
+ bounds.left + bounds.width() * points[2],
3548
+ bounds.top + bounds.height() * points[3],
3549
+ colors,
3550
+ null,
3551
+ Shader.TileMode.CLAMP,
3552
+ )
3553
+ }
3554
+
3260
3555
  override fun draw(canvas: Canvas) {
3261
3556
  val bounds = RectF(this.bounds)
3262
3557
  paint.style = Paint.Style.FILL
3263
3558
  paint.color = fillColor
3264
- if (Color.alpha(fillColor) > 0) {
3559
+ paint.shader = gradient(bounds)
3560
+ if (paint.shader != null || Color.alpha(fillColor) > 0) {
3265
3561
  val path = Path().apply { addRoundRect(bounds, radii, Path.Direction.CW) }
3266
3562
  canvas.drawPath(path, paint)
3267
3563
  }
3564
+ paint.shader = null
3268
3565
  val top = widths.getOrElse(0) { 0 }
3269
3566
  val right = widths.getOrElse(1) { 0 }
3270
3567
  val bottom = widths.getOrElse(2) { 0 }
3271
3568
  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) }
3569
+ if (borderStyle == "dashed" || borderStyle == "dotted") {
3570
+ paint.style = Paint.Style.STROKE
3571
+ paint.strokeCap = if (borderStyle == "dotted") Paint.Cap.ROUND else Paint.Cap.BUTT
3572
+ paint.pathEffect = null
3573
+ fun drawEdge(index: Int, width: Int, startX: Float, startY: Float, endX: Float, endY: Float) {
3574
+ if (width <= 0) return
3575
+ paint.color = colors.getOrElse(index) { Color.TRANSPARENT }
3576
+ paint.strokeWidth = width.toFloat()
3577
+ paint.pathEffect = if (borderStyle == "dotted") {
3578
+ DashPathEffect(floatArrayOf(width.toFloat(), width * 1.5f), 0f)
3579
+ } else {
3580
+ DashPathEffect(floatArrayOf(width * 3f, width * 2f), 0f)
3581
+ }
3582
+ canvas.drawLine(startX, startY, endX, endY, paint)
3583
+ }
3584
+ drawEdge(0, top, bounds.left, bounds.top + top / 2f, bounds.right, bounds.top + top / 2f)
3585
+ drawEdge(1, right, bounds.right - right / 2f, bounds.top, bounds.right - right / 2f, bounds.bottom)
3586
+ drawEdge(2, bottom, bounds.left, bounds.bottom - bottom / 2f, bounds.right, bounds.bottom - bottom / 2f)
3587
+ drawEdge(3, left, bounds.left + left / 2f, bounds.top, bounds.left + left / 2f, bounds.bottom)
3588
+ paint.pathEffect = null
3589
+ } else {
3590
+ paint.style = Paint.Style.FILL
3591
+ if (top > 0) { paint.color = colors.getOrElse(0) { Color.TRANSPARENT }; canvas.drawRect(bounds.left, bounds.top, bounds.right, bounds.top + top, paint) }
3592
+ if (right > 0) { paint.color = colors.getOrElse(1) { Color.TRANSPARENT }; canvas.drawRect(bounds.right - right, bounds.top, bounds.right, bounds.bottom, paint) }
3593
+ if (bottom > 0) { paint.color = colors.getOrElse(2) { Color.TRANSPARENT }; canvas.drawRect(bounds.left, bounds.bottom - bottom, bounds.right, bounds.bottom, paint) }
3594
+ if (left > 0) { paint.color = colors.getOrElse(3) { Color.TRANSPARENT }; canvas.drawRect(bounds.left, bounds.top, bounds.left + left, bounds.bottom, paint) }
3595
+ }
3276
3596
  }
3277
3597
 
3278
3598
  override fun setAlpha(alpha: Int) { paint.alpha = alpha; invalidateSelf() }
@@ -3326,6 +3646,8 @@ class MainActivity : AppCompatActivity() {
3326
3646
  val linkTargets = mutableMapOf<View, String>()
3327
3647
  val pressInHandlers = mutableMapOf<View, String>()
3328
3648
  val pressOutHandlers = mutableMapOf<View, String>()
3649
+ val pressUnderlayShowHandlers = mutableMapOf<View, String>()
3650
+ val pressUnderlayHideHandlers = mutableMapOf<View, String>()
3329
3651
  val longPressHandlers = mutableMapOf<View, String>()
3330
3652
  val scrollHandlers = mutableMapOf<View, String>()
3331
3653
  val scrollBeginHandlers = mutableMapOf<View, String>()
@@ -3344,6 +3666,8 @@ class MainActivity : AppCompatActivity() {
3344
3666
  val imageErrorHandlers = mutableMapOf<ImageView, String>()
3345
3667
  val pressActiveOpacities = mutableMapOf<View, Float>()
3346
3668
  val pressBaseOpacities = mutableMapOf<View, Float>()
3669
+ val pressUnderlayColors = mutableMapOf<View, Int>()
3670
+ val pressBaseBackgrounds = mutableMapOf<View, Drawable?>()
3347
3671
  val modalVisibility = mutableMapOf<View, Boolean>()
3348
3672
  val modalCloseHandlers = mutableMapOf<View, String>()
3349
3673
  val layoutHandlers = mutableMapOf<View, String>()
@@ -3459,6 +3783,7 @@ class MainActivity : AppCompatActivity() {
3459
3783
  /** Fields that already carry their text watcher. */
3460
3784
  private val watchedInputs: MutableSet<EditText> = java.util.Collections.newSetFromMap(WeakHashMap())
3461
3785
  private val inputHighlightDefaults = mutableMapOf<EditText, Int>()
3786
+ private val inputPaddingDefaults = mutableMapOf<EditText, IntArray>()
3462
3787
  private val defaultBackgrounds = mutableMapOf<View, Drawable?>()
3463
3788
  private var keyboardInset = 0
3464
3789
  private var animationScaleObserverRegistered = false
@@ -3670,6 +3995,7 @@ class MainActivity : AppCompatActivity() {
3670
3995
  val target = ensureTabRoot(targetIndex) ?: return false
3671
3996
  val host = chrome?.content ?: return false
3672
3997
  if (target === current || host.width <= 0) return false
3998
+ updateSafeAreaInsets(target)
3673
3999
  val chromeLocation = IntArray(2)
3674
4000
  chrome?.getLocationOnScreen(chromeLocation)
3675
4001
  if (horizontalScrollCanConsume(current.view, chromeLocation[0] + x, chromeLocation[1] + y, dx)) return false
@@ -4114,6 +4440,7 @@ class MainActivity : AppCompatActivity() {
4114
4440
  visibleScreen = screen
4115
4441
  if (screen.frameRequested) postFrame(screen)
4116
4442
  if (bars != null) applyChrome(screen)
4443
+ applyScreenBackground(screen)
4117
4444
  if (previous !== screen) {
4118
4445
  if (previous != null && previous.active) send(previous, "SCREEN_FOCUS", JSONObject().put("focused", false))
4119
4446
  if (screen.shown) send(screen, "SCREEN_FOCUS", JSONObject().put("focused", true))
@@ -4149,6 +4476,15 @@ class MainActivity : AppCompatActivity() {
4149
4476
  bars.apply(screen.options, depth, fallback)
4150
4477
  }
4151
4478
 
4479
+ /** Applies a screen-specific background, falling back to the dynamic app colour. */
4480
+ private fun applyScreenBackground(screen: Screen) {
4481
+ val configured = screen.options.opt("backgroundColor")
4482
+ ?.takeUnless { it == JSONObject.NULL }
4483
+ ?.let { craftNativeOptionalString(it) }
4484
+ ?.let { runCatching { craftNativeColor(it) }.getOrNull() }
4485
+ screen.view.setBackgroundColor(configured ?: ContextCompat.getColor(this, R.color.background))
4486
+ }
4487
+
4152
4488
  /** The bridge's capability names: Craft's core set and this host's services. */
4153
4489
  private fun capabilityNames(): JSONArray = capabilities.names().apply {
4154
4490
  services.names().forEach { name -> put(name) }
@@ -4214,6 +4550,7 @@ class MainActivity : AppCompatActivity() {
4214
4550
  }
4215
4551
 
4216
4552
  private fun refreshScreenTheme(screen: Screen) {
4553
+ applyScreenBackground(screen)
4217
4554
  screen.mutations.node("root")?.let { renderCommitted(screen, it) }
4218
4555
  screen.controls.values.mapNotNull {
4219
4556
  when (it) {
@@ -4236,7 +4573,20 @@ class MainActivity : AppCompatActivity() {
4236
4573
  if (safeRoot) 0 else windowSafeInsets[2],
4237
4574
  if (safeRoot) 0 else windowSafeInsets[3],
4238
4575
  )
4239
- val tree = active?.view?.getChildAt(0) as? CraftNativeFlexLayout
4576
+ active?.let(::updateSafeAreaInsets)
4577
+ }
4578
+
4579
+ /**
4580
+ * Applies window insets to a screen that is mounted for an interactive
4581
+ * transition but is not visibleScreen yet. The tab swipe keeps both
4582
+ * screens attached while it moves the incoming one across the window, so
4583
+ * updating only the active screen leaves a SafeAreaView briefly measured
4584
+ * against zero insets.
4585
+ */
4586
+ private fun updateSafeAreaInsets(screen: Screen) {
4587
+ val rootNode = screen.mutations.node("root")
4588
+ val safeRoot = rootNode?.let { canonicalType(it.optString("type")) == "SafeAreaView" } == true
4589
+ val tree = screen.view.getChildAt(0) as? CraftNativeFlexLayout
4240
4590
  if (safeRoot) tree?.setWindowSafeInsets(windowSafeInsets[0], windowSafeInsets[1], windowSafeInsets[2], windowSafeInsets[3])
4241
4591
  else tree?.setWindowSafeInsets(0, 0, 0, 0)
4242
4592
  }
@@ -4325,6 +4675,7 @@ class MainActivity : AppCompatActivity() {
4325
4675
  if (screen !== appScreen) {
4326
4676
  payload.keys().forEach { key -> screen.options.put(key, payload.opt(key)) }
4327
4677
  if (visibleScreen === screen) applyChrome(screen)
4678
+ if (payload.has("backgroundColor")) applyScreenBackground(screen)
4328
4679
  }
4329
4680
  }
4330
4681
  "ANIMATE_NEXT" -> if (screen !== appScreen && visibleScreen === screen) animateNextChange(screen, payload)
@@ -4659,6 +5010,9 @@ class MainActivity : AppCompatActivity() {
4659
5010
  hintTextColors ?: ColorStateList.valueOf(Color.GRAY)
4660
5011
  }
4661
5012
  val defaultHighlightColor = inputHighlightDefaults.getOrPut(this) { highlightColor }
5013
+ val defaultPadding = inputPaddingDefaults.getOrPut(this) {
5014
+ intArrayOf(paddingLeft, paddingTop, paddingRight, paddingBottom)
5015
+ }
4662
5016
  hint = craftNativeOptionalString(props.opt("placeholder")).orEmpty()
4663
5017
  setHintTextColor(
4664
5018
  craftNativeOptionalString(props.opt("placeholderTextColor"))
@@ -4685,7 +5039,8 @@ class MainActivity : AppCompatActivity() {
4685
5039
  configureTextStyle(this, style)
4686
5040
  // The style's padding is where the text starts, as on iOS and
4687
5041
  // the web; without one the field keeps its own.
4688
- if (listOf("padding", "paddingHorizontal", "paddingVertical", "paddingLeft", "paddingRight", "paddingTop", "paddingBottom").any { style.has(it) }) {
5042
+ val paddingKeys = listOf("padding", "paddingHorizontal", "paddingVertical", "paddingLeft", "paddingRight", "paddingTop", "paddingBottom")
5043
+ if (paddingKeys.any(style::has)) {
4689
5044
  val density = resources.displayMetrics.density
4690
5045
  val all = craftNativePadding(style, "padding", 0, density)
4691
5046
  val horizontal = craftNativePadding(style, "paddingHorizontal", all, density)
@@ -4696,6 +5051,11 @@ class MainActivity : AppCompatActivity() {
4696
5051
  craftNativePadding(style, "paddingRight", horizontal, density),
4697
5052
  craftNativePadding(style, "paddingBottom", vertical, density),
4698
5053
  )
5054
+ } else {
5055
+ // A keyed field can lose its authored padding while the
5056
+ // EditText itself is retained. Restore the native baseline
5057
+ // rather than carrying the previous field's spacing.
5058
+ setPadding(defaultPadding[0], defaultPadding[1], defaultPadding[2], defaultPadding[3])
4699
5059
  }
4700
5060
  // Reused EditTexts must forget the previous field's hint as
4701
5061
  // well as accept a new one. Otherwise a password manager can
@@ -4772,7 +5132,14 @@ class MainActivity : AppCompatActivity() {
4772
5132
  })
4773
5133
  }
4774
5134
  "Picker" -> (previous as? Spinner ?: Spinner(this)).apply {
4775
- val options = (0 until children.length()).mapNotNull { index ->
5135
+ val itemOptions = props.optJSONArray("items")?.let { items ->
5136
+ (0 until items.length()).mapNotNull { index ->
5137
+ val item = items.optJSONObject(index) ?: return@mapNotNull null
5138
+ val label = craftNativeOptionalString(item.opt("label")) ?: return@mapNotNull null
5139
+ craftNativePickerValue(item.opt("value"))?.let { it to label } ?: (label to label)
5140
+ }
5141
+ }.orEmpty()
5142
+ val options = if (itemOptions.isNotEmpty()) itemOptions else (0 until children.length()).mapNotNull { index ->
4776
5143
  val option = children.optJSONObject(index) ?: return@mapNotNull null
4777
5144
  if (option.optString("type") != "Text") return@mapNotNull null
4778
5145
  val label = (option.optJSONArray("children") ?: JSONArray()).let { text ->
@@ -4780,7 +5147,7 @@ class MainActivity : AppCompatActivity() {
4780
5147
  }
4781
5148
  if (label.isBlank()) return@mapNotNull null
4782
5149
  val optionProps = option.optJSONObject("props")
4783
- val value = craftNativeOptionalString(optionProps?.opt("value")) ?: label
5150
+ val value = craftNativePickerValue(optionProps?.opt("value")) ?: label
4784
5151
  value to label
4785
5152
  }
4786
5153
  val handler = craftNativeEventName(events, "onValueChange", "onChange")
@@ -4792,8 +5159,8 @@ class MainActivity : AppCompatActivity() {
4792
5159
  adapter = ArrayAdapter(this@MainActivity, android.R.layout.simple_spinner_item, options.map { it.second }).also {
4793
5160
  it.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
4794
5161
  }
4795
- val requested = craftNativeOptionalString(props.opt("selectedValue"))
4796
- ?: craftNativeOptionalString(props.opt("value")).orEmpty()
5162
+ val requested = craftNativePickerValue(props.opt("selectedValue"))
5163
+ ?: craftNativePickerValue(props.opt("value")).orEmpty()
4797
5164
  val selected = options.indexOfFirst { it.first == requested }.takeIf { it >= 0 } ?: 0
4798
5165
  isEnabled = !props.optBoolean("disabled", false)
4799
5166
  // Mounting a controlled picker must not look like a user
@@ -4870,8 +5237,16 @@ class MainActivity : AppCompatActivity() {
4870
5237
  }
4871
5238
  "TouchableOpacity", "TouchableHighlight", "Pressable" -> (previous as? CraftNativeFlexLayout ?: CraftNativeFlexLayout(this)).apply {
4872
5239
  configureContainer(this, style)
5240
+ val disabled = props.optBoolean("disabled", false)
4873
5241
  screen.pressActiveOpacities[this] = craftNativeFiniteFloat(props, "activeOpacity", 0.5f)
4874
5242
  screen.pressBaseOpacities[this] = craftNativeFiniteFloat(style, "opacity", 1f)
5243
+ if (type == "TouchableHighlight" && !disabled) {
5244
+ craftNativeOptionalString(props.opt("underlayColor"))?.let { screen.pressUnderlayColors[this] = color(it, Color.TRANSPARENT) }
5245
+ ?: screen.pressUnderlayColors.remove(this)
5246
+ } else {
5247
+ screen.pressUnderlayColors.remove(this)
5248
+ }
5249
+ screen.pressBaseBackgrounds[this] = this.background
4875
5250
  reconcileChildren(screen, this, children, identity, next, style, previousControls)
4876
5251
  }
4877
5252
  "Switch" -> (previous as? Switch ?: Switch(this)).apply {
@@ -5011,6 +5386,9 @@ class MainActivity : AppCompatActivity() {
5011
5386
  "center" -> ImageView.ScaleType.CENTER
5012
5387
  else -> ImageView.ScaleType.CENTER_INSIDE
5013
5388
  }
5389
+ craftNativeOptionalString(style.opt("tintColor"))
5390
+ ?.let { image.setColorFilter(color(it, Color.TRANSPARENT)) }
5391
+ ?: image.clearColorFilter()
5014
5392
  val onLoadStart = craftNativeEventName(events, "onLoadStart")
5015
5393
  if (onLoadStart.isNotBlank()) screen.imageLoadStartHandlers[image] = onLoadStart else screen.imageLoadStartHandlers.remove(image)
5016
5394
  val onLoad = craftNativeEventName(events, "onLoad")
@@ -5041,8 +5419,9 @@ class MainActivity : AppCompatActivity() {
5041
5419
  )
5042
5420
  }
5043
5421
  "Map" -> (previous as? CraftNativeMapView ?: CraftNativeMapView(this)).apply {
5422
+ val mapMarkerHandler = craftNativeOptionalString(events.opt("onMarkerPress"))
5044
5423
  configure(props, children) { markerId ->
5045
- markerHandler(markerId)?.let { handler ->
5424
+ (markerHandler(markerId) ?: mapMarkerHandler)?.let { handler ->
5046
5425
  send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", JSONObject().put("id", markerId)))
5047
5426
  }
5048
5427
  }
@@ -5069,7 +5448,8 @@ class MainActivity : AppCompatActivity() {
5069
5448
  val refreshNode = (0 until children.length()).asSequence()
5070
5449
  .mapNotNull { children.optJSONObject(it) }
5071
5450
  .firstOrNull { canonicalType(it.optString("type")) == "RefreshControl" }
5072
- val refreshProps = refreshNode?.optJSONObject("props") ?: JSONObject()
5451
+ val propRefresh = props.optJSONObject("refreshControl")
5452
+ val refreshProps = refreshNode?.optJSONObject("props") ?: propRefresh ?: JSONObject()
5073
5453
  val refreshEvents = refreshNode?.optJSONObject("events") ?: JSONObject()
5074
5454
  val refreshHandlerName = craftNativeEventName(refreshEvents, "onRefresh")
5075
5455
  .takeIf { it.isNotBlank() }
@@ -5077,7 +5457,7 @@ class MainActivity : AppCompatActivity() {
5077
5457
  val refreshHandler = refreshHandlerName?.let { handler ->
5078
5458
  { send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", JSONObject())) }
5079
5459
  }
5080
- setRefreshHandler(refreshHandler, if (refreshNode == null) props.optBoolean("refreshing", false) else refreshProps.optBoolean("refreshing", false))
5460
+ setRefreshHandler(refreshHandler, refreshProps.optBoolean("refreshing", props.optBoolean("refreshing", false)))
5081
5461
  setRefreshAppearance(refreshProps, resources.displayMetrics.density)
5082
5462
  val onScroll = craftNativeEventName(events, "onScroll")
5083
5463
  if (onScroll.isNotBlank()) screen.scrollHandlers[this] = onScroll else screen.scrollHandlers.remove(this)
@@ -5143,6 +5523,8 @@ class MainActivity : AppCompatActivity() {
5143
5523
  if (child !is JSONObject || canonicalType(child.optString("type")) != "RefreshControl") contentChildren.put(child)
5144
5524
  }
5145
5525
  reconcileChildren(screen, content, contentChildren, identity, next, contentStyle, previousControls)
5526
+ setStickyHeaderIndices(props.optJSONArray("stickyHeaderIndices"))
5527
+ setScrollTarget(props.opt("scrollTarget"))
5146
5528
  }
5147
5529
  "FlatList" -> (previous as? CraftNativeRefreshableFlatList ?: CraftNativeRefreshableFlatList(this)).apply {
5148
5530
  val nativeList = list
@@ -5155,7 +5537,6 @@ class MainActivity : AppCompatActivity() {
5155
5537
  (getSystemService(INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager)
5156
5538
  ?.hideSoftInputFromWindow(windowToken, 0)
5157
5539
  }
5158
- nativeList.overScrollMode = if (props.optBoolean("bounces", true)) View.OVER_SCROLL_ALWAYS else View.OVER_SCROLL_NEVER
5159
5540
  reconcileFlatList(screen, nativeList, children, identity, protocolId ?: identity, props, events)
5160
5541
  }
5161
5542
  "View", "SafeAreaView" -> (previous as? CraftNativeFlexLayout ?: CraftNativeFlexLayout(this)).apply {
@@ -5182,6 +5563,10 @@ class MainActivity : AppCompatActivity() {
5182
5563
  val pressOutHandler = craftNativeEventName(events, "onPressOut").takeIf { it.isNotBlank() }
5183
5564
  if (pressInHandler != null) screen.pressInHandlers[control] = pressInHandler else screen.pressInHandlers.remove(control)
5184
5565
  if (pressOutHandler != null) screen.pressOutHandlers[control] = pressOutHandler else screen.pressOutHandlers.remove(control)
5566
+ val underlayShowHandler = if (type == "TouchableHighlight") craftNativeEventName(events, "onShowUnderlay").takeIf { it.isNotBlank() } else null
5567
+ val underlayHideHandler = if (type == "TouchableHighlight") craftNativeEventName(events, "onHideUnderlay").takeIf { it.isNotBlank() } else null
5568
+ if (underlayShowHandler != null) screen.pressUnderlayShowHandlers[control] = underlayShowHandler else screen.pressUnderlayShowHandlers.remove(control)
5569
+ if (underlayHideHandler != null) screen.pressUnderlayHideHandlers[control] = underlayHideHandler else screen.pressUnderlayHideHandlers.remove(control)
5185
5570
  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
5571
  val handler = craftNativeEventName(events, "onPress", "onClick")
5187
5572
  val disabled = props.optBoolean("disabled", false)
@@ -5190,7 +5575,8 @@ class MainActivity : AppCompatActivity() {
5190
5575
  ?.trim()
5191
5576
  ?.takeIf { !disabled && type in setOf("TouchableOpacity", "TouchableHighlight", "Pressable") }
5192
5577
  if (target != null) screen.linkTargets[control] = target else screen.linkTargets.remove(control)
5193
- val hasPressLifecycle = pressInHandler != null || pressOutHandler != null
5578
+ val hasUnderlay = type == "TouchableHighlight" && screen.pressUnderlayColors[control] != null
5579
+ val hasPressLifecycle = pressInHandler != null || pressOutHandler != null || hasUnderlay
5194
5580
  if ((handler.isNotBlank() || target != null || (type in setOf("TouchableOpacity", "TouchableHighlight", "Pressable") && hasPressLifecycle)) && !disabled) {
5195
5581
  if (handler.isNotBlank()) screen.handlers[control] = handler else screen.handlers.remove(control)
5196
5582
  control.isClickable = true
@@ -5206,16 +5592,24 @@ class MainActivity : AppCompatActivity() {
5206
5592
  val active = screen.pressActiveOpacities[view] ?: 0.5f
5207
5593
  val base = screen.pressBaseOpacities[view] ?: 1f
5208
5594
  view.alpha = base * active
5595
+ screen.pressUnderlayColors[view]?.let { view.background = ColorDrawable(it) }
5209
5596
  screen.pressInHandlers[view]?.let { current ->
5210
5597
  send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
5211
5598
  }
5599
+ screen.pressUnderlayShowHandlers[view]?.let { current ->
5600
+ send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
5601
+ }
5212
5602
  true
5213
5603
  }
5214
5604
  android.view.MotionEvent.ACTION_UP, android.view.MotionEvent.ACTION_CANCEL -> {
5215
5605
  view.alpha = screen.pressBaseOpacities[view] ?: 1f
5606
+ if (screen.pressBaseBackgrounds.containsKey(view)) view.background = screen.pressBaseBackgrounds[view]
5216
5607
  screen.pressOutHandlers[view]?.let { current ->
5217
5608
  send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
5218
5609
  }
5610
+ screen.pressUnderlayHideHandlers[view]?.let { current ->
5611
+ send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
5612
+ }
5219
5613
  if (event.actionMasked == android.view.MotionEvent.ACTION_UP) {
5220
5614
  screen.linkTargets[view]?.let { target -> navigateOpen(target); return@setOnTouchListener true }
5221
5615
  val current = screen.handlers[view] ?: return@setOnTouchListener true
@@ -5228,6 +5622,7 @@ class MainActivity : AppCompatActivity() {
5228
5622
  }
5229
5623
  } else {
5230
5624
  control.setOnTouchListener(null)
5625
+ if (screen.pressBaseBackgrounds.containsKey(control)) control.background = screen.pressBaseBackgrounds[control]
5231
5626
  control.setOnClickListener {
5232
5627
  screen.linkTargets[control]?.let { target -> navigateOpen(target); return@setOnClickListener }
5233
5628
  val current = screen.handlers[control] ?: return@setOnClickListener
@@ -5464,9 +5859,10 @@ class MainActivity : AppCompatActivity() {
5464
5859
  }
5465
5860
  val threshold = craftNativeNonNegativeDouble(props, "threshold", 0.1)
5466
5861
  val authoredThreshold = craftNativeNonNegativeDouble(props, "onEndReachedThreshold", threshold)
5862
+ val horizontal = props.optBoolean("horizontal", false)
5467
5863
  list.apply(
5468
5864
  nodes = nodes,
5469
- horizontal = props.optBoolean("horizontal", false),
5865
+ horizontal = horizontal,
5470
5866
  columns = props.optInt("numColumns", 1),
5471
5867
  inverted = props.optBoolean("inverted", false),
5472
5868
  endReachedThreshold = authoredThreshold,
@@ -5487,6 +5883,18 @@ class MainActivity : AppCompatActivity() {
5487
5883
  { send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", JSONObject())) }
5488
5884
  },
5489
5885
  )
5886
+ list.setScrollIndicators(
5887
+ props.optBoolean("showsVerticalScrollIndicator", true),
5888
+ props.optBoolean("showsHorizontalScrollIndicator", true),
5889
+ )
5890
+ list.setScrollEnabled(props.optBoolean("scrollEnabled", true))
5891
+ list.setPagingEnabled(props.optBoolean("pagingEnabled", false))
5892
+ list.setScrollTarget(props.opt("scrollTarget"))
5893
+ list.setBounceOptions(
5894
+ props.optBoolean("bounces", true),
5895
+ props.optBoolean("alwaysBounceVertical", !horizontal),
5896
+ props.optBoolean("alwaysBounceHorizontal", horizontal),
5897
+ )
5490
5898
  }
5491
5899
 
5492
5900
  private fun registerFlatListOwnership(screen: Screen, node: JSONObject, owner: String) {
@@ -5519,7 +5927,11 @@ class MainActivity : AppCompatActivity() {
5519
5927
  craftNativePadding(style, "paddingRight", horizontal, density),
5520
5928
  craftNativePadding(style, "paddingBottom", vertical, density),
5521
5929
  )
5522
- } else if (view.javaClass == TextView::class.java) {
5930
+ } else if (view.javaClass == TextView::class.java || view.javaClass == Button::class.java) {
5931
+ // Plain Android buttons start with zero padding in the native
5932
+ // contract. A keyed button may lose a reactive padding style;
5933
+ // clear that old value just as iOS reapplies its padding every
5934
+ // reconciliation.
5523
5935
  view.setPadding(0, 0, 0, 0)
5524
5936
  }
5525
5937
  }
@@ -5537,6 +5949,17 @@ class MainActivity : AppCompatActivity() {
5537
5949
  else color(style.optString("backgroundColor"), Color.TRANSPARENT)
5538
5950
  val border = craftNativeNonNegativeDouble(style, "borderWidth", 0.0)
5539
5951
  val radius = craftNativeNonNegativeDouble(style, "borderRadius", 0.0)
5952
+ val gradientDirection = style.optString("gradientDirection").takeIf { it.isNotBlank() }
5953
+ val gradientFrom = style.optString("gradientFrom").takeIf { it.isNotBlank() }
5954
+ val gradientVia = style.optString("gradientVia").takeIf { it.isNotBlank() }
5955
+ val gradientTo = style.optString("gradientTo").takeIf { it.isNotBlank() }
5956
+ val gradientColors = if (gradientDirection != null && gradientFrom != null && gradientTo != null) {
5957
+ listOfNotNull(
5958
+ color(gradientFrom, Color.TRANSPARENT),
5959
+ gradientVia?.let { color(it, Color.TRANSPARENT) },
5960
+ color(gradientTo, Color.TRANSPARENT),
5961
+ ).toIntArray()
5962
+ } else null
5540
5963
  val sideBorderKeys = listOf("borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth")
5541
5964
  val hasSideBorders = (sideBorderKeys + listOf("borderTopColor", "borderRightColor", "borderBottomColor", "borderLeftColor")).any(style::has)
5542
5965
  val sideWidths = sideBorderKeys.map { dp(craftNativeNonNegativeDouble(style, it, border)) }.toIntArray()
@@ -5554,17 +5977,23 @@ class MainActivity : AppCompatActivity() {
5554
5977
  } else {
5555
5978
  view.background.also { defaultBackgrounds[view] = it }
5556
5979
  }
5557
- if (background || border > 0 || radius > 0 || hasSideBorders) {
5980
+ if (background || border > 0 || radius > 0 || hasSideBorders || gradientColors != null) {
5558
5981
  if (hasSideBorders) {
5559
5982
  view.background = CraftNativeSideBorderDrawable(
5560
5983
  backgroundColor,
5561
5984
  cornerRadii,
5562
5985
  sideWidths,
5563
5986
  sideColors,
5987
+ style.optString("borderStyle").takeIf { it == "dashed" || it == "dotted" },
5988
+ gradientDirection,
5989
+ gradientColors,
5564
5990
  )
5565
5991
  } else {
5566
5992
  view.background = GradientDrawable().apply {
5567
- setColor(backgroundColor)
5993
+ if (gradientColors != null) {
5994
+ orientation = craftNativeGradientOrientation(gradientDirection)
5995
+ colors = gradientColors
5996
+ } else setColor(backgroundColor)
5568
5997
  if (hasCornerRadii) setCornerRadii(cornerRadii) else cornerRadius = dp(radius).toFloat()
5569
5998
  if (border > 0) {
5570
5999
  val borderColor = color(style.optString("borderColor"), Color.TRANSPARENT)
@@ -5605,6 +6034,15 @@ class MainActivity : AppCompatActivity() {
5605
6034
  view.invalidateOutline()
5606
6035
  } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
5607
6036
  view.outlineProvider = ViewOutlineProvider.BACKGROUND
6037
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
6038
+ // A retained view may lose an authored shadow while keeping
6039
+ // its native object. Restore Android's default outline colors
6040
+ // too; otherwise a later elevation-only style inherits the
6041
+ // previous shadow's custom ambient/spot tint.
6042
+ view.outlineAmbientShadowColor = Color.BLACK
6043
+ view.outlineSpotShadowColor = Color.BLACK
6044
+ view.invalidateOutline()
6045
+ }
5608
6046
  }
5609
6047
  if (view is ImageView) {
5610
6048
  val tint = style.opt("tintColor") as? String
@@ -5706,10 +6144,18 @@ class MainActivity : AppCompatActivity() {
5706
6144
  else -> Typeface.NORMAL
5707
6145
  }
5708
6146
  view.typeface = Typeface.create("sans-serif", weight)
5709
- if (!style.has("width") && !style.has("height")) {
6147
+ val hasExplicitWidth = style.has("width") && !style.isNull("width")
6148
+ val hasExplicitHeight = style.has("height") && !style.isNull("height")
6149
+ if (!hasExplicitWidth && !hasExplicitHeight) {
5710
6150
  val box = dp(size.toDouble())
5711
6151
  view.minimumWidth = box
5712
6152
  view.minimumHeight = box
6153
+ } else {
6154
+ // A retained icon may move from the implicit square to an
6155
+ // explicitly sized layout. Do not let the old minimum constrain
6156
+ // the authored dimensions.
6157
+ view.minimumWidth = 0
6158
+ view.minimumHeight = 0
5713
6159
  }
5714
6160
  }
5715
6161
 
@@ -5859,6 +6305,17 @@ class MainActivity : AppCompatActivity() {
5859
6305
  else -> ""
5860
6306
  }
5861
6307
 
6308
+ private fun imageHeaders(source: Any?): Map<String, String> {
6309
+ val objectSource = source as? JSONObject ?: return emptyMap()
6310
+ val raw = objectSource.optJSONObject("headers") ?: return emptyMap()
6311
+ return linkedMapOf<String, String>().apply {
6312
+ raw.keys().forEach { name -> craftNativeOptionalString(raw.opt(name))?.let { put(name, it) } }
6313
+ }
6314
+ }
6315
+
6316
+ private fun imageRequestKey(uri: String, headers: Map<String, String>): String =
6317
+ uri + "\u0000" + headers.toSortedMap().entries.joinToString("\u0000") { "${it.key}=${it.value}" }
6318
+
5862
6319
  private fun immediateImage(uri: String): Bitmap? {
5863
6320
  if (uri.startsWith("data:image/") && uri.contains(',')) {
5864
6321
  val bytes = runCatching { Base64.decode(uri.substringAfter(','), Base64.DEFAULT) }.getOrNull()
@@ -5870,6 +6327,8 @@ class MainActivity : AppCompatActivity() {
5870
6327
 
5871
6328
  private fun updateImage(screen: Screen, view: ImageView, source: Any?, defaultSource: Any? = null) {
5872
6329
  val uri = imageUri(source)
6330
+ val headers = imageHeaders(source)
6331
+ val requestKey = imageRequestKey(uri, headers)
5873
6332
  if (uri.isBlank()) {
5874
6333
  if (imageSources[view] == "") return
5875
6334
  imageJobs.remove(view)?.cancel(true)
@@ -5880,9 +6339,9 @@ class MainActivity : AppCompatActivity() {
5880
6339
  imageFailure(screen, view, uri, "Image source is missing")
5881
6340
  return
5882
6341
  }
5883
- if (imageSources[view] == uri) return
6342
+ if (imageSources[view] == requestKey) return
5884
6343
  imageJobs.remove(view)?.cancel(true)
5885
- imageSources[view] = uri
6344
+ imageSources[view] = requestKey
5886
6345
  imageErrors.remove(view)
5887
6346
  view.setImageDrawable(null)
5888
6347
  imageDidChange(view)
@@ -5920,11 +6379,12 @@ class MainActivity : AppCompatActivity() {
5920
6379
  val connection = URL(uri).openConnection().apply {
5921
6380
  connectTimeout = 10_000
5922
6381
  readTimeout = 10_000
6382
+ headers.forEach { (name, value) -> setRequestProperty(name, value) }
5923
6383
  }
5924
6384
  connection.getInputStream().use(BitmapFactory::decodeStream)
5925
6385
  }.getOrNull()
5926
6386
  runOnUiThread {
5927
- if (imageSources[view] != uri) return@runOnUiThread
6387
+ if (imageSources[view] != requestKey) return@runOnUiThread
5928
6388
  imageJobs.remove(view)
5929
6389
  if (bitmap != null) {
5930
6390
  view.setImageBitmap(bitmap)
@@ -6508,7 +6968,13 @@ class MainActivity : AppCompatActivity() {
6508
6968
 
6509
6969
  override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
6510
6970
  super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
6511
- if (isInPictureInPictureMode) showVideoAlone() else putVideoBack()
6971
+ if (isInPictureInPictureMode) {
6972
+ floatingVideo()?.notifyPictureInPicture(true)
6973
+ showVideoAlone()
6974
+ } else {
6975
+ pictureInPictureVideo?.notifyPictureInPicture(false)
6976
+ putVideoBack()
6977
+ }
6512
6978
  }
6513
6979
 
6514
6980
  /**
@@ -6584,6 +7050,7 @@ class MainActivity : AppCompatActivity() {
6584
7050
  released.filterIsInstance<EditText>().forEach {
6585
7051
  inputHintDefaults.remove(it)
6586
7052
  inputHighlightDefaults.remove(it)
7053
+ inputPaddingDefaults.remove(it)
6587
7054
  screen.suppressedTextChanges.remove(it)
6588
7055
  screen.inputEchoes.remove(it)
6589
7056
  val identity = screen.inputIdentities.remove(it)
@@ -6594,6 +7061,10 @@ class MainActivity : AppCompatActivity() {
6594
7061
  screen.handlers.keys.removeAll(released)
6595
7062
  screen.pressInHandlers.keys.removeAll(released)
6596
7063
  screen.pressOutHandlers.keys.removeAll(released)
7064
+ screen.pressUnderlayShowHandlers.keys.removeAll(released)
7065
+ screen.pressUnderlayHideHandlers.keys.removeAll(released)
7066
+ screen.pressUnderlayColors.keys.removeAll(released)
7067
+ screen.pressBaseBackgrounds.keys.removeAll(released)
6597
7068
  screen.longPressHandlers.keys.removeAll(released)
6598
7069
  screen.layoutHandlers.keys.removeAll(released)
6599
7070
  screen.layoutListeners.forEach { (view, listener) ->
@@ -6618,6 +7089,8 @@ class MainActivity : AppCompatActivity() {
6618
7089
  screen.imageErrorHandlers.keys.removeAll(releasedImages)
6619
7090
  screen.pressActiveOpacities.keys.removeAll(released)
6620
7091
  screen.pressBaseOpacities.keys.removeAll(released)
7092
+ screen.pressUnderlayColors.keys.removeAll(released)
7093
+ screen.pressBaseBackgrounds.keys.removeAll(released)
6621
7094
  screen.modalVisibility.keys.removeAll(released)
6622
7095
  screen.modalCloseHandlers.keys.removeAll(released)
6623
7096
  }
@@ -6648,6 +7121,10 @@ class MainActivity : AppCompatActivity() {
6648
7121
  screen.handlers.clear()
6649
7122
  screen.pressInHandlers.clear()
6650
7123
  screen.pressOutHandlers.clear()
7124
+ screen.pressUnderlayShowHandlers.clear()
7125
+ screen.pressUnderlayHideHandlers.clear()
7126
+ screen.pressUnderlayColors.clear()
7127
+ screen.pressBaseBackgrounds.clear()
6651
7128
  screen.layoutHandlers.clear()
6652
7129
  screen.lastLayoutBounds.clear()
6653
7130
  screen.scrollHandlers.clear()