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.
- package/dist/android/src/index.d.ts +6 -0
- package/dist/android/src/index.js +2 -0
- package/dist/android/templates/CraftNativeFlatList.kt.template +59 -4
- package/dist/android/templates/CraftNativeShell.kt.template +110 -2
- package/dist/android/templates/MainActivityNative.kt.template +180 -47
- package/dist/api/process.d.ts +23 -28
- package/dist/bridge/core.d.ts +30 -6
- package/dist/cli.js +39 -19
- package/dist/components/index.d.ts +1 -1
- package/dist/index.cjs +90 -99
- package/dist/index.js +90 -99
- package/dist/ios/templates/CraftApp.swift +17 -0
- package/dist/ios/templates/CraftHybrid.swift +16 -0
- package/dist/ios/templates/CraftNativeFiles.swift +152 -7
- package/dist/ios/templates/CraftNativeFlatList.swift +43 -6
- package/dist/ios/templates/CraftNativeScreen.swift +286 -86
- package/dist/ios/templates/CraftScheduledWorkouts.swift +11 -4
- package/dist/ios/templates/CraftWebAudio.swift +20 -1
- package/dist/package.d.ts +22 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
370
|
-
if (value is Number) return
|
|
371
|
-
val
|
|
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
|
-
|
|
376
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
7073
|
-
|
|
7074
|
-
|
|
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() }?.
|
|
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
|
-
|
|
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
|
-
|
|
7949
|
-
|
|
7950
|
-
|
|
7951
|
-
|
|
7952
|
-
|
|
7953
|
-
|
|
7954
|
-
|
|
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
|
|
7958
|
-
val transform = transforms
|
|
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" ->
|
|
7962
|
-
"translateY" ->
|
|
7963
|
-
"scale" -> craftNativeFiniteDouble(transform, key, 1.0).toFloat().let {
|
|
7964
|
-
"scaleX" ->
|
|
7965
|
-
"scaleY" ->
|
|
7966
|
-
"rotate", "rotateZ" ->
|
|
7967
|
-
"rotateX" ->
|
|
7968
|
-
"rotateY" ->
|
|
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
|
-
|
|
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 =
|