craft-native 0.0.124 → 0.0.126

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.
@@ -425,10 +425,12 @@ async function init(options) {
425
425
  if (config.renderer === "native") {
426
426
  const mutationTemplate = readFileSync(join(TEMPLATES_DIR, "CraftNativeMutation.kt.template"), "utf-8");
427
427
  const flatListTemplate = readFileSync(join(TEMPLATES_DIR, "CraftNativeFlatList.kt.template"), "utf-8");
428
+ const refreshableFlatListTemplate = readFileSync(join(TEMPLATES_DIR, "CraftNativeRefreshableFlatList.kt.template"), "utf-8");
428
429
  const capabilityTemplate = readFileSync(join(TEMPLATES_DIR, "CraftNativeCapabilities.kt.template"), "utf-8");
429
430
  const notificationTemplate = readFileSync(join(TEMPLATES_DIR, "CraftNativeNotificationReceiver.kt.template"), "utf-8");
430
431
  writeFileSync(join(output, "app/src/main/java", packagePath, "CraftNativeMutation.kt"), mutationTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
431
432
  writeFileSync(join(output, "app/src/main/java", packagePath, "CraftNativeFlatList.kt"), flatListTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
433
+ writeFileSync(join(output, "app/src/main/java", packagePath, "CraftNativeRefreshableFlatList.kt"), refreshableFlatListTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
432
434
  writeFileSync(join(output, "app/src/main/java", packagePath, "CraftNativeCapabilities.kt"), capabilityTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
433
435
  writeFileSync(join(output, "app/src/main/java", packagePath, "CraftNativeNotificationReceiver.kt"), notificationTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
434
436
  }
@@ -475,7 +477,7 @@ async function init(options) {
475
477
  const projectGradleTemplate = readFileSync(join(TEMPLATES_DIR, "build.gradle.kts.project.template"), "utf-8");
476
478
  writeFileSync(join(output, "build.gradle.kts"), projectGradleTemplate.replace(/\{\{GOOGLE_SERVICES_PLUGIN\}\}/g, hasGoogleServices ? ' id("com.google.gms.google-services") version "4.4.2" apply false' : ""));
477
479
  const appGradleTemplate = readFileSync(join(TEMPLATES_DIR, "build.gradle.kts.app.template"), "utf-8");
478
- const appGradle = appGradleTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{VERSION_NAME\}\}/g, escapeKotlinString(config.version || "1.0.0")).replace(/\{\{VERSION_CODE\}\}/g, String(config.versionCode || 1)).replace(/\{\{MIN_SDK\}\}/g, String(config.minSdk ?? ANDROID_MIN_SDK)).replace(/\{\{COMPILE_SDK\}\}/g, String(config.compileSdk || 36)).replace(/\{\{TARGET_SDK\}\}/g, String(config.targetSdk || 35)).replace(/\{\{GOOGLE_SERVICES_PLUGIN\}\}/g, hasGoogleServices ? ' id("com.google.gms.google-services")' : "").replace(/\{\{FIREBASE_MESSAGING_DEPENDENCY\}\}/g, config.enablePushNotifications ? ' implementation("com.google.firebase:firebase-messaging:24.1.0")' : "").replace(/\{\{HEALTH_CONNECT_DEPENDENCIES\}\}/g, config.enableHealthConnect ? ` implementation("androidx.health.connect:connect-client:1.1.0")
480
+ const appGradle = appGradleTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{VERSION_NAME\}\}/g, escapeKotlinString(config.version || "1.0.0")).replace(/\{\{VERSION_CODE\}\}/g, String(config.versionCode || 1)).replace(/\{\{MIN_SDK\}\}/g, String(config.minSdk ?? ANDROID_MIN_SDK)).replace(/\{\{COMPILE_SDK\}\}/g, String(config.compileSdk || 36)).replace(/\{\{TARGET_SDK\}\}/g, String(config.targetSdk || 35)).replace(/\{\{GOOGLE_SERVICES_PLUGIN\}\}/g, hasGoogleServices ? ' id("com.google.gms.google-services")' : "").replace(/\{\{FIREBASE_MESSAGING_DEPENDENCY\}\}/g, config.enablePushNotifications ? ' implementation("com.google.firebase:firebase-messaging:24.1.0")' : "").replace(/\{\{REFRESH_LAYOUT_DEPENDENCY\}\}/g, config.renderer === "native" ? ' implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")' : "").replace(/\{\{HEALTH_CONNECT_DEPENDENCIES\}\}/g, config.enableHealthConnect ? ` implementation("androidx.health.connect:connect-client:1.1.0")
479
481
  implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")` : "").replace(/\{\{JAVASCRIPT_ENGINE_DEPENDENCY\}\}/g, config.renderer === "native" ? ' implementation("androidx.javascriptengine:javascriptengine:1.1.0")' : "");
480
482
  writeFileSync(join(output, "app/build.gradle.kts"), appGradle);
481
483
  const proguardTemplate = readFileSync(join(TEMPLATES_DIR, "proguard-rules.pro.template"), "utf-8");
@@ -618,7 +620,7 @@ zipStorePath=wrapper/dists
618
620
  console.log("Next steps:");
619
621
  console.log(` 1. cd ${output}`);
620
622
  if (config.renderer === "native") {
621
- console.log(" 2. Compile your screen: stx-native compile Screen.stx --format bundle --output screen.js");
623
+ console.log(" 2. Compile your screen: stx native compile Screen.stx --format bundle --output screen.js");
622
624
  console.log(" 3. Run: craft android build --native-bundle screen.js");
623
625
  } else {
624
626
  console.log(" 2. Add your web content to app/src/main/assets/index.html");
@@ -644,7 +646,7 @@ async function build(options) {
644
646
  if (nativeBundlePath && resolve(nativeBundlePath) !== resolve(destination))
645
647
  cpSync(nativeBundlePath, destination);
646
648
  if (!existsSync(destination)) {
647
- throw new Error("Native Android mode needs app/src/main/assets/native-screen.js. Compile a .stx screen with stx-native first.");
649
+ throw new Error("Native Android mode needs app/src/main/assets/native-screen.js. Compile a .stx screen with `stx native compile` first.");
648
650
  }
649
651
  } else if (nativeBundlePath) {
650
652
  throw new Error('--native-bundle requires renderer: "native" in craft.config.json.');
@@ -14,7 +14,7 @@ import kotlin.math.ceil
14
14
 
15
15
  internal typealias CraftNativeRenderItem = (node: JSONObject, identity: String, previous: View?) -> View
16
16
 
17
- /** A stable-key RecyclerView host for stx-native FlatList nodes. */
17
+ /** A stable-key RecyclerView host for stx native FlatList nodes. */
18
18
  internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
19
19
  private data class Item(
20
20
  val identity: String,
@@ -90,6 +90,7 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
90
90
  private var dataContentSignature = ""
91
91
  private var horizontal = false
92
92
  private var columns = 1
93
+ private var keyboardShouldPersistTaps = "never"
93
94
  private var submissionGeneration = 0
94
95
  private var isSubmittingList = false
95
96
  private var pendingThemeRefresh = false
@@ -161,7 +162,7 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
161
162
  }
162
163
 
163
164
  fun setKeyboardDismissMode(mode: String, dismissKeyboard: () -> Unit) {
164
- if (mode !in setOf("on-drag", "interactive")) {
165
+ if (mode !in setOf("on-drag", "interactive") && keyboardShouldPersistTaps == "always") {
165
166
  setOnTouchListener(null)
166
167
  return
167
168
  }
@@ -173,11 +174,47 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
173
174
  dismissed = true
174
175
  dismissKeyboard()
175
176
  }
177
+ android.view.MotionEvent.ACTION_UP -> if (shouldDismissKeyboard(event)) dismissKeyboard()
176
178
  }
177
179
  false
178
180
  }
179
181
  }
180
182
 
183
+ fun setKeyboardShouldPersistTaps(value: String?) {
184
+ keyboardShouldPersistTaps = value?.takeIf { it in setOf("always", "handled", "never") } ?: "never"
185
+ }
186
+
187
+ private fun tapTarget(view: View, x: Float, y: Float): View? {
188
+ if (x < 0 || y < 0 || x >= view.width || y >= view.height || view.visibility != View.VISIBLE) return null
189
+ if (view is ViewGroup) {
190
+ for (index in view.childCount - 1 downTo 0) {
191
+ val child = view.getChildAt(index)
192
+ tapTarget(child, x - child.left, y - child.top)?.let { return it }
193
+ }
194
+ }
195
+ return view
196
+ }
197
+
198
+ private fun shouldDismissKeyboard(event: android.view.MotionEvent): Boolean {
199
+ if (keyboardShouldPersistTaps == "always") return false
200
+ if (keyboardShouldPersistTaps == "never") return true
201
+ return tapTarget(this, event.x, event.y)?.isClickable != true
202
+ }
203
+
204
+ fun setContentContainerStyle(style: JSONObject?, density: Float) {
205
+ val padding = (style?.optDouble("padding", 0.0) ?: 0.0) * density
206
+ val horizontal = (style?.optDouble("paddingHorizontal", padding / density) ?: (padding / density)) * density
207
+ val vertical = (style?.optDouble("paddingVertical", padding / density) ?: (padding / density)) * density
208
+ setPadding(
209
+ ((style?.optDouble("paddingLeft", horizontal / density) ?: (horizontal / density)) * density).toInt(),
210
+ ((style?.optDouble("paddingTop", vertical / density) ?: (vertical / density)) * density).toInt(),
211
+ ((style?.optDouble("paddingRight", horizontal / density) ?: (horizontal / density)) * density).toInt(),
212
+ ((style?.optDouble("paddingBottom", vertical / density) ?: (vertical / density)) * density).toInt(),
213
+ )
214
+ clipToPadding = true
215
+ requestLayout()
216
+ }
217
+
181
218
  fun apply(
182
219
  nodes: List<JSONObject>,
183
220
  horizontal: Boolean,
@@ -0,0 +1,29 @@
1
+ package {{PACKAGE_NAME}}
2
+
3
+ import android.content.Context
4
+ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
5
+
6
+ /** Swipe-to-refresh host for a native FlatList. */
7
+ internal class CraftNativeRefreshableFlatList(context: Context) : SwipeRefreshLayout(context) {
8
+ val list = CraftNativeFlatList(context)
9
+ private var refreshHandler: (() -> Unit)? = null
10
+
11
+ init {
12
+ addView(list, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
13
+ setOnRefreshListener { refreshHandler?.invoke() }
14
+ isEnabled = false
15
+ }
16
+
17
+ fun configureRefresh(handler: (() -> Unit)?, refreshing: Boolean) {
18
+ refreshHandler = handler
19
+ isEnabled = handler != null
20
+ isRefreshing = handler != null && refreshing
21
+ if (handler == null) isRefreshing = false
22
+ }
23
+
24
+ override fun onDetachedFromWindow() {
25
+ refreshHandler = null
26
+ isRefreshing = false
27
+ super.onDetachedFromWindow()
28
+ }
29
+ }
@@ -33,6 +33,8 @@ import android.text.InputType
33
33
  import android.text.Layout
34
34
  import android.view.View
35
35
  import android.view.ViewGroup
36
+ import android.widget.AdapterView
37
+ import android.widget.ArrayAdapter
36
38
  import android.widget.Button
37
39
  import android.widget.EditText
38
40
  import android.widget.FrameLayout
@@ -41,6 +43,7 @@ import android.widget.ImageView
41
43
  import android.widget.LinearLayout
42
44
  import android.widget.ScrollView
43
45
  import android.widget.SeekBar
46
+ import android.widget.Spinner
44
47
  import android.widget.Switch
45
48
  import android.widget.TextView
46
49
  import androidx.appcompat.app.AppCompatActivity
@@ -191,6 +194,16 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
191
194
  val lp = child.layoutParams as? MarginLayoutParams
192
195
  val horizontalMargins = (lp?.leftMargin ?: 0) + (lp?.rightMargin ?: 0)
193
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
+ }
194
207
  val childWidth = measureChildWidth(child, style, width - paddingLeft - paddingRight, horizontalMargins)
195
208
  val childHeight = measureChildHeight(child, style, height - paddingTop - paddingBottom, verticalMargins)
196
209
  val entry = Entry(child, style, childWidth, childHeight)
@@ -223,6 +236,16 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
223
236
  val lp = child.layoutParams as? MarginLayoutParams
224
237
  val horizontalMargins = (lp?.leftMargin ?: 0) + (lp?.rightMargin ?: 0)
225
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
+ }
226
249
  val childWidth = measureChildWidth(child, style, cellWidth, horizontalMargins)
227
250
  val childHeight = measureChildHeight(child, style, height - paddingTop - paddingBottom, verticalMargins)
228
251
  val entry = Entry(child, style, childWidth, childHeight)
@@ -250,7 +273,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
250
273
  val child = getChildAt(index)
251
274
  if (child.visibility == GONE) continue
252
275
  val style = styles[child] ?: CraftNativeLayoutStyle()
253
- if (style.position == "absolute") continue
276
+ if (style.position == "absolute" || child is CraftNativeModalView) continue
254
277
  val lp = child.layoutParams as? MarginLayoutParams
255
278
  val horizontalMargins = (lp?.leftMargin ?: 0) + (lp?.rightMargin ?: 0)
256
279
  val verticalMargins = (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0)
@@ -365,6 +388,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
365
388
  val contentWidth = width - paddingLeft - paddingRight
366
389
  val contentHeight = height - paddingTop - paddingBottom
367
390
  for (entry in layout.absolute) {
391
+ if (entry.view is CraftNativeModalView && entry.view.visibility == View.VISIBLE) entry.view.bringToFront()
368
392
  val childWidth = if (entry.style.left != null && entry.style.right != null) contentWidth - entry.style.left!! - entry.style.right!! else entry.width
369
393
  val childHeight = if (entry.style.top != null && entry.style.bottom != null) contentHeight - entry.style.top!! - entry.style.bottom!! else entry.height
370
394
  val x = paddingLeft + (entry.style.left ?: (entry.style.right?.let { contentWidth - it - childWidth } ?: 0))
@@ -435,6 +459,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
435
459
  crossOffset += line.cross + crossGap
436
460
  }
437
461
  for (entry in absolute) {
462
+ if (entry.view is CraftNativeModalView && entry.view.visibility == View.VISIBLE) entry.view.bringToFront()
438
463
  val widthValue = if (entry.style.left != null && entry.style.right != null) contentWidth - entry.style.left!! - entry.style.right!! else entry.width
439
464
  val heightValue = if (entry.style.top != null && entry.style.bottom != null) contentHeight - entry.style.top!! - entry.style.bottom!! else entry.height
440
465
  val x = paddingLeft + (entry.style.left ?: (entry.style.right?.let { contentWidth - it - widthValue } ?: 0))
@@ -463,6 +488,7 @@ private class CraftNativeScrollView(context: Context) : FrameLayout(context) {
463
488
  private var bounces = true
464
489
  private var alwaysBounceVertical = true
465
490
  private var alwaysBounceHorizontal = true
491
+ private var keyboardShouldPersistTaps = "never"
466
492
 
467
493
  init {
468
494
  vertical.isNestedScrollingEnabled = true
@@ -496,6 +522,10 @@ private class CraftNativeScrollView(context: Context) : FrameLayout(context) {
496
522
  horizontal.isEnabled = value
497
523
  }
498
524
 
525
+ fun setKeyboardShouldPersistTaps(value: String?) {
526
+ keyboardShouldPersistTaps = value?.takeIf { it in setOf("always", "handled", "never") } ?: "never"
527
+ }
528
+
499
529
  fun setBounces(value: Boolean) {
500
530
  bounces = value
501
531
  applyBounceModes()
@@ -512,6 +542,25 @@ private class CraftNativeScrollView(context: Context) : FrameLayout(context) {
512
542
  horizontal.overScrollMode = if (bounces && alwaysBounceHorizontal) View.OVER_SCROLL_ALWAYS else View.OVER_SCROLL_NEVER
513
543
  }
514
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
+
515
564
  fun setPagingEnabled(value: Boolean) {
516
565
  pagingEnabled = value
517
566
  }
@@ -584,6 +633,9 @@ private class CraftNativeScrollView(context: Context) : FrameLayout(context) {
584
633
  }
585
634
  }
586
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
+ }
587
639
  onEndDrag?.invoke()
588
640
  if (pagingEnabled) snapToPage(scroller)
589
641
  if (moved && !momentumActive) {
@@ -605,6 +657,46 @@ private class CraftNativeScrollView(context: Context) : FrameLayout(context) {
605
657
 
606
658
  private class CraftNativeFlexSpacer(context: Context) : View(context)
607
659
 
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
+
608
700
  private data class LayoutBounds(
609
701
  val x: Double,
610
702
  val y: Double,
@@ -612,7 +704,7 @@ private data class LayoutBounds(
612
704
  val height: Double,
613
705
  )
614
706
 
615
- /** WebView-free host for a compiled stx-native route bundle. */
707
+ /** WebView-free host for a route bundle compiled by `stx native compile`. */
616
708
  class MainActivity : AppCompatActivity() {
617
709
  private class Screen(
618
710
  val route: String?,
@@ -626,6 +718,8 @@ class MainActivity : AppCompatActivity() {
626
718
  val suppressedTextChanges = mutableSetOf<EditText>()
627
719
  val controls = mutableMapOf<String, View>()
628
720
  val handlers = mutableMapOf<View, String>()
721
+ val pressInHandlers = mutableMapOf<View, String>()
722
+ val pressOutHandlers = mutableMapOf<View, String>()
629
723
  val longPressHandlers = mutableMapOf<View, String>()
630
724
  val scrollHandlers = mutableMapOf<View, String>()
631
725
  val scrollBeginHandlers = mutableMapOf<View, String>()
@@ -642,6 +736,10 @@ class MainActivity : AppCompatActivity() {
642
736
  val imageLoadHandlers = mutableMapOf<ImageView, String>()
643
737
  val imageLoadEndHandlers = mutableMapOf<ImageView, String>()
644
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>()
645
743
  val layoutHandlers = mutableMapOf<View, String>()
646
744
  val lastLayoutBounds = mutableMapOf<View, LayoutBounds>()
647
745
  val flatListRows = mutableMapOf<CraftNativeFlatList, MutableMap<String, MutableMap<String, View>>>()
@@ -695,6 +793,8 @@ class MainActivity : AppCompatActivity() {
695
793
  ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets ->
696
794
  val safe = insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout())
697
795
  view.setPadding(safe.left, safe.top, safe.right, safe.bottom)
796
+ view.requestLayout()
797
+ view.post { screens.lastOrNull()?.takeIf { it.active }?.let(::emitLayoutEvents) }
698
798
  insets
699
799
  }
700
800
  ViewCompat.requestApplyInsets(root)
@@ -896,7 +996,13 @@ class MainActivity : AppCompatActivity() {
896
996
 
897
997
  private fun refreshScreenTheme(screen: Screen) {
898
998
  screen.mutations.node("root")?.let { renderCommitted(screen, it) }
899
- screen.controls.values.filterIsInstance<CraftNativeFlatList>().forEach { it.refreshThemeDefaults() }
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() }
900
1006
  }
901
1007
 
902
1008
  private fun refreshThemeDefaults() {
@@ -1169,6 +1275,60 @@ class MainActivity : AppCompatActivity() {
1169
1275
  override fun afterTextChanged(s: Editable?) {}
1170
1276
  })
1171
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
+ }
1172
1332
  "Switch" -> (previous as? Switch ?: Switch(this)).apply {
1173
1333
  setOnCheckedChangeListener(null)
1174
1334
  val requestedValue = props.opt("value")
@@ -1286,6 +1446,7 @@ class MainActivity : AppCompatActivity() {
1286
1446
  props.optBoolean("showsHorizontalScrollIndicator", true),
1287
1447
  )
1288
1448
  setScrollEnabled(props.optBoolean("scrollEnabled", true))
1449
+ setKeyboardShouldPersistTaps(props.optString("keyboardShouldPersistTaps", "never"))
1289
1450
  setBounces(props.optBoolean("bounces", true))
1290
1451
  setAlwaysBounce(
1291
1452
  props.optBoolean("alwaysBounceVertical", !horizontal),
@@ -1339,16 +1500,27 @@ class MainActivity : AppCompatActivity() {
1339
1500
  ?.hideSoftInputFromWindow(nativeScroll.windowToken, 0)
1340
1501
  },
1341
1502
  )
1342
- configureContainer(content, style)
1343
- 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)
1344
1510
  }
1345
- "FlatList" -> (previous as? CraftNativeFlatList ?: CraftNativeFlatList(this)).apply {
1346
- setKeyboardDismissMode(props.optString("keyboardDismissMode")) {
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")) {
1347
1519
  (getSystemService(INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager)
1348
1520
  ?.hideSoftInputFromWindow(windowToken, 0)
1349
1521
  }
1350
- overScrollMode = if (props.optBoolean("bounces", true)) View.OVER_SCROLL_ALWAYS else View.OVER_SCROLL_NEVER
1351
- reconcileFlatList(screen, this, children, path, protocolId ?: path, props, events)
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)
1352
1524
  }
1353
1525
  "View", "SafeAreaView" -> (previous as? CraftNativeFlexLayout ?: CraftNativeFlexLayout(this)).apply {
1354
1526
  configureContainer(this, style)
@@ -1359,22 +1531,60 @@ class MainActivity : AppCompatActivity() {
1359
1531
  reconcileChildren(screen, this, children, path, next, style, previousControls)
1360
1532
  }
1361
1533
  }
1362
- if (type != "Button" && type != "Link" && type != "TextInput" && type != "Switch" && type != "Slider" && type != "ActivityIndicator") {
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") {
1363
1539
  val handler = events.optString("onPress").ifBlank { events.optString("onClick") }
1364
- if (handler.isNotBlank()) {
1365
- 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)
1366
1544
  control.isClickable = true
1367
- control.setOnClickListener {
1368
- val current = screen.handlers[control] ?: return@setOnClickListener
1369
- 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
+ }
1370
1578
  }
1371
1579
  } else {
1372
1580
  screen.handlers.remove(control)
1373
1581
  control.isClickable = false
1374
1582
  control.setOnClickListener(null)
1583
+ control.setOnTouchListener(null)
1375
1584
  }
1585
+ control.isEnabled = !disabled
1376
1586
  }
1377
- val longPressHandler = events.optString("onLongPress")
1587
+ val longPressHandler = events.optString("onLongPress").takeUnless { props.optBoolean("disabled", false) } ?: ""
1378
1588
  if (longPressHandler.isNotBlank()) {
1379
1589
  screen.longPressHandlers[control] = longPressHandler
1380
1590
  control.setOnLongClickListener {
@@ -1403,6 +1613,7 @@ class MainActivity : AppCompatActivity() {
1403
1613
  }
1404
1614
  next[identity] = control
1405
1615
  applyViewStyle(control, style)
1616
+ if (type == "Modal" && !props.optBoolean("visible", false)) control.visibility = View.GONE
1406
1617
  val width = style.optDouble("width", Double.NaN).takeUnless { it.isNaN() }?.let(::dp)
1407
1618
  val height = style.optDouble("height", Double.NaN).takeUnless { it.isNaN() }?.let(::dp)
1408
1619
  control.layoutParams = LinearLayout.LayoutParams(
@@ -1751,7 +1962,7 @@ class MainActivity : AppCompatActivity() {
1751
1962
  val role = props.optString("accessibilityRole").ifBlank { type.lowercase() }
1752
1963
  val state = props.optJSONObject("accessibilityState")
1753
1964
  val disabled = state?.optBoolean("disabled", false) == true
1754
- if (type !in setOf("Button", "Link", "TextInput", "Switch", "Slider", "ActivityIndicator")) {
1965
+ if (type !in setOf("Button", "Link", "TextInput", "Picker", "Modal", "Switch", "Slider", "ActivityIndicator")) {
1755
1966
  view.isEnabled = !disabled
1756
1967
  } else if (disabled) {
1757
1968
  view.isEnabled = false
@@ -1997,19 +2208,33 @@ class MainActivity : AppCompatActivity() {
1997
2208
  screen.flatListRows.values.forEach { rows -> rows.values.forEach { addAll(it.values) } }
1998
2209
  }
1999
2210
 
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
+
2000
2224
  private fun releaseViewState(
2001
2225
  screen: Screen,
2002
2226
  views: Collection<View>,
2003
2227
  preservingInputDrafts: Boolean = false,
2004
2228
  ) {
2005
- val released = views.toSet()
2006
- released.filterIsInstance<CraftNativeFlatList>().forEach { list ->
2007
- val owner = screen.flatListOwnerByView.remove(list)
2008
- list.discardAll()
2009
- screen.flatListRows.remove(list)?.values?.forEach {
2010
- releaseViewState(screen, it.values, preservingInputDrafts)
2011
- }
2012
- if (owner != null) screen.flatListOwners.entries.removeAll { it.value == owner }
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)
2013
2238
  }
2014
2239
  releaseImages(released)
2015
2240
  released.filterIsInstance<EditText>().forEach {
@@ -2021,6 +2246,8 @@ class MainActivity : AppCompatActivity() {
2021
2246
  }
2022
2247
  released.forEach(defaultBackgrounds::remove)
2023
2248
  screen.handlers.keys.removeAll(released)
2249
+ screen.pressInHandlers.keys.removeAll(released)
2250
+ screen.pressOutHandlers.keys.removeAll(released)
2024
2251
  screen.longPressHandlers.keys.removeAll(released)
2025
2252
  screen.layoutHandlers.keys.removeAll(released)
2026
2253
  screen.lastLayoutBounds.keys.removeAll(released)
@@ -2039,6 +2266,10 @@ class MainActivity : AppCompatActivity() {
2039
2266
  screen.imageLoadHandlers.keys.removeAll(released)
2040
2267
  screen.imageLoadEndHandlers.keys.removeAll(released)
2041
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)
2042
2273
  }
2043
2274
 
2044
2275
  private fun closeScreen(screen: Screen) {
@@ -2049,6 +2280,8 @@ class MainActivity : AppCompatActivity() {
2049
2280
  releaseViewState(screen, retainedViews(screen).toList())
2050
2281
  screen.controls.clear()
2051
2282
  screen.handlers.clear()
2283
+ screen.pressInHandlers.clear()
2284
+ screen.pressOutHandlers.clear()
2052
2285
  screen.layoutHandlers.clear()
2053
2286
  screen.lastLayoutBounds.clear()
2054
2287
  screen.scrollHandlers.clear()
@@ -2073,7 +2306,17 @@ class MainActivity : AppCompatActivity() {
2073
2306
  }
2074
2307
 
2075
2308
  @Deprecated("Uses the activity back callback for native route navigation")
2076
- 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
+ }
2077
2320
 
2078
2321
  private fun showError(message: String) {
2079
2322
  root.removeAllViews()