craft-native 0.0.499 → 0.0.502

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.
@@ -47,6 +47,12 @@ export interface CraftAndroidConfig {
47
47
  renderer?: 'web' | 'native';
48
48
  /** "system" follows the device; light/dark pin the generated host. */
49
49
  appearance?: 'light' | 'dark' | 'system';
50
+ /**
51
+ * The native chrome's accent as #RRGGBB: Back, the bar's buttons and the
52
+ * selected tab. Omitted keeps the system blue, as before. iOS has had the
53
+ * same key; without it an app themed on iOS drew its Android chrome blue.
54
+ */
55
+ tintColor?: string;
50
56
  /** Static native tab-bar styling applied before the page's own tabBar.set call. */
51
57
  tabBar?: CraftTabBarConfig;
52
58
  /** Runs from the generated project root before Gradle compiles the app. */
@@ -1140,6 +1140,8 @@ function validateAndroidConfig(config) {
1140
1140
  if (config.appearance !== undefined && !["light", "dark", "system"].includes(config.appearance)) {
1141
1141
  throw new Error(`Unknown Android appearance: ${config.appearance}. Expected light, dark, or system.`);
1142
1142
  }
1143
+ if (config.tintColor !== undefined && !/^#[0-9a-f]{6}$/i.test(config.tintColor))
1144
+ throw new Error("Android tintColor must be a #RRGGBB color");
1143
1145
  const selection = config.tabBar?.selection;
1144
1146
  if (selection?.cornerRadius !== undefined && (!Number.isFinite(selection.cornerRadius) || selection.cornerRadius < 0))
1145
1147
  throw new Error("Android tabBar.selection.cornerRadius must be a finite non-negative number");
@@ -17,6 +17,9 @@ import kotlin.math.max
17
17
 
18
18
  internal typealias CraftNativeRenderItem = (node: JSONObject, identity: String, previous: View?) -> View
19
19
 
20
+ /** The most columns a grid is given: more than any screen has pixels across to show. */
21
+ internal const val CRAFT_NATIVE_FLAT_LIST_MAX_COLUMNS = 1024
22
+
20
23
  /** A stable-key RecyclerView host for stx native FlatList nodes. */
21
24
  internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
22
25
  override fun dispatchTouchEvent(event: MotionEvent): Boolean =
@@ -297,10 +300,38 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
297
300
  post { onContentSizeChanged?.invoke() }
298
301
  }
299
302
 
303
+ /**
304
+ * How far the grouped tab actions reach over the end of this list, in
305
+ * pixels (#523). The last row is given that much room below it, so it can
306
+ * be brought clear of them; the rows above still scroll behind them, and
307
+ * the list's own padding and viewport are as they were.
308
+ */
309
+ private var floatingCover = 0
310
+
311
+ fun setFloatingCover(cover: Int) {
312
+ val next = if (horizontal) 0 else max(0, cover)
313
+ if (next == floatingCover) return
314
+ floatingCover = next
315
+ invalidateItemDecorations()
316
+ }
317
+
318
+ private val floatingCoverRoom = object : ItemDecoration() {
319
+ override fun getItemOffsets(outRect: android.graphics.Rect, view: View, parent: RecyclerView, state: State) {
320
+ val position = parent.getChildAdapterPosition(view)
321
+ val last = state.itemCount - 1
322
+ if (floatingCover <= 0 || horizontal || position == NO_POSITION || last < 0) return
323
+ val grid = layoutManager as? GridLayoutManager
324
+ val endsTheList = if (grid == null) position == last
325
+ else grid.spanSizeLookup.getSpanGroupIndex(position, grid.spanCount) == grid.spanSizeLookup.getSpanGroupIndex(last, grid.spanCount)
326
+ if (endsTheList) outRect.bottom = floatingCover
327
+ }
328
+ }
329
+
300
330
  init {
301
331
  adapter = listAdapter
302
332
  itemAnimator = null
303
333
  addOnScrollListener(scrollListener)
334
+ addItemDecoration(floatingCoverRoom)
304
335
  }
305
336
 
306
337
  fun setKeyboardDismissMode(mode: String, dismissKeyboard: () -> Unit) {
@@ -375,7 +406,11 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
375
406
  this.endReached = endReached
376
407
  threshold = endReachedThreshold.takeIf { it.isFinite() }?.coerceAtLeast(0.0) ?: 0.1
377
408
 
378
- val nextColumns = columns.coerceAtLeast(1)
409
+ // A grid keeps an array one longer than its column count, so a
410
+ // count from data (`numColumns={Number.MAX_VALUE}` arrives as the
411
+ // largest Int) asked it for a negative length and ended the process
412
+ // in layout. No screen is as many pixels wide as this many columns.
413
+ val nextColumns = columns.coerceIn(1, CRAFT_NATIVE_FLAT_LIST_MAX_COLUMNS)
379
414
  val layoutChanged = this.horizontal != horizontal || this.columns != nextColumns
380
415
  val retainedState = if (layoutChanged) null else layoutManager?.onSaveInstanceState()
381
416
  this.horizontal = horizontal
@@ -387,11 +422,10 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
387
422
  }
388
423
 
389
424
  val seen = mutableMapOf<String, Int>()
425
+ val taken = mutableSetOf<String>()
390
426
  val next = nodes.mapIndexed { index, node ->
391
427
  val base = identity(node) ?: "index:$index"
392
- val occurrence = seen.getOrDefault(base, 0)
393
- seen[base] = occurrence + 1
394
- val unique = if (occurrence == 0) base else "$base#$occurrence"
428
+ val unique = uniqueIdentity(base, seen, taken)
395
429
  val role = node.optJSONObject("props")?.optString("listRole").orEmpty()
396
430
  Item(unique, node, node.toString(), role.isNotBlank() && role != "item")
397
431
  }.let { if (inverted) it.reversed() else it }
@@ -546,6 +580,27 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
546
580
  endReached?.invoke()
547
581
  }
548
582
 
583
+ /**
584
+ * A row's identity, its own in this list.
585
+ *
586
+ * Rows that share a key are told apart by a suffix: `run`, then `run#1`.
587
+ * A row whose own key was `run#1` then had the identity of the second
588
+ * `run`, and with it the same stable id, which a RecyclerView requires to
589
+ * be unique; UIKit's list ended the process on the same rows. The suffix
590
+ * counts on until the identity is one no row has taken. A list with no
591
+ * such key has the identities it always had.
592
+ */
593
+ private fun uniqueIdentity(base: String, seen: MutableMap<String, Int>, taken: MutableSet<String>): String {
594
+ var occurrence = seen.getOrDefault(base, 0)
595
+ var identity = if (occurrence == 0) base else "$base#$occurrence"
596
+ while (!taken.add(identity)) {
597
+ occurrence += 1
598
+ identity = "$base#$occurrence"
599
+ }
600
+ seen[base] = occurrence + 1
601
+ return identity
602
+ }
603
+
549
604
  private fun identity(node: JSONObject): String? {
550
605
  val props = node.optJSONObject("props") ?: JSONObject()
551
606
  return listOf(node.optString("id"), node.optString("key"), props.optString("key"), props.optString("testID"))
@@ -187,6 +187,10 @@ internal class CraftNativeRoutes(config: JSONObject) {
187
187
  }
188
188
  ?: CraftNativeTabBarAppearance()
189
189
 
190
+ /** The chrome's accent, where the app set one (`tintColor`); the system blue otherwise. */
191
+ val tintColor: Int? = config.optString("tintColor").takeUnless { config.isNull("tintColor") || it.isBlank() }
192
+ ?.let { runCatching { Color.parseColor(it) }.getOrNull() }
193
+
190
194
  /** CSS stacks for the chrome's labels and titles, as the app configured them. */
191
195
  val fontFamily: String? = config.optString("fontFamily").takeUnless { config.isNull("fontFamily") || it.isBlank() }
192
196
  val headingFontFamily: String? = config.optString("headingFontFamily").takeUnless { config.isNull("headingFontFamily") || it.isBlank() }
@@ -993,7 +997,7 @@ internal object CraftNativePrelude {
993
997
  * screen sets its own chrome with
994
998
  *
995
999
  * `craft.navigation.setOptions({ title, largeTitle, titleColor, hidden,
996
- * backgroundColor, leftButtons, rightButtons, tabItem, tabAccessory,
1000
+ * backgroundColor, backButton, leftButtons, rightButtons, tabItem, tabAccessory,
997
1001
  * tabBarHidden, tabBarHiddenWhenPushed, tabSwipeEnabled })`
998
1002
  *
999
1003
  * which `apply` below reads. That list is checked against `apply` itself
@@ -1013,6 +1017,7 @@ internal class CraftNativeChrome(
1013
1017
  private val tabs: List<CraftNativeTab>,
1014
1018
  private val tabBarAppearance: CraftNativeTabBarAppearance = CraftNativeTabBarAppearance(),
1015
1019
  private val fonts: CraftNativeChromeFonts = CraftNativeChromeFonts(),
1020
+ private val tintColor: Int? = null,
1016
1021
  ) : LinearLayout(context) {
1017
1022
  val content = FrameLayout(context)
1018
1023
  private val body = FrameLayout(context)
@@ -1249,6 +1254,8 @@ internal class CraftNativeChrome(
1249
1254
  val title = options.optString("title").takeIf { options.has("title") } ?: fallbackTitle.orEmpty()
1250
1255
  bar.visibility = if (hidden) View.GONE else View.VISIBLE
1251
1256
  backButton.visibility = if (depth > 0) View.VISIBLE else View.GONE
1257
+ backButtonSpec = options.optJSONObject("backButton")
1258
+ applyBackButton()
1252
1259
  bar.setPadding(if (depth > 0) dp(4) else dp(4), 0, dp(8), 0)
1253
1260
  titleView.text = if (large) "" else title
1254
1261
  largeTitleView.text = title
@@ -1335,6 +1342,68 @@ internal class CraftNativeChrome(
1335
1342
  refreshColors()
1336
1343
  }
1337
1344
 
1345
+ /**
1346
+ * How far the grouped actions reach up from the tab bar, in pixels: their
1347
+ * height and their lift above it. Zero when they are not floating over
1348
+ * the screen (no group, a hidden bar, or actions that sit in the tab row).
1349
+ */
1350
+ internal fun floatingActionsReach(): Int =
1351
+ if (accessoryGroup.parent === body && accessoryGroup.visibility == View.VISIBLE && accessoryGroup.height > 0) (body.height - accessoryGroup.top).coerceAtLeast(0)
1352
+ else 0
1353
+
1354
+ private var floatingClearancePosted = false
1355
+ private var floatingClearanceGiven = false
1356
+
1357
+ /**
1358
+ * The grouped actions float over the trailing end of whatever scrolls
1359
+ * behind them, and nothing was reserved, so the last rows of a screen
1360
+ * could not be brought out from under them (#523). Every vertical scroll
1361
+ * view and list that reaches into them is given that much room at its
1362
+ * end; one that ends above them gets none. Measured after each layout,
1363
+ * where each of them now ends.
1364
+ */
1365
+ private fun clearFloatingActions() {
1366
+ val reach = floatingActionsReach()
1367
+ // Nothing floats and nothing was given room: no screen to walk.
1368
+ if (reach <= 0 && !floatingClearanceGiven) return
1369
+ floatingClearanceGiven = reach > 0
1370
+ val top = body.height - reach
1371
+ val origin = IntArray(2).also { body.getLocationInWindow(it) }
1372
+ val at = IntArray(2)
1373
+ fun cover(view: View): Int {
1374
+ if (reach <= 0 || !view.isLaidOut) return 0
1375
+ view.getLocationInWindow(at)
1376
+ return (at[1] - origin[1] + view.height - top).coerceIn(0, reach)
1377
+ }
1378
+ // `inside` a scroll view that scrolls down the screen, a list or a
1379
+ // second scroll view is brought clear by that one, and gets none of
1380
+ // its own: where it sits in a page not yet scrolled to it says
1381
+ // nothing about the screen, and a short list below the fold was
1382
+ // given the whole reach and scrolled inside its box into blank space.
1383
+ fun visit(view: View, inside: Boolean) {
1384
+ when (view) {
1385
+ is CraftNativeFlatList -> view.setFloatingCover(if (inside) 0 else cover(view))
1386
+ is CraftNativeScrollView -> {
1387
+ view.setFloatingCover(if (inside) 0 else cover(view))
1388
+ visit(view.content, inside || view.scrollsVertically)
1389
+ }
1390
+ is ViewGroup -> for (index in 0 until view.childCount) visit(view.getChildAt(index), inside)
1391
+ }
1392
+ }
1393
+ visit(content, false)
1394
+ }
1395
+
1396
+ override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
1397
+ super.onLayout(changed, left, top, right, bottom)
1398
+ if (floatingClearancePosted) return
1399
+ floatingClearancePosted = true
1400
+ // After this pass: giving a scroll view room asks for a layout.
1401
+ post {
1402
+ floatingClearancePosted = false
1403
+ clearFloatingActions()
1404
+ }
1405
+ }
1406
+
1338
1407
  /** The same actions and popup anchors survive switching between grouped and separate chrome. */
1339
1408
  private fun layoutAccessoryGroup(hidden: Boolean) {
1340
1409
  val layout = accessorySpec?.optString("layout").orEmpty()
@@ -1443,6 +1512,42 @@ internal class CraftNativeChrome(
1443
1512
  )
1444
1513
  }
1445
1514
 
1515
+ private var backButtonSpec: JSONObject? = null
1516
+ private var renderedBackButton = ""
1517
+
1518
+ /**
1519
+ * `backButton`: what this screen's Back is drawn with, when it is not the
1520
+ * arrow (`{ symbol: "xmark", accessibilityLabel: "Close" }` for a screen
1521
+ * that closes rather than goes back). `leftButtons` sit beside Back, so a
1522
+ * screen could not put its own symbol in Back's place (#527). It is still
1523
+ * the bar's back button: the same tap, shown at the same depths.
1524
+ *
1525
+ * The symbol is the app's own icon when it has one, and AppCompat's close
1526
+ * mark for an `xmark` it has none for. Any other symbol the app has no
1527
+ * icon for leaves the arrow, and its "Back".
1528
+ */
1529
+ private fun applyBackButton() {
1530
+ val symbol = backButtonSpec?.optString("symbol").orEmpty()
1531
+ val label = backButtonSpec?.optString("accessibilityLabel").orEmpty()
1532
+ val tint = backButton.imageTintList?.defaultColor ?: Color.BLACK
1533
+ // The activity passes cumulative options for every live patch: the
1534
+ // same symbol in the same colour keeps the picture it has.
1535
+ val rendered = "$symbol\u0000$label\u0000$tint"
1536
+ if (rendered == renderedBackButton) return
1537
+ renderedBackButton = rendered
1538
+ val icon = if (symbol.isBlank()) null else CraftNativeIcons.drawable(context, null, symbol, tint)
1539
+ ?: if (symbol.startsWith("xmark")) {
1540
+ androidx.appcompat.content.res.AppCompatResources.getDrawable(context, androidx.appcompat.R.drawable.abc_ic_clear_material)
1541
+ } else null
1542
+ if (icon == null) {
1543
+ backButton.setImageResource(androidx.appcompat.R.drawable.abc_ic_ab_back_material)
1544
+ backButton.contentDescription = "Back"
1545
+ } else {
1546
+ backButton.setImageDrawable(icon)
1547
+ backButton.contentDescription = label.ifBlank { if (symbol.startsWith("xmark")) "Close" else "Back" }
1548
+ }
1549
+ }
1550
+
1446
1551
  /** Light or dark changed: the bars follow the screen's background. */
1447
1552
  fun refreshColors() {
1448
1553
  val background = screenBackgroundColor ?: ContextCompat.getColor(context, R.color.background)
@@ -1456,6 +1561,8 @@ internal class CraftNativeChrome(
1456
1561
  titleView.setTextColor(navigationTitleColor ?: text)
1457
1562
  largeTitleView.setTextColor(navigationTitleColor ?: text)
1458
1563
  backButton.imageTintList = ColorStateList.valueOf(accent)
1564
+ // A screen's own back symbol is drawn in the accent, not tinted by it.
1565
+ applyBackButton()
1459
1566
  val surface = if (dark) Color.parseColor("#1C1C1E") else Color.parseColor("#F9F9F9")
1460
1567
  val grouped = accessoryGroup.parent === body
1461
1568
  accessoryGroup.background = if (grouped) GradientDrawable().apply { setColor(surface); cornerRadius = dp(26).toFloat() } else null
@@ -1579,7 +1686,8 @@ internal class CraftNativeChrome(
1579
1686
  }
1580
1687
  }
1581
1688
 
1582
- private fun accentColor(dark: Boolean): Int = if (dark) Color.parseColor("#0A84FF") else Color.parseColor("#007AFF")
1689
+ /** Back, the bar's buttons and the selected tab: the app's own tint in both appearances, else the system blue. */
1690
+ private fun accentColor(dark: Boolean): Int = tintColor ?: if (dark) Color.parseColor("#0A84FF") else Color.parseColor("#007AFF")
1583
1691
 
1584
1692
  private fun tabCellBackground(index: Int, dark: Boolean): android.graphics.drawable.Drawable? {
1585
1693
  if (accessorySelected || index != selectedTab || (tabBarAppearance.selectionColor == null && tabBarAppearance.selectionCornerRadius == null)) return rippleBackground()
@@ -229,6 +229,21 @@ internal fun craftNativeFiniteFloat(style: JSONObject?, name: String, fallback:
229
229
  return if (value.isFinite() && narrowed.isFinite()) narrowed else fallback
230
230
  }
231
231
 
232
+ /**
233
+ * The most a number may be on a view, either side of zero: 2^25, as on UIKit.
234
+ * It is far past any screen, and a view refuses anything past the largest
235
+ * Float: `setScaleX` and its like throw on infinity and on what is not a
236
+ * number, and a style is whatever the script said.
237
+ */
238
+ internal const val CRAFT_NATIVE_LAYOUT_LIMIT = 33_554_432f
239
+
240
+ /** A number for a view: inside that limit, and 0 when it is not a number. */
241
+ internal fun craftNativeLayoutNumber(value: Float): Float =
242
+ if (value.isNaN()) 0f else value.coerceIn(-CRAFT_NATIVE_LAYOUT_LIMIT, CRAFT_NATIVE_LAYOUT_LIMIT)
243
+
244
+ internal fun craftNativeLayoutNumber(value: Double): Float =
245
+ if (value.isNaN()) 0f else value.coerceIn(-CRAFT_NATIVE_LAYOUT_LIMIT.toDouble(), CRAFT_NATIVE_LAYOUT_LIMIT.toDouble()).toFloat()
246
+
232
247
  internal fun craftNativeFiniteDouble(style: JSONObject?, name: String, fallback: Double): Double {
233
248
  val value = craftNativeNumber(style?.opt(name)) ?: Double.NaN
234
249
  return value.takeIf { it.isFinite() } ?: fallback
@@ -355,29 +370,32 @@ internal fun craftNativeComponentLayoutStyle(node: JSONObject): JSONObject {
355
370
  return merged
356
371
  }
357
372
 
358
- private fun craftNativeGridColumnCount(value: Any?): Int {
359
- if (value is Number) return max(1, value.toInt())
360
- val text = value as? String ?: return 1
361
- val trimmed = text.trim()
362
- if (trimmed.startsWith("repeat(")) {
363
- val count = trimmed.removePrefix("repeat(").takeWhile { it.isDigit() }.toIntOrNull()
364
- if (count != null) return max(1, count)
365
- }
366
- return max(1, trimmed.split(Regex("\\s+")).count { it.isNotBlank() })
367
- }
373
+ /**
374
+ * The most tracks a grid is given, either way. A count is whatever the script
375
+ * said (`gridTemplateRows: Number.MAX_VALUE`, `repeat(2147483647, 1fr)`), and
376
+ * the layout makes a list of rows and an array of columns that long: the
377
+ * process ran out of memory in measure. No screen has room to show more
378
+ * tracks than this.
379
+ */
380
+ internal const val CRAFT_NATIVE_GRID_TRACK_LIMIT = 1024
368
381
 
369
- private fun craftNativeGridRowCount(value: Any?): Int? {
370
- if (value is Number) return max(1, value.toInt())
371
- val text = value as? String ?: return null
372
- val trimmed = text.trim()
373
- if (trimmed.isEmpty()) return null
382
+ internal fun craftNativeGridTrackCount(value: Any?): Int? {
383
+ if (value is Number) return value.toInt().coerceIn(1, CRAFT_NATIVE_GRID_TRACK_LIMIT)
384
+ val trimmed = (value as? String)?.trim()?.takeIf { it.isNotEmpty() } ?: return null
374
385
  if (trimmed.startsWith("repeat(")) {
375
- val count = trimmed.removePrefix("repeat(").takeWhile { it.isDigit() }.toIntOrNull()
376
- if (count != null) return max(1, count)
386
+ // Digits past what an Int holds are still a count, and still too many.
387
+ val digits = trimmed.removePrefix("repeat(").takeWhile { it.isDigit() }
388
+ if (digits.isNotEmpty()) {
389
+ return (digits.toIntOrNull() ?: CRAFT_NATIVE_GRID_TRACK_LIMIT).coerceIn(1, CRAFT_NATIVE_GRID_TRACK_LIMIT)
390
+ }
377
391
  }
378
- return max(1, trimmed.split(Regex("\\s+")).count { it.isNotBlank() })
392
+ return trimmed.split(Regex("\\s+")).count { it.isNotBlank() }.coerceIn(1, CRAFT_NATIVE_GRID_TRACK_LIMIT)
379
393
  }
380
394
 
395
+ private fun craftNativeGridColumnCount(value: Any?): Int = craftNativeGridTrackCount(value) ?: 1
396
+
397
+ private fun craftNativeGridRowCount(value: Any?): Int? = craftNativeGridTrackCount(value)
398
+
381
399
  /**
382
400
  * A CSS colour as Android's ARGB. STX writes `#RRGGBBAA` (and `#RGBA`) for a
383
401
  * colour with alpha, where Android's parser reads `#AARRGGBB`: a translucent
@@ -412,6 +430,54 @@ internal fun craftNativeColor(raw: String): Int {
412
430
  }
413
431
 
414
432
  /** Android View has no skew setters before API 29; draw the shared skew around the view's center. */
433
+ /**
434
+ * Times a hold on a view whose touch listener answers every event itself.
435
+ *
436
+ * The platform times a long click in `View.onTouchEvent`, and a listener
437
+ * that consumes the touch keeps that from running. A pressable with
438
+ * `onPress` has such a listener (it sends the press, and the press-in and
439
+ * press-out around it), so its `onLongPress`, and the menu a `contextMenu`
440
+ * opens on a hold, were never reached by a finger: holding a row sent its
441
+ * press when the finger lifted (#529).
442
+ *
443
+ * The same rules as the platform's: the hold is up after its long-press
444
+ * timeout, a finger that moves past the touch slop is not holding, and a
445
+ * hold that something handled is not also a press.
446
+ */
447
+ private object CraftNativePressHolds {
448
+ private class Hold(val fire: Runnable, val x: Float, val y: Float)
449
+ private val pending = WeakHashMap<View, Hold>()
450
+ private val handled = WeakHashMap<View, Boolean>()
451
+
452
+ fun began(view: View, event: MotionEvent) {
453
+ cancel(view)
454
+ handled.remove(view)
455
+ if (!view.isLongClickable) return
456
+ val fire = Runnable {
457
+ pending.remove(view)
458
+ if (view.isAttachedToWindow && view.isLongClickable && view.performLongClick()) handled[view] = true
459
+ }
460
+ pending[view] = Hold(fire, event.x, event.y)
461
+ view.postDelayed(fire, android.view.ViewConfiguration.getLongPressTimeout().toLong())
462
+ }
463
+
464
+ fun moved(view: View, event: MotionEvent) {
465
+ val hold = pending[view] ?: return
466
+ val slop = android.view.ViewConfiguration.get(view.context).scaledTouchSlop
467
+ if (abs(event.x - hold.x) > slop || abs(event.y - hold.y) > slop) cancel(view)
468
+ }
469
+
470
+ /** The touch is over. Whether its hold was handled, so that lifting is not a press. */
471
+ fun ended(view: View): Boolean {
472
+ cancel(view)
473
+ return handled.remove(view) == true
474
+ }
475
+
476
+ private fun cancel(view: View) {
477
+ pending.remove(view)?.let { view.removeCallbacks(it.fire) }
478
+ }
479
+ }
480
+
415
481
  private object CraftNativeSkewTransforms {
416
482
  private val values = WeakHashMap<View, Pair<Float, Float>>()
417
483
 
@@ -1344,7 +1410,19 @@ internal class CraftNativePointerImageView(context: Context) : ImageView(context
1344
1410
  ?: style.opt(name)?.takeUnless { it == JSONObject.NULL }
1345
1411
  val fit = value("objectFit")
1346
1412
  val position = value("objectPosition")
1347
- objectFit = if (fit != null || position != null) (fit as? String)
1413
+ // A position does not choose a fit, as in CSS. STX compiles
1414
+ // `object-cover` to `resizeMode: "cover"` and sets no objectFit, so a
1415
+ // cover image given a position was drawn as "fill": stretched to its
1416
+ // box instead of cropped (#532).
1417
+ val requested = if (fit != null) fit as? String else when (
1418
+ craftNativeOptionalString(style.opt("resizeMode")) ?: craftNativeOptionalString(props.opt("resizeMode"))
1419
+ ) {
1420
+ "cover" -> "cover"
1421
+ "contain" -> "contain"
1422
+ "center" -> "none"
1423
+ else -> "fill"
1424
+ }
1425
+ objectFit = if (fit != null || position != null) requested
1348
1426
  ?.takeIf { it in setOf("fill", "contain", "cover", "none", "scale-down") } ?: "fill" else null
1349
1427
  val point = parsePosition(position)
1350
1428
  objectX = point.first; objectY = point.second
@@ -3067,6 +3145,22 @@ internal class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(cont
3067
3145
  }
3068
3146
  }
3069
3147
 
3148
+ /**
3149
+ * How far the grouped tab actions reach over the end of this scroll view,
3150
+ * in pixels (#523). Its content gets that much room at the end, so the
3151
+ * last of it can be brought clear of them; the rest still scrolls behind
3152
+ * them, in the viewport it had.
3153
+ */
3154
+ fun setFloatingCover(cover: Int) {
3155
+ val next = if (horizontalMode == true) 0 else max(0, cover)
3156
+ if (vertical.paddingBottom == next) return
3157
+ vertical.clipToPadding = false
3158
+ vertical.setPadding(vertical.paddingLeft, vertical.paddingTop, vertical.paddingRight, next)
3159
+ }
3160
+
3161
+ /** Whether this scrolls down the screen: what is inside it is brought clear of the actions by it. */
3162
+ val scrollsVertically: Boolean get() = horizontalMode != true
3163
+
3070
3164
  private var bounces = true
3071
3165
  private var alwaysBounceVertical = true
3072
3166
  private var alwaysBounceHorizontal = true
@@ -3585,6 +3679,17 @@ private class CraftNativeTextField(context: Context) : EditText(context) {
3585
3679
  CraftNativePointers.dispatch(this, event) { super.dispatchTouchEvent(it) }
3586
3680
 
3587
3681
  companion object { val CLEAR_ACTION_ID = View.generateViewId() }
3682
+ /**
3683
+ * `autoFocus` as this field was last drawn. Focus is taken when it turns
3684
+ * on, which mounting with it on is, and not on each render while it stays
3685
+ * on: that took the keyboard back whenever anything else changed (#511).
3686
+ */
3687
+ private var autoFocus = false
3688
+ fun autoFocusTurnedOn(wanted: Boolean): Boolean {
3689
+ val turnedOn = wanted && !autoFocus
3690
+ autoFocus = wanted
3691
+ return turnedOn
3692
+ }
3588
3693
  var clearButtonMode = "never"
3589
3694
  var onClearRequest: (() -> Unit)? = null
3590
3695
  private var clearPressed = false
@@ -5321,7 +5426,8 @@ class MainActivity : AppCompatActivity() {
5321
5426
  private fun installChrome() {
5322
5427
  val bodyFont = craftNativeChromeTypeface(routes.fontFamily, bundledFonts)
5323
5428
  val bars = CraftNativeChrome(this, routes.tabs, routes.tabBarAppearance,
5324
- CraftNativeChromeFonts(bodyFont, craftNativeChromeTypeface(routes.headingFontFamily, bundledFonts) ?: bodyFont))
5429
+ CraftNativeChromeFonts(bodyFont, craftNativeChromeTypeface(routes.headingFontFamily, bundledFonts) ?: bodyFont),
5430
+ routes.tintColor)
5325
5431
  bars.layoutParams = FrameLayout.LayoutParams(-1, -1)
5326
5432
  bars.onBack = { onBackPressedDispatcherCompat() }
5327
5433
  bars.onAccessory = { path -> navigateOpen(path) }
@@ -6802,7 +6908,7 @@ class MainActivity : AppCompatActivity() {
6802
6908
  send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject().put("text", text.toString())))
6803
6909
  true
6804
6910
  }
6805
- if (craftNativeBoolean(props.opt("autoFocus")) && !isFocused) post {
6911
+ if (autoFocusTurnedOn(craftNativeBoolean(props.opt("autoFocus"))) && !isFocused) post {
6806
6912
  if (!isFocused) {
6807
6913
  requestFocus()
6808
6914
  (getSystemService(INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager)
@@ -7069,9 +7175,17 @@ class MainActivity : AppCompatActivity() {
7069
7175
  }
7070
7176
  }
7071
7177
  "Slider" -> (previous as? SeekBar ?: CraftNativePointerSeekBar(this)).apply {
7072
- val minimum = craftNativeFiniteNumber(props, "minimumValue", 0.0)
7073
- val maximumCandidate = craftNativeFiniteNumber(props, "maximumValue", 1.0)
7074
- val maximum = if (maximumCandidate > minimum) maximumCandidate else minimum + Double.MIN_VALUE
7178
+ // Held to a quarter of the largest Double, so the range, and
7179
+ // a value's distance along it, are numbers: at the largest
7180
+ // their difference is infinite, and infinity over infinity
7181
+ // is not a number, which `roundToInt` throws on and JSON
7182
+ // will not carry back to the screen.
7183
+ val bound = Double.MAX_VALUE / 4
7184
+ val minimum = craftNativeFiniteNumber(props, "minimumValue", 0.0).coerceIn(-bound, bound)
7185
+ val maximumCandidate = craftNativeFiniteNumber(props, "maximumValue", 1.0).coerceIn(-bound, bound)
7186
+ // The next number up, whatever the size of the first: adding
7187
+ // the smallest Double to anything but zero adds nothing.
7188
+ val maximum = if (maximumCandidate > minimum) maximumCandidate else Math.nextUp(minimum)
7075
7189
  setOnSeekBarChangeListener(null)
7076
7190
  val step = craftNativeFiniteNumber(props, "step", 0.0).takeIf { it > 0 }
7077
7191
  // A stepped slider needs one native position per authored
@@ -7087,7 +7201,7 @@ class MainActivity : AppCompatActivity() {
7087
7201
  .toInt()
7088
7202
  } ?: 1000
7089
7203
  max = progressMax
7090
- val requested = craftNativeFiniteNumber(props, "value", minimum)
7204
+ val requested = craftNativeFiniteNumber(props, "value", minimum).coerceIn(minimum, maximum)
7091
7205
  val value = (if (step != null) minimum + ((requested - minimum) / step).roundToInt() * step else requested)
7092
7206
  .coerceIn(minimum, maximum)
7093
7207
  progress = if (step != null) {
@@ -7135,7 +7249,7 @@ class MainActivity : AppCompatActivity() {
7135
7249
  val scale = when (requestedSize) {
7136
7250
  "large" -> 1.35f
7137
7251
  "small" -> 0.75f
7138
- is Number -> (requestedSize.toDouble() / 24.0).takeIf { it.isFinite() }?.toFloat()?.coerceAtLeast(0.5f) ?: 1f
7252
+ is Number -> (requestedSize.toDouble() / 24.0).takeIf { it.isFinite() }?.let { craftNativeLayoutNumber(it).coerceAtLeast(0.5f) } ?: 1f
7139
7253
  else -> 1f
7140
7254
  }
7141
7255
  indicatorScale = scale
@@ -7417,9 +7531,17 @@ class MainActivity : AppCompatActivity() {
7417
7531
  screen.pressUnderlayShowHandlers[view]?.let { current ->
7418
7532
  send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
7419
7533
  }
7534
+ // This listener answers every event, so the
7535
+ // platform never times the hold: do it here.
7536
+ CraftNativePressHolds.began(view, event)
7537
+ true
7538
+ }
7539
+ android.view.MotionEvent.ACTION_MOVE -> {
7540
+ CraftNativePressHolds.moved(view, event)
7420
7541
  true
7421
7542
  }
7422
7543
  android.view.MotionEvent.ACTION_UP, android.view.MotionEvent.ACTION_CANCEL -> {
7544
+ val held = CraftNativePressHolds.ended(view)
7423
7545
  view.alpha = screen.pressBaseOpacities[view] ?: 1f
7424
7546
  if (screen.pressBaseBackgrounds.containsKey(view)) view.background = screen.pressBaseBackgrounds[view]
7425
7547
  screen.pressOutHandlers[view]?.let { current ->
@@ -7428,7 +7550,9 @@ class MainActivity : AppCompatActivity() {
7428
7550
  screen.pressUnderlayHideHandlers[view]?.let { current ->
7429
7551
  send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
7430
7552
  }
7431
- if (event.actionMasked == android.view.MotionEvent.ACTION_UP) {
7553
+ // A hold that sent its long press, or opened
7554
+ // its menu, is not a press as well.
7555
+ if (event.actionMasked == android.view.MotionEvent.ACTION_UP && !held) {
7432
7556
  dispatchNativePress(screen, view)
7433
7557
  }
7434
7558
  true
@@ -7940,37 +8064,46 @@ class MainActivity : AppCompatActivity() {
7940
8064
  }
7941
8065
 
7942
8066
  private fun applyTransform(view: View, transforms: JSONArray?) {
7943
- view.translationX = 0f
7944
- view.translationY = 0f
7945
8067
  // Reconciliation reapplies transforms; start from the control's
7946
8068
  // intrinsic scale rather than erasing its authored indicator size.
7947
8069
  val intrinsicScale = (view as? CraftNativeActivityIndicator)?.indicatorScale ?: 1f
7948
- view.scaleX = intrinsicScale
7949
- view.scaleY = intrinsicScale
7950
- view.rotation = 0f
7951
- view.rotationX = 0f
7952
- view.rotationY = 0f
7953
- CraftNativeSkewTransforms.clear(view)
7954
- if (transforms == null) return
8070
+ var translationX = 0f
8071
+ var translationY = 0f
8072
+ var scaleX = intrinsicScale
8073
+ var scaleY = intrinsicScale
8074
+ var rotation = 0f
8075
+ var rotationX = 0f
8076
+ var rotationY = 0f
7955
8077
  var skewX = 0f
7956
8078
  var skewY = 0f
7957
- for (index in 0 until transforms.length()) {
7958
- val transform = transforms.optJSONObject(index) ?: continue
8079
+ for (index in 0 until (transforms?.length() ?: 0)) {
8080
+ val transform = transforms?.optJSONObject(index) ?: continue
7959
8081
  val key = transform.keys().asSequence().firstOrNull() ?: continue
7960
8082
  when (key) {
7961
- "translateX" -> view.translationX += dp(craftNativeFiniteDouble(transform, key, 0.0)).toFloat()
7962
- "translateY" -> view.translationY += dp(craftNativeFiniteDouble(transform, key, 0.0)).toFloat()
7963
- "scale" -> craftNativeFiniteDouble(transform, key, 1.0).toFloat().let { view.scaleX *= it; view.scaleY *= it }
7964
- "scaleX" -> view.scaleX *= craftNativeFiniteDouble(transform, key, 1.0).toFloat()
7965
- "scaleY" -> view.scaleY *= craftNativeFiniteDouble(transform, key, 1.0).toFloat()
7966
- "rotate", "rotateZ" -> view.rotation += angleDegrees(transform.opt(key))
7967
- "rotateX" -> view.rotationX += angleDegrees(transform.opt(key))
7968
- "rotateY" -> view.rotationY += angleDegrees(transform.opt(key))
8083
+ "translateX" -> translationX += dp(craftNativeFiniteDouble(transform, key, 0.0)).toFloat()
8084
+ "translateY" -> translationY += dp(craftNativeFiniteDouble(transform, key, 0.0)).toFloat()
8085
+ "scale" -> craftNativeFiniteDouble(transform, key, 1.0).toFloat().let { scaleX *= it; scaleY *= it }
8086
+ "scaleX" -> scaleX *= craftNativeFiniteDouble(transform, key, 1.0).toFloat()
8087
+ "scaleY" -> scaleY *= craftNativeFiniteDouble(transform, key, 1.0).toFloat()
8088
+ "rotate", "rotateZ" -> rotation += angleDegrees(transform.opt(key))
8089
+ "rotateX" -> rotationX += angleDegrees(transform.opt(key))
8090
+ "rotateY" -> rotationY += angleDegrees(transform.opt(key))
7969
8091
  "skewX" -> skewX += skewFactor(transform.opt(key))
7970
8092
  "skewY" -> skewY += skewFactor(transform.opt(key))
7971
8093
  }
7972
8094
  }
7973
- CraftNativeSkewTransforms.set(view, skewX, skewY)
8095
+ // Summed and multiplied first, and bounded once: a view throws on a
8096
+ // scale or an offset that is infinite or not a number, and two finite
8097
+ // factors can make either.
8098
+ view.translationX = craftNativeLayoutNumber(translationX)
8099
+ view.translationY = craftNativeLayoutNumber(translationY)
8100
+ view.scaleX = craftNativeLayoutNumber(scaleX)
8101
+ view.scaleY = craftNativeLayoutNumber(scaleY)
8102
+ view.rotation = craftNativeLayoutNumber(rotation)
8103
+ view.rotationX = craftNativeLayoutNumber(rotationX)
8104
+ view.rotationY = craftNativeLayoutNumber(rotationY)
8105
+ CraftNativeSkewTransforms.clear(view)
8106
+ if (transforms != null) CraftNativeSkewTransforms.set(view, craftNativeLayoutNumber(skewX), craftNativeLayoutNumber(skewY))
7974
8107
  }
7975
8108
 
7976
8109
  private fun skewFactor(value: Any?): Float =