craft-native 0.0.123 → 0.0.125

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,8 +30,11 @@ 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
36
+ import android.widget.AdapterView
37
+ import android.widget.ArrayAdapter
30
38
  import android.widget.Button
31
39
  import android.widget.EditText
32
40
  import android.widget.FrameLayout
@@ -34,10 +42,15 @@ import android.widget.HorizontalScrollView
34
42
  import android.widget.ImageView
35
43
  import android.widget.LinearLayout
36
44
  import android.widget.ScrollView
45
+ import android.widget.SeekBar
46
+ import android.widget.Spinner
47
+ import android.widget.Switch
37
48
  import android.widget.TextView
38
49
  import androidx.appcompat.app.AppCompatActivity
50
+ import androidx.appcompat.app.AppCompatDelegate
39
51
  import androidx.core.content.ContextCompat
40
52
  import androidx.core.view.AccessibilityDelegateCompat
53
+ import androidx.core.view.WindowInsetsCompat
41
54
  import androidx.core.view.ViewCompat
42
55
  import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
43
56
  import androidx.javascriptengine.JavaScriptIsolate
@@ -55,6 +68,7 @@ import java.util.concurrent.Future
55
68
  import java.util.UUID
56
69
  import kotlin.math.max
57
70
  import kotlin.math.min
71
+ import kotlin.math.roundToInt
58
72
 
59
73
  private const val CRAFT_NATIVE_CAPABILITY_TIMEOUT_MS = 30_000L
60
74
 
@@ -71,30 +85,61 @@ private data class CraftNativeLayoutStyle(
71
85
  val bottom: Int? = null,
72
86
  val left: Int? = null,
73
87
  val alignSelf: String? = null,
88
+ val flexGrow: Double = 0.0,
89
+ val flexShrink: Double = 0.0,
90
+ val flexBasis: Int? = null,
74
91
  ) {
75
92
  companion object {
76
93
  fun from(raw: JSONObject, density: Float): CraftNativeLayoutStyle {
77
- fun number(name: String): Int? = if (raw.has(name)) (raw.optDouble(name) * density).toInt() else null
94
+ fun number(name: String): Int? {
95
+ if (!raw.has(name) || raw.isNull(name)) return null
96
+ return raw.optDouble(name, Double.NaN).takeUnless { it.isNaN() }?.let { (it * density).toInt() }
97
+ }
78
98
  return CraftNativeLayoutStyle(
79
99
  width = number("width"), height = number("height"),
80
100
  minWidth = number("minWidth"), maxWidth = number("maxWidth"),
81
101
  minHeight = number("minHeight"), maxHeight = number("maxHeight"),
82
- position = raw.optString("position", "relative"),
102
+ position = raw.opt("position")
103
+ ?.takeUnless { it == JSONObject.NULL }
104
+ ?.toString()
105
+ ?.takeIf { it.isNotBlank() }
106
+ ?: "relative",
83
107
  top = number("top"), right = number("right"), bottom = number("bottom"), left = number("left"),
84
- alignSelf = raw.optString("alignSelf").takeIf { it.isNotBlank() },
108
+ alignSelf = raw.opt("alignSelf")
109
+ ?.takeUnless { it == JSONObject.NULL }
110
+ ?.toString()
111
+ ?.takeIf { it.isNotBlank() },
112
+ flexGrow = raw.optDouble("flexGrow", raw.optDouble("flex", 0.0)),
113
+ flexShrink = raw.optDouble("flexShrink", 0.0),
114
+ flexBasis = number("flexBasis"),
85
115
  )
86
116
  }
87
117
  }
88
118
  }
89
119
 
120
+ private fun craftNativeGridColumnCount(value: Any?): Int {
121
+ if (value is Number) return max(1, value.toInt())
122
+ val text = value as? String ?: return 1
123
+ val trimmed = text.trim()
124
+ if (trimmed.startsWith("repeat(")) {
125
+ val count = trimmed.removePrefix("repeat(").takeWhile { it.isDigit() }.toIntOrNull()
126
+ if (count != null) return max(1, count)
127
+ }
128
+ return max(1, trimmed.split(Regex("\\s+")).count { it.isNotBlank() })
129
+ }
130
+
90
131
  /** A deterministic flex-shaped container shared by normal views and scroll content. */
91
132
  private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
133
+ var onLayoutChanged: (() -> Unit)? = null
92
134
  private val styles = mutableMapOf<View, CraftNativeLayoutStyle>()
93
135
  private var wrap = false
94
136
  private var alignItems = "stretch"
95
137
  private var justifyContent = "flex-start"
96
138
  private var rowGap = 0
97
139
  private var columnGap = 0
140
+ private var grid = false
141
+ private var gridColumns = 1
142
+ private var gridAutoRows: Int? = null
98
143
 
99
144
  fun configure(style: JSONObject, density: Float) {
100
145
  orientation = if (style.optString("flexDirection") in setOf("row", "row-reverse")) HORIZONTAL else VERTICAL
@@ -104,6 +149,11 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
104
149
  val gap = (style.optDouble("gap", 0.0) * density).toInt()
105
150
  rowGap = (style.optDouble("rowGap", gap.toDouble() / density.toDouble()) * density).toInt()
106
151
  columnGap = (style.optDouble("columnGap", gap.toDouble() / density.toDouble()) * density).toInt()
152
+ grid = style.optString("display") == "grid"
153
+ gridColumns = craftNativeGridColumnCount(
154
+ if (style.has("gridTemplateColumns")) style.opt("gridTemplateColumns") else style.opt("gridColumns")
155
+ )
156
+ gridAutoRows = style.optDouble("gridAutoRows", Double.NaN).takeUnless { it.isNaN() }?.let { (it * density).toInt() }
107
157
  val padding = (style.optDouble("padding", 0.0) * density).toInt()
108
158
  val horizontal = (style.optDouble("paddingHorizontal", padding.toDouble() / density.toDouble()) * density).toInt()
109
159
  val vertical = (style.optDouble("paddingVertical", padding.toDouble() / density.toDouble()) * density).toInt()
@@ -128,6 +178,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
128
178
 
129
179
  private data class Entry(val view: View, val style: CraftNativeLayoutStyle, val width: Int, val height: Int)
130
180
  private data class Line(val entries: MutableList<Entry> = mutableListOf(), var main: Int = 0, var cross: Int = 0)
181
+ private data class GridLayout(val rows: List<List<Entry>>, val absolute: List<Entry>, val cellWidth: Int, val rowHeights: List<Int>)
131
182
 
132
183
  private fun entries(width: Int, height: Int): Pair<List<Line>, List<Entry>> {
133
184
  val availableMain = if (orientation == HORIZONTAL) max(0, width - paddingLeft - paddingRight)
@@ -143,6 +194,16 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
143
194
  val lp = child.layoutParams as? MarginLayoutParams
144
195
  val horizontalMargins = (lp?.leftMargin ?: 0) + (lp?.rightMargin ?: 0)
145
196
  val verticalMargins = (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0)
197
+ if (child is CraftNativeModalView) {
198
+ val modalWidth = max(0, width - paddingLeft - paddingRight)
199
+ val modalHeight = max(0, height - paddingTop - paddingBottom)
200
+ child.measure(
201
+ View.MeasureSpec.makeMeasureSpec(modalWidth, View.MeasureSpec.EXACTLY),
202
+ View.MeasureSpec.makeMeasureSpec(modalHeight, View.MeasureSpec.EXACTLY),
203
+ )
204
+ absolute += Entry(child, style, modalWidth, modalHeight)
205
+ continue
206
+ }
146
207
  val childWidth = measureChildWidth(child, style, width - paddingLeft - paddingRight, horizontalMargins)
147
208
  val childHeight = measureChildHeight(child, style, height - paddingTop - paddingBottom, verticalMargins)
148
209
  val entry = Entry(child, style, childWidth, childHeight)
@@ -162,8 +223,80 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
162
223
  return lines to absolute
163
224
  }
164
225
 
226
+ private fun gridLayout(width: Int, height: Int): GridLayout {
227
+ val availableWidth = max(0, width - paddingLeft - paddingRight)
228
+ val gap = columnGap
229
+ val cellWidth = max(0, (availableWidth - max(0, gridColumns - 1) * gap) / max(1, gridColumns))
230
+ val rows = mutableListOf<MutableList<Entry>>()
231
+ val absolute = mutableListOf<Entry>()
232
+ for (index in 0 until childCount) {
233
+ val child = getChildAt(index)
234
+ if (child.visibility == GONE) continue
235
+ val style = styles[child] ?: CraftNativeLayoutStyle()
236
+ val lp = child.layoutParams as? MarginLayoutParams
237
+ val horizontalMargins = (lp?.leftMargin ?: 0) + (lp?.rightMargin ?: 0)
238
+ val verticalMargins = (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0)
239
+ if (child is CraftNativeModalView) {
240
+ val modalWidth = max(0, width - paddingLeft - paddingRight)
241
+ val modalHeight = max(0, height - paddingTop - paddingBottom)
242
+ child.measure(
243
+ View.MeasureSpec.makeMeasureSpec(modalWidth, View.MeasureSpec.EXACTLY),
244
+ View.MeasureSpec.makeMeasureSpec(modalHeight, View.MeasureSpec.EXACTLY),
245
+ )
246
+ absolute += Entry(child, style, modalWidth, modalHeight)
247
+ continue
248
+ }
249
+ val childWidth = measureChildWidth(child, style, cellWidth, horizontalMargins)
250
+ val childHeight = measureChildHeight(child, style, height - paddingTop - paddingBottom, verticalMargins)
251
+ val entry = Entry(child, style, childWidth, childHeight)
252
+ if (style.position == "absolute") absolute += entry
253
+ else {
254
+ if (rows.isEmpty() || rows.last().size == gridColumns) rows.add(mutableListOf())
255
+ rows.last() += entry
256
+ }
257
+ }
258
+ val rowHeights = rows.map { row ->
259
+ max(gridAutoRows ?: 0, row.maxOfOrNull { entry ->
260
+ val lp = entry.view.layoutParams as? MarginLayoutParams
261
+ entry.height + (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0)
262
+ } ?: 0)
263
+ }
264
+ return GridLayout(rows, absolute, cellWidth, rowHeights)
265
+ }
266
+
267
+ private fun gridIntrinsicSize(height: Int): Pair<Int, Int> {
268
+ val columns = max(1, gridColumns)
269
+ val columnWidths = IntArray(columns)
270
+ val rowHeights = mutableListOf<Int>()
271
+ var flowIndex = 0
272
+ for (index in 0 until childCount) {
273
+ val child = getChildAt(index)
274
+ if (child.visibility == GONE) continue
275
+ val style = styles[child] ?: CraftNativeLayoutStyle()
276
+ if (style.position == "absolute" || child is CraftNativeModalView) continue
277
+ val lp = child.layoutParams as? MarginLayoutParams
278
+ val horizontalMargins = (lp?.leftMargin ?: 0) + (lp?.rightMargin ?: 0)
279
+ val verticalMargins = (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0)
280
+ val childWidth = measureChildWidth(child, style, 1_000_000, horizontalMargins)
281
+ val childHeight = measureChildHeight(child, style, height, verticalMargins)
282
+ val column = flowIndex % columns
283
+ val row = flowIndex / columns
284
+ while (rowHeights.size <= row) rowHeights += gridAutoRows ?: 0
285
+ columnWidths[column] = max(columnWidths[column], childWidth + horizontalMargins)
286
+ rowHeights[row] = max(rowHeights[row], childHeight + verticalMargins)
287
+ flowIndex += 1
288
+ }
289
+ val cellWidth = columnWidths.maxOrNull() ?: 0
290
+ val width = paddingLeft + paddingRight + cellWidth * columns + max(0, columns - 1) * columnGap
291
+ val rowGapTotal = max(0, rowHeights.size - 1) * rowGap
292
+ val measuredHeight = paddingTop + paddingBottom + rowHeights.sum() + rowGapTotal
293
+ return width to measuredHeight
294
+ }
295
+
165
296
  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 }
297
+ val flexBasis = if (orientation == HORIZONTAL) style.flexBasis else null
298
+ val explicit = style.width ?: flexBasis
299
+ ?: (child.layoutParams?.width ?: ViewGroup.LayoutParams.WRAP_CONTENT).takeIf { it >= 0 }
167
300
  val target = explicit ?: max(0, available - margins)
168
301
  val spec = if (explicit != null) View.MeasureSpec.makeMeasureSpec(target, View.MeasureSpec.EXACTLY)
169
302
  else View.MeasureSpec.makeMeasureSpec(max(0, available - margins), View.MeasureSpec.AT_MOST)
@@ -172,7 +305,9 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
172
305
  }
173
306
 
174
307
  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 }
308
+ val flexBasis = if (orientation == VERTICAL) style.flexBasis else null
309
+ val explicit = style.height ?: flexBasis
310
+ ?: (child.layoutParams?.height ?: ViewGroup.LayoutParams.WRAP_CONTENT).takeIf { it >= 0 }
176
311
  val target = explicit ?: max(0, available - margins)
177
312
  val spec = if (explicit != null) View.MeasureSpec.makeMeasureSpec(target, View.MeasureSpec.EXACTLY)
178
313
  else View.MeasureSpec.makeMeasureSpec(max(0, available - margins), View.MeasureSpec.AT_MOST)
@@ -188,8 +323,24 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
188
323
  }
189
324
 
190
325
  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
326
+ val widthMode = View.MeasureSpec.getMode(widthMeasureSpec)
327
+ val width = View.MeasureSpec.getSize(widthMeasureSpec).takeUnless { widthMode == View.MeasureSpec.UNSPECIFIED } ?: 1_000_000
192
328
  val height = View.MeasureSpec.getSize(heightMeasureSpec).takeUnless { View.MeasureSpec.getMode(heightMeasureSpec) == View.MeasureSpec.UNSPECIFIED } ?: 1_000_000
329
+ if (grid) {
330
+ if (widthMode == View.MeasureSpec.UNSPECIFIED) {
331
+ val (intrinsicWidth, intrinsicHeight) = gridIntrinsicSize(height)
332
+ setMeasuredDimension(
333
+ resolveSize(intrinsicWidth, widthMeasureSpec),
334
+ resolveSize(intrinsicHeight, heightMeasureSpec),
335
+ )
336
+ return
337
+ }
338
+ val layout = gridLayout(width, height)
339
+ val desiredWidth = paddingLeft + paddingRight + layout.cellWidth * gridColumns + max(0, gridColumns - 1) * columnGap
340
+ val desiredHeight = paddingTop + paddingBottom + layout.rowHeights.sum() + max(0, layout.rowHeights.size - 1) * rowGap
341
+ setMeasuredDimension(resolveSize(desiredWidth, widthMeasureSpec), resolveSize(desiredHeight, heightMeasureSpec))
342
+ return
343
+ }
193
344
  val (lines, absolute) = entries(width, height)
194
345
  val main = lines.maxOfOrNull { it.main } ?: 0
195
346
  val cross = lines.sumOf { it.cross } + max(0, lines.size - 1) * (if (orientation == HORIZONTAL) rowGap else columnGap)
@@ -204,17 +355,78 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
204
355
  }
205
356
 
206
357
  override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
358
+ if (grid) {
359
+ val layout = gridLayout(width, height)
360
+ var rowTop = paddingTop
361
+ for ((rowIndex, row) in layout.rows.withIndex()) {
362
+ val rowHeight = layout.rowHeights[rowIndex]
363
+ for ((column, entry) in row.withIndex()) {
364
+ val align = entry.style.alignSelf ?: alignItems
365
+ val lp = entry.view.layoutParams as? MarginLayoutParams
366
+ val marginLeft = lp?.leftMargin ?: 0
367
+ val marginRight = lp?.rightMargin ?: 0
368
+ val marginTop = lp?.topMargin ?: 0
369
+ val marginBottom = lp?.bottomMargin ?: 0
370
+ val availableWidth = max(0, layout.cellWidth - marginLeft - marginRight)
371
+ val availableHeight = max(0, rowHeight - marginTop - marginBottom)
372
+ val childWidth = if (align == "stretch" && entry.style.width == null) availableWidth else min(availableWidth, entry.width)
373
+ val childHeight = if (align == "stretch" && entry.style.height == null) availableHeight else min(availableHeight, entry.height)
374
+ val x = paddingLeft + column * (layout.cellWidth + columnGap) + marginLeft + when (align) {
375
+ "center" -> (availableWidth - childWidth) / 2
376
+ "flex-end" -> availableWidth - childWidth
377
+ else -> 0
378
+ }
379
+ val y = rowTop + marginTop + when (align) {
380
+ "center" -> (availableHeight - childHeight) / 2
381
+ "flex-end" -> availableHeight - childHeight
382
+ else -> 0
383
+ }
384
+ entry.view.layout(x, y, x + childWidth, y + childHeight)
385
+ }
386
+ rowTop += rowHeight + rowGap
387
+ }
388
+ val contentWidth = width - paddingLeft - paddingRight
389
+ val contentHeight = height - paddingTop - paddingBottom
390
+ for (entry in layout.absolute) {
391
+ if (entry.view is CraftNativeModalView && entry.view.visibility == View.VISIBLE) entry.view.bringToFront()
392
+ val childWidth = if (entry.style.left != null && entry.style.right != null) contentWidth - entry.style.left!! - entry.style.right!! else entry.width
393
+ val childHeight = if (entry.style.top != null && entry.style.bottom != null) contentHeight - entry.style.top!! - entry.style.bottom!! else entry.height
394
+ val x = paddingLeft + (entry.style.left ?: (entry.style.right?.let { contentWidth - it - childWidth } ?: 0))
395
+ val y = paddingTop + (entry.style.top ?: (entry.style.bottom?.let { contentHeight - it - childHeight } ?: 0))
396
+ entry.view.layout(x, y, x + max(0, childWidth), y + max(0, childHeight))
397
+ }
398
+ onLayoutChanged?.invoke()
399
+ return
400
+ }
207
401
  val contentWidth = width - paddingLeft - paddingRight
208
402
  val contentHeight = height - paddingTop - paddingBottom
209
403
  val (lines, absolute) = entries(width, height)
210
404
  val gap = if (orientation == HORIZONTAL) columnGap else rowGap
405
+ val crossGap = if (orientation == HORIZONTAL) rowGap else columnGap
406
+ val availableMain = if (orientation == HORIZONTAL) contentWidth else contentHeight
211
407
  var crossOffset = if (orientation == HORIZONTAL) paddingTop else paddingLeft
212
408
  for (line in lines) {
213
- val free = max(0, (if (orientation == HORIZONTAL) contentWidth else contentHeight) - line.main)
409
+ val rawFree = availableMain - line.main
410
+ val factors = line.entries.map { if (rawFree >= 0) it.style.flexGrow else it.style.flexShrink }
411
+ val totalFactor = factors.sum()
412
+ val distributedMain = line.entries.indices.sumOf { index ->
413
+ val base = if (orientation == HORIZONTAL) line.entries[index].width else line.entries[index].height
414
+ val primary = if (totalFactor > 0) max(0, (base + rawFree * factors[index] / totalFactor).toInt()) else base
415
+ val constrained = if (orientation == HORIZONTAL) {
416
+ clamp(primary, line.entries[index].style.minWidth, line.entries[index].style.maxWidth)
417
+ } else {
418
+ clamp(primary, line.entries[index].style.minHeight, line.entries[index].style.maxHeight)
419
+ }
420
+ val lp = line.entries[index].view.layoutParams as? MarginLayoutParams
421
+ val margins = if (orientation == HORIZONTAL) (lp?.leftMargin ?: 0) + (lp?.rightMargin ?: 0)
422
+ else (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0)
423
+ constrained + margins
424
+ } + max(0, line.entries.size - 1) * gap
425
+ val free = max(0, availableMain - distributedMain)
214
426
  val (leading, between) = distribution(free, line.entries.size)
215
427
  var mainOffset = if (orientation == HORIZONTAL) paddingLeft else paddingTop
216
428
  mainOffset += leading
217
- for (entry in line.entries) {
429
+ for ((index, entry) in line.entries.withIndex()) {
218
430
  val lp = entry.view.layoutParams as? MarginLayoutParams
219
431
  val marginStart = if (orientation == HORIZONTAL) lp?.leftMargin ?: 0 else lp?.topMargin ?: 0
220
432
  val marginEnd = if (orientation == HORIZONTAL) lp?.rightMargin ?: 0 else lp?.bottomMargin ?: 0
@@ -222,27 +434,39 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
222
434
  max(0, line.cross - (if (orientation == HORIZONTAL) (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0) else (lp?.leftMargin ?: 0) + (lp?.rightMargin ?: 0)))
223
435
  else if (orientation == HORIZONTAL) entry.height else entry.width
224
436
  val align = entry.style.alignSelf ?: alignItems
437
+ val crossLeading = if (orientation == HORIZONTAL) lp?.topMargin ?: 0 else lp?.leftMargin ?: 0
438
+ val crossTrailing = if (orientation == HORIZONTAL) lp?.bottomMargin ?: 0 else lp?.rightMargin ?: 0
439
+ val crossAvailable = max(0, line.cross - crossLeading - crossTrailing)
225
440
  val crossPosition = when (align) {
226
- "center" -> (line.cross - crossSize) / 2
227
- "flex-end" -> line.cross - crossSize
228
- else -> 0
441
+ "center" -> crossLeading + (crossAvailable - crossSize) / 2
442
+ "flex-end" -> line.cross - crossTrailing - crossSize
443
+ else -> crossLeading
229
444
  }
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
445
+ val x = if (orientation == HORIZONTAL) mainOffset + marginStart else crossOffset + crossPosition
446
+ val y = if (orientation == HORIZONTAL) crossOffset + crossPosition else mainOffset + marginStart
447
+ val primary = if (orientation == HORIZONTAL) entry.width else entry.height
448
+ val distributed = if (totalFactor > 0) max(0, (primary + rawFree * factors[index] / totalFactor).toInt()) else primary
449
+ val distributedPrimary = if (orientation == HORIZONTAL) {
450
+ clamp(distributed, entry.style.minWidth, entry.style.maxWidth)
451
+ } else {
452
+ clamp(distributed, entry.style.minHeight, entry.style.maxHeight)
453
+ }
454
+ val w = if (orientation == HORIZONTAL) distributedPrimary else crossSize
455
+ val h = if (orientation == HORIZONTAL) crossSize else distributedPrimary
234
456
  entry.view.layout(x, y, x + w, y + h)
235
- mainOffset += (if (orientation == HORIZONTAL) entry.width else entry.height) + marginStart + marginEnd + gap + between
457
+ mainOffset += distributedPrimary + marginStart + marginEnd + gap + between
236
458
  }
237
- crossOffset += line.cross + gap
459
+ crossOffset += line.cross + crossGap
238
460
  }
239
461
  for (entry in absolute) {
462
+ if (entry.view is CraftNativeModalView && entry.view.visibility == View.VISIBLE) entry.view.bringToFront()
240
463
  val widthValue = if (entry.style.left != null && entry.style.right != null) contentWidth - entry.style.left!! - entry.style.right!! else entry.width
241
464
  val heightValue = if (entry.style.top != null && entry.style.bottom != null) contentHeight - entry.style.top!! - entry.style.bottom!! else entry.height
242
465
  val x = paddingLeft + (entry.style.left ?: (entry.style.right?.let { contentWidth - it - widthValue } ?: 0))
243
466
  val y = paddingTop + (entry.style.top ?: (entry.style.bottom?.let { contentHeight - it - heightValue } ?: 0))
244
467
  entry.view.layout(x, y, x + max(0, widthValue), y + max(0, heightValue))
245
468
  }
469
+ onLayoutChanged?.invoke()
246
470
  }
247
471
 
248
472
  private fun distribution(free: Int, count: Int): Pair<Int, Int> = when (justifyContent) {
@@ -260,6 +484,11 @@ private class CraftNativeScrollView(context: Context) : FrameLayout(context) {
260
484
  private val vertical = ScrollView(context)
261
485
  private val horizontal = HorizontalScrollView(context)
262
486
  private var horizontalMode: Boolean? = null
487
+ private var pagingEnabled = false
488
+ private var bounces = true
489
+ private var alwaysBounceVertical = true
490
+ private var alwaysBounceHorizontal = true
491
+ private var keyboardShouldPersistTaps = "never"
263
492
 
264
493
  init {
265
494
  vertical.isNestedScrollingEnabled = true
@@ -293,6 +522,62 @@ private class CraftNativeScrollView(context: Context) : FrameLayout(context) {
293
522
  horizontal.isEnabled = value
294
523
  }
295
524
 
525
+ fun setKeyboardShouldPersistTaps(value: String?) {
526
+ keyboardShouldPersistTaps = value?.takeIf { it in setOf("always", "handled", "never") } ?: "never"
527
+ }
528
+
529
+ fun setBounces(value: Boolean) {
530
+ bounces = value
531
+ applyBounceModes()
532
+ }
533
+
534
+ fun setAlwaysBounce(vertical: Boolean, horizontal: Boolean) {
535
+ alwaysBounceVertical = vertical
536
+ alwaysBounceHorizontal = horizontal
537
+ applyBounceModes()
538
+ }
539
+
540
+ private fun applyBounceModes() {
541
+ vertical.overScrollMode = if (bounces && alwaysBounceVertical) View.OVER_SCROLL_ALWAYS else View.OVER_SCROLL_NEVER
542
+ horizontal.overScrollMode = if (bounces && alwaysBounceHorizontal) View.OVER_SCROLL_ALWAYS else View.OVER_SCROLL_NEVER
543
+ }
544
+
545
+ private fun tapTarget(view: View, x: Float, y: Float): View? {
546
+ if (x < 0 || y < 0 || x >= view.width || y >= view.height || view.visibility != View.VISIBLE) return null
547
+ if (view is ViewGroup) {
548
+ for (index in view.childCount - 1 downTo 0) {
549
+ val child = view.getChildAt(index)
550
+ tapTarget(child, x - child.left, y - child.top)?.let { return it }
551
+ }
552
+ }
553
+ return view
554
+ }
555
+
556
+ private fun shouldDismissKeyboard(scroller: View, event: android.view.MotionEvent): Boolean {
557
+ if (keyboardShouldPersistTaps == "always") return false
558
+ return when (keyboardShouldPersistTaps) {
559
+ "handled" -> (scroller as? ViewGroup)?.let { tapTarget(it, event.x, event.y)?.isClickable != true } ?: true
560
+ else -> true
561
+ }
562
+ }
563
+
564
+ fun setPagingEnabled(value: Boolean) {
565
+ pagingEnabled = value
566
+ }
567
+
568
+ private fun snapToPage(scroller: View) {
569
+ val horizontal = horizontalMode == true
570
+ val pageSize = if (horizontal) scroller.width else scroller.height
571
+ if (pageSize <= 0) return
572
+ val offset = if (horizontal) scroller.scrollX else scroller.scrollY
573
+ val target = (offset.toFloat() / pageSize).roundToInt() * pageSize
574
+ if (horizontal) {
575
+ (scroller as HorizontalScrollView).smoothScrollTo(target, 0)
576
+ } else {
577
+ (scroller as ScrollView).smoothScrollTo(0, target)
578
+ }
579
+ }
580
+
296
581
  fun setScrollIndicators(verticalVisible: Boolean, horizontalVisible: Boolean) {
297
582
  vertical.isVerticalScrollBarEnabled = verticalVisible
298
583
  horizontal.isHorizontalScrollBarEnabled = horizontalVisible
@@ -302,13 +587,65 @@ private class CraftNativeScrollView(context: Context) : FrameLayout(context) {
302
587
  onScroll: ((Int, Int) -> Unit)?,
303
588
  onBeginDrag: (() -> Unit)?,
304
589
  onEndDrag: (() -> Unit)?,
590
+ onMomentumBegin: (() -> Unit)?,
591
+ onMomentumEnd: (() -> Unit)?,
592
+ keyboardDismissMode: String,
593
+ dismissKeyboard: (() -> Unit)?,
305
594
  ) {
306
595
  fun attach(scroller: View) {
596
+ var dismissed = false
597
+ var moved = false
598
+ var momentumActive = false
599
+ var lastX = 0
600
+ var lastY = 0
601
+
602
+ fun pollMomentum() {
603
+ if (!momentumActive) return
604
+ val x = scroller.scrollX
605
+ val y = scroller.scrollY
606
+ if (x == lastX && y == lastY) {
607
+ momentumActive = false
608
+ onMomentumEnd?.invoke()
609
+ } else {
610
+ lastX = x
611
+ lastY = y
612
+ scroller.postDelayed({ pollMomentum() }, 16L)
613
+ }
614
+ }
615
+
307
616
  scroller.setOnScrollChangeListener { _, x, y, _, _ -> onScroll?.invoke(x, y) }
308
617
  scroller.setOnTouchListener { _, event ->
309
618
  when (event.actionMasked) {
310
- android.view.MotionEvent.ACTION_DOWN -> onBeginDrag?.invoke()
311
- android.view.MotionEvent.ACTION_UP, android.view.MotionEvent.ACTION_CANCEL -> onEndDrag?.invoke()
619
+ android.view.MotionEvent.ACTION_DOWN -> {
620
+ if (momentumActive) {
621
+ momentumActive = false
622
+ onMomentumEnd?.invoke()
623
+ }
624
+ dismissed = false
625
+ moved = false
626
+ onBeginDrag?.invoke()
627
+ }
628
+ android.view.MotionEvent.ACTION_MOVE -> {
629
+ moved = true
630
+ if (!dismissed && keyboardDismissMode in setOf("on-drag", "interactive")) {
631
+ dismissed = true
632
+ dismissKeyboard?.invoke()
633
+ }
634
+ }
635
+ android.view.MotionEvent.ACTION_UP, android.view.MotionEvent.ACTION_CANCEL -> {
636
+ if (event.actionMasked == android.view.MotionEvent.ACTION_UP && shouldDismissKeyboard(scroller, event)) {
637
+ dismissKeyboard?.invoke()
638
+ }
639
+ onEndDrag?.invoke()
640
+ if (pagingEnabled) snapToPage(scroller)
641
+ if (moved && !momentumActive) {
642
+ momentumActive = true
643
+ lastX = scroller.scrollX
644
+ lastY = scroller.scrollY
645
+ onMomentumBegin?.invoke()
646
+ scroller.postDelayed({ pollMomentum() }, 16L)
647
+ }
648
+ }
312
649
  }
313
650
  false
314
651
  }
@@ -320,7 +657,54 @@ private class CraftNativeScrollView(context: Context) : FrameLayout(context) {
320
657
 
321
658
  private class CraftNativeFlexSpacer(context: Context) : View(context)
322
659
 
323
- /** WebView-free host for a compiled stx-native route bundle. */
660
+ private class CraftNativeModalView(context: Context) : FrameLayout(context) {
661
+ val content = CraftNativeFlexLayout(context)
662
+ private val blocker = View(context)
663
+ private var renderedVisible = false
664
+ var onRequestClose: (() -> Unit)? = null
665
+
666
+ var transparent: Boolean = false
667
+ set(value) {
668
+ field = value
669
+ blocker.setBackgroundColor(if (value) Color.TRANSPARENT else Color.argb(82, 0, 0, 0))
670
+ }
671
+
672
+ init {
673
+ isClickable = true
674
+ blocker.isClickable = true
675
+ blocker.setOnTouchListener { _, _ -> true }
676
+ blocker.setBackgroundColor(Color.argb(82, 0, 0, 0))
677
+ addView(blocker, LayoutParams(-1, -1))
678
+ addView(content, LayoutParams(-1, -1))
679
+ }
680
+
681
+ fun setVisible(visible: Boolean, opacity: Float, animationType: String?) {
682
+ if (renderedVisible == visible && visibility == if (visible) View.VISIBLE else View.GONE) return
683
+ renderedVisible = visible
684
+ if (!visible) {
685
+ animate().cancel()
686
+ alpha = 0f
687
+ visibility = View.GONE
688
+ return
689
+ }
690
+ visibility = View.VISIBLE
691
+ if (animationType == "none") {
692
+ alpha = opacity
693
+ } else {
694
+ alpha = 0f
695
+ animate().alpha(opacity).setDuration(if (animationType == "slide") 240L else 180L).start()
696
+ }
697
+ }
698
+ }
699
+
700
+ private data class LayoutBounds(
701
+ val x: Double,
702
+ val y: Double,
703
+ val width: Double,
704
+ val height: Double,
705
+ )
706
+
707
+ /** WebView-free host for a route bundle compiled by `stx native compile`. */
324
708
  class MainActivity : AppCompatActivity() {
325
709
  private class Screen(
326
710
  val route: String?,
@@ -330,14 +714,34 @@ class MainActivity : AppCompatActivity() {
330
714
  val port: MessagePort?
331
715
  ) {
332
716
  val drafts = mutableMapOf<String, String>()
717
+ val inputIdentities = mutableMapOf<EditText, String>()
718
+ val suppressedTextChanges = mutableSetOf<EditText>()
333
719
  val controls = mutableMapOf<String, View>()
334
720
  val handlers = mutableMapOf<View, String>()
721
+ val pressInHandlers = mutableMapOf<View, String>()
722
+ val pressOutHandlers = mutableMapOf<View, String>()
723
+ val longPressHandlers = mutableMapOf<View, String>()
335
724
  val scrollHandlers = mutableMapOf<View, String>()
336
725
  val scrollBeginHandlers = mutableMapOf<View, String>()
337
726
  val scrollEndHandlers = mutableMapOf<View, String>()
727
+ val scrollMomentumBeginHandlers = mutableMapOf<View, String>()
728
+ val scrollMomentumEndHandlers = mutableMapOf<View, String>()
729
+ val sliderCompleteHandlers = mutableMapOf<View, String>()
730
+ val sliderSteps = mutableMapOf<View, Double>()
338
731
  val focusHandlers = mutableMapOf<View, String>()
339
732
  val blurHandlers = mutableMapOf<View, String>()
733
+ val endEditingHandlers = mutableMapOf<View, String>()
340
734
  val submitHandlers = mutableMapOf<View, String>()
735
+ val imageLoadStartHandlers = mutableMapOf<ImageView, String>()
736
+ val imageLoadHandlers = mutableMapOf<ImageView, String>()
737
+ val imageLoadEndHandlers = mutableMapOf<ImageView, String>()
738
+ val imageErrorHandlers = mutableMapOf<ImageView, String>()
739
+ val pressActiveOpacities = mutableMapOf<View, Float>()
740
+ val pressBaseOpacities = mutableMapOf<View, Float>()
741
+ val modalVisibility = mutableMapOf<View, Boolean>()
742
+ val modalCloseHandlers = mutableMapOf<View, String>()
743
+ val layoutHandlers = mutableMapOf<View, String>()
744
+ val lastLayoutBounds = mutableMapOf<View, LayoutBounds>()
341
745
  val flatListRows = mutableMapOf<CraftNativeFlatList, MutableMap<String, MutableMap<String, View>>>()
342
746
  val flatListOwners = mutableMapOf<String, String>()
343
747
  val flatListOwnerByView = mutableMapOf<CraftNativeFlatList, String>()
@@ -374,14 +778,26 @@ class MainActivity : AppCompatActivity() {
374
778
  private val imageSources = mutableMapOf<ImageView, String>()
375
779
  private val imageJobs = mutableMapOf<ImageView, Future<*>>()
376
780
  private val imageErrors = mutableMapOf<ImageView, String>()
781
+ private val inputHintDefaults = mutableMapOf<EditText, ColorStateList>()
782
+ private val inputHighlightDefaults = mutableMapOf<EditText, Int>()
783
+ private val defaultBackgrounds = mutableMapOf<View, Drawable?>()
377
784
 
378
785
  override fun onCreate(savedInstanceState: Bundle?) {
786
+ applyConfiguredAppearance()
379
787
  super.onCreate(savedInstanceState)
380
788
  initialDeepLink = savedInstanceState?.getString("craft.initialDeepLink")?.let(Uri::parse) ?: intent?.data
381
789
  initialDeepLinkClaimed = savedInstanceState?.getBoolean("craft.initialDeepLinkClaimed", false) ?: false
382
790
  deliveredInitialDeepLink = savedInstanceState?.getBoolean("craft.deliveredInitialDeepLink", false) ?: false
383
791
  setContentView(R.layout.activity_main)
384
792
  root = findViewById(R.id.native_root)
793
+ ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets ->
794
+ val safe = insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout())
795
+ view.setPadding(safe.left, safe.top, safe.right, safe.bottom)
796
+ view.requestLayout()
797
+ view.post { screens.lastOrNull()?.takeIf { it.active }?.let(::emitLayoutEvents) }
798
+ insets
799
+ }
800
+ ViewCompat.requestApplyInsets(root)
385
801
  config = runCatching {
386
802
  assets.open("craft.config.json").bufferedReader().use {
387
803
  JSONObject(it.readText())
@@ -390,9 +806,7 @@ class MainActivity : AppCompatActivity() {
390
806
  hapticsEnabled = config.optBoolean("enableHaptics", false)
391
807
  clipboardEnabled = config.optBoolean("enableClipboard", false)
392
808
  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
809
+ refreshThemeDefaults()
396
810
  bundle = runCatching {
397
811
  assets.open("native-screen.js").bufferedReader().use { it.readText() }
398
812
  }.getOrElse {
@@ -433,6 +847,23 @@ class MainActivity : AppCompatActivity() {
433
847
  }
434
848
  }
435
849
 
850
+ private fun applyConfiguredAppearance() {
851
+ val configured = runCatching {
852
+ assets.open("craft.config.json").bufferedReader().use { JSONObject(it.readText()) }
853
+ }.getOrNull()
854
+ val mode = when (configured?.optString("appearance")) {
855
+ "light" -> AppCompatDelegate.MODE_NIGHT_NO
856
+ "dark" -> AppCompatDelegate.MODE_NIGHT_YES
857
+ "system" -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
858
+ else -> if (configured?.optBoolean("darkMode", true) == false) {
859
+ AppCompatDelegate.MODE_NIGHT_NO
860
+ } else {
861
+ AppCompatDelegate.MODE_NIGHT_YES
862
+ }
863
+ }
864
+ AppCompatDelegate.setDefaultNightMode(mode)
865
+ }
866
+
436
867
  private fun attachSandbox(engine: JavaScriptSandbox) {
437
868
  useMessagePorts = engine.isFeatureSupported(JavaScriptSandbox.JS_FEATURE_MESSAGE_PORTS)
438
869
  && engine.isFeatureSupported(JavaScriptSandbox.JS_FEATURE_PROMISE_RETURN)
@@ -470,6 +901,9 @@ class MainActivity : AppCompatActivity() {
470
901
  null
471
902
  }
472
903
  val activeScreen = Screen(route, params, view, isolate, port)
904
+ activeScreen.view.onLayoutChanged = {
905
+ if (activeScreen.active) emitLayoutEvents(activeScreen)
906
+ }
473
907
  screen = activeScreen
474
908
  screens.add(activeScreen)
475
909
  show(activeScreen)
@@ -539,6 +973,7 @@ class MainActivity : AppCompatActivity() {
539
973
  root.removeAllViews()
540
974
  (screen.view.parent as? ViewGroup)?.removeView(screen.view)
541
975
  root.addView(screen.view)
976
+ refreshScreenTheme(screen)
542
977
  }
543
978
 
544
979
  override fun onSaveInstanceState(outState: Bundle) {
@@ -548,6 +983,43 @@ class MainActivity : AppCompatActivity() {
548
983
  super.onSaveInstanceState(outState)
549
984
  }
550
985
 
986
+ override fun onConfigurationChanged(newConfig: Configuration) {
987
+ super.onConfigurationChanged(newConfig)
988
+ if (!::root.isInitialized) return
989
+ refreshThemeDefaults()
990
+ ViewCompat.requestApplyInsets(root)
991
+ refreshInputDefaultsForTraitChange()
992
+ screens.lastOrNull()?.let { screen ->
993
+ refreshScreenTheme(screen)
994
+ }
995
+ }
996
+
997
+ private fun refreshScreenTheme(screen: Screen) {
998
+ screen.mutations.node("root")?.let { renderCommitted(screen, it) }
999
+ screen.controls.values.mapNotNull {
1000
+ when (it) {
1001
+ is CraftNativeRefreshableFlatList -> it.list
1002
+ is CraftNativeFlatList -> it
1003
+ else -> null
1004
+ }
1005
+ }.forEach { it.refreshThemeDefaults() }
1006
+ }
1007
+
1008
+ private fun refreshThemeDefaults() {
1009
+ val background = ContextCompat.getColor(this, R.color.background)
1010
+ root.setBackgroundColor(background)
1011
+ defaultTextColor = if (Color.luminance(background) < 0.5) Color.WHITE else Color.BLACK
1012
+ }
1013
+
1014
+ private fun refreshInputDefaultsForTraitChange() {
1015
+ if (inputHintDefaults.isEmpty() && inputHighlightDefaults.isEmpty()) return
1016
+ val probe = EditText(this)
1017
+ val hintColors = probe.hintTextColors
1018
+ val highlightColor = probe.highlightColor
1019
+ inputHintDefaults.keys.toList().forEach { inputHintDefaults[it] = hintColors }
1020
+ inputHighlightDefaults.keys.toList().forEach { inputHighlightDefaults[it] = highlightColor }
1021
+ }
1022
+
551
1023
  private fun receive(screen: Screen, raw: String) {
552
1024
  val message = runCatching { JSONObject(raw) }.getOrNull() ?: return
553
1025
  val payload = message.optJSONObject("payload") ?: JSONObject()
@@ -679,12 +1151,38 @@ class MainActivity : AppCompatActivity() {
679
1151
  ?: explicitKey?.let { "${path.substringBeforeLast('.', "")}/$it" }
680
1152
  ?: path
681
1153
  val previous = previousControls[identity]
1154
+ val reusableInput = previous as? EditText
682
1155
  val control = when (type) {
683
1156
  "Text" -> ((previous as? TextView)?.takeUnless { it is Button || it is EditText } ?: TextView(this)).apply {
1157
+ maxLines = props.optInt("numberOfLines", 0).takeIf { it > 0 } ?: Int.MAX_VALUE
1158
+ ellipsize = when (props.optString("ellipsizeMode")) {
1159
+ "head" -> TextUtils.TruncateAt.START
1160
+ "middle" -> TextUtils.TruncateAt.MIDDLE
1161
+ "tail" -> TextUtils.TruncateAt.END
1162
+ else -> null
1163
+ }
684
1164
  configureText(this, childText(children), style)
685
1165
  }
1166
+ "Link" -> ((previous as? TextView)?.takeUnless { it is Button } ?: TextView(this)).apply {
1167
+ configureText(this, props.optString("title").ifBlank { childText(children) }, style)
1168
+ val linkColor = props.optString("color").takeIf { it.isNotBlank() } ?: style.optString("color")
1169
+ setTextColor(color(linkColor, Color.rgb(33, 150, 243)))
1170
+ paintFlags = paintFlags or Paint.UNDERLINE_TEXT_FLAG
1171
+ isEnabled = !props.optBoolean("disabled", false)
1172
+ isClickable = true
1173
+ isFocusable = true
1174
+ val handler = events.optString("onPress").ifBlank { events.optString("onClick") }
1175
+ if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
1176
+ setOnClickListener {
1177
+ val current = screen.handlers[this] ?: return@setOnClickListener
1178
+ send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
1179
+ }
1180
+ }
686
1181
  "Button" -> (previous as? Button ?: Button(this)).apply {
687
- text = props.optString("title").ifBlank { childText(children) }
1182
+ val title = props.optString("title").ifBlank { childText(children) }
1183
+ configureText(this, title, style)
1184
+ props.optString("color").takeIf { it.isNotBlank() }?.let { setTextColor(color(it, defaultTextColor)) }
1185
+ isEnabled = !props.optBoolean("disabled", false)
688
1186
  val handler = events.optString("onPress").ifBlank { events.optString("onClick") }
689
1187
  if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
690
1188
  setOnClickListener {
@@ -692,26 +1190,62 @@ class MainActivity : AppCompatActivity() {
692
1190
  send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
693
1191
  }
694
1192
  }
695
- "TextInput" -> (previous as? EditText ?: EditText(this)).apply {
1193
+ "TextInput" -> (reusableInput?.takeIf { it.isSingleLine == !props.optBoolean("multiline", false) }
1194
+ ?: EditText(this)).apply {
1195
+ val defaultHintColors = inputHintDefaults.getOrPut(this) {
1196
+ hintTextColors ?: ColorStateList.valueOf(Color.GRAY)
1197
+ }
1198
+ val defaultHighlightColor = inputHighlightDefaults.getOrPut(this) { highlightColor }
696
1199
  hint = props.optString("placeholder")
1200
+ setHintTextColor(
1201
+ props.optString("placeholderTextColor").takeIf { it.isNotBlank() }
1202
+ ?.let { ColorStateList.valueOf(color(it, Color.GRAY)) }
1203
+ ?: defaultHintColors,
1204
+ )
1205
+ highlightColor = props.optString("selectionColor").takeIf { it.isNotBlank() }
1206
+ ?.let { color(it, defaultHighlightColor) }
1207
+ ?: defaultHighlightColor
1208
+ val maxLength = props.optInt("maxLength", 0)
1209
+ filters = if (maxLength > 0) arrayOf(InputFilter.LengthFilter(maxLength)) else emptyArray()
697
1210
  inputType = inputTypeFor(props)
698
1211
  imeOptions = imeOptionsFor(props)
699
- isSingleLine = !props.optBoolean("multiline", false)
1212
+ val multiline = props.optBoolean("multiline", false)
1213
+ isSingleLine = !multiline
1214
+ maxLines = if (multiline) props.optInt("numberOfLines", Int.MAX_VALUE).coerceAtLeast(1) else 1
700
1215
  isEnabled = props.optBoolean("editable", true)
701
- if (props.has("value") && text.toString() != props.optString("value")) setText(props.optString("value"))
1216
+ configureTextStyle(this, style)
1217
+ val controlledValue = props.opt("value")?.takeUnless { it == JSONObject.NULL }?.toString()
1218
+ if (controlledValue != null && text.toString() != controlledValue) {
1219
+ screen.suppressedTextChanges.add(this)
1220
+ setTextPreservingSelection(this, controlledValue)
1221
+ }
702
1222
  else if (this !== previous && screen.drafts.containsKey(identity)) setText(screen.drafts[identity])
1223
+ else if (this !== previous && props.has("defaultValue") && !props.isNull("defaultValue")) setText(props.optString("defaultValue"))
1224
+ screen.inputIdentities[this] = identity
703
1225
  val handler = events.optString("onChange").ifBlank { events.optString("onChangeText") }
704
1226
  if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
705
1227
  val onFocus = events.optString("onFocus")
706
1228
  if (onFocus.isNotBlank()) screen.focusHandlers[this] = onFocus else screen.focusHandlers.remove(this)
707
- val onBlur = events.optString("onBlur").ifBlank { events.optString("onEndEditing") }
1229
+ val onBlur = events.optString("onBlur")
708
1230
  if (onBlur.isNotBlank()) screen.blurHandlers[this] = onBlur else screen.blurHandlers.remove(this)
1231
+ val onEndEditing = events.optString("onEndEditing")
1232
+ if (onEndEditing.isNotBlank()) screen.endEditingHandlers[this] = onEndEditing else screen.endEditingHandlers.remove(this)
709
1233
  val onSubmit = events.optString("onSubmitEditing")
710
1234
  if (onSubmit.isNotBlank()) screen.submitHandlers[this] = onSubmit else screen.submitHandlers.remove(this)
711
1235
  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())))
1236
+ val nativeEvent = JSONObject().put("text", text.toString())
1237
+ if (focused) {
1238
+ screen.focusHandlers[view]?.let { current ->
1239
+ send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", nativeEvent))
1240
+ }
1241
+ } else {
1242
+ screen.blurHandlers[view]?.let { current ->
1243
+ send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", nativeEvent))
1244
+ }
1245
+ screen.endEditingHandlers[view]?.let { current ->
1246
+ send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", nativeEvent))
1247
+ }
1248
+ }
715
1249
  }
716
1250
  setOnEditorActionListener { view, actionId, event ->
717
1251
  val submitted = actionId != EditorInfo.IME_ACTION_NONE || event?.keyCode == KeyEvent.KEYCODE_ENTER
@@ -720,16 +1254,19 @@ class MainActivity : AppCompatActivity() {
720
1254
  send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject().put("text", text.toString())))
721
1255
  true
722
1256
  }
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)
1257
+ if (props.optBoolean("autoFocus", false) && !isFocused) post {
1258
+ if (!isFocused) {
1259
+ requestFocus()
1260
+ (getSystemService(INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager)
1261
+ ?.showSoftInput(this, android.view.inputmethod.InputMethodManager.SHOW_IMPLICIT)
1262
+ }
727
1263
  }
728
1264
  if (this !== previous) addTextChangedListener(object : TextWatcher {
729
1265
  override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
730
1266
  override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
731
1267
  val updated = s?.toString().orEmpty()
732
1268
  screen.drafts[identity] = updated
1269
+ if (screen.suppressedTextChanges.remove(this@apply)) return
733
1270
  val current = screen.handlers[this@apply] ?: return
734
1271
  send(screen, "EVENT", JSONObject()
735
1272
  .put("handlerName", current)
@@ -738,6 +1275,150 @@ class MainActivity : AppCompatActivity() {
738
1275
  override fun afterTextChanged(s: Editable?) {}
739
1276
  })
740
1277
  }
1278
+ "Picker" -> (previous as? Spinner ?: Spinner(this)).apply {
1279
+ val options = (0 until children.length()).mapNotNull { index ->
1280
+ val option = children.optJSONObject(index) ?: return@mapNotNull null
1281
+ if (option.optString("type") != "Text") return@mapNotNull null
1282
+ val label = (option.optJSONArray("children") ?: JSONArray()).let { text ->
1283
+ (0 until text.length()).joinToString("") { text.optString(it) }
1284
+ }
1285
+ if (label.isBlank()) return@mapNotNull null
1286
+ val optionProps = option.optJSONObject("props")
1287
+ val value = optionProps?.optString("value").takeIf { !it.isNullOrBlank() } ?: label
1288
+ value to label
1289
+ }
1290
+ adapter = ArrayAdapter(this@MainActivity, android.R.layout.simple_spinner_item, options.map { it.second }).also {
1291
+ it.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
1292
+ }
1293
+ val requested = props.optString("selectedValue").ifBlank { props.optString("value") }
1294
+ val selected = options.indexOfFirst { it.first == requested }.takeIf { it >= 0 } ?: 0
1295
+ isEnabled = !props.optBoolean("disabled", false)
1296
+ val handler = events.optString("onValueChange").ifBlank { events.optString("onChange") }
1297
+ if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
1298
+ onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
1299
+ override fun onNothingSelected(parent: AdapterView<*>?) = Unit
1300
+ override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
1301
+ val current = screen.handlers[this@apply] ?: return
1302
+ val option = options.getOrNull(position) ?: return
1303
+ send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()
1304
+ .put("value", option.first).put("index", position)))
1305
+ }
1306
+ }
1307
+ setSelection(selected, false)
1308
+ }
1309
+ "Modal" -> (previous as? CraftNativeModalView ?: CraftNativeModalView(this)).apply {
1310
+ transparent = props.optBoolean("transparent", false)
1311
+ content.configure(style, resources.displayMetrics.density)
1312
+ reconcileChildren(screen, content, children, path, next, style, previousControls)
1313
+ val visible = props.optBoolean("visible", false)
1314
+ val wasVisible = screen.modalVisibility[this]
1315
+ screen.modalVisibility[this] = visible
1316
+ val requestClose = events.optString("onRequestClose")
1317
+ if (requestClose.isNotBlank()) screen.modalCloseHandlers[this] = requestClose else screen.modalCloseHandlers.remove(this)
1318
+ setVisible(visible, style.optDouble("opacity", 1.0).toFloat(), props.optString("animationType").ifBlank { null })
1319
+ if (wasVisible != visible && (visible || wasVisible == true)) {
1320
+ val lifecycleHandler = if (visible) events.optString("onShow") else events.optString("onDismiss")
1321
+ if (lifecycleHandler.isNotBlank()) {
1322
+ send(screen, "EVENT", JSONObject().put("handlerName", lifecycleHandler).put("nativeEvent", JSONObject()))
1323
+ }
1324
+ }
1325
+ }
1326
+ "TouchableOpacity", "TouchableHighlight", "Pressable" -> (previous as? CraftNativeFlexLayout ?: CraftNativeFlexLayout(this)).apply {
1327
+ configureContainer(this, style)
1328
+ screen.pressActiveOpacities[this] = props.optDouble("activeOpacity", 0.5).toFloat()
1329
+ screen.pressBaseOpacities[this] = style.optDouble("opacity", 1.0).toFloat()
1330
+ reconcileChildren(screen, this, children, path, next, style, previousControls)
1331
+ }
1332
+ "Switch" -> (previous as? Switch ?: Switch(this)).apply {
1333
+ setOnCheckedChangeListener(null)
1334
+ val requestedValue = props.opt("value")
1335
+ isChecked = if (requestedValue != null && requestedValue != JSONObject.NULL) {
1336
+ props.optBoolean("value")
1337
+ } else {
1338
+ props.optBoolean("checked", false)
1339
+ }
1340
+ isEnabled = !props.optBoolean("disabled", false)
1341
+ props.optJSONObject("trackColor")?.let { track ->
1342
+ val falseColor = color(track.optString("false"), Color.GRAY)
1343
+ val trueColor = color(track.optString("true"), falseColor)
1344
+ trackTintList = ColorStateList(
1345
+ arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()),
1346
+ intArrayOf(trueColor, falseColor),
1347
+ )
1348
+ } ?: run { trackTintList = null }
1349
+ thumbTintList = props.optString("thumbColor").takeIf { it.isNotBlank() }
1350
+ ?.let { ColorStateList.valueOf(color(it, Color.WHITE)) }
1351
+ val handler = events.optString("onValueChange").ifBlank { events.optString("onChange") }
1352
+ if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
1353
+ setOnCheckedChangeListener { _, checked ->
1354
+ val current = screen.handlers[this] ?: return@setOnCheckedChangeListener
1355
+ send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject().put("value", checked)))
1356
+ }
1357
+ }
1358
+ "Slider" -> (previous as? SeekBar ?: SeekBar(this)).apply {
1359
+ val minimum = props.optDouble("minimumValue", 0.0)
1360
+ val maximumCandidate = props.optDouble("maximumValue", 1.0)
1361
+ val maximum = if (maximumCandidate > minimum) maximumCandidate else minimum + Double.MIN_VALUE
1362
+ max = 1000
1363
+ setOnSeekBarChangeListener(null)
1364
+ val step = props.optDouble("step", 0.0).takeIf { it > 0 }
1365
+ val requested = props.optDouble("value", minimum)
1366
+ val value = if (step != null) minimum + ((requested - minimum) / step).roundToInt() * step else requested
1367
+ progress = (((value - minimum) / (maximum - minimum)) * max).roundToInt().coerceIn(0, max)
1368
+ if (step != null) screen.sliderSteps[this] = step else screen.sliderSteps.remove(this)
1369
+ progressTintList = props.optString("minimumTrackTintColor").takeIf { it.isNotBlank() }
1370
+ ?.let { ColorStateList.valueOf(color(it, Color.WHITE)) }
1371
+ progressBackgroundTintList = props.optString("maximumTrackTintColor").takeIf { it.isNotBlank() }
1372
+ ?.let { ColorStateList.valueOf(color(it, Color.GRAY)) }
1373
+ thumbTintList = props.optString("thumbTintColor").takeIf { it.isNotBlank() }
1374
+ ?.let { ColorStateList.valueOf(color(it, Color.WHITE)) }
1375
+ isEnabled = !props.optBoolean("disabled", false)
1376
+ val handler = events.optString("onValueChange").ifBlank { events.optString("onChange") }
1377
+ if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
1378
+ val complete = events.optString("onSlidingComplete")
1379
+ if (complete.isNotBlank()) screen.sliderCompleteHandlers[this] = complete else screen.sliderCompleteHandlers.remove(this)
1380
+ setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
1381
+ override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
1382
+ val current = screen.handlers[seekBar] ?: return
1383
+ val raw = minimum + (maximum - minimum) * progress.toDouble() / max.toDouble()
1384
+ val step = screen.sliderSteps[seekBar]
1385
+ val value = if (step != null) minimum + ((raw - minimum) / step).roundToInt() * step else raw
1386
+ if (step != null) {
1387
+ val snappedProgress = (((value - minimum) / (maximum - minimum)) * max)
1388
+ .roundToInt().coerceIn(0, max)
1389
+ if (seekBar.progress != snappedProgress) {
1390
+ seekBar.progress = snappedProgress
1391
+ return
1392
+ }
1393
+ }
1394
+ send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject().put("value", value)))
1395
+ }
1396
+ override fun onStartTrackingTouch(seekBar: SeekBar) {}
1397
+ override fun onStopTrackingTouch(seekBar: SeekBar) {
1398
+ val current = screen.sliderCompleteHandlers[seekBar] ?: return
1399
+ val raw = minimum + (maximum - minimum) * seekBar.progress.toDouble() / max.toDouble()
1400
+ val step = screen.sliderSteps[seekBar]
1401
+ val value = if (step != null) minimum + ((raw - minimum) / step).roundToInt() * step else raw
1402
+ send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject().put("value", value)))
1403
+ }
1404
+ })
1405
+ }
1406
+ "ActivityIndicator" -> (previous as? android.widget.ProgressBar ?: android.widget.ProgressBar(this)).apply {
1407
+ val requestedSize = props.opt("size")
1408
+ val scale = when (requestedSize) {
1409
+ "large" -> 1.35f
1410
+ "small" -> 0.75f
1411
+ is Number -> (requestedSize.toDouble() / 24.0).toFloat().coerceAtLeast(0.5f)
1412
+ else -> 1f
1413
+ }
1414
+ scaleX = scale
1415
+ scaleY = scale
1416
+ val animating = props.optBoolean("animating", true)
1417
+ isIndeterminate = animating
1418
+ visibility = if (!animating && props.optBoolean("hidesWhenStopped", true)) View.GONE else View.VISIBLE
1419
+ indeterminateTintList = props.optString("color").takeIf { it.isNotBlank() }
1420
+ ?.let { ColorStateList.valueOf(color(it, Color.WHITE)) }
1421
+ }
741
1422
  "Image" -> (previous as? ImageView ?: ImageView(this)).apply {
742
1423
  scaleType = when (style.optString("resizeMode").ifBlank { props.optString("resizeMode") }) {
743
1424
  "cover" -> ImageView.ScaleType.CENTER_CROP
@@ -746,10 +1427,18 @@ class MainActivity : AppCompatActivity() {
746
1427
  else -> ImageView.ScaleType.CENTER_INSIDE
747
1428
  }
748
1429
  adjustViewBounds = false
749
- updateImage(this, props.opt("source"))
1430
+ val onLoadStart = events.optString("onLoadStart")
1431
+ if (onLoadStart.isNotBlank()) screen.imageLoadStartHandlers[this] = onLoadStart else screen.imageLoadStartHandlers.remove(this)
1432
+ val onLoad = events.optString("onLoad")
1433
+ if (onLoad.isNotBlank()) screen.imageLoadHandlers[this] = onLoad else screen.imageLoadHandlers.remove(this)
1434
+ val onLoadEnd = events.optString("onLoadEnd")
1435
+ if (onLoadEnd.isNotBlank()) screen.imageLoadEndHandlers[this] = onLoadEnd else screen.imageLoadEndHandlers.remove(this)
1436
+ val onError = events.optString("onError")
1437
+ if (onError.isNotBlank()) screen.imageErrorHandlers[this] = onError else screen.imageErrorHandlers.remove(this)
1438
+ updateImage(screen, this, props.opt("source"))
750
1439
  }
751
1440
  "ScrollView" -> (previous as? CraftNativeScrollView ?: CraftNativeScrollView(this)).apply {
752
- val horizontal = props.optBoolean("horizontal", false) || style.optString("flexDirection") == "row"
1441
+ val horizontal = props.optBoolean("horizontal", false) || style.optString("flexDirection") in setOf("row", "row-reverse")
753
1442
  setHorizontal(horizontal)
754
1443
  setNativeNestedScrollingEnabled(true)
755
1444
  setScrollIndicators(
@@ -757,12 +1446,23 @@ class MainActivity : AppCompatActivity() {
757
1446
  props.optBoolean("showsHorizontalScrollIndicator", true),
758
1447
  )
759
1448
  setScrollEnabled(props.optBoolean("scrollEnabled", true))
1449
+ setKeyboardShouldPersistTaps(props.optString("keyboardShouldPersistTaps", "never"))
1450
+ setBounces(props.optBoolean("bounces", true))
1451
+ setAlwaysBounce(
1452
+ props.optBoolean("alwaysBounceVertical", !horizontal),
1453
+ props.optBoolean("alwaysBounceHorizontal", horizontal),
1454
+ )
1455
+ setPagingEnabled(props.optBoolean("pagingEnabled", false))
760
1456
  val onScroll = events.optString("onScroll")
761
1457
  if (onScroll.isNotBlank()) screen.scrollHandlers[this] = onScroll else screen.scrollHandlers.remove(this)
762
1458
  val onBegin = events.optString("onScrollBeginDrag")
763
1459
  if (onBegin.isNotBlank()) screen.scrollBeginHandlers[this] = onBegin else screen.scrollBeginHandlers.remove(this)
764
1460
  val onEnd = events.optString("onScrollEndDrag")
765
1461
  if (onEnd.isNotBlank()) screen.scrollEndHandlers[this] = onEnd else screen.scrollEndHandlers.remove(this)
1462
+ val onMomentumBegin = events.optString("onMomentumScrollBegin")
1463
+ if (onMomentumBegin.isNotBlank()) screen.scrollMomentumBeginHandlers[this] = onMomentumBegin else screen.scrollMomentumBeginHandlers.remove(this)
1464
+ val onMomentumEnd = events.optString("onMomentumScrollEnd")
1465
+ if (onMomentumEnd.isNotBlank()) screen.scrollMomentumEndHandlers[this] = onMomentumEnd else screen.scrollMomentumEndHandlers.remove(this)
766
1466
  val nativeScroll = this
767
1467
  setScrollListeners(
768
1468
  onScroll = { scrollX, scrollY ->
@@ -784,40 +1484,141 @@ class MainActivity : AppCompatActivity() {
784
1484
  send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
785
1485
  }
786
1486
  },
1487
+ onMomentumBegin = {
1488
+ screen.scrollMomentumBeginHandlers[nativeScroll]?.let { current ->
1489
+ send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
1490
+ }
1491
+ },
1492
+ onMomentumEnd = {
1493
+ screen.scrollMomentumEndHandlers[nativeScroll]?.let { current ->
1494
+ send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
1495
+ }
1496
+ },
1497
+ keyboardDismissMode = props.optString("keyboardDismissMode"),
1498
+ dismissKeyboard = {
1499
+ (getSystemService(INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager)
1500
+ ?.hideSoftInputFromWindow(nativeScroll.windowToken, 0)
1501
+ },
787
1502
  )
788
- configureContainer(content, style)
789
- reconcileChildren(screen, content, children, path, next, style, previousControls)
1503
+ val contentStyle = JSONObject(style.toString()).apply {
1504
+ props.optJSONObject("contentContainerStyle")?.let { override ->
1505
+ override.keys().forEach { key -> put(key, override.get(key)) }
1506
+ }
1507
+ }
1508
+ configureContainer(content, contentStyle)
1509
+ reconcileChildren(screen, content, children, path, next, contentStyle, previousControls)
790
1510
  }
791
- "FlatList" -> (previous as? CraftNativeFlatList ?: CraftNativeFlatList(this)).apply {
792
- reconcileFlatList(screen, this, children, path, protocolId ?: path, props, events)
1511
+ "FlatList" -> (previous as? CraftNativeRefreshableFlatList ?: CraftNativeRefreshableFlatList(this)).apply {
1512
+ val nativeList = list
1513
+ configureRefresh(events.optString("onRefresh").takeIf { it.isNotBlank() }?.let { handler ->
1514
+ { send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", JSONObject())) }
1515
+ }, props.optBoolean("refreshing", false))
1516
+ nativeList.setKeyboardShouldPersistTaps(props.optString("keyboardShouldPersistTaps", "never"))
1517
+ nativeList.setContentContainerStyle(props.optJSONObject("contentContainerStyle"), resources.displayMetrics.density)
1518
+ nativeList.setKeyboardDismissMode(props.optString("keyboardDismissMode")) {
1519
+ (getSystemService(INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager)
1520
+ ?.hideSoftInputFromWindow(windowToken, 0)
1521
+ }
1522
+ nativeList.overScrollMode = if (props.optBoolean("bounces", true)) View.OVER_SCROLL_ALWAYS else View.OVER_SCROLL_NEVER
1523
+ reconcileFlatList(screen, nativeList, children, path, protocolId ?: path, props, events)
1524
+ }
1525
+ "View", "SafeAreaView" -> (previous as? CraftNativeFlexLayout ?: CraftNativeFlexLayout(this)).apply {
1526
+ configureContainer(this, style)
1527
+ reconcileChildren(screen, this, children, path, next, style, previousControls)
793
1528
  }
794
1529
  else -> (previous as? CraftNativeFlexLayout ?: CraftNativeFlexLayout(this)).apply {
795
1530
  configureContainer(this, style)
796
1531
  reconcileChildren(screen, this, children, path, next, style, previousControls)
797
1532
  }
798
1533
  }
799
- if (type != "Button" && type != "TextInput") {
1534
+ val pressInHandler = events.optString("onPressIn").takeIf { it.isNotBlank() }
1535
+ val pressOutHandler = events.optString("onPressOut").takeIf { it.isNotBlank() }
1536
+ if (pressInHandler != null) screen.pressInHandlers[control] = pressInHandler else screen.pressInHandlers.remove(control)
1537
+ if (pressOutHandler != null) screen.pressOutHandlers[control] = pressOutHandler else screen.pressOutHandlers.remove(control)
1538
+ if (type != "Button" && type != "Link" && type != "TextInput" && type != "Picker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator") {
800
1539
  val handler = events.optString("onPress").ifBlank { events.optString("onClick") }
801
- if (handler.isNotBlank()) {
802
- screen.handlers[control] = handler
1540
+ val disabled = props.optBoolean("disabled", false)
1541
+ val hasPressLifecycle = pressInHandler != null || pressOutHandler != null
1542
+ if ((handler.isNotBlank() || (type in setOf("TouchableOpacity", "TouchableHighlight", "Pressable") && hasPressLifecycle)) && !disabled) {
1543
+ if (handler.isNotBlank()) screen.handlers[control] = handler else screen.handlers.remove(control)
803
1544
  control.isClickable = true
804
- control.setOnClickListener {
805
- val current = screen.handlers[control] ?: return@setOnClickListener
806
- send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
1545
+ if (type in setOf("TouchableOpacity", "TouchableHighlight", "Pressable")) {
1546
+ control.setOnClickListener(null)
1547
+ control.setOnTouchListener { view, event ->
1548
+ when (event.actionMasked) {
1549
+ android.view.MotionEvent.ACTION_DOWN -> {
1550
+ val active = screen.pressActiveOpacities[view] ?: 0.5f
1551
+ val base = screen.pressBaseOpacities[view] ?: 1f
1552
+ view.alpha = base * active
1553
+ screen.pressInHandlers[view]?.let { current ->
1554
+ send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
1555
+ }
1556
+ true
1557
+ }
1558
+ android.view.MotionEvent.ACTION_UP, android.view.MotionEvent.ACTION_CANCEL -> {
1559
+ view.alpha = screen.pressBaseOpacities[view] ?: 1f
1560
+ screen.pressOutHandlers[view]?.let { current ->
1561
+ send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
1562
+ }
1563
+ if (event.actionMasked == android.view.MotionEvent.ACTION_UP) {
1564
+ val current = screen.handlers[view] ?: return@setOnTouchListener true
1565
+ send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
1566
+ }
1567
+ true
1568
+ }
1569
+ else -> true
1570
+ }
1571
+ }
1572
+ } else {
1573
+ control.setOnTouchListener(null)
1574
+ control.setOnClickListener {
1575
+ val current = screen.handlers[control] ?: return@setOnClickListener
1576
+ send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
1577
+ }
807
1578
  }
808
1579
  } else {
809
1580
  screen.handlers.remove(control)
810
1581
  control.isClickable = false
811
1582
  control.setOnClickListener(null)
1583
+ control.setOnTouchListener(null)
812
1584
  }
1585
+ control.isEnabled = !disabled
1586
+ }
1587
+ val longPressHandler = events.optString("onLongPress").takeUnless { props.optBoolean("disabled", false) } ?: ""
1588
+ if (longPressHandler.isNotBlank()) {
1589
+ screen.longPressHandlers[control] = longPressHandler
1590
+ control.setOnLongClickListener {
1591
+ val current = screen.longPressHandlers[control] ?: return@setOnLongClickListener false
1592
+ send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
1593
+ true
1594
+ }
1595
+ } else {
1596
+ screen.longPressHandlers.remove(control)
1597
+ control.setOnLongClickListener(null)
813
1598
  }
814
1599
  applyAccessibility(control, props, type, explicitKey ?: protocolId ?: path)
815
- if (control is ImageView) imageErrors[control]?.let { control.contentDescription = "${control.contentDescription}, $it" }
1600
+ val layoutHandler = events.optString("onLayout")
1601
+ if (layoutHandler.isNotBlank()) {
1602
+ if (screen.layoutHandlers[control] != layoutHandler) screen.lastLayoutBounds.remove(control)
1603
+ screen.layoutHandlers[control] = layoutHandler
1604
+ } else {
1605
+ screen.layoutHandlers.remove(control)
1606
+ screen.lastLayoutBounds.remove(control)
1607
+ }
1608
+ if (control is ImageView) imageErrors[control]?.let { message ->
1609
+ val current = control.contentDescription?.toString().orEmpty()
1610
+ if (!current.contains(message)) {
1611
+ control.contentDescription = listOf(current, message).filter { it.isNotBlank() }.joinToString(", ")
1612
+ }
1613
+ }
816
1614
  next[identity] = control
817
1615
  applyViewStyle(control, style)
1616
+ if (type == "Modal" && !props.optBoolean("visible", false)) control.visibility = View.GONE
1617
+ val width = style.optDouble("width", Double.NaN).takeUnless { it.isNaN() }?.let(::dp)
1618
+ val height = style.optDouble("height", Double.NaN).takeUnless { it.isNaN() }?.let(::dp)
818
1619
  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
1620
+ width ?: ViewGroup.LayoutParams.WRAP_CONTENT,
1621
+ height ?: ViewGroup.LayoutParams.WRAP_CONTENT
821
1622
  ).apply {
822
1623
  val margin = style.optDouble("margin", 0.0)
823
1624
  val horizontal = style.optDouble("marginHorizontal", margin)
@@ -843,6 +1644,17 @@ class MainActivity : AppCompatActivity() {
843
1644
  layout.configure(style, resources.displayMetrics.density)
844
1645
  }
845
1646
 
1647
+ private fun flatListScrollEvent(list: CraftNativeFlatList): JSONObject = JSONObject()
1648
+ .put("contentOffset", JSONObject()
1649
+ .put("x", list.computeHorizontalScrollOffset())
1650
+ .put("y", list.computeVerticalScrollOffset()))
1651
+ .put("contentSize", JSONObject()
1652
+ .put("width", list.computeHorizontalScrollRange())
1653
+ .put("height", list.computeVerticalScrollRange()))
1654
+ .put("layoutMeasurement", JSONObject()
1655
+ .put("width", list.width)
1656
+ .put("height", list.height))
1657
+
846
1658
  private fun reconcileChildren(
847
1659
  screen: Screen,
848
1660
  layout: CraftNativeFlexLayout,
@@ -895,17 +1707,35 @@ class MainActivity : AppCompatActivity() {
895
1707
  ) {
896
1708
  screen.flatListOwners.entries.removeAll { it.value == owner }
897
1709
  screen.flatListOwnerByView[list] = owner
1710
+ list.onLayoutChanged = {
1711
+ if (screen.active) emitLayoutEvents(screen)
1712
+ }
898
1713
  val nodes = (0 until children.length()).mapNotNull { children.optJSONObject(it) }
899
1714
  nodes.forEach { registerFlatListOwnership(screen, it, owner) }
1715
+ list.onScrollEvent = events.optString("onScroll").takeIf { it.isNotBlank() }?.let { handler ->
1716
+ { send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", flatListScrollEvent(list))) }
1717
+ }
1718
+ list.onScrollBeginDrag = events.optString("onScrollBeginDrag").takeIf { it.isNotBlank() }?.let { handler ->
1719
+ { send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", flatListScrollEvent(list))) }
1720
+ }
1721
+ list.onScrollEndDrag = events.optString("onScrollEndDrag").takeIf { it.isNotBlank() }?.let { handler ->
1722
+ { send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", flatListScrollEvent(list))) }
1723
+ }
1724
+ list.onMomentumScrollBegin = events.optString("onMomentumScrollBegin").takeIf { it.isNotBlank() }?.let { handler ->
1725
+ { send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", flatListScrollEvent(list))) }
1726
+ }
1727
+ list.onMomentumScrollEnd = events.optString("onMomentumScrollEnd").takeIf { it.isNotBlank() }?.let { handler ->
1728
+ { send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", flatListScrollEvent(list))) }
1729
+ }
900
1730
  list.apply(
901
1731
  nodes = nodes,
902
1732
  horizontal = props.optBoolean("horizontal", false),
903
1733
  columns = props.optInt("numColumns", 1),
904
1734
  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
- },
1735
+ endReachedThreshold = props.opt("onEndReachedThreshold")
1736
+ ?.takeUnless { it == JSONObject.NULL }
1737
+ ?.let { props.optDouble("onEndReachedThreshold", 0.1) }
1738
+ ?: props.optDouble("threshold", 0.1),
909
1739
  renderItem = { node, itemIdentity, _ ->
910
1740
  val rows = screen.flatListRows.getOrPut(list) { mutableMapOf() }
911
1741
  val previous = rows[itemIdentity].orEmpty()
@@ -917,7 +1747,7 @@ class MainActivity : AppCompatActivity() {
917
1747
  },
918
1748
  recycleItem = { itemIdentity ->
919
1749
  val controls = screen.flatListRows[list]?.remove(itemIdentity).orEmpty()
920
- releaseViewState(screen, controls.values.toList())
1750
+ releaseViewState(screen, controls.values.toList(), preservingInputDrafts = true)
921
1751
  },
922
1752
  endReached = events.optString("onEndReached").takeIf { it.isNotBlank() }?.let { handler ->
923
1753
  { send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", JSONObject())) }
@@ -942,21 +1772,26 @@ class MainActivity : AppCompatActivity() {
942
1772
  view.alpha = style.optDouble("opacity", 1.0).toFloat()
943
1773
  view.visibility = if (style.optString("display") == "none") View.GONE else View.VISIBLE
944
1774
  view.elevation = dp(style.optDouble("elevation", 0.0)).toFloat()
945
- val background = style.has("backgroundColor")
1775
+ val background = style.has("backgroundColor") && !style.isNull("backgroundColor")
946
1776
  val border = style.optDouble("borderWidth", 0.0)
947
1777
  val radius = style.optDouble("borderRadius", 0.0)
1778
+ val defaultBackground = if (defaultBackgrounds.containsKey(view)) {
1779
+ defaultBackgrounds[view]
1780
+ } else {
1781
+ view.background.also { defaultBackgrounds[view] = it }
1782
+ }
948
1783
  if (background || border > 0 || radius > 0) {
949
1784
  view.background = GradientDrawable().apply {
950
1785
  setColor(color(style.optString("backgroundColor"), Color.TRANSPARENT))
951
1786
  cornerRadius = dp(radius).toFloat()
952
1787
  if (border > 0) setStroke(dp(border), color(style.optString("borderColor"), Color.TRANSPARENT))
953
1788
  }
954
- }
1789
+ } else view.background = defaultBackground
955
1790
  view.clipToOutline = style.optString("overflow") == "hidden"
956
1791
  if (view is ViewGroup) view.clipChildren = style.optString("overflow") == "hidden"
957
1792
  if (view is ImageView) {
958
- if (style.has("tintColor")) view.setColorFilter(color(style.optString("tintColor"), Color.TRANSPARENT))
959
- else view.clearColorFilter()
1793
+ style.optString("tintColor").takeIf { it.isNotBlank() }?.let { view.setColorFilter(color(it, Color.TRANSPARENT)) }
1794
+ ?: view.clearColorFilter()
960
1795
  }
961
1796
  }
962
1797
 
@@ -967,8 +1802,12 @@ class MainActivity : AppCompatActivity() {
967
1802
  "capitalize" -> value.split(' ').joinToString(" ") { word -> word.replaceFirstChar(Char::uppercase) }
968
1803
  else -> value
969
1804
  }
1805
+ configureTextStyle(view, style)
1806
+ }
1807
+
1808
+ private fun configureTextStyle(view: TextView, style: JSONObject) {
970
1809
  view.setTextColor(color(style.optString("color"), defaultTextColor))
971
- view.setTextSize(TypedValue.COMPLEX_UNIT_SP, style.optDouble("fontSize", 14.0).toFloat())
1810
+ view.setTextSize(TypedValue.COMPLEX_UNIT_SP, style.optDouble("fontSize", 16.0).toFloat())
972
1811
  val weight = when (style.optString("fontWeight")) {
973
1812
  "bold" -> 700
974
1813
  "100", "200", "300", "400", "500", "600", "700", "800", "900" -> style.optInt("fontWeight")
@@ -987,10 +1826,19 @@ class MainActivity : AppCompatActivity() {
987
1826
  "justify" -> View.TEXT_ALIGNMENT_VIEW_START
988
1827
  else -> View.TEXT_ALIGNMENT_INHERIT
989
1828
  }
990
- view.letterSpacing = if (style.has("letterSpacing") && view.textSize > 0)
991
- dp(style.optDouble("letterSpacing")).toFloat() / view.textSize else 0f
1829
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
1830
+ view.justificationMode = if (style.optString("textAlign") == "justify") {
1831
+ Layout.JUSTIFICATION_MODE_INTER_WORD
1832
+ } else {
1833
+ Layout.JUSTIFICATION_MODE_NONE
1834
+ }
1835
+ }
1836
+ view.letterSpacing = style.optDouble("letterSpacing", Double.NaN)
1837
+ .takeUnless { it.isNaN() }
1838
+ ?.let { if (view.textSize > 0) dp(it).toFloat() / view.textSize else 0f }
1839
+ ?: 0f
992
1840
  view.setLineSpacing(
993
- if (style.has("lineHeight")) dp(style.optDouble("lineHeight")) - view.textSize else 0f,
1841
+ style.optDouble("lineHeight", Double.NaN).takeUnless { it.isNaN() }?.let { dp(it) - view.textSize } ?: 0f,
994
1842
  1f
995
1843
  )
996
1844
  view.paintFlags = view.paintFlags and Paint.UNDERLINE_TEXT_FLAG.inv() and Paint.STRIKE_THRU_TEXT_FLAG.inv()
@@ -1002,7 +1850,7 @@ class MainActivity : AppCompatActivity() {
1002
1850
  }
1003
1851
  }
1004
1852
 
1005
- private fun updateImage(view: ImageView, source: Any?) {
1853
+ private fun updateImage(screen: Screen, view: ImageView, source: Any?) {
1006
1854
  val uri = when (source) {
1007
1855
  is String -> source
1008
1856
  is JSONObject -> source.optString("uri")
@@ -1013,6 +1861,7 @@ class MainActivity : AppCompatActivity() {
1013
1861
  imageSources.remove(view)
1014
1862
  imageErrors[view] = "Image source is missing"
1015
1863
  view.setImageDrawable(null)
1864
+ imageFailure(screen, view, uri, "Image source is missing")
1016
1865
  return
1017
1866
  }
1018
1867
  if (imageSources[view] == uri) return
@@ -1020,20 +1869,29 @@ class MainActivity : AppCompatActivity() {
1020
1869
  imageSources[view] = uri
1021
1870
  imageErrors.remove(view)
1022
1871
  view.setImageDrawable(null)
1872
+ emitImageEvent(screen, view, screen.imageLoadStartHandlers[view])
1023
1873
 
1024
1874
  if (uri.startsWith("data:image/") && uri.contains(',')) {
1025
1875
  val bytes = runCatching { Base64.decode(uri.substringAfter(','), Base64.DEFAULT) }.getOrNull()
1026
1876
  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")
1877
+ if (bitmap != null) {
1878
+ view.setImageBitmap(bitmap)
1879
+ emitImageEvent(screen, view, screen.imageLoadHandlers[view])
1880
+ emitImageEvent(screen, view, screen.imageLoadEndHandlers[view])
1881
+ } else imageFailure(screen, view, uri, "Image data is invalid")
1028
1882
  return
1029
1883
  }
1030
1884
  if (!uri.contains(':')) {
1031
1885
  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")
1886
+ if (bitmap != null) {
1887
+ view.setImageBitmap(bitmap)
1888
+ emitImageEvent(screen, view, screen.imageLoadHandlers[view])
1889
+ emitImageEvent(screen, view, screen.imageLoadEndHandlers[view])
1890
+ } else imageFailure(screen, view, uri, "Bundled image was not found")
1033
1891
  return
1034
1892
  }
1035
1893
  if (!uri.startsWith("https://")) {
1036
- imageFailure(view, uri, "Unsupported image source")
1894
+ imageFailure(screen, view, uri, "Unsupported image source")
1037
1895
  return
1038
1896
  }
1039
1897
  imageJobs[view] = imageExecutor.submit {
@@ -1047,27 +1905,68 @@ class MainActivity : AppCompatActivity() {
1047
1905
  runOnUiThread {
1048
1906
  if (imageSources[view] != uri) return@runOnUiThread
1049
1907
  imageJobs.remove(view)
1050
- if (bitmap != null) view.setImageBitmap(bitmap) else imageFailure(view, uri, "Image download failed")
1908
+ if (bitmap != null) {
1909
+ view.setImageBitmap(bitmap)
1910
+ emitImageEvent(screen, view, screen.imageLoadHandlers[view])
1911
+ emitImageEvent(screen, view, screen.imageLoadEndHandlers[view])
1912
+ } else imageFailure(screen, view, uri, "Image download failed")
1051
1913
  }
1052
1914
  }
1053
1915
  }
1054
1916
 
1055
- private fun imageFailure(view: ImageView, uri: String, message: String) {
1917
+ private fun imageFailure(screen: Screen, view: ImageView, uri: String, message: String) {
1056
1918
  imageErrors[view] = message
1057
1919
  view.setImageDrawable(null)
1058
- view.contentDescription = "${view.tag ?: "Image"}, $message"
1920
+ val current = view.contentDescription?.toString().orEmpty()
1921
+ if (!current.contains(message)) {
1922
+ view.contentDescription = listOf(
1923
+ current.ifBlank { view.tag?.toString() ?: "Image" },
1924
+ message,
1925
+ ).joinToString(", ")
1926
+ }
1927
+ emitImageEvent(screen, view, screen.imageErrorHandlers[view], JSONObject().put("error", JSONObject().put("message", message)))
1928
+ emitImageEvent(screen, view, screen.imageLoadEndHandlers[view])
1059
1929
  Log.e(TAG, "$message: $uri")
1060
1930
  }
1061
1931
 
1932
+ private fun emitImageEvent(screen: Screen, view: ImageView, handler: String?, nativeEvent: JSONObject = JSONObject()) {
1933
+ if (handler.isNullOrBlank()) return
1934
+ send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", nativeEvent))
1935
+ }
1936
+
1937
+ private fun setTextPreservingSelection(field: EditText, value: String) {
1938
+ val start = field.selectionStart
1939
+ val end = field.selectionEnd
1940
+ field.setText(value)
1941
+ if (start < 0 || end < 0) {
1942
+ field.setSelection(value.length)
1943
+ return
1944
+ }
1945
+ val boundedStart = start.coerceIn(0, value.length)
1946
+ val boundedEnd = end.coerceIn(0, value.length)
1947
+ field.setSelection(minOf(boundedStart, boundedEnd), maxOf(boundedStart, boundedEnd))
1948
+ }
1949
+
1062
1950
  private fun applyAccessibility(view: View, props: JSONObject, type: String, fallbackLabel: String) {
1063
1951
  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 }
1952
+ val label = props.optString("accessibilityLabel").takeIf { it.isNotBlank() }
1953
+ ?: props.optString("alt").takeIf { type == "Image" && it.isNotBlank() }
1954
+ val value = props.opt("accessibilityValue")?.takeUnless { it == JSONObject.NULL }?.toString()
1955
+ view.contentDescription = when {
1956
+ label != null && value != null -> "$label, $value"
1957
+ label != null -> label
1958
+ value != null -> value
1959
+ else -> null
1960
+ }
1961
+ view.tooltipText = props.optString("accessibilityHint").takeIf { it.isNotBlank() }
1067
1962
  val role = props.optString("accessibilityRole").ifBlank { type.lowercase() }
1068
1963
  val state = props.optJSONObject("accessibilityState")
1069
1964
  val disabled = state?.optBoolean("disabled", false) == true
1070
- if (state != null && state.has("disabled")) view.isEnabled = !disabled
1965
+ if (type !in setOf("Button", "Link", "TextInput", "Picker", "Modal", "Switch", "Slider", "ActivityIndicator")) {
1966
+ view.isEnabled = !disabled
1967
+ } else if (disabled) {
1968
+ view.isEnabled = false
1969
+ }
1071
1970
  ViewCompat.setAccessibilityDelegate(view, object : AccessibilityDelegateCompat() {
1072
1971
  override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfoCompat) {
1073
1972
  super.onInitializeAccessibilityNodeInfo(host, info)
@@ -1080,7 +1979,8 @@ class MainActivity : AppCompatActivity() {
1080
1979
  }
1081
1980
  info.isHeading = role == "header"
1082
1981
  info.roleDescription = role.takeIf { it !in setOf("view", "text", "textinput", "scrollview") }
1083
- info.isEnabled = !disabled
1982
+ info.hintText = host.tooltipText
1983
+ info.isEnabled = host.isEnabled && !disabled
1084
1984
  info.isSelected = state?.optBoolean("selected", false) == true
1085
1985
  info.isChecked = state?.optBoolean("checked", false) == true
1086
1986
  }
@@ -1100,9 +2000,11 @@ class MainActivity : AppCompatActivity() {
1100
2000
  val keyboard = when (props.optString("keyboardType")) {
1101
2001
  "email-address" -> InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS
1102
2002
  "url" -> InputType.TYPE_TEXT_VARIATION_URI
1103
- "numeric" -> InputType.TYPE_CLASS_NUMBER
2003
+ "numeric", "number-pad" -> InputType.TYPE_CLASS_NUMBER
1104
2004
  "decimal-pad" -> InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL
1105
2005
  "phone-pad" -> InputType.TYPE_CLASS_PHONE
2006
+ "web-search" -> InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT
2007
+ "visible-password" -> InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
1106
2008
  else -> 0
1107
2009
  }
1108
2010
  val capitalization = when (props.optString("autoCapitalize")) {
@@ -1243,6 +2145,36 @@ class MainActivity : AppCompatActivity() {
1243
2145
  return initialDeepLink
1244
2146
  }
1245
2147
 
2148
+ private fun emitLayoutEvents(screen: Screen) {
2149
+ emitLayoutEvents(screen, screen.view)
2150
+ }
2151
+
2152
+ private fun emitLayoutEvents(screen: Screen, view: View) {
2153
+ val handler = screen.layoutHandlers[view]
2154
+ if (handler != null && view.isShown) {
2155
+ val density = resources.displayMetrics.density
2156
+ val bounds = LayoutBounds(
2157
+ x = view.left.toDouble() / density.toDouble(),
2158
+ y = view.top.toDouble() / density.toDouble(),
2159
+ width = view.width.toDouble() / density.toDouble(),
2160
+ height = view.height.toDouble() / density.toDouble(),
2161
+ )
2162
+ if (screen.lastLayoutBounds[view] != bounds) {
2163
+ screen.lastLayoutBounds[view] = bounds
2164
+ send(screen, "EVENT", JSONObject()
2165
+ .put("handlerName", handler)
2166
+ .put("nativeEvent", JSONObject().put("layout", JSONObject()
2167
+ .put("x", bounds.x)
2168
+ .put("y", bounds.y)
2169
+ .put("width", bounds.width)
2170
+ .put("height", bounds.height))))
2171
+ }
2172
+ }
2173
+ if (view is ViewGroup) {
2174
+ for (index in 0 until view.childCount) emitLayoutEvents(screen, view.getChildAt(index))
2175
+ }
2176
+ }
2177
+
1246
2178
  private fun send(screen: Screen, type: String, payload: JSONObject, correlationId: String? = null) {
1247
2179
  if (!screen.active) return
1248
2180
  val message = JSONObject().put("type", type).put("payload", payload)
@@ -1276,22 +2208,68 @@ class MainActivity : AppCompatActivity() {
1276
2208
  screen.flatListRows.values.forEach { rows -> rows.values.forEach { addAll(it.values) } }
1277
2209
  }
1278
2210
 
1279
- private fun releaseViewState(screen: Screen, views: Collection<View>) {
1280
- val released = views.toSet()
1281
- released.filterIsInstance<CraftNativeFlatList>().forEach { list ->
1282
- val owner = screen.flatListOwnerByView.remove(list)
1283
- list.discardAll()
1284
- screen.flatListRows.remove(list)?.values?.forEach { releaseViewState(screen, it.values) }
1285
- if (owner != null) screen.flatListOwners.entries.removeAll { it.value == owner }
2211
+ private fun releaseFlatList(
2212
+ screen: Screen,
2213
+ list: CraftNativeFlatList,
2214
+ preservingInputDrafts: Boolean,
2215
+ ) {
2216
+ val owner = screen.flatListOwnerByView.remove(list)
2217
+ list.discardAll()
2218
+ screen.flatListRows.remove(list)?.values?.forEach {
2219
+ releaseViewState(screen, it.values, preservingInputDrafts)
2220
+ }
2221
+ if (owner != null) screen.flatListOwners.entries.removeAll { it.value == owner }
2222
+ }
2223
+
2224
+ private fun releaseViewState(
2225
+ screen: Screen,
2226
+ views: Collection<View>,
2227
+ preservingInputDrafts: Boolean = false,
2228
+ ) {
2229
+ val released = views.toMutableSet()
2230
+ val refreshableLists = released.filterIsInstance<CraftNativeRefreshableFlatList>()
2231
+ val nestedLists = refreshableLists.map { it.list }.toSet()
2232
+ released.addAll(nestedLists)
2233
+ refreshableLists.forEach {
2234
+ releaseFlatList(screen, it.list, preservingInputDrafts)
2235
+ }
2236
+ released.filterIsInstance<CraftNativeFlatList>().filterNot { nestedLists.contains(it) }.forEach {
2237
+ releaseFlatList(screen, it, preservingInputDrafts)
1286
2238
  }
1287
2239
  releaseImages(released)
2240
+ released.filterIsInstance<EditText>().forEach {
2241
+ inputHintDefaults.remove(it)
2242
+ inputHighlightDefaults.remove(it)
2243
+ screen.suppressedTextChanges.remove(it)
2244
+ val identity = screen.inputIdentities.remove(it)
2245
+ if (!preservingInputDrafts) identity?.let(screen.drafts::remove)
2246
+ }
2247
+ released.forEach(defaultBackgrounds::remove)
1288
2248
  screen.handlers.keys.removeAll(released)
2249
+ screen.pressInHandlers.keys.removeAll(released)
2250
+ screen.pressOutHandlers.keys.removeAll(released)
2251
+ screen.longPressHandlers.keys.removeAll(released)
2252
+ screen.layoutHandlers.keys.removeAll(released)
2253
+ screen.lastLayoutBounds.keys.removeAll(released)
1289
2254
  screen.scrollHandlers.keys.removeAll(released)
1290
2255
  screen.scrollBeginHandlers.keys.removeAll(released)
1291
2256
  screen.scrollEndHandlers.keys.removeAll(released)
2257
+ screen.scrollMomentumBeginHandlers.keys.removeAll(released)
2258
+ screen.scrollMomentumEndHandlers.keys.removeAll(released)
2259
+ screen.sliderCompleteHandlers.keys.removeAll(released)
2260
+ screen.sliderSteps.keys.removeAll(released)
1292
2261
  screen.focusHandlers.keys.removeAll(released)
1293
2262
  screen.blurHandlers.keys.removeAll(released)
2263
+ screen.endEditingHandlers.keys.removeAll(released)
1294
2264
  screen.submitHandlers.keys.removeAll(released)
2265
+ screen.imageLoadStartHandlers.keys.removeAll(released)
2266
+ screen.imageLoadHandlers.keys.removeAll(released)
2267
+ screen.imageLoadEndHandlers.keys.removeAll(released)
2268
+ screen.imageErrorHandlers.keys.removeAll(released)
2269
+ screen.pressActiveOpacities.keys.removeAll(released)
2270
+ screen.pressBaseOpacities.keys.removeAll(released)
2271
+ screen.modalVisibility.keys.removeAll(released)
2272
+ screen.modalCloseHandlers.keys.removeAll(released)
1295
2273
  }
1296
2274
 
1297
2275
  private fun closeScreen(screen: Screen) {
@@ -1302,12 +2280,25 @@ class MainActivity : AppCompatActivity() {
1302
2280
  releaseViewState(screen, retainedViews(screen).toList())
1303
2281
  screen.controls.clear()
1304
2282
  screen.handlers.clear()
2283
+ screen.pressInHandlers.clear()
2284
+ screen.pressOutHandlers.clear()
2285
+ screen.layoutHandlers.clear()
2286
+ screen.lastLayoutBounds.clear()
1305
2287
  screen.scrollHandlers.clear()
1306
2288
  screen.scrollBeginHandlers.clear()
1307
2289
  screen.scrollEndHandlers.clear()
2290
+ screen.scrollMomentumBeginHandlers.clear()
2291
+ screen.scrollMomentumEndHandlers.clear()
2292
+ screen.sliderCompleteHandlers.clear()
2293
+ screen.sliderSteps.clear()
1308
2294
  screen.focusHandlers.clear()
1309
2295
  screen.blurHandlers.clear()
2296
+ screen.endEditingHandlers.clear()
1310
2297
  screen.submitHandlers.clear()
2298
+ screen.imageLoadStartHandlers.clear()
2299
+ screen.imageLoadHandlers.clear()
2300
+ screen.imageLoadEndHandlers.clear()
2301
+ screen.imageErrorHandlers.clear()
1311
2302
  screen.flatListRows.clear()
1312
2303
  screen.flatListOwners.clear()
1313
2304
  screen.flatListOwnerByView.clear()
@@ -1315,7 +2306,17 @@ class MainActivity : AppCompatActivity() {
1315
2306
  }
1316
2307
 
1317
2308
  @Deprecated("Uses the activity back callback for native route navigation")
1318
- override fun onBackPressed() = back()
2309
+ override fun onBackPressed() {
2310
+ val screen = screens.lastOrNull() ?: return back()
2311
+ val modal = screen.modalVisibility.entries.lastOrNull { (view, visible) -> visible && view.visibility == View.VISIBLE }
2312
+ if (modal != null) {
2313
+ screen.modalCloseHandlers[modal.key]?.let { handler ->
2314
+ send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", JSONObject()))
2315
+ }
2316
+ return
2317
+ }
2318
+ back()
2319
+ }
1319
2320
 
1320
2321
  private fun showError(message: String) {
1321
2322
  root.removeAllViews()