craft-native 0.0.123 → 0.0.124

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.
@@ -3,11 +3,14 @@ package {{PACKAGE_NAME}}
3
3
  import android.content.ClipData
4
4
  import android.content.ClipboardManager
5
5
  import android.content.Context
6
+ import android.content.res.ColorStateList
7
+ import android.content.res.Configuration
6
8
  import android.content.Intent
7
9
  import android.graphics.BitmapFactory
8
10
  import android.graphics.Color
9
11
  import android.graphics.Paint
10
12
  import android.graphics.Typeface
13
+ import android.graphics.drawable.Drawable
11
14
  import android.graphics.drawable.GradientDrawable
12
15
  import android.net.Uri
13
16
  import android.os.Build
@@ -17,6 +20,8 @@ import android.os.Looper
17
20
  import android.os.VibrationEffect
18
21
  import android.os.Vibrator
19
22
  import android.text.Editable
23
+ import android.text.InputFilter
24
+ import android.text.TextUtils
20
25
  import android.text.TextWatcher
21
26
  import android.util.Log
22
27
  import android.util.Base64
@@ -25,6 +30,7 @@ import android.view.Gravity
25
30
  import android.view.KeyEvent
26
31
  import android.view.inputmethod.EditorInfo
27
32
  import android.text.InputType
33
+ import android.text.Layout
28
34
  import android.view.View
29
35
  import android.view.ViewGroup
30
36
  import android.widget.Button
@@ -34,10 +40,14 @@ import android.widget.HorizontalScrollView
34
40
  import android.widget.ImageView
35
41
  import android.widget.LinearLayout
36
42
  import android.widget.ScrollView
43
+ import android.widget.SeekBar
44
+ import android.widget.Switch
37
45
  import android.widget.TextView
38
46
  import androidx.appcompat.app.AppCompatActivity
47
+ import androidx.appcompat.app.AppCompatDelegate
39
48
  import androidx.core.content.ContextCompat
40
49
  import androidx.core.view.AccessibilityDelegateCompat
50
+ import androidx.core.view.WindowInsetsCompat
41
51
  import androidx.core.view.ViewCompat
42
52
  import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
43
53
  import androidx.javascriptengine.JavaScriptIsolate
@@ -55,6 +65,7 @@ import java.util.concurrent.Future
55
65
  import java.util.UUID
56
66
  import kotlin.math.max
57
67
  import kotlin.math.min
68
+ import kotlin.math.roundToInt
58
69
 
59
70
  private const val CRAFT_NATIVE_CAPABILITY_TIMEOUT_MS = 30_000L
60
71
 
@@ -71,30 +82,61 @@ private data class CraftNativeLayoutStyle(
71
82
  val bottom: Int? = null,
72
83
  val left: Int? = null,
73
84
  val alignSelf: String? = null,
85
+ val flexGrow: Double = 0.0,
86
+ val flexShrink: Double = 0.0,
87
+ val flexBasis: Int? = null,
74
88
  ) {
75
89
  companion object {
76
90
  fun from(raw: JSONObject, density: Float): CraftNativeLayoutStyle {
77
- fun number(name: String): Int? = if (raw.has(name)) (raw.optDouble(name) * density).toInt() else null
91
+ fun number(name: String): Int? {
92
+ if (!raw.has(name) || raw.isNull(name)) return null
93
+ return raw.optDouble(name, Double.NaN).takeUnless { it.isNaN() }?.let { (it * density).toInt() }
94
+ }
78
95
  return CraftNativeLayoutStyle(
79
96
  width = number("width"), height = number("height"),
80
97
  minWidth = number("minWidth"), maxWidth = number("maxWidth"),
81
98
  minHeight = number("minHeight"), maxHeight = number("maxHeight"),
82
- position = raw.optString("position", "relative"),
99
+ position = raw.opt("position")
100
+ ?.takeUnless { it == JSONObject.NULL }
101
+ ?.toString()
102
+ ?.takeIf { it.isNotBlank() }
103
+ ?: "relative",
83
104
  top = number("top"), right = number("right"), bottom = number("bottom"), left = number("left"),
84
- alignSelf = raw.optString("alignSelf").takeIf { it.isNotBlank() },
105
+ alignSelf = raw.opt("alignSelf")
106
+ ?.takeUnless { it == JSONObject.NULL }
107
+ ?.toString()
108
+ ?.takeIf { it.isNotBlank() },
109
+ flexGrow = raw.optDouble("flexGrow", raw.optDouble("flex", 0.0)),
110
+ flexShrink = raw.optDouble("flexShrink", 0.0),
111
+ flexBasis = number("flexBasis"),
85
112
  )
86
113
  }
87
114
  }
88
115
  }
89
116
 
117
+ private fun craftNativeGridColumnCount(value: Any?): Int {
118
+ if (value is Number) return max(1, value.toInt())
119
+ val text = value as? String ?: return 1
120
+ val trimmed = text.trim()
121
+ if (trimmed.startsWith("repeat(")) {
122
+ val count = trimmed.removePrefix("repeat(").takeWhile { it.isDigit() }.toIntOrNull()
123
+ if (count != null) return max(1, count)
124
+ }
125
+ return max(1, trimmed.split(Regex("\\s+")).count { it.isNotBlank() })
126
+ }
127
+
90
128
  /** A deterministic flex-shaped container shared by normal views and scroll content. */
91
129
  private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
130
+ var onLayoutChanged: (() -> Unit)? = null
92
131
  private val styles = mutableMapOf<View, CraftNativeLayoutStyle>()
93
132
  private var wrap = false
94
133
  private var alignItems = "stretch"
95
134
  private var justifyContent = "flex-start"
96
135
  private var rowGap = 0
97
136
  private var columnGap = 0
137
+ private var grid = false
138
+ private var gridColumns = 1
139
+ private var gridAutoRows: Int? = null
98
140
 
99
141
  fun configure(style: JSONObject, density: Float) {
100
142
  orientation = if (style.optString("flexDirection") in setOf("row", "row-reverse")) HORIZONTAL else VERTICAL
@@ -104,6 +146,11 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
104
146
  val gap = (style.optDouble("gap", 0.0) * density).toInt()
105
147
  rowGap = (style.optDouble("rowGap", gap.toDouble() / density.toDouble()) * density).toInt()
106
148
  columnGap = (style.optDouble("columnGap", gap.toDouble() / density.toDouble()) * density).toInt()
149
+ grid = style.optString("display") == "grid"
150
+ gridColumns = craftNativeGridColumnCount(
151
+ if (style.has("gridTemplateColumns")) style.opt("gridTemplateColumns") else style.opt("gridColumns")
152
+ )
153
+ gridAutoRows = style.optDouble("gridAutoRows", Double.NaN).takeUnless { it.isNaN() }?.let { (it * density).toInt() }
107
154
  val padding = (style.optDouble("padding", 0.0) * density).toInt()
108
155
  val horizontal = (style.optDouble("paddingHorizontal", padding.toDouble() / density.toDouble()) * density).toInt()
109
156
  val vertical = (style.optDouble("paddingVertical", padding.toDouble() / density.toDouble()) * density).toInt()
@@ -128,6 +175,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
128
175
 
129
176
  private data class Entry(val view: View, val style: CraftNativeLayoutStyle, val width: Int, val height: Int)
130
177
  private data class Line(val entries: MutableList<Entry> = mutableListOf(), var main: Int = 0, var cross: Int = 0)
178
+ private data class GridLayout(val rows: List<List<Entry>>, val absolute: List<Entry>, val cellWidth: Int, val rowHeights: List<Int>)
131
179
 
132
180
  private fun entries(width: Int, height: Int): Pair<List<Line>, List<Entry>> {
133
181
  val availableMain = if (orientation == HORIZONTAL) max(0, width - paddingLeft - paddingRight)
@@ -162,8 +210,70 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
162
210
  return lines to absolute
163
211
  }
164
212
 
213
+ private fun gridLayout(width: Int, height: Int): GridLayout {
214
+ val availableWidth = max(0, width - paddingLeft - paddingRight)
215
+ val gap = columnGap
216
+ val cellWidth = max(0, (availableWidth - max(0, gridColumns - 1) * gap) / max(1, gridColumns))
217
+ val rows = mutableListOf<MutableList<Entry>>()
218
+ val absolute = mutableListOf<Entry>()
219
+ for (index in 0 until childCount) {
220
+ val child = getChildAt(index)
221
+ if (child.visibility == GONE) continue
222
+ val style = styles[child] ?: CraftNativeLayoutStyle()
223
+ val lp = child.layoutParams as? MarginLayoutParams
224
+ val horizontalMargins = (lp?.leftMargin ?: 0) + (lp?.rightMargin ?: 0)
225
+ val verticalMargins = (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0)
226
+ val childWidth = measureChildWidth(child, style, cellWidth, horizontalMargins)
227
+ val childHeight = measureChildHeight(child, style, height - paddingTop - paddingBottom, verticalMargins)
228
+ val entry = Entry(child, style, childWidth, childHeight)
229
+ if (style.position == "absolute") absolute += entry
230
+ else {
231
+ if (rows.isEmpty() || rows.last().size == gridColumns) rows.add(mutableListOf())
232
+ rows.last() += entry
233
+ }
234
+ }
235
+ val rowHeights = rows.map { row ->
236
+ max(gridAutoRows ?: 0, row.maxOfOrNull { entry ->
237
+ val lp = entry.view.layoutParams as? MarginLayoutParams
238
+ entry.height + (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0)
239
+ } ?: 0)
240
+ }
241
+ return GridLayout(rows, absolute, cellWidth, rowHeights)
242
+ }
243
+
244
+ private fun gridIntrinsicSize(height: Int): Pair<Int, Int> {
245
+ val columns = max(1, gridColumns)
246
+ val columnWidths = IntArray(columns)
247
+ val rowHeights = mutableListOf<Int>()
248
+ var flowIndex = 0
249
+ for (index in 0 until childCount) {
250
+ val child = getChildAt(index)
251
+ if (child.visibility == GONE) continue
252
+ val style = styles[child] ?: CraftNativeLayoutStyle()
253
+ if (style.position == "absolute") continue
254
+ val lp = child.layoutParams as? MarginLayoutParams
255
+ val horizontalMargins = (lp?.leftMargin ?: 0) + (lp?.rightMargin ?: 0)
256
+ val verticalMargins = (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0)
257
+ val childWidth = measureChildWidth(child, style, 1_000_000, horizontalMargins)
258
+ val childHeight = measureChildHeight(child, style, height, verticalMargins)
259
+ val column = flowIndex % columns
260
+ val row = flowIndex / columns
261
+ while (rowHeights.size <= row) rowHeights += gridAutoRows ?: 0
262
+ columnWidths[column] = max(columnWidths[column], childWidth + horizontalMargins)
263
+ rowHeights[row] = max(rowHeights[row], childHeight + verticalMargins)
264
+ flowIndex += 1
265
+ }
266
+ val cellWidth = columnWidths.maxOrNull() ?: 0
267
+ val width = paddingLeft + paddingRight + cellWidth * columns + max(0, columns - 1) * columnGap
268
+ val rowGapTotal = max(0, rowHeights.size - 1) * rowGap
269
+ val measuredHeight = paddingTop + paddingBottom + rowHeights.sum() + rowGapTotal
270
+ return width to measuredHeight
271
+ }
272
+
165
273
  private fun measureChildWidth(child: View, style: CraftNativeLayoutStyle, available: Int, margins: Int): Int {
166
- val explicit = style.width ?: (child.layoutParams?.width ?: ViewGroup.LayoutParams.WRAP_CONTENT).takeIf { it >= 0 }
274
+ val flexBasis = if (orientation == HORIZONTAL) style.flexBasis else null
275
+ val explicit = style.width ?: flexBasis
276
+ ?: (child.layoutParams?.width ?: ViewGroup.LayoutParams.WRAP_CONTENT).takeIf { it >= 0 }
167
277
  val target = explicit ?: max(0, available - margins)
168
278
  val spec = if (explicit != null) View.MeasureSpec.makeMeasureSpec(target, View.MeasureSpec.EXACTLY)
169
279
  else View.MeasureSpec.makeMeasureSpec(max(0, available - margins), View.MeasureSpec.AT_MOST)
@@ -172,7 +282,9 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
172
282
  }
173
283
 
174
284
  private fun measureChildHeight(child: View, style: CraftNativeLayoutStyle, available: Int, margins: Int): Int {
175
- val explicit = style.height ?: (child.layoutParams?.height ?: ViewGroup.LayoutParams.WRAP_CONTENT).takeIf { it >= 0 }
285
+ val flexBasis = if (orientation == VERTICAL) style.flexBasis else null
286
+ val explicit = style.height ?: flexBasis
287
+ ?: (child.layoutParams?.height ?: ViewGroup.LayoutParams.WRAP_CONTENT).takeIf { it >= 0 }
176
288
  val target = explicit ?: max(0, available - margins)
177
289
  val spec = if (explicit != null) View.MeasureSpec.makeMeasureSpec(target, View.MeasureSpec.EXACTLY)
178
290
  else View.MeasureSpec.makeMeasureSpec(max(0, available - margins), View.MeasureSpec.AT_MOST)
@@ -188,8 +300,24 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
188
300
  }
189
301
 
190
302
  override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
191
- val width = View.MeasureSpec.getSize(widthMeasureSpec).takeUnless { View.MeasureSpec.getMode(widthMeasureSpec) == View.MeasureSpec.UNSPECIFIED } ?: 1_000_000
303
+ val widthMode = View.MeasureSpec.getMode(widthMeasureSpec)
304
+ val width = View.MeasureSpec.getSize(widthMeasureSpec).takeUnless { widthMode == View.MeasureSpec.UNSPECIFIED } ?: 1_000_000
192
305
  val height = View.MeasureSpec.getSize(heightMeasureSpec).takeUnless { View.MeasureSpec.getMode(heightMeasureSpec) == View.MeasureSpec.UNSPECIFIED } ?: 1_000_000
306
+ if (grid) {
307
+ if (widthMode == View.MeasureSpec.UNSPECIFIED) {
308
+ val (intrinsicWidth, intrinsicHeight) = gridIntrinsicSize(height)
309
+ setMeasuredDimension(
310
+ resolveSize(intrinsicWidth, widthMeasureSpec),
311
+ resolveSize(intrinsicHeight, heightMeasureSpec),
312
+ )
313
+ return
314
+ }
315
+ val layout = gridLayout(width, height)
316
+ val desiredWidth = paddingLeft + paddingRight + layout.cellWidth * gridColumns + max(0, gridColumns - 1) * columnGap
317
+ val desiredHeight = paddingTop + paddingBottom + layout.rowHeights.sum() + max(0, layout.rowHeights.size - 1) * rowGap
318
+ setMeasuredDimension(resolveSize(desiredWidth, widthMeasureSpec), resolveSize(desiredHeight, heightMeasureSpec))
319
+ return
320
+ }
193
321
  val (lines, absolute) = entries(width, height)
194
322
  val main = lines.maxOfOrNull { it.main } ?: 0
195
323
  val cross = lines.sumOf { it.cross } + max(0, lines.size - 1) * (if (orientation == HORIZONTAL) rowGap else columnGap)
@@ -204,17 +332,77 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
204
332
  }
205
333
 
206
334
  override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
335
+ if (grid) {
336
+ val layout = gridLayout(width, height)
337
+ var rowTop = paddingTop
338
+ for ((rowIndex, row) in layout.rows.withIndex()) {
339
+ val rowHeight = layout.rowHeights[rowIndex]
340
+ for ((column, entry) in row.withIndex()) {
341
+ val align = entry.style.alignSelf ?: alignItems
342
+ val lp = entry.view.layoutParams as? MarginLayoutParams
343
+ val marginLeft = lp?.leftMargin ?: 0
344
+ val marginRight = lp?.rightMargin ?: 0
345
+ val marginTop = lp?.topMargin ?: 0
346
+ val marginBottom = lp?.bottomMargin ?: 0
347
+ val availableWidth = max(0, layout.cellWidth - marginLeft - marginRight)
348
+ val availableHeight = max(0, rowHeight - marginTop - marginBottom)
349
+ val childWidth = if (align == "stretch" && entry.style.width == null) availableWidth else min(availableWidth, entry.width)
350
+ val childHeight = if (align == "stretch" && entry.style.height == null) availableHeight else min(availableHeight, entry.height)
351
+ val x = paddingLeft + column * (layout.cellWidth + columnGap) + marginLeft + when (align) {
352
+ "center" -> (availableWidth - childWidth) / 2
353
+ "flex-end" -> availableWidth - childWidth
354
+ else -> 0
355
+ }
356
+ val y = rowTop + marginTop + when (align) {
357
+ "center" -> (availableHeight - childHeight) / 2
358
+ "flex-end" -> availableHeight - childHeight
359
+ else -> 0
360
+ }
361
+ entry.view.layout(x, y, x + childWidth, y + childHeight)
362
+ }
363
+ rowTop += rowHeight + rowGap
364
+ }
365
+ val contentWidth = width - paddingLeft - paddingRight
366
+ val contentHeight = height - paddingTop - paddingBottom
367
+ for (entry in layout.absolute) {
368
+ val childWidth = if (entry.style.left != null && entry.style.right != null) contentWidth - entry.style.left!! - entry.style.right!! else entry.width
369
+ val childHeight = if (entry.style.top != null && entry.style.bottom != null) contentHeight - entry.style.top!! - entry.style.bottom!! else entry.height
370
+ val x = paddingLeft + (entry.style.left ?: (entry.style.right?.let { contentWidth - it - childWidth } ?: 0))
371
+ val y = paddingTop + (entry.style.top ?: (entry.style.bottom?.let { contentHeight - it - childHeight } ?: 0))
372
+ entry.view.layout(x, y, x + max(0, childWidth), y + max(0, childHeight))
373
+ }
374
+ onLayoutChanged?.invoke()
375
+ return
376
+ }
207
377
  val contentWidth = width - paddingLeft - paddingRight
208
378
  val contentHeight = height - paddingTop - paddingBottom
209
379
  val (lines, absolute) = entries(width, height)
210
380
  val gap = if (orientation == HORIZONTAL) columnGap else rowGap
381
+ val crossGap = if (orientation == HORIZONTAL) rowGap else columnGap
382
+ val availableMain = if (orientation == HORIZONTAL) contentWidth else contentHeight
211
383
  var crossOffset = if (orientation == HORIZONTAL) paddingTop else paddingLeft
212
384
  for (line in lines) {
213
- val free = max(0, (if (orientation == HORIZONTAL) contentWidth else contentHeight) - line.main)
385
+ val rawFree = availableMain - line.main
386
+ val factors = line.entries.map { if (rawFree >= 0) it.style.flexGrow else it.style.flexShrink }
387
+ val totalFactor = factors.sum()
388
+ val distributedMain = line.entries.indices.sumOf { index ->
389
+ val base = if (orientation == HORIZONTAL) line.entries[index].width else line.entries[index].height
390
+ val primary = if (totalFactor > 0) max(0, (base + rawFree * factors[index] / totalFactor).toInt()) else base
391
+ val constrained = if (orientation == HORIZONTAL) {
392
+ clamp(primary, line.entries[index].style.minWidth, line.entries[index].style.maxWidth)
393
+ } else {
394
+ clamp(primary, line.entries[index].style.minHeight, line.entries[index].style.maxHeight)
395
+ }
396
+ val lp = line.entries[index].view.layoutParams as? MarginLayoutParams
397
+ val margins = if (orientation == HORIZONTAL) (lp?.leftMargin ?: 0) + (lp?.rightMargin ?: 0)
398
+ else (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0)
399
+ constrained + margins
400
+ } + max(0, line.entries.size - 1) * gap
401
+ val free = max(0, availableMain - distributedMain)
214
402
  val (leading, between) = distribution(free, line.entries.size)
215
403
  var mainOffset = if (orientation == HORIZONTAL) paddingLeft else paddingTop
216
404
  mainOffset += leading
217
- for (entry in line.entries) {
405
+ for ((index, entry) in line.entries.withIndex()) {
218
406
  val lp = entry.view.layoutParams as? MarginLayoutParams
219
407
  val marginStart = if (orientation == HORIZONTAL) lp?.leftMargin ?: 0 else lp?.topMargin ?: 0
220
408
  val marginEnd = if (orientation == HORIZONTAL) lp?.rightMargin ?: 0 else lp?.bottomMargin ?: 0
@@ -222,19 +410,29 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
222
410
  max(0, line.cross - (if (orientation == HORIZONTAL) (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0) else (lp?.leftMargin ?: 0) + (lp?.rightMargin ?: 0)))
223
411
  else if (orientation == HORIZONTAL) entry.height else entry.width
224
412
  val align = entry.style.alignSelf ?: alignItems
413
+ val crossLeading = if (orientation == HORIZONTAL) lp?.topMargin ?: 0 else lp?.leftMargin ?: 0
414
+ val crossTrailing = if (orientation == HORIZONTAL) lp?.bottomMargin ?: 0 else lp?.rightMargin ?: 0
415
+ val crossAvailable = max(0, line.cross - crossLeading - crossTrailing)
225
416
  val crossPosition = when (align) {
226
- "center" -> (line.cross - crossSize) / 2
227
- "flex-end" -> line.cross - crossSize
228
- else -> 0
417
+ "center" -> crossLeading + (crossAvailable - crossSize) / 2
418
+ "flex-end" -> line.cross - crossTrailing - crossSize
419
+ else -> crossLeading
420
+ }
421
+ val x = if (orientation == HORIZONTAL) mainOffset + marginStart else crossOffset + crossPosition
422
+ val y = if (orientation == HORIZONTAL) crossOffset + crossPosition else mainOffset + marginStart
423
+ val primary = if (orientation == HORIZONTAL) entry.width else entry.height
424
+ val distributed = if (totalFactor > 0) max(0, (primary + rawFree * factors[index] / totalFactor).toInt()) else primary
425
+ val distributedPrimary = if (orientation == HORIZONTAL) {
426
+ clamp(distributed, entry.style.minWidth, entry.style.maxWidth)
427
+ } else {
428
+ clamp(distributed, entry.style.minHeight, entry.style.maxHeight)
229
429
  }
230
- val x = if (orientation == HORIZONTAL) mainOffset + marginStart else crossOffset + crossPosition + (lp?.leftMargin ?: 0)
231
- val y = if (orientation == HORIZONTAL) crossOffset + crossPosition + (lp?.topMargin ?: 0) else mainOffset + marginStart
232
- val w = if (orientation == HORIZONTAL) entry.width else crossSize
233
- val h = if (orientation == HORIZONTAL) crossSize else entry.height
430
+ val w = if (orientation == HORIZONTAL) distributedPrimary else crossSize
431
+ val h = if (orientation == HORIZONTAL) crossSize else distributedPrimary
234
432
  entry.view.layout(x, y, x + w, y + h)
235
- mainOffset += (if (orientation == HORIZONTAL) entry.width else entry.height) + marginStart + marginEnd + gap + between
433
+ mainOffset += distributedPrimary + marginStart + marginEnd + gap + between
236
434
  }
237
- crossOffset += line.cross + gap
435
+ crossOffset += line.cross + crossGap
238
436
  }
239
437
  for (entry in absolute) {
240
438
  val widthValue = if (entry.style.left != null && entry.style.right != null) contentWidth - entry.style.left!! - entry.style.right!! else entry.width
@@ -243,6 +441,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
243
441
  val y = paddingTop + (entry.style.top ?: (entry.style.bottom?.let { contentHeight - it - heightValue } ?: 0))
244
442
  entry.view.layout(x, y, x + max(0, widthValue), y + max(0, heightValue))
245
443
  }
444
+ onLayoutChanged?.invoke()
246
445
  }
247
446
 
248
447
  private fun distribution(free: Int, count: Int): Pair<Int, Int> = when (justifyContent) {
@@ -260,6 +459,10 @@ private class CraftNativeScrollView(context: Context) : FrameLayout(context) {
260
459
  private val vertical = ScrollView(context)
261
460
  private val horizontal = HorizontalScrollView(context)
262
461
  private var horizontalMode: Boolean? = null
462
+ private var pagingEnabled = false
463
+ private var bounces = true
464
+ private var alwaysBounceVertical = true
465
+ private var alwaysBounceHorizontal = true
263
466
 
264
467
  init {
265
468
  vertical.isNestedScrollingEnabled = true
@@ -293,6 +496,39 @@ private class CraftNativeScrollView(context: Context) : FrameLayout(context) {
293
496
  horizontal.isEnabled = value
294
497
  }
295
498
 
499
+ fun setBounces(value: Boolean) {
500
+ bounces = value
501
+ applyBounceModes()
502
+ }
503
+
504
+ fun setAlwaysBounce(vertical: Boolean, horizontal: Boolean) {
505
+ alwaysBounceVertical = vertical
506
+ alwaysBounceHorizontal = horizontal
507
+ applyBounceModes()
508
+ }
509
+
510
+ private fun applyBounceModes() {
511
+ vertical.overScrollMode = if (bounces && alwaysBounceVertical) View.OVER_SCROLL_ALWAYS else View.OVER_SCROLL_NEVER
512
+ horizontal.overScrollMode = if (bounces && alwaysBounceHorizontal) View.OVER_SCROLL_ALWAYS else View.OVER_SCROLL_NEVER
513
+ }
514
+
515
+ fun setPagingEnabled(value: Boolean) {
516
+ pagingEnabled = value
517
+ }
518
+
519
+ private fun snapToPage(scroller: View) {
520
+ val horizontal = horizontalMode == true
521
+ val pageSize = if (horizontal) scroller.width else scroller.height
522
+ if (pageSize <= 0) return
523
+ val offset = if (horizontal) scroller.scrollX else scroller.scrollY
524
+ val target = (offset.toFloat() / pageSize).roundToInt() * pageSize
525
+ if (horizontal) {
526
+ (scroller as HorizontalScrollView).smoothScrollTo(target, 0)
527
+ } else {
528
+ (scroller as ScrollView).smoothScrollTo(0, target)
529
+ }
530
+ }
531
+
296
532
  fun setScrollIndicators(verticalVisible: Boolean, horizontalVisible: Boolean) {
297
533
  vertical.isVerticalScrollBarEnabled = verticalVisible
298
534
  horizontal.isHorizontalScrollBarEnabled = horizontalVisible
@@ -302,13 +538,62 @@ private class CraftNativeScrollView(context: Context) : FrameLayout(context) {
302
538
  onScroll: ((Int, Int) -> Unit)?,
303
539
  onBeginDrag: (() -> Unit)?,
304
540
  onEndDrag: (() -> Unit)?,
541
+ onMomentumBegin: (() -> Unit)?,
542
+ onMomentumEnd: (() -> Unit)?,
543
+ keyboardDismissMode: String,
544
+ dismissKeyboard: (() -> Unit)?,
305
545
  ) {
306
546
  fun attach(scroller: View) {
547
+ var dismissed = false
548
+ var moved = false
549
+ var momentumActive = false
550
+ var lastX = 0
551
+ var lastY = 0
552
+
553
+ fun pollMomentum() {
554
+ if (!momentumActive) return
555
+ val x = scroller.scrollX
556
+ val y = scroller.scrollY
557
+ if (x == lastX && y == lastY) {
558
+ momentumActive = false
559
+ onMomentumEnd?.invoke()
560
+ } else {
561
+ lastX = x
562
+ lastY = y
563
+ scroller.postDelayed({ pollMomentum() }, 16L)
564
+ }
565
+ }
566
+
307
567
  scroller.setOnScrollChangeListener { _, x, y, _, _ -> onScroll?.invoke(x, y) }
308
568
  scroller.setOnTouchListener { _, event ->
309
569
  when (event.actionMasked) {
310
- android.view.MotionEvent.ACTION_DOWN -> onBeginDrag?.invoke()
311
- android.view.MotionEvent.ACTION_UP, android.view.MotionEvent.ACTION_CANCEL -> onEndDrag?.invoke()
570
+ android.view.MotionEvent.ACTION_DOWN -> {
571
+ if (momentumActive) {
572
+ momentumActive = false
573
+ onMomentumEnd?.invoke()
574
+ }
575
+ dismissed = false
576
+ moved = false
577
+ onBeginDrag?.invoke()
578
+ }
579
+ android.view.MotionEvent.ACTION_MOVE -> {
580
+ moved = true
581
+ if (!dismissed && keyboardDismissMode in setOf("on-drag", "interactive")) {
582
+ dismissed = true
583
+ dismissKeyboard?.invoke()
584
+ }
585
+ }
586
+ android.view.MotionEvent.ACTION_UP, android.view.MotionEvent.ACTION_CANCEL -> {
587
+ onEndDrag?.invoke()
588
+ if (pagingEnabled) snapToPage(scroller)
589
+ if (moved && !momentumActive) {
590
+ momentumActive = true
591
+ lastX = scroller.scrollX
592
+ lastY = scroller.scrollY
593
+ onMomentumBegin?.invoke()
594
+ scroller.postDelayed({ pollMomentum() }, 16L)
595
+ }
596
+ }
312
597
  }
313
598
  false
314
599
  }
@@ -320,6 +605,13 @@ private class CraftNativeScrollView(context: Context) : FrameLayout(context) {
320
605
 
321
606
  private class CraftNativeFlexSpacer(context: Context) : View(context)
322
607
 
608
+ private data class LayoutBounds(
609
+ val x: Double,
610
+ val y: Double,
611
+ val width: Double,
612
+ val height: Double,
613
+ )
614
+
323
615
  /** WebView-free host for a compiled stx-native route bundle. */
324
616
  class MainActivity : AppCompatActivity() {
325
617
  private class Screen(
@@ -330,14 +622,28 @@ class MainActivity : AppCompatActivity() {
330
622
  val port: MessagePort?
331
623
  ) {
332
624
  val drafts = mutableMapOf<String, String>()
625
+ val inputIdentities = mutableMapOf<EditText, String>()
626
+ val suppressedTextChanges = mutableSetOf<EditText>()
333
627
  val controls = mutableMapOf<String, View>()
334
628
  val handlers = mutableMapOf<View, String>()
629
+ val longPressHandlers = mutableMapOf<View, String>()
335
630
  val scrollHandlers = mutableMapOf<View, String>()
336
631
  val scrollBeginHandlers = mutableMapOf<View, String>()
337
632
  val scrollEndHandlers = mutableMapOf<View, String>()
633
+ val scrollMomentumBeginHandlers = mutableMapOf<View, String>()
634
+ val scrollMomentumEndHandlers = mutableMapOf<View, String>()
635
+ val sliderCompleteHandlers = mutableMapOf<View, String>()
636
+ val sliderSteps = mutableMapOf<View, Double>()
338
637
  val focusHandlers = mutableMapOf<View, String>()
339
638
  val blurHandlers = mutableMapOf<View, String>()
639
+ val endEditingHandlers = mutableMapOf<View, String>()
340
640
  val submitHandlers = mutableMapOf<View, String>()
641
+ val imageLoadStartHandlers = mutableMapOf<ImageView, String>()
642
+ val imageLoadHandlers = mutableMapOf<ImageView, String>()
643
+ val imageLoadEndHandlers = mutableMapOf<ImageView, String>()
644
+ val imageErrorHandlers = mutableMapOf<ImageView, String>()
645
+ val layoutHandlers = mutableMapOf<View, String>()
646
+ val lastLayoutBounds = mutableMapOf<View, LayoutBounds>()
341
647
  val flatListRows = mutableMapOf<CraftNativeFlatList, MutableMap<String, MutableMap<String, View>>>()
342
648
  val flatListOwners = mutableMapOf<String, String>()
343
649
  val flatListOwnerByView = mutableMapOf<CraftNativeFlatList, String>()
@@ -374,14 +680,24 @@ class MainActivity : AppCompatActivity() {
374
680
  private val imageSources = mutableMapOf<ImageView, String>()
375
681
  private val imageJobs = mutableMapOf<ImageView, Future<*>>()
376
682
  private val imageErrors = mutableMapOf<ImageView, String>()
683
+ private val inputHintDefaults = mutableMapOf<EditText, ColorStateList>()
684
+ private val inputHighlightDefaults = mutableMapOf<EditText, Int>()
685
+ private val defaultBackgrounds = mutableMapOf<View, Drawable?>()
377
686
 
378
687
  override fun onCreate(savedInstanceState: Bundle?) {
688
+ applyConfiguredAppearance()
379
689
  super.onCreate(savedInstanceState)
380
690
  initialDeepLink = savedInstanceState?.getString("craft.initialDeepLink")?.let(Uri::parse) ?: intent?.data
381
691
  initialDeepLinkClaimed = savedInstanceState?.getBoolean("craft.initialDeepLinkClaimed", false) ?: false
382
692
  deliveredInitialDeepLink = savedInstanceState?.getBoolean("craft.deliveredInitialDeepLink", false) ?: false
383
693
  setContentView(R.layout.activity_main)
384
694
  root = findViewById(R.id.native_root)
695
+ ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets ->
696
+ val safe = insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout())
697
+ view.setPadding(safe.left, safe.top, safe.right, safe.bottom)
698
+ insets
699
+ }
700
+ ViewCompat.requestApplyInsets(root)
385
701
  config = runCatching {
386
702
  assets.open("craft.config.json").bufferedReader().use {
387
703
  JSONObject(it.readText())
@@ -390,9 +706,7 @@ class MainActivity : AppCompatActivity() {
390
706
  hapticsEnabled = config.optBoolean("enableHaptics", false)
391
707
  clipboardEnabled = config.optBoolean("enableClipboard", false)
392
708
  capabilities = CraftNativeCapabilities(this, config)
393
- val background = color(config.optString("backgroundColor", "#1a1a2e"), Color.BLACK)
394
- root.setBackgroundColor(background)
395
- defaultTextColor = if (Color.luminance(background) < 0.5) Color.WHITE else Color.BLACK
709
+ refreshThemeDefaults()
396
710
  bundle = runCatching {
397
711
  assets.open("native-screen.js").bufferedReader().use { it.readText() }
398
712
  }.getOrElse {
@@ -433,6 +747,23 @@ class MainActivity : AppCompatActivity() {
433
747
  }
434
748
  }
435
749
 
750
+ private fun applyConfiguredAppearance() {
751
+ val configured = runCatching {
752
+ assets.open("craft.config.json").bufferedReader().use { JSONObject(it.readText()) }
753
+ }.getOrNull()
754
+ val mode = when (configured?.optString("appearance")) {
755
+ "light" -> AppCompatDelegate.MODE_NIGHT_NO
756
+ "dark" -> AppCompatDelegate.MODE_NIGHT_YES
757
+ "system" -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
758
+ else -> if (configured?.optBoolean("darkMode", true) == false) {
759
+ AppCompatDelegate.MODE_NIGHT_NO
760
+ } else {
761
+ AppCompatDelegate.MODE_NIGHT_YES
762
+ }
763
+ }
764
+ AppCompatDelegate.setDefaultNightMode(mode)
765
+ }
766
+
436
767
  private fun attachSandbox(engine: JavaScriptSandbox) {
437
768
  useMessagePorts = engine.isFeatureSupported(JavaScriptSandbox.JS_FEATURE_MESSAGE_PORTS)
438
769
  && engine.isFeatureSupported(JavaScriptSandbox.JS_FEATURE_PROMISE_RETURN)
@@ -470,6 +801,9 @@ class MainActivity : AppCompatActivity() {
470
801
  null
471
802
  }
472
803
  val activeScreen = Screen(route, params, view, isolate, port)
804
+ activeScreen.view.onLayoutChanged = {
805
+ if (activeScreen.active) emitLayoutEvents(activeScreen)
806
+ }
473
807
  screen = activeScreen
474
808
  screens.add(activeScreen)
475
809
  show(activeScreen)
@@ -539,6 +873,7 @@ class MainActivity : AppCompatActivity() {
539
873
  root.removeAllViews()
540
874
  (screen.view.parent as? ViewGroup)?.removeView(screen.view)
541
875
  root.addView(screen.view)
876
+ refreshScreenTheme(screen)
542
877
  }
543
878
 
544
879
  override fun onSaveInstanceState(outState: Bundle) {
@@ -548,6 +883,37 @@ class MainActivity : AppCompatActivity() {
548
883
  super.onSaveInstanceState(outState)
549
884
  }
550
885
 
886
+ override fun onConfigurationChanged(newConfig: Configuration) {
887
+ super.onConfigurationChanged(newConfig)
888
+ if (!::root.isInitialized) return
889
+ refreshThemeDefaults()
890
+ ViewCompat.requestApplyInsets(root)
891
+ refreshInputDefaultsForTraitChange()
892
+ screens.lastOrNull()?.let { screen ->
893
+ refreshScreenTheme(screen)
894
+ }
895
+ }
896
+
897
+ private fun refreshScreenTheme(screen: Screen) {
898
+ screen.mutations.node("root")?.let { renderCommitted(screen, it) }
899
+ screen.controls.values.filterIsInstance<CraftNativeFlatList>().forEach { it.refreshThemeDefaults() }
900
+ }
901
+
902
+ private fun refreshThemeDefaults() {
903
+ val background = ContextCompat.getColor(this, R.color.background)
904
+ root.setBackgroundColor(background)
905
+ defaultTextColor = if (Color.luminance(background) < 0.5) Color.WHITE else Color.BLACK
906
+ }
907
+
908
+ private fun refreshInputDefaultsForTraitChange() {
909
+ if (inputHintDefaults.isEmpty() && inputHighlightDefaults.isEmpty()) return
910
+ val probe = EditText(this)
911
+ val hintColors = probe.hintTextColors
912
+ val highlightColor = probe.highlightColor
913
+ inputHintDefaults.keys.toList().forEach { inputHintDefaults[it] = hintColors }
914
+ inputHighlightDefaults.keys.toList().forEach { inputHighlightDefaults[it] = highlightColor }
915
+ }
916
+
551
917
  private fun receive(screen: Screen, raw: String) {
552
918
  val message = runCatching { JSONObject(raw) }.getOrNull() ?: return
553
919
  val payload = message.optJSONObject("payload") ?: JSONObject()
@@ -679,12 +1045,38 @@ class MainActivity : AppCompatActivity() {
679
1045
  ?: explicitKey?.let { "${path.substringBeforeLast('.', "")}/$it" }
680
1046
  ?: path
681
1047
  val previous = previousControls[identity]
1048
+ val reusableInput = previous as? EditText
682
1049
  val control = when (type) {
683
1050
  "Text" -> ((previous as? TextView)?.takeUnless { it is Button || it is EditText } ?: TextView(this)).apply {
1051
+ maxLines = props.optInt("numberOfLines", 0).takeIf { it > 0 } ?: Int.MAX_VALUE
1052
+ ellipsize = when (props.optString("ellipsizeMode")) {
1053
+ "head" -> TextUtils.TruncateAt.START
1054
+ "middle" -> TextUtils.TruncateAt.MIDDLE
1055
+ "tail" -> TextUtils.TruncateAt.END
1056
+ else -> null
1057
+ }
684
1058
  configureText(this, childText(children), style)
685
1059
  }
1060
+ "Link" -> ((previous as? TextView)?.takeUnless { it is Button } ?: TextView(this)).apply {
1061
+ configureText(this, props.optString("title").ifBlank { childText(children) }, style)
1062
+ val linkColor = props.optString("color").takeIf { it.isNotBlank() } ?: style.optString("color")
1063
+ setTextColor(color(linkColor, Color.rgb(33, 150, 243)))
1064
+ paintFlags = paintFlags or Paint.UNDERLINE_TEXT_FLAG
1065
+ isEnabled = !props.optBoolean("disabled", false)
1066
+ isClickable = true
1067
+ isFocusable = true
1068
+ val handler = events.optString("onPress").ifBlank { events.optString("onClick") }
1069
+ if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
1070
+ setOnClickListener {
1071
+ val current = screen.handlers[this] ?: return@setOnClickListener
1072
+ send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
1073
+ }
1074
+ }
686
1075
  "Button" -> (previous as? Button ?: Button(this)).apply {
687
- text = props.optString("title").ifBlank { childText(children) }
1076
+ val title = props.optString("title").ifBlank { childText(children) }
1077
+ configureText(this, title, style)
1078
+ props.optString("color").takeIf { it.isNotBlank() }?.let { setTextColor(color(it, defaultTextColor)) }
1079
+ isEnabled = !props.optBoolean("disabled", false)
688
1080
  val handler = events.optString("onPress").ifBlank { events.optString("onClick") }
689
1081
  if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
690
1082
  setOnClickListener {
@@ -692,26 +1084,62 @@ class MainActivity : AppCompatActivity() {
692
1084
  send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
693
1085
  }
694
1086
  }
695
- "TextInput" -> (previous as? EditText ?: EditText(this)).apply {
1087
+ "TextInput" -> (reusableInput?.takeIf { it.isSingleLine == !props.optBoolean("multiline", false) }
1088
+ ?: EditText(this)).apply {
1089
+ val defaultHintColors = inputHintDefaults.getOrPut(this) {
1090
+ hintTextColors ?: ColorStateList.valueOf(Color.GRAY)
1091
+ }
1092
+ val defaultHighlightColor = inputHighlightDefaults.getOrPut(this) { highlightColor }
696
1093
  hint = props.optString("placeholder")
1094
+ setHintTextColor(
1095
+ props.optString("placeholderTextColor").takeIf { it.isNotBlank() }
1096
+ ?.let { ColorStateList.valueOf(color(it, Color.GRAY)) }
1097
+ ?: defaultHintColors,
1098
+ )
1099
+ highlightColor = props.optString("selectionColor").takeIf { it.isNotBlank() }
1100
+ ?.let { color(it, defaultHighlightColor) }
1101
+ ?: defaultHighlightColor
1102
+ val maxLength = props.optInt("maxLength", 0)
1103
+ filters = if (maxLength > 0) arrayOf(InputFilter.LengthFilter(maxLength)) else emptyArray()
697
1104
  inputType = inputTypeFor(props)
698
1105
  imeOptions = imeOptionsFor(props)
699
- isSingleLine = !props.optBoolean("multiline", false)
1106
+ val multiline = props.optBoolean("multiline", false)
1107
+ isSingleLine = !multiline
1108
+ maxLines = if (multiline) props.optInt("numberOfLines", Int.MAX_VALUE).coerceAtLeast(1) else 1
700
1109
  isEnabled = props.optBoolean("editable", true)
701
- if (props.has("value") && text.toString() != props.optString("value")) setText(props.optString("value"))
1110
+ configureTextStyle(this, style)
1111
+ val controlledValue = props.opt("value")?.takeUnless { it == JSONObject.NULL }?.toString()
1112
+ if (controlledValue != null && text.toString() != controlledValue) {
1113
+ screen.suppressedTextChanges.add(this)
1114
+ setTextPreservingSelection(this, controlledValue)
1115
+ }
702
1116
  else if (this !== previous && screen.drafts.containsKey(identity)) setText(screen.drafts[identity])
1117
+ else if (this !== previous && props.has("defaultValue") && !props.isNull("defaultValue")) setText(props.optString("defaultValue"))
1118
+ screen.inputIdentities[this] = identity
703
1119
  val handler = events.optString("onChange").ifBlank { events.optString("onChangeText") }
704
1120
  if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
705
1121
  val onFocus = events.optString("onFocus")
706
1122
  if (onFocus.isNotBlank()) screen.focusHandlers[this] = onFocus else screen.focusHandlers.remove(this)
707
- val onBlur = events.optString("onBlur").ifBlank { events.optString("onEndEditing") }
1123
+ val onBlur = events.optString("onBlur")
708
1124
  if (onBlur.isNotBlank()) screen.blurHandlers[this] = onBlur else screen.blurHandlers.remove(this)
1125
+ val onEndEditing = events.optString("onEndEditing")
1126
+ if (onEndEditing.isNotBlank()) screen.endEditingHandlers[this] = onEndEditing else screen.endEditingHandlers.remove(this)
709
1127
  val onSubmit = events.optString("onSubmitEditing")
710
1128
  if (onSubmit.isNotBlank()) screen.submitHandlers[this] = onSubmit else screen.submitHandlers.remove(this)
711
1129
  setOnFocusChangeListener { view, focused ->
712
- val current = if (focused) screen.focusHandlers[view] else screen.blurHandlers[view]
713
- if (current == null) return@setOnFocusChangeListener
714
- send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject().put("text", text.toString())))
1130
+ val nativeEvent = JSONObject().put("text", text.toString())
1131
+ if (focused) {
1132
+ screen.focusHandlers[view]?.let { current ->
1133
+ send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", nativeEvent))
1134
+ }
1135
+ } else {
1136
+ screen.blurHandlers[view]?.let { current ->
1137
+ send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", nativeEvent))
1138
+ }
1139
+ screen.endEditingHandlers[view]?.let { current ->
1140
+ send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", nativeEvent))
1141
+ }
1142
+ }
715
1143
  }
716
1144
  setOnEditorActionListener { view, actionId, event ->
717
1145
  val submitted = actionId != EditorInfo.IME_ACTION_NONE || event?.keyCode == KeyEvent.KEYCODE_ENTER
@@ -720,16 +1148,19 @@ class MainActivity : AppCompatActivity() {
720
1148
  send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject().put("text", text.toString())))
721
1149
  true
722
1150
  }
723
- if (props.optBoolean("autoFocus", false)) post {
724
- requestFocus()
725
- (getSystemService(INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager)
726
- ?.showSoftInput(this, android.view.inputmethod.InputMethodManager.SHOW_IMPLICIT)
1151
+ if (props.optBoolean("autoFocus", false) && !isFocused) post {
1152
+ if (!isFocused) {
1153
+ requestFocus()
1154
+ (getSystemService(INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager)
1155
+ ?.showSoftInput(this, android.view.inputmethod.InputMethodManager.SHOW_IMPLICIT)
1156
+ }
727
1157
  }
728
1158
  if (this !== previous) addTextChangedListener(object : TextWatcher {
729
1159
  override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
730
1160
  override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
731
1161
  val updated = s?.toString().orEmpty()
732
1162
  screen.drafts[identity] = updated
1163
+ if (screen.suppressedTextChanges.remove(this@apply)) return
733
1164
  val current = screen.handlers[this@apply] ?: return
734
1165
  send(screen, "EVENT", JSONObject()
735
1166
  .put("handlerName", current)
@@ -738,6 +1169,96 @@ class MainActivity : AppCompatActivity() {
738
1169
  override fun afterTextChanged(s: Editable?) {}
739
1170
  })
740
1171
  }
1172
+ "Switch" -> (previous as? Switch ?: Switch(this)).apply {
1173
+ setOnCheckedChangeListener(null)
1174
+ val requestedValue = props.opt("value")
1175
+ isChecked = if (requestedValue != null && requestedValue != JSONObject.NULL) {
1176
+ props.optBoolean("value")
1177
+ } else {
1178
+ props.optBoolean("checked", false)
1179
+ }
1180
+ isEnabled = !props.optBoolean("disabled", false)
1181
+ props.optJSONObject("trackColor")?.let { track ->
1182
+ val falseColor = color(track.optString("false"), Color.GRAY)
1183
+ val trueColor = color(track.optString("true"), falseColor)
1184
+ trackTintList = ColorStateList(
1185
+ arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()),
1186
+ intArrayOf(trueColor, falseColor),
1187
+ )
1188
+ } ?: run { trackTintList = null }
1189
+ thumbTintList = props.optString("thumbColor").takeIf { it.isNotBlank() }
1190
+ ?.let { ColorStateList.valueOf(color(it, Color.WHITE)) }
1191
+ val handler = events.optString("onValueChange").ifBlank { events.optString("onChange") }
1192
+ if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
1193
+ setOnCheckedChangeListener { _, checked ->
1194
+ val current = screen.handlers[this] ?: return@setOnCheckedChangeListener
1195
+ send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject().put("value", checked)))
1196
+ }
1197
+ }
1198
+ "Slider" -> (previous as? SeekBar ?: SeekBar(this)).apply {
1199
+ val minimum = props.optDouble("minimumValue", 0.0)
1200
+ val maximumCandidate = props.optDouble("maximumValue", 1.0)
1201
+ val maximum = if (maximumCandidate > minimum) maximumCandidate else minimum + Double.MIN_VALUE
1202
+ max = 1000
1203
+ setOnSeekBarChangeListener(null)
1204
+ val step = props.optDouble("step", 0.0).takeIf { it > 0 }
1205
+ val requested = props.optDouble("value", minimum)
1206
+ val value = if (step != null) minimum + ((requested - minimum) / step).roundToInt() * step else requested
1207
+ progress = (((value - minimum) / (maximum - minimum)) * max).roundToInt().coerceIn(0, max)
1208
+ if (step != null) screen.sliderSteps[this] = step else screen.sliderSteps.remove(this)
1209
+ progressTintList = props.optString("minimumTrackTintColor").takeIf { it.isNotBlank() }
1210
+ ?.let { ColorStateList.valueOf(color(it, Color.WHITE)) }
1211
+ progressBackgroundTintList = props.optString("maximumTrackTintColor").takeIf { it.isNotBlank() }
1212
+ ?.let { ColorStateList.valueOf(color(it, Color.GRAY)) }
1213
+ thumbTintList = props.optString("thumbTintColor").takeIf { it.isNotBlank() }
1214
+ ?.let { ColorStateList.valueOf(color(it, Color.WHITE)) }
1215
+ isEnabled = !props.optBoolean("disabled", false)
1216
+ val handler = events.optString("onValueChange").ifBlank { events.optString("onChange") }
1217
+ if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
1218
+ val complete = events.optString("onSlidingComplete")
1219
+ if (complete.isNotBlank()) screen.sliderCompleteHandlers[this] = complete else screen.sliderCompleteHandlers.remove(this)
1220
+ setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
1221
+ override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
1222
+ val current = screen.handlers[seekBar] ?: return
1223
+ val raw = minimum + (maximum - minimum) * progress.toDouble() / max.toDouble()
1224
+ val step = screen.sliderSteps[seekBar]
1225
+ val value = if (step != null) minimum + ((raw - minimum) / step).roundToInt() * step else raw
1226
+ if (step != null) {
1227
+ val snappedProgress = (((value - minimum) / (maximum - minimum)) * max)
1228
+ .roundToInt().coerceIn(0, max)
1229
+ if (seekBar.progress != snappedProgress) {
1230
+ seekBar.progress = snappedProgress
1231
+ return
1232
+ }
1233
+ }
1234
+ send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject().put("value", value)))
1235
+ }
1236
+ override fun onStartTrackingTouch(seekBar: SeekBar) {}
1237
+ override fun onStopTrackingTouch(seekBar: SeekBar) {
1238
+ val current = screen.sliderCompleteHandlers[seekBar] ?: return
1239
+ val raw = minimum + (maximum - minimum) * seekBar.progress.toDouble() / max.toDouble()
1240
+ val step = screen.sliderSteps[seekBar]
1241
+ val value = if (step != null) minimum + ((raw - minimum) / step).roundToInt() * step else raw
1242
+ send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject().put("value", value)))
1243
+ }
1244
+ })
1245
+ }
1246
+ "ActivityIndicator" -> (previous as? android.widget.ProgressBar ?: android.widget.ProgressBar(this)).apply {
1247
+ val requestedSize = props.opt("size")
1248
+ val scale = when (requestedSize) {
1249
+ "large" -> 1.35f
1250
+ "small" -> 0.75f
1251
+ is Number -> (requestedSize.toDouble() / 24.0).toFloat().coerceAtLeast(0.5f)
1252
+ else -> 1f
1253
+ }
1254
+ scaleX = scale
1255
+ scaleY = scale
1256
+ val animating = props.optBoolean("animating", true)
1257
+ isIndeterminate = animating
1258
+ visibility = if (!animating && props.optBoolean("hidesWhenStopped", true)) View.GONE else View.VISIBLE
1259
+ indeterminateTintList = props.optString("color").takeIf { it.isNotBlank() }
1260
+ ?.let { ColorStateList.valueOf(color(it, Color.WHITE)) }
1261
+ }
741
1262
  "Image" -> (previous as? ImageView ?: ImageView(this)).apply {
742
1263
  scaleType = when (style.optString("resizeMode").ifBlank { props.optString("resizeMode") }) {
743
1264
  "cover" -> ImageView.ScaleType.CENTER_CROP
@@ -746,10 +1267,18 @@ class MainActivity : AppCompatActivity() {
746
1267
  else -> ImageView.ScaleType.CENTER_INSIDE
747
1268
  }
748
1269
  adjustViewBounds = false
749
- updateImage(this, props.opt("source"))
1270
+ val onLoadStart = events.optString("onLoadStart")
1271
+ if (onLoadStart.isNotBlank()) screen.imageLoadStartHandlers[this] = onLoadStart else screen.imageLoadStartHandlers.remove(this)
1272
+ val onLoad = events.optString("onLoad")
1273
+ if (onLoad.isNotBlank()) screen.imageLoadHandlers[this] = onLoad else screen.imageLoadHandlers.remove(this)
1274
+ val onLoadEnd = events.optString("onLoadEnd")
1275
+ if (onLoadEnd.isNotBlank()) screen.imageLoadEndHandlers[this] = onLoadEnd else screen.imageLoadEndHandlers.remove(this)
1276
+ val onError = events.optString("onError")
1277
+ if (onError.isNotBlank()) screen.imageErrorHandlers[this] = onError else screen.imageErrorHandlers.remove(this)
1278
+ updateImage(screen, this, props.opt("source"))
750
1279
  }
751
1280
  "ScrollView" -> (previous as? CraftNativeScrollView ?: CraftNativeScrollView(this)).apply {
752
- val horizontal = props.optBoolean("horizontal", false) || style.optString("flexDirection") == "row"
1281
+ val horizontal = props.optBoolean("horizontal", false) || style.optString("flexDirection") in setOf("row", "row-reverse")
753
1282
  setHorizontal(horizontal)
754
1283
  setNativeNestedScrollingEnabled(true)
755
1284
  setScrollIndicators(
@@ -757,12 +1286,22 @@ class MainActivity : AppCompatActivity() {
757
1286
  props.optBoolean("showsHorizontalScrollIndicator", true),
758
1287
  )
759
1288
  setScrollEnabled(props.optBoolean("scrollEnabled", true))
1289
+ setBounces(props.optBoolean("bounces", true))
1290
+ setAlwaysBounce(
1291
+ props.optBoolean("alwaysBounceVertical", !horizontal),
1292
+ props.optBoolean("alwaysBounceHorizontal", horizontal),
1293
+ )
1294
+ setPagingEnabled(props.optBoolean("pagingEnabled", false))
760
1295
  val onScroll = events.optString("onScroll")
761
1296
  if (onScroll.isNotBlank()) screen.scrollHandlers[this] = onScroll else screen.scrollHandlers.remove(this)
762
1297
  val onBegin = events.optString("onScrollBeginDrag")
763
1298
  if (onBegin.isNotBlank()) screen.scrollBeginHandlers[this] = onBegin else screen.scrollBeginHandlers.remove(this)
764
1299
  val onEnd = events.optString("onScrollEndDrag")
765
1300
  if (onEnd.isNotBlank()) screen.scrollEndHandlers[this] = onEnd else screen.scrollEndHandlers.remove(this)
1301
+ val onMomentumBegin = events.optString("onMomentumScrollBegin")
1302
+ if (onMomentumBegin.isNotBlank()) screen.scrollMomentumBeginHandlers[this] = onMomentumBegin else screen.scrollMomentumBeginHandlers.remove(this)
1303
+ val onMomentumEnd = events.optString("onMomentumScrollEnd")
1304
+ if (onMomentumEnd.isNotBlank()) screen.scrollMomentumEndHandlers[this] = onMomentumEnd else screen.scrollMomentumEndHandlers.remove(this)
766
1305
  val nativeScroll = this
767
1306
  setScrollListeners(
768
1307
  onScroll = { scrollX, scrollY ->
@@ -784,19 +1323,43 @@ class MainActivity : AppCompatActivity() {
784
1323
  send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
785
1324
  }
786
1325
  },
1326
+ onMomentumBegin = {
1327
+ screen.scrollMomentumBeginHandlers[nativeScroll]?.let { current ->
1328
+ send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
1329
+ }
1330
+ },
1331
+ onMomentumEnd = {
1332
+ screen.scrollMomentumEndHandlers[nativeScroll]?.let { current ->
1333
+ send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
1334
+ }
1335
+ },
1336
+ keyboardDismissMode = props.optString("keyboardDismissMode"),
1337
+ dismissKeyboard = {
1338
+ (getSystemService(INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager)
1339
+ ?.hideSoftInputFromWindow(nativeScroll.windowToken, 0)
1340
+ },
787
1341
  )
788
1342
  configureContainer(content, style)
789
1343
  reconcileChildren(screen, content, children, path, next, style, previousControls)
790
1344
  }
791
1345
  "FlatList" -> (previous as? CraftNativeFlatList ?: CraftNativeFlatList(this)).apply {
1346
+ setKeyboardDismissMode(props.optString("keyboardDismissMode")) {
1347
+ (getSystemService(INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager)
1348
+ ?.hideSoftInputFromWindow(windowToken, 0)
1349
+ }
1350
+ overScrollMode = if (props.optBoolean("bounces", true)) View.OVER_SCROLL_ALWAYS else View.OVER_SCROLL_NEVER
792
1351
  reconcileFlatList(screen, this, children, path, protocolId ?: path, props, events)
793
1352
  }
1353
+ "View", "SafeAreaView" -> (previous as? CraftNativeFlexLayout ?: CraftNativeFlexLayout(this)).apply {
1354
+ configureContainer(this, style)
1355
+ reconcileChildren(screen, this, children, path, next, style, previousControls)
1356
+ }
794
1357
  else -> (previous as? CraftNativeFlexLayout ?: CraftNativeFlexLayout(this)).apply {
795
1358
  configureContainer(this, style)
796
1359
  reconcileChildren(screen, this, children, path, next, style, previousControls)
797
1360
  }
798
1361
  }
799
- if (type != "Button" && type != "TextInput") {
1362
+ if (type != "Button" && type != "Link" && type != "TextInput" && type != "Switch" && type != "Slider" && type != "ActivityIndicator") {
800
1363
  val handler = events.optString("onPress").ifBlank { events.optString("onClick") }
801
1364
  if (handler.isNotBlank()) {
802
1365
  screen.handlers[control] = handler
@@ -811,13 +1374,40 @@ class MainActivity : AppCompatActivity() {
811
1374
  control.setOnClickListener(null)
812
1375
  }
813
1376
  }
1377
+ val longPressHandler = events.optString("onLongPress")
1378
+ if (longPressHandler.isNotBlank()) {
1379
+ screen.longPressHandlers[control] = longPressHandler
1380
+ control.setOnLongClickListener {
1381
+ val current = screen.longPressHandlers[control] ?: return@setOnLongClickListener false
1382
+ send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
1383
+ true
1384
+ }
1385
+ } else {
1386
+ screen.longPressHandlers.remove(control)
1387
+ control.setOnLongClickListener(null)
1388
+ }
814
1389
  applyAccessibility(control, props, type, explicitKey ?: protocolId ?: path)
815
- if (control is ImageView) imageErrors[control]?.let { control.contentDescription = "${control.contentDescription}, $it" }
1390
+ val layoutHandler = events.optString("onLayout")
1391
+ if (layoutHandler.isNotBlank()) {
1392
+ if (screen.layoutHandlers[control] != layoutHandler) screen.lastLayoutBounds.remove(control)
1393
+ screen.layoutHandlers[control] = layoutHandler
1394
+ } else {
1395
+ screen.layoutHandlers.remove(control)
1396
+ screen.lastLayoutBounds.remove(control)
1397
+ }
1398
+ if (control is ImageView) imageErrors[control]?.let { message ->
1399
+ val current = control.contentDescription?.toString().orEmpty()
1400
+ if (!current.contains(message)) {
1401
+ control.contentDescription = listOf(current, message).filter { it.isNotBlank() }.joinToString(", ")
1402
+ }
1403
+ }
816
1404
  next[identity] = control
817
1405
  applyViewStyle(control, style)
1406
+ val width = style.optDouble("width", Double.NaN).takeUnless { it.isNaN() }?.let(::dp)
1407
+ val height = style.optDouble("height", Double.NaN).takeUnless { it.isNaN() }?.let(::dp)
818
1408
  control.layoutParams = LinearLayout.LayoutParams(
819
- if (style.has("width")) dp(style.optDouble("width")) else ViewGroup.LayoutParams.WRAP_CONTENT,
820
- if (style.has("height")) dp(style.optDouble("height")) else ViewGroup.LayoutParams.WRAP_CONTENT
1409
+ width ?: ViewGroup.LayoutParams.WRAP_CONTENT,
1410
+ height ?: ViewGroup.LayoutParams.WRAP_CONTENT
821
1411
  ).apply {
822
1412
  val margin = style.optDouble("margin", 0.0)
823
1413
  val horizontal = style.optDouble("marginHorizontal", margin)
@@ -843,6 +1433,17 @@ class MainActivity : AppCompatActivity() {
843
1433
  layout.configure(style, resources.displayMetrics.density)
844
1434
  }
845
1435
 
1436
+ private fun flatListScrollEvent(list: CraftNativeFlatList): JSONObject = JSONObject()
1437
+ .put("contentOffset", JSONObject()
1438
+ .put("x", list.computeHorizontalScrollOffset())
1439
+ .put("y", list.computeVerticalScrollOffset()))
1440
+ .put("contentSize", JSONObject()
1441
+ .put("width", list.computeHorizontalScrollRange())
1442
+ .put("height", list.computeVerticalScrollRange()))
1443
+ .put("layoutMeasurement", JSONObject()
1444
+ .put("width", list.width)
1445
+ .put("height", list.height))
1446
+
846
1447
  private fun reconcileChildren(
847
1448
  screen: Screen,
848
1449
  layout: CraftNativeFlexLayout,
@@ -895,17 +1496,35 @@ class MainActivity : AppCompatActivity() {
895
1496
  ) {
896
1497
  screen.flatListOwners.entries.removeAll { it.value == owner }
897
1498
  screen.flatListOwnerByView[list] = owner
1499
+ list.onLayoutChanged = {
1500
+ if (screen.active) emitLayoutEvents(screen)
1501
+ }
898
1502
  val nodes = (0 until children.length()).mapNotNull { children.optJSONObject(it) }
899
1503
  nodes.forEach { registerFlatListOwnership(screen, it, owner) }
1504
+ list.onScrollEvent = events.optString("onScroll").takeIf { it.isNotBlank() }?.let { handler ->
1505
+ { send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", flatListScrollEvent(list))) }
1506
+ }
1507
+ list.onScrollBeginDrag = events.optString("onScrollBeginDrag").takeIf { it.isNotBlank() }?.let { handler ->
1508
+ { send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", flatListScrollEvent(list))) }
1509
+ }
1510
+ list.onScrollEndDrag = events.optString("onScrollEndDrag").takeIf { it.isNotBlank() }?.let { handler ->
1511
+ { send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", flatListScrollEvent(list))) }
1512
+ }
1513
+ list.onMomentumScrollBegin = events.optString("onMomentumScrollBegin").takeIf { it.isNotBlank() }?.let { handler ->
1514
+ { send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", flatListScrollEvent(list))) }
1515
+ }
1516
+ list.onMomentumScrollEnd = events.optString("onMomentumScrollEnd").takeIf { it.isNotBlank() }?.let { handler ->
1517
+ { send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", flatListScrollEvent(list))) }
1518
+ }
900
1519
  list.apply(
901
1520
  nodes = nodes,
902
1521
  horizontal = props.optBoolean("horizontal", false),
903
1522
  columns = props.optInt("numColumns", 1),
904
1523
  inverted = props.optBoolean("inverted", false),
905
- endReachedThreshold = when {
906
- props.has("onEndReachedThreshold") -> props.optDouble("onEndReachedThreshold", 0.1)
907
- else -> props.optDouble("threshold", 0.1)
908
- },
1524
+ endReachedThreshold = props.opt("onEndReachedThreshold")
1525
+ ?.takeUnless { it == JSONObject.NULL }
1526
+ ?.let { props.optDouble("onEndReachedThreshold", 0.1) }
1527
+ ?: props.optDouble("threshold", 0.1),
909
1528
  renderItem = { node, itemIdentity, _ ->
910
1529
  val rows = screen.flatListRows.getOrPut(list) { mutableMapOf() }
911
1530
  val previous = rows[itemIdentity].orEmpty()
@@ -917,7 +1536,7 @@ class MainActivity : AppCompatActivity() {
917
1536
  },
918
1537
  recycleItem = { itemIdentity ->
919
1538
  val controls = screen.flatListRows[list]?.remove(itemIdentity).orEmpty()
920
- releaseViewState(screen, controls.values.toList())
1539
+ releaseViewState(screen, controls.values.toList(), preservingInputDrafts = true)
921
1540
  },
922
1541
  endReached = events.optString("onEndReached").takeIf { it.isNotBlank() }?.let { handler ->
923
1542
  { send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", JSONObject())) }
@@ -942,21 +1561,26 @@ class MainActivity : AppCompatActivity() {
942
1561
  view.alpha = style.optDouble("opacity", 1.0).toFloat()
943
1562
  view.visibility = if (style.optString("display") == "none") View.GONE else View.VISIBLE
944
1563
  view.elevation = dp(style.optDouble("elevation", 0.0)).toFloat()
945
- val background = style.has("backgroundColor")
1564
+ val background = style.has("backgroundColor") && !style.isNull("backgroundColor")
946
1565
  val border = style.optDouble("borderWidth", 0.0)
947
1566
  val radius = style.optDouble("borderRadius", 0.0)
1567
+ val defaultBackground = if (defaultBackgrounds.containsKey(view)) {
1568
+ defaultBackgrounds[view]
1569
+ } else {
1570
+ view.background.also { defaultBackgrounds[view] = it }
1571
+ }
948
1572
  if (background || border > 0 || radius > 0) {
949
1573
  view.background = GradientDrawable().apply {
950
1574
  setColor(color(style.optString("backgroundColor"), Color.TRANSPARENT))
951
1575
  cornerRadius = dp(radius).toFloat()
952
1576
  if (border > 0) setStroke(dp(border), color(style.optString("borderColor"), Color.TRANSPARENT))
953
1577
  }
954
- }
1578
+ } else view.background = defaultBackground
955
1579
  view.clipToOutline = style.optString("overflow") == "hidden"
956
1580
  if (view is ViewGroup) view.clipChildren = style.optString("overflow") == "hidden"
957
1581
  if (view is ImageView) {
958
- if (style.has("tintColor")) view.setColorFilter(color(style.optString("tintColor"), Color.TRANSPARENT))
959
- else view.clearColorFilter()
1582
+ style.optString("tintColor").takeIf { it.isNotBlank() }?.let { view.setColorFilter(color(it, Color.TRANSPARENT)) }
1583
+ ?: view.clearColorFilter()
960
1584
  }
961
1585
  }
962
1586
 
@@ -967,8 +1591,12 @@ class MainActivity : AppCompatActivity() {
967
1591
  "capitalize" -> value.split(' ').joinToString(" ") { word -> word.replaceFirstChar(Char::uppercase) }
968
1592
  else -> value
969
1593
  }
1594
+ configureTextStyle(view, style)
1595
+ }
1596
+
1597
+ private fun configureTextStyle(view: TextView, style: JSONObject) {
970
1598
  view.setTextColor(color(style.optString("color"), defaultTextColor))
971
- view.setTextSize(TypedValue.COMPLEX_UNIT_SP, style.optDouble("fontSize", 14.0).toFloat())
1599
+ view.setTextSize(TypedValue.COMPLEX_UNIT_SP, style.optDouble("fontSize", 16.0).toFloat())
972
1600
  val weight = when (style.optString("fontWeight")) {
973
1601
  "bold" -> 700
974
1602
  "100", "200", "300", "400", "500", "600", "700", "800", "900" -> style.optInt("fontWeight")
@@ -987,10 +1615,19 @@ class MainActivity : AppCompatActivity() {
987
1615
  "justify" -> View.TEXT_ALIGNMENT_VIEW_START
988
1616
  else -> View.TEXT_ALIGNMENT_INHERIT
989
1617
  }
990
- view.letterSpacing = if (style.has("letterSpacing") && view.textSize > 0)
991
- dp(style.optDouble("letterSpacing")).toFloat() / view.textSize else 0f
1618
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
1619
+ view.justificationMode = if (style.optString("textAlign") == "justify") {
1620
+ Layout.JUSTIFICATION_MODE_INTER_WORD
1621
+ } else {
1622
+ Layout.JUSTIFICATION_MODE_NONE
1623
+ }
1624
+ }
1625
+ view.letterSpacing = style.optDouble("letterSpacing", Double.NaN)
1626
+ .takeUnless { it.isNaN() }
1627
+ ?.let { if (view.textSize > 0) dp(it).toFloat() / view.textSize else 0f }
1628
+ ?: 0f
992
1629
  view.setLineSpacing(
993
- if (style.has("lineHeight")) dp(style.optDouble("lineHeight")) - view.textSize else 0f,
1630
+ style.optDouble("lineHeight", Double.NaN).takeUnless { it.isNaN() }?.let { dp(it) - view.textSize } ?: 0f,
994
1631
  1f
995
1632
  )
996
1633
  view.paintFlags = view.paintFlags and Paint.UNDERLINE_TEXT_FLAG.inv() and Paint.STRIKE_THRU_TEXT_FLAG.inv()
@@ -1002,7 +1639,7 @@ class MainActivity : AppCompatActivity() {
1002
1639
  }
1003
1640
  }
1004
1641
 
1005
- private fun updateImage(view: ImageView, source: Any?) {
1642
+ private fun updateImage(screen: Screen, view: ImageView, source: Any?) {
1006
1643
  val uri = when (source) {
1007
1644
  is String -> source
1008
1645
  is JSONObject -> source.optString("uri")
@@ -1013,6 +1650,7 @@ class MainActivity : AppCompatActivity() {
1013
1650
  imageSources.remove(view)
1014
1651
  imageErrors[view] = "Image source is missing"
1015
1652
  view.setImageDrawable(null)
1653
+ imageFailure(screen, view, uri, "Image source is missing")
1016
1654
  return
1017
1655
  }
1018
1656
  if (imageSources[view] == uri) return
@@ -1020,20 +1658,29 @@ class MainActivity : AppCompatActivity() {
1020
1658
  imageSources[view] = uri
1021
1659
  imageErrors.remove(view)
1022
1660
  view.setImageDrawable(null)
1661
+ emitImageEvent(screen, view, screen.imageLoadStartHandlers[view])
1023
1662
 
1024
1663
  if (uri.startsWith("data:image/") && uri.contains(',')) {
1025
1664
  val bytes = runCatching { Base64.decode(uri.substringAfter(','), Base64.DEFAULT) }.getOrNull()
1026
1665
  val bitmap = bytes?.let { BitmapFactory.decodeByteArray(it, 0, it.size) }
1027
- if (bitmap != null) view.setImageBitmap(bitmap) else imageFailure(view, uri, "Image data is invalid")
1666
+ if (bitmap != null) {
1667
+ view.setImageBitmap(bitmap)
1668
+ emitImageEvent(screen, view, screen.imageLoadHandlers[view])
1669
+ emitImageEvent(screen, view, screen.imageLoadEndHandlers[view])
1670
+ } else imageFailure(screen, view, uri, "Image data is invalid")
1028
1671
  return
1029
1672
  }
1030
1673
  if (!uri.contains(':')) {
1031
1674
  val bitmap = runCatching { assets.open(uri).use { BitmapFactory.decodeStream(it) } }.getOrNull()
1032
- if (bitmap != null) view.setImageBitmap(bitmap) else imageFailure(view, uri, "Bundled image was not found")
1675
+ if (bitmap != null) {
1676
+ view.setImageBitmap(bitmap)
1677
+ emitImageEvent(screen, view, screen.imageLoadHandlers[view])
1678
+ emitImageEvent(screen, view, screen.imageLoadEndHandlers[view])
1679
+ } else imageFailure(screen, view, uri, "Bundled image was not found")
1033
1680
  return
1034
1681
  }
1035
1682
  if (!uri.startsWith("https://")) {
1036
- imageFailure(view, uri, "Unsupported image source")
1683
+ imageFailure(screen, view, uri, "Unsupported image source")
1037
1684
  return
1038
1685
  }
1039
1686
  imageJobs[view] = imageExecutor.submit {
@@ -1047,27 +1694,68 @@ class MainActivity : AppCompatActivity() {
1047
1694
  runOnUiThread {
1048
1695
  if (imageSources[view] != uri) return@runOnUiThread
1049
1696
  imageJobs.remove(view)
1050
- if (bitmap != null) view.setImageBitmap(bitmap) else imageFailure(view, uri, "Image download failed")
1697
+ if (bitmap != null) {
1698
+ view.setImageBitmap(bitmap)
1699
+ emitImageEvent(screen, view, screen.imageLoadHandlers[view])
1700
+ emitImageEvent(screen, view, screen.imageLoadEndHandlers[view])
1701
+ } else imageFailure(screen, view, uri, "Image download failed")
1051
1702
  }
1052
1703
  }
1053
1704
  }
1054
1705
 
1055
- private fun imageFailure(view: ImageView, uri: String, message: String) {
1706
+ private fun imageFailure(screen: Screen, view: ImageView, uri: String, message: String) {
1056
1707
  imageErrors[view] = message
1057
1708
  view.setImageDrawable(null)
1058
- view.contentDescription = "${view.tag ?: "Image"}, $message"
1709
+ val current = view.contentDescription?.toString().orEmpty()
1710
+ if (!current.contains(message)) {
1711
+ view.contentDescription = listOf(
1712
+ current.ifBlank { view.tag?.toString() ?: "Image" },
1713
+ message,
1714
+ ).joinToString(", ")
1715
+ }
1716
+ emitImageEvent(screen, view, screen.imageErrorHandlers[view], JSONObject().put("error", JSONObject().put("message", message)))
1717
+ emitImageEvent(screen, view, screen.imageLoadEndHandlers[view])
1059
1718
  Log.e(TAG, "$message: $uri")
1060
1719
  }
1061
1720
 
1721
+ private fun emitImageEvent(screen: Screen, view: ImageView, handler: String?, nativeEvent: JSONObject = JSONObject()) {
1722
+ if (handler.isNullOrBlank()) return
1723
+ send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", nativeEvent))
1724
+ }
1725
+
1726
+ private fun setTextPreservingSelection(field: EditText, value: String) {
1727
+ val start = field.selectionStart
1728
+ val end = field.selectionEnd
1729
+ field.setText(value)
1730
+ if (start < 0 || end < 0) {
1731
+ field.setSelection(value.length)
1732
+ return
1733
+ }
1734
+ val boundedStart = start.coerceIn(0, value.length)
1735
+ val boundedEnd = end.coerceIn(0, value.length)
1736
+ field.setSelection(minOf(boundedStart, boundedEnd), maxOf(boundedStart, boundedEnd))
1737
+ }
1738
+
1062
1739
  private fun applyAccessibility(view: View, props: JSONObject, type: String, fallbackLabel: String) {
1063
1740
  view.tag = fallbackLabel
1064
- view.contentDescription = props.optString("accessibilityLabel").ifBlank { fallbackLabel }
1065
- props.opt("accessibilityValue")?.takeUnless { it == JSONObject.NULL }?.let { view.contentDescription = "${view.contentDescription}, $it" }
1066
- props.optString("accessibilityHint").takeIf { it.isNotBlank() }?.let { view.tooltipText = it }
1741
+ val label = props.optString("accessibilityLabel").takeIf { it.isNotBlank() }
1742
+ ?: props.optString("alt").takeIf { type == "Image" && it.isNotBlank() }
1743
+ val value = props.opt("accessibilityValue")?.takeUnless { it == JSONObject.NULL }?.toString()
1744
+ view.contentDescription = when {
1745
+ label != null && value != null -> "$label, $value"
1746
+ label != null -> label
1747
+ value != null -> value
1748
+ else -> null
1749
+ }
1750
+ view.tooltipText = props.optString("accessibilityHint").takeIf { it.isNotBlank() }
1067
1751
  val role = props.optString("accessibilityRole").ifBlank { type.lowercase() }
1068
1752
  val state = props.optJSONObject("accessibilityState")
1069
1753
  val disabled = state?.optBoolean("disabled", false) == true
1070
- if (state != null && state.has("disabled")) view.isEnabled = !disabled
1754
+ if (type !in setOf("Button", "Link", "TextInput", "Switch", "Slider", "ActivityIndicator")) {
1755
+ view.isEnabled = !disabled
1756
+ } else if (disabled) {
1757
+ view.isEnabled = false
1758
+ }
1071
1759
  ViewCompat.setAccessibilityDelegate(view, object : AccessibilityDelegateCompat() {
1072
1760
  override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfoCompat) {
1073
1761
  super.onInitializeAccessibilityNodeInfo(host, info)
@@ -1080,7 +1768,8 @@ class MainActivity : AppCompatActivity() {
1080
1768
  }
1081
1769
  info.isHeading = role == "header"
1082
1770
  info.roleDescription = role.takeIf { it !in setOf("view", "text", "textinput", "scrollview") }
1083
- info.isEnabled = !disabled
1771
+ info.hintText = host.tooltipText
1772
+ info.isEnabled = host.isEnabled && !disabled
1084
1773
  info.isSelected = state?.optBoolean("selected", false) == true
1085
1774
  info.isChecked = state?.optBoolean("checked", false) == true
1086
1775
  }
@@ -1100,9 +1789,11 @@ class MainActivity : AppCompatActivity() {
1100
1789
  val keyboard = when (props.optString("keyboardType")) {
1101
1790
  "email-address" -> InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS
1102
1791
  "url" -> InputType.TYPE_TEXT_VARIATION_URI
1103
- "numeric" -> InputType.TYPE_CLASS_NUMBER
1792
+ "numeric", "number-pad" -> InputType.TYPE_CLASS_NUMBER
1104
1793
  "decimal-pad" -> InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL
1105
1794
  "phone-pad" -> InputType.TYPE_CLASS_PHONE
1795
+ "web-search" -> InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT
1796
+ "visible-password" -> InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
1106
1797
  else -> 0
1107
1798
  }
1108
1799
  val capitalization = when (props.optString("autoCapitalize")) {
@@ -1243,6 +1934,36 @@ class MainActivity : AppCompatActivity() {
1243
1934
  return initialDeepLink
1244
1935
  }
1245
1936
 
1937
+ private fun emitLayoutEvents(screen: Screen) {
1938
+ emitLayoutEvents(screen, screen.view)
1939
+ }
1940
+
1941
+ private fun emitLayoutEvents(screen: Screen, view: View) {
1942
+ val handler = screen.layoutHandlers[view]
1943
+ if (handler != null && view.isShown) {
1944
+ val density = resources.displayMetrics.density
1945
+ val bounds = LayoutBounds(
1946
+ x = view.left.toDouble() / density.toDouble(),
1947
+ y = view.top.toDouble() / density.toDouble(),
1948
+ width = view.width.toDouble() / density.toDouble(),
1949
+ height = view.height.toDouble() / density.toDouble(),
1950
+ )
1951
+ if (screen.lastLayoutBounds[view] != bounds) {
1952
+ screen.lastLayoutBounds[view] = bounds
1953
+ send(screen, "EVENT", JSONObject()
1954
+ .put("handlerName", handler)
1955
+ .put("nativeEvent", JSONObject().put("layout", JSONObject()
1956
+ .put("x", bounds.x)
1957
+ .put("y", bounds.y)
1958
+ .put("width", bounds.width)
1959
+ .put("height", bounds.height))))
1960
+ }
1961
+ }
1962
+ if (view is ViewGroup) {
1963
+ for (index in 0 until view.childCount) emitLayoutEvents(screen, view.getChildAt(index))
1964
+ }
1965
+ }
1966
+
1246
1967
  private fun send(screen: Screen, type: String, payload: JSONObject, correlationId: String? = null) {
1247
1968
  if (!screen.active) return
1248
1969
  val message = JSONObject().put("type", type).put("payload", payload)
@@ -1276,22 +1997,48 @@ class MainActivity : AppCompatActivity() {
1276
1997
  screen.flatListRows.values.forEach { rows -> rows.values.forEach { addAll(it.values) } }
1277
1998
  }
1278
1999
 
1279
- private fun releaseViewState(screen: Screen, views: Collection<View>) {
2000
+ private fun releaseViewState(
2001
+ screen: Screen,
2002
+ views: Collection<View>,
2003
+ preservingInputDrafts: Boolean = false,
2004
+ ) {
1280
2005
  val released = views.toSet()
1281
2006
  released.filterIsInstance<CraftNativeFlatList>().forEach { list ->
1282
2007
  val owner = screen.flatListOwnerByView.remove(list)
1283
2008
  list.discardAll()
1284
- screen.flatListRows.remove(list)?.values?.forEach { releaseViewState(screen, it.values) }
2009
+ screen.flatListRows.remove(list)?.values?.forEach {
2010
+ releaseViewState(screen, it.values, preservingInputDrafts)
2011
+ }
1285
2012
  if (owner != null) screen.flatListOwners.entries.removeAll { it.value == owner }
1286
2013
  }
1287
2014
  releaseImages(released)
2015
+ released.filterIsInstance<EditText>().forEach {
2016
+ inputHintDefaults.remove(it)
2017
+ inputHighlightDefaults.remove(it)
2018
+ screen.suppressedTextChanges.remove(it)
2019
+ val identity = screen.inputIdentities.remove(it)
2020
+ if (!preservingInputDrafts) identity?.let(screen.drafts::remove)
2021
+ }
2022
+ released.forEach(defaultBackgrounds::remove)
1288
2023
  screen.handlers.keys.removeAll(released)
2024
+ screen.longPressHandlers.keys.removeAll(released)
2025
+ screen.layoutHandlers.keys.removeAll(released)
2026
+ screen.lastLayoutBounds.keys.removeAll(released)
1289
2027
  screen.scrollHandlers.keys.removeAll(released)
1290
2028
  screen.scrollBeginHandlers.keys.removeAll(released)
1291
2029
  screen.scrollEndHandlers.keys.removeAll(released)
2030
+ screen.scrollMomentumBeginHandlers.keys.removeAll(released)
2031
+ screen.scrollMomentumEndHandlers.keys.removeAll(released)
2032
+ screen.sliderCompleteHandlers.keys.removeAll(released)
2033
+ screen.sliderSteps.keys.removeAll(released)
1292
2034
  screen.focusHandlers.keys.removeAll(released)
1293
2035
  screen.blurHandlers.keys.removeAll(released)
2036
+ screen.endEditingHandlers.keys.removeAll(released)
1294
2037
  screen.submitHandlers.keys.removeAll(released)
2038
+ screen.imageLoadStartHandlers.keys.removeAll(released)
2039
+ screen.imageLoadHandlers.keys.removeAll(released)
2040
+ screen.imageLoadEndHandlers.keys.removeAll(released)
2041
+ screen.imageErrorHandlers.keys.removeAll(released)
1295
2042
  }
1296
2043
 
1297
2044
  private fun closeScreen(screen: Screen) {
@@ -1302,12 +2049,23 @@ class MainActivity : AppCompatActivity() {
1302
2049
  releaseViewState(screen, retainedViews(screen).toList())
1303
2050
  screen.controls.clear()
1304
2051
  screen.handlers.clear()
2052
+ screen.layoutHandlers.clear()
2053
+ screen.lastLayoutBounds.clear()
1305
2054
  screen.scrollHandlers.clear()
1306
2055
  screen.scrollBeginHandlers.clear()
1307
2056
  screen.scrollEndHandlers.clear()
2057
+ screen.scrollMomentumBeginHandlers.clear()
2058
+ screen.scrollMomentumEndHandlers.clear()
2059
+ screen.sliderCompleteHandlers.clear()
2060
+ screen.sliderSteps.clear()
1308
2061
  screen.focusHandlers.clear()
1309
2062
  screen.blurHandlers.clear()
2063
+ screen.endEditingHandlers.clear()
1310
2064
  screen.submitHandlers.clear()
2065
+ screen.imageLoadStartHandlers.clear()
2066
+ screen.imageLoadHandlers.clear()
2067
+ screen.imageLoadEndHandlers.clear()
2068
+ screen.imageErrorHandlers.clear()
1311
2069
  screen.flatListRows.clear()
1312
2070
  screen.flatListOwners.clear()
1313
2071
  screen.flatListOwnerByView.clear()