craft-native 0.0.119 → 0.0.121

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.
@@ -22,6 +22,9 @@ import android.util.Log
22
22
  import android.util.Base64
23
23
  import android.util.TypedValue
24
24
  import android.view.Gravity
25
+ import android.view.KeyEvent
26
+ import android.view.inputmethod.EditorInfo
27
+ import android.text.InputType
25
28
  import android.view.View
26
29
  import android.view.ViewGroup
27
30
  import android.widget.Button
@@ -50,16 +53,221 @@ import java.net.URL
50
53
  import java.util.concurrent.Executors
51
54
  import java.util.concurrent.Future
52
55
  import java.util.UUID
56
+ import kotlin.math.max
57
+ import kotlin.math.min
53
58
 
54
59
  private const val CRAFT_NATIVE_CAPABILITY_TIMEOUT_MS = 30_000L
55
60
 
61
+ private data class CraftNativeLayoutStyle(
62
+ val width: Int? = null,
63
+ val height: Int? = null,
64
+ val minWidth: Int? = null,
65
+ val maxWidth: Int? = null,
66
+ val minHeight: Int? = null,
67
+ val maxHeight: Int? = null,
68
+ val position: String = "relative",
69
+ val top: Int? = null,
70
+ val right: Int? = null,
71
+ val bottom: Int? = null,
72
+ val left: Int? = null,
73
+ val alignSelf: String? = null,
74
+ ) {
75
+ companion object {
76
+ fun from(raw: JSONObject, density: Float): CraftNativeLayoutStyle {
77
+ fun number(name: String): Int? = if (raw.has(name)) (raw.optDouble(name) * density).toInt() else null
78
+ return CraftNativeLayoutStyle(
79
+ width = number("width"), height = number("height"),
80
+ minWidth = number("minWidth"), maxWidth = number("maxWidth"),
81
+ minHeight = number("minHeight"), maxHeight = number("maxHeight"),
82
+ position = raw.optString("position", "relative"),
83
+ top = number("top"), right = number("right"), bottom = number("bottom"), left = number("left"),
84
+ alignSelf = raw.optString("alignSelf").takeIf { it.isNotBlank() },
85
+ )
86
+ }
87
+ }
88
+ }
89
+
90
+ /** A deterministic flex-shaped container shared by normal views and scroll content. */
91
+ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
92
+ private val styles = mutableMapOf<View, CraftNativeLayoutStyle>()
93
+ private var wrap = false
94
+ private var alignItems = "stretch"
95
+ private var justifyContent = "flex-start"
96
+ private var rowGap = 0
97
+ private var columnGap = 0
98
+
99
+ fun configure(style: JSONObject, density: Float) {
100
+ orientation = if (style.optString("flexDirection") in setOf("row", "row-reverse")) HORIZONTAL else VERTICAL
101
+ wrap = style.optString("flexWrap") == "wrap" || style.optBoolean("flexWrap", false)
102
+ alignItems = style.optString("alignItems", "stretch")
103
+ justifyContent = style.optString("justifyContent", "flex-start")
104
+ val gap = (style.optDouble("gap", 0.0) * density).toInt()
105
+ rowGap = (style.optDouble("rowGap", gap.toDouble() / density.toDouble()) * density).toInt()
106
+ columnGap = (style.optDouble("columnGap", gap.toDouble() / density.toDouble()) * density).toInt()
107
+ val padding = (style.optDouble("padding", 0.0) * density).toInt()
108
+ val horizontal = (style.optDouble("paddingHorizontal", padding.toDouble() / density.toDouble()) * density).toInt()
109
+ val vertical = (style.optDouble("paddingVertical", padding.toDouble() / density.toDouble()) * density).toInt()
110
+ setPadding(
111
+ (style.optDouble("paddingLeft", horizontal.toDouble() / density.toDouble()) * density).toInt(),
112
+ (style.optDouble("paddingTop", vertical.toDouble() / density.toDouble()) * density).toInt(),
113
+ (style.optDouble("paddingRight", horizontal.toDouble() / density.toDouble()) * density).toInt(),
114
+ (style.optDouble("paddingBottom", vertical.toDouble() / density.toDouble()) * density).toInt(),
115
+ )
116
+ requestLayout()
117
+ }
118
+
119
+ fun setChildStyle(view: View, style: JSONObject, density: Float) {
120
+ styles[view] = CraftNativeLayoutStyle.from(style, density)
121
+ requestLayout()
122
+ }
123
+
124
+ override fun onViewRemoved(child: View) {
125
+ styles.remove(child)
126
+ super.onViewRemoved(child)
127
+ }
128
+
129
+ private data class Entry(val view: View, val style: CraftNativeLayoutStyle, val width: Int, val height: Int)
130
+ private data class Line(val entries: MutableList<Entry> = mutableListOf(), var main: Int = 0, var cross: Int = 0)
131
+
132
+ private fun entries(width: Int, height: Int): Pair<List<Line>, List<Entry>> {
133
+ val availableMain = if (orientation == HORIZONTAL) max(0, width - paddingLeft - paddingRight)
134
+ else max(0, height - paddingTop - paddingBottom)
135
+ val gap = if (orientation == HORIZONTAL) columnGap else rowGap
136
+ val lines = mutableListOf<Line>()
137
+ val absolute = mutableListOf<Entry>()
138
+ var line = Line()
139
+ for (index in 0 until childCount) {
140
+ val child = getChildAt(index)
141
+ if (child.visibility == GONE) continue
142
+ val style = styles[child] ?: CraftNativeLayoutStyle()
143
+ val lp = child.layoutParams as? MarginLayoutParams
144
+ val horizontalMargins = (lp?.leftMargin ?: 0) + (lp?.rightMargin ?: 0)
145
+ val verticalMargins = (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0)
146
+ val childWidth = measureChildWidth(child, style, width - paddingLeft - paddingRight, horizontalMargins)
147
+ val childHeight = measureChildHeight(child, style, height - paddingTop - paddingBottom, verticalMargins)
148
+ val entry = Entry(child, style, childWidth, childHeight)
149
+ if (style.position == "absolute") { absolute += entry; continue }
150
+ val childMain = (if (orientation == HORIZONTAL) childWidth + horizontalMargins else childHeight + verticalMargins)
151
+ val childCross = (if (orientation == HORIZONTAL) childHeight + verticalMargins else childWidth + horizontalMargins)
152
+ val next = if (line.entries.isEmpty()) childMain else line.main + gap + childMain
153
+ if (wrap && line.entries.isNotEmpty() && next > availableMain) {
154
+ lines += line
155
+ line = Line()
156
+ }
157
+ line.entries += entry
158
+ line.main = if (line.entries.size == 1) childMain else line.main + gap + childMain
159
+ line.cross = max(line.cross, childCross)
160
+ }
161
+ if (line.entries.isNotEmpty()) lines += line
162
+ return lines to absolute
163
+ }
164
+
165
+ 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 }
167
+ val target = explicit ?: max(0, available - margins)
168
+ val spec = if (explicit != null) View.MeasureSpec.makeMeasureSpec(target, View.MeasureSpec.EXACTLY)
169
+ else View.MeasureSpec.makeMeasureSpec(max(0, available - margins), View.MeasureSpec.AT_MOST)
170
+ child.measure(spec, View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED))
171
+ return clamp(if (explicit != null) target else child.measuredWidth, style.minWidth, style.maxWidth)
172
+ }
173
+
174
+ 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 }
176
+ val target = explicit ?: max(0, available - margins)
177
+ val spec = if (explicit != null) View.MeasureSpec.makeMeasureSpec(target, View.MeasureSpec.EXACTLY)
178
+ else View.MeasureSpec.makeMeasureSpec(max(0, available - margins), View.MeasureSpec.AT_MOST)
179
+ child.measure(child.measuredWidth.let { View.MeasureSpec.makeMeasureSpec(max(0, it), View.MeasureSpec.EXACTLY) }, spec)
180
+ return clamp(if (explicit != null) target else child.measuredHeight, style.minHeight, style.maxHeight)
181
+ }
182
+
183
+ private fun clamp(value: Int, lower: Int?, upper: Int?): Int {
184
+ var result = value
185
+ if (lower != null) result = max(result, lower)
186
+ if (upper != null) result = min(result, upper)
187
+ return result
188
+ }
189
+
190
+ 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
192
+ val height = View.MeasureSpec.getSize(heightMeasureSpec).takeUnless { View.MeasureSpec.getMode(heightMeasureSpec) == View.MeasureSpec.UNSPECIFIED } ?: 1_000_000
193
+ val (lines, absolute) = entries(width, height)
194
+ val main = lines.maxOfOrNull { it.main } ?: 0
195
+ val cross = lines.sumOf { it.cross } + max(0, lines.size - 1) * (if (orientation == HORIZONTAL) rowGap else columnGap)
196
+ val desiredWidth = if (orientation == HORIZONTAL) main else cross
197
+ val desiredHeight = if (orientation == HORIZONTAL) cross else main
198
+ val absWidth = absolute.maxOfOrNull { it.width } ?: 0
199
+ val absHeight = absolute.maxOfOrNull { it.height } ?: 0
200
+ setMeasuredDimension(
201
+ resolveSize(max(desiredWidth + paddingLeft + paddingRight, absWidth + paddingLeft + paddingRight), widthMeasureSpec),
202
+ resolveSize(max(desiredHeight + paddingTop + paddingBottom, absHeight + paddingTop + paddingBottom), heightMeasureSpec),
203
+ )
204
+ }
205
+
206
+ override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
207
+ val contentWidth = width - paddingLeft - paddingRight
208
+ val contentHeight = height - paddingTop - paddingBottom
209
+ val (lines, absolute) = entries(width, height)
210
+ val gap = if (orientation == HORIZONTAL) columnGap else rowGap
211
+ var crossOffset = if (orientation == HORIZONTAL) paddingTop else paddingLeft
212
+ for (line in lines) {
213
+ val free = max(0, (if (orientation == HORIZONTAL) contentWidth else contentHeight) - line.main)
214
+ val (leading, between) = distribution(free, line.entries.size)
215
+ var mainOffset = if (orientation == HORIZONTAL) paddingLeft else paddingTop
216
+ mainOffset += leading
217
+ for (entry in line.entries) {
218
+ val lp = entry.view.layoutParams as? MarginLayoutParams
219
+ val marginStart = if (orientation == HORIZONTAL) lp?.leftMargin ?: 0 else lp?.topMargin ?: 0
220
+ val marginEnd = if (orientation == HORIZONTAL) lp?.rightMargin ?: 0 else lp?.bottomMargin ?: 0
221
+ val crossSize = if ((entry.style.alignSelf ?: alignItems) == "stretch" && (if (orientation == HORIZONTAL) entry.style.height else entry.style.width) == null)
222
+ max(0, line.cross - (if (orientation == HORIZONTAL) (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0) else (lp?.leftMargin ?: 0) + (lp?.rightMargin ?: 0)))
223
+ else if (orientation == HORIZONTAL) entry.height else entry.width
224
+ val align = entry.style.alignSelf ?: alignItems
225
+ val crossPosition = when (align) {
226
+ "center" -> (line.cross - crossSize) / 2
227
+ "flex-end" -> line.cross - crossSize
228
+ else -> 0
229
+ }
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
234
+ entry.view.layout(x, y, x + w, y + h)
235
+ mainOffset += (if (orientation == HORIZONTAL) entry.width else entry.height) + marginStart + marginEnd + gap + between
236
+ }
237
+ crossOffset += line.cross + gap
238
+ }
239
+ for (entry in absolute) {
240
+ val widthValue = if (entry.style.left != null && entry.style.right != null) contentWidth - entry.style.left!! - entry.style.right!! else entry.width
241
+ val heightValue = if (entry.style.top != null && entry.style.bottom != null) contentHeight - entry.style.top!! - entry.style.bottom!! else entry.height
242
+ val x = paddingLeft + (entry.style.left ?: (entry.style.right?.let { contentWidth - it - widthValue } ?: 0))
243
+ val y = paddingTop + (entry.style.top ?: (entry.style.bottom?.let { contentHeight - it - heightValue } ?: 0))
244
+ entry.view.layout(x, y, x + max(0, widthValue), y + max(0, heightValue))
245
+ }
246
+ }
247
+
248
+ private fun distribution(free: Int, count: Int): Pair<Int, Int> = when (justifyContent) {
249
+ "center" -> free / 2 to 0
250
+ "flex-end" -> free to 0
251
+ "space-between" -> 0 to if (count > 1) free / (count - 1) else 0
252
+ "space-around" -> free / (count * 2) to free / count
253
+ "space-evenly" -> free / (count + 1) to free / (count + 1)
254
+ else -> 0 to 0
255
+ }
256
+ }
257
+
56
258
  private class CraftNativeScrollView(context: Context) : FrameLayout(context) {
57
- val content = LinearLayout(context)
259
+ val content = CraftNativeFlexLayout(context)
58
260
  private val vertical = ScrollView(context)
59
261
  private val horizontal = HorizontalScrollView(context)
60
262
  private var horizontalMode: Boolean? = null
61
263
 
62
- init { setHorizontal(false) }
264
+ init {
265
+ vertical.isNestedScrollingEnabled = true
266
+ horizontal.isNestedScrollingEnabled = true
267
+ vertical.isFillViewport = true
268
+ horizontal.isFillViewport = true
269
+ setHorizontal(false)
270
+ }
63
271
 
64
272
  fun setHorizontal(value: Boolean) {
65
273
  if (horizontalMode == value) return
@@ -74,6 +282,40 @@ private class CraftNativeScrollView(context: Context) : FrameLayout(context) {
74
282
  else ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))
75
283
  addView(scroller, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
76
284
  }
285
+
286
+ fun setNativeNestedScrollingEnabled(value: Boolean) {
287
+ vertical.isNestedScrollingEnabled = value
288
+ horizontal.isNestedScrollingEnabled = value
289
+ }
290
+
291
+ fun setScrollEnabled(value: Boolean) {
292
+ vertical.isEnabled = value
293
+ horizontal.isEnabled = value
294
+ }
295
+
296
+ fun setScrollIndicators(verticalVisible: Boolean, horizontalVisible: Boolean) {
297
+ vertical.isVerticalScrollBarEnabled = verticalVisible
298
+ horizontal.isHorizontalScrollBarEnabled = horizontalVisible
299
+ }
300
+
301
+ fun setScrollListeners(
302
+ onScroll: ((Int, Int) -> Unit)?,
303
+ onBeginDrag: (() -> Unit)?,
304
+ onEndDrag: (() -> Unit)?,
305
+ ) {
306
+ fun attach(scroller: View) {
307
+ scroller.setOnScrollChangeListener { _, x, y, _, _ -> onScroll?.invoke(x, y) }
308
+ scroller.setOnTouchListener { _, event ->
309
+ when (event.actionMasked) {
310
+ android.view.MotionEvent.ACTION_DOWN -> onBeginDrag?.invoke()
311
+ android.view.MotionEvent.ACTION_UP, android.view.MotionEvent.ACTION_CANCEL -> onEndDrag?.invoke()
312
+ }
313
+ false
314
+ }
315
+ }
316
+ attach(vertical)
317
+ attach(horizontal)
318
+ }
77
319
  }
78
320
 
79
321
  private class CraftNativeFlexSpacer(context: Context) : View(context)
@@ -83,13 +325,19 @@ class MainActivity : AppCompatActivity() {
83
325
  private class Screen(
84
326
  val route: String?,
85
327
  val params: JSONObject,
86
- val view: LinearLayout,
328
+ val view: CraftNativeFlexLayout,
87
329
  val isolate: JavaScriptIsolate,
88
330
  val port: MessagePort?
89
331
  ) {
90
332
  val drafts = mutableMapOf<String, String>()
91
333
  val controls = mutableMapOf<String, View>()
92
334
  val handlers = mutableMapOf<View, String>()
335
+ val scrollHandlers = mutableMapOf<View, String>()
336
+ val scrollBeginHandlers = mutableMapOf<View, String>()
337
+ val scrollEndHandlers = mutableMapOf<View, String>()
338
+ val focusHandlers = mutableMapOf<View, String>()
339
+ val blurHandlers = mutableMapOf<View, String>()
340
+ val submitHandlers = mutableMapOf<View, String>()
93
341
  val flatListRows = mutableMapOf<CraftNativeFlatList, MutableMap<String, MutableMap<String, View>>>()
94
342
  val flatListOwners = mutableMapOf<String, String>()
95
343
  val flatListOwnerByView = mutableMapOf<CraftNativeFlatList, String>()
@@ -199,7 +447,7 @@ class MainActivity : AppCompatActivity() {
199
447
  private fun openScreen(route: String?, params: JSONObject = JSONObject()) {
200
448
  val engine = sandbox ?: return
201
449
  val isolate = engine.createIsolate()
202
- val view = LinearLayout(this).apply {
450
+ val view = CraftNativeFlexLayout(this).apply {
203
451
  orientation = LinearLayout.VERTICAL
204
452
  layoutParams = FrameLayout.LayoutParams(-1, -1)
205
453
  }
@@ -399,6 +647,7 @@ class MainActivity : AppCompatActivity() {
399
647
  screen.controls.clear()
400
648
  screen.controls.putAll(next)
401
649
  screen.handlers.keys.retainAll(retainedViews(screen))
650
+ screen.view.setChildStyle(tree, document.optJSONObject("style") ?: JSONObject(), resources.displayMetrics.density)
402
651
  }
403
652
 
404
653
  private fun clearRenderedTree(screen: Screen) {
@@ -445,10 +694,37 @@ class MainActivity : AppCompatActivity() {
445
694
  }
446
695
  "TextInput" -> (previous as? EditText ?: EditText(this)).apply {
447
696
  hint = props.optString("placeholder")
697
+ inputType = inputTypeFor(props)
698
+ imeOptions = imeOptionsFor(props)
699
+ isSingleLine = !props.optBoolean("multiline", false)
700
+ isEnabled = props.optBoolean("editable", true)
448
701
  if (props.has("value") && text.toString() != props.optString("value")) setText(props.optString("value"))
449
702
  else if (this !== previous && screen.drafts.containsKey(identity)) setText(screen.drafts[identity])
450
703
  val handler = events.optString("onChange").ifBlank { events.optString("onChangeText") }
451
704
  if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
705
+ val onFocus = events.optString("onFocus")
706
+ if (onFocus.isNotBlank()) screen.focusHandlers[this] = onFocus else screen.focusHandlers.remove(this)
707
+ val onBlur = events.optString("onBlur").ifBlank { events.optString("onEndEditing") }
708
+ if (onBlur.isNotBlank()) screen.blurHandlers[this] = onBlur else screen.blurHandlers.remove(this)
709
+ val onSubmit = events.optString("onSubmitEditing")
710
+ if (onSubmit.isNotBlank()) screen.submitHandlers[this] = onSubmit else screen.submitHandlers.remove(this)
711
+ 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())))
715
+ }
716
+ setOnEditorActionListener { view, actionId, event ->
717
+ val submitted = actionId != EditorInfo.IME_ACTION_NONE || event?.keyCode == KeyEvent.KEYCODE_ENTER
718
+ if (!submitted) return@setOnEditorActionListener false
719
+ val current = screen.submitHandlers[view] ?: return@setOnEditorActionListener false
720
+ send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject().put("text", text.toString())))
721
+ true
722
+ }
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)
727
+ }
452
728
  if (this !== previous) addTextChangedListener(object : TextWatcher {
453
729
  override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
454
730
  override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
@@ -475,13 +751,47 @@ class MainActivity : AppCompatActivity() {
475
751
  "ScrollView" -> (previous as? CraftNativeScrollView ?: CraftNativeScrollView(this)).apply {
476
752
  val horizontal = props.optBoolean("horizontal", false) || style.optString("flexDirection") == "row"
477
753
  setHorizontal(horizontal)
754
+ setNativeNestedScrollingEnabled(true)
755
+ setScrollIndicators(
756
+ props.optBoolean("showsVerticalScrollIndicator", true),
757
+ props.optBoolean("showsHorizontalScrollIndicator", true),
758
+ )
759
+ setScrollEnabled(props.optBoolean("scrollEnabled", true))
760
+ val onScroll = events.optString("onScroll")
761
+ if (onScroll.isNotBlank()) screen.scrollHandlers[this] = onScroll else screen.scrollHandlers.remove(this)
762
+ val onBegin = events.optString("onScrollBeginDrag")
763
+ if (onBegin.isNotBlank()) screen.scrollBeginHandlers[this] = onBegin else screen.scrollBeginHandlers.remove(this)
764
+ val onEnd = events.optString("onScrollEndDrag")
765
+ if (onEnd.isNotBlank()) screen.scrollEndHandlers[this] = onEnd else screen.scrollEndHandlers.remove(this)
766
+ val nativeScroll = this
767
+ setScrollListeners(
768
+ onScroll = { scrollX, scrollY ->
769
+ screen.scrollHandlers[nativeScroll]?.let { current ->
770
+ val contentView = nativeScroll.content
771
+ send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()
772
+ .put("contentOffset", JSONObject().put("x", scrollX).put("y", scrollY))
773
+ .put("contentSize", JSONObject().put("width", contentView.width).put("height", contentView.height))
774
+ .put("layoutMeasurement", JSONObject().put("width", nativeScroll.width).put("height", nativeScroll.height))))
775
+ }
776
+ },
777
+ onBeginDrag = {
778
+ screen.scrollBeginHandlers[nativeScroll]?.let { current ->
779
+ send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
780
+ }
781
+ },
782
+ onEndDrag = {
783
+ screen.scrollEndHandlers[nativeScroll]?.let { current ->
784
+ send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
785
+ }
786
+ },
787
+ )
478
788
  configureContainer(content, style)
479
789
  reconcileChildren(screen, content, children, path, next, style, previousControls)
480
790
  }
481
791
  "FlatList" -> (previous as? CraftNativeFlatList ?: CraftNativeFlatList(this)).apply {
482
792
  reconcileFlatList(screen, this, children, path, protocolId ?: path, props, events)
483
793
  }
484
- else -> (previous as? LinearLayout ?: LinearLayout(this)).apply {
794
+ else -> (previous as? CraftNativeFlexLayout ?: CraftNativeFlexLayout(this)).apply {
485
795
  configureContainer(this, style)
486
796
  reconcileChildren(screen, this, children, path, next, style, previousControls)
487
797
  }
@@ -506,7 +816,7 @@ class MainActivity : AppCompatActivity() {
506
816
  next[identity] = control
507
817
  applyViewStyle(control, style)
508
818
  control.layoutParams = LinearLayout.LayoutParams(
509
- if (style.has("width")) dp(style.optDouble("width")) else ViewGroup.LayoutParams.MATCH_PARENT,
819
+ if (style.has("width")) dp(style.optDouble("width")) else ViewGroup.LayoutParams.WRAP_CONTENT,
510
820
  if (style.has("height")) dp(style.optDouble("height")) else ViewGroup.LayoutParams.WRAP_CONTENT
511
821
  ).apply {
512
822
  val margin = style.optDouble("margin", 0.0)
@@ -529,30 +839,13 @@ class MainActivity : AppCompatActivity() {
529
839
  }
530
840
  }
531
841
 
532
- private fun configureContainer(layout: LinearLayout, style: JSONObject) {
533
- val direction = style.optString("flexDirection")
534
- layout.orientation = if (direction == "row" || direction == "row-reverse")
535
- LinearLayout.HORIZONTAL else LinearLayout.VERTICAL
536
- val cross = when (style.optString("alignItems")) {
537
- "center" -> if (layout.orientation == LinearLayout.HORIZONTAL) Gravity.CENTER_VERTICAL else Gravity.CENTER_HORIZONTAL
538
- "flex-end" -> if (layout.orientation == LinearLayout.HORIZONTAL) Gravity.BOTTOM else Gravity.END
539
- else -> if (layout.orientation == LinearLayout.HORIZONTAL) Gravity.TOP else Gravity.START
540
- }
541
- layout.gravity = cross
542
- val padding = style.optDouble("padding", 0.0)
543
- val horizontal = style.optDouble("paddingHorizontal", padding)
544
- val vertical = style.optDouble("paddingVertical", padding)
545
- layout.setPadding(
546
- dp(style.optDouble("paddingLeft", horizontal)),
547
- dp(style.optDouble("paddingTop", vertical)),
548
- dp(style.optDouble("paddingRight", horizontal)),
549
- dp(style.optDouble("paddingBottom", vertical))
550
- )
842
+ private fun configureContainer(layout: CraftNativeFlexLayout, style: JSONObject) {
843
+ layout.configure(style, resources.displayMetrics.density)
551
844
  }
552
845
 
553
846
  private fun reconcileChildren(
554
847
  screen: Screen,
555
- layout: LinearLayout,
848
+ layout: CraftNativeFlexLayout,
556
849
  children: JSONArray,
557
850
  path: String,
558
851
  next: MutableMap<String, View>,
@@ -571,6 +864,9 @@ class MainActivity : AppCompatActivity() {
571
864
  else -> null
572
865
  }
573
866
  if (childView != null) desired.add(childView)
867
+ if (childView != null && child is JSONObject) {
868
+ layout.setChildStyle(childView, child.optJSONObject("style") ?: JSONObject(), resources.displayMetrics.density)
869
+ }
574
870
  }
575
871
  if (style.optString("flexDirection").endsWith("-reverse")) desired.reverse()
576
872
  val desiredSet = desired.toSet()
@@ -580,16 +876,12 @@ class MainActivity : AppCompatActivity() {
580
876
  (childView.parent as? ViewGroup)?.removeView(childView)
581
877
  layout.addView(childView, index)
582
878
  }
583
- val gapName = if (layout.orientation == LinearLayout.HORIZONTAL) "columnGap" else "rowGap"
584
- val gap = dp(style.optDouble(gapName, style.optDouble("gap", 0.0)))
585
879
  val params = childView.layoutParams as? LinearLayout.LayoutParams
586
880
  ?: LinearLayout.LayoutParams(childView.layoutParams?.width ?: -1, childView.layoutParams?.height ?: -2)
587
- params.leftMargin = if (layout.orientation == LinearLayout.HORIZONTAL && index > 0) gap else 0
588
- params.topMargin = if (layout.orientation == LinearLayout.VERTICAL && index > 0) gap else 0
589
881
  childView.layoutParams = params
590
882
  }
591
883
  while (layout.childCount > desired.size) layout.removeViewAt(layout.childCount - 1)
592
- addJustificationSpacers(layout, style.optString("justifyContent"))
884
+ layout.requestLayout()
593
885
  }
594
886
 
595
887
  private fun reconcileFlatList(
@@ -644,28 +936,7 @@ class MainActivity : AppCompatActivity() {
644
936
  }
645
937
  }
646
938
 
647
- private fun addJustificationSpacers(layout: LinearLayout, value: String) {
648
- if (layout.childCount == 0 || value == "flex-start" || value.isBlank()) return
649
- fun spacer(weight: Float): CraftNativeFlexSpacer = CraftNativeFlexSpacer(this).apply {
650
- layoutParams = if (layout.orientation == LinearLayout.HORIZONTAL)
651
- LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, weight)
652
- else LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, weight)
653
- }
654
- when (value) {
655
- "flex-end" -> layout.addView(spacer(1f), 0)
656
- "center" -> {
657
- layout.addView(spacer(1f), 0)
658
- layout.addView(spacer(1f))
659
- }
660
- "space-between" -> for (index in layout.childCount - 1 downTo 1) layout.addView(spacer(1f), index)
661
- "space-around", "space-evenly" -> {
662
- val edgeWeight = if (value == "space-around") 0.5f else 1f
663
- for (index in layout.childCount - 1 downTo 1) layout.addView(spacer(1f), index)
664
- layout.addView(spacer(edgeWeight), 0)
665
- layout.addView(spacer(edgeWeight))
666
- }
667
- }
668
- }
939
+ private fun addJustificationSpacers(layout: CraftNativeFlexLayout, value: String) { }
669
940
 
670
941
  private fun applyViewStyle(view: View, style: JSONObject) {
671
942
  view.alpha = style.optDouble("opacity", 1.0).toFloat()
@@ -791,8 +1062,12 @@ class MainActivity : AppCompatActivity() {
791
1062
  private fun applyAccessibility(view: View, props: JSONObject, type: String, fallbackLabel: String) {
792
1063
  view.tag = fallbackLabel
793
1064
  view.contentDescription = props.optString("accessibilityLabel").ifBlank { fallbackLabel }
1065
+ props.opt("accessibilityValue")?.takeUnless { it == JSONObject.NULL }?.let { view.contentDescription = "${view.contentDescription}, $it" }
794
1066
  props.optString("accessibilityHint").takeIf { it.isNotBlank() }?.let { view.tooltipText = it }
795
1067
  val role = props.optString("accessibilityRole").ifBlank { type.lowercase() }
1068
+ val state = props.optJSONObject("accessibilityState")
1069
+ val disabled = state?.optBoolean("disabled", false) == true
1070
+ if (state != null && state.has("disabled")) view.isEnabled = !disabled
796
1071
  ViewCompat.setAccessibilityDelegate(view, object : AccessibilityDelegateCompat() {
797
1072
  override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfoCompat) {
798
1073
  super.onInitializeAccessibilityNodeInfo(host, info)
@@ -805,14 +1080,51 @@ class MainActivity : AppCompatActivity() {
805
1080
  }
806
1081
  info.isHeading = role == "header"
807
1082
  info.roleDescription = role.takeIf { it !in setOf("view", "text", "textinput", "scrollview") }
1083
+ info.isEnabled = !disabled
1084
+ info.isSelected = state?.optBoolean("selected", false) == true
1085
+ info.isChecked = state?.optBoolean("checked", false) == true
808
1086
  }
809
1087
  })
810
- ViewCompat.setImportantForAccessibility(view, ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_YES)
1088
+ ViewCompat.setImportantForAccessibility(
1089
+ view,
1090
+ if (role == "none") ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_NO else ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_YES,
1091
+ )
811
1092
  }
812
1093
 
813
1094
  private fun dp(value: Double) = (value * resources.displayMetrics.density).toInt()
814
1095
  private fun color(raw: String, fallback: Int) = runCatching { Color.parseColor(raw) }.getOrDefault(fallback)
815
1096
 
1097
+ private fun inputTypeFor(props: JSONObject): Int {
1098
+ val base = if (props.optBoolean("multiline", false)) InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE
1099
+ else InputType.TYPE_CLASS_TEXT
1100
+ val keyboard = when (props.optString("keyboardType")) {
1101
+ "email-address" -> InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS
1102
+ "url" -> InputType.TYPE_TEXT_VARIATION_URI
1103
+ "numeric" -> InputType.TYPE_CLASS_NUMBER
1104
+ "decimal-pad" -> InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL
1105
+ "phone-pad" -> InputType.TYPE_CLASS_PHONE
1106
+ else -> 0
1107
+ }
1108
+ val capitalization = when (props.optString("autoCapitalize")) {
1109
+ "characters" -> InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS
1110
+ "words" -> InputType.TYPE_TEXT_FLAG_CAP_WORDS
1111
+ "sentences" -> InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
1112
+ else -> 0
1113
+ }
1114
+ val correction = if (props.optBoolean("autoCorrect", true)) InputType.TYPE_TEXT_FLAG_AUTO_CORRECT else 0
1115
+ val secure = if (props.optBoolean("secureTextEntry", false)) InputType.TYPE_TEXT_VARIATION_PASSWORD else 0
1116
+ return (if (keyboard == 0) base else keyboard) or capitalization or correction or secure
1117
+ }
1118
+
1119
+ private fun imeOptionsFor(props: JSONObject): Int = when (props.optString("returnKeyType")) {
1120
+ "done" -> EditorInfo.IME_ACTION_DONE
1121
+ "go" -> EditorInfo.IME_ACTION_GO
1122
+ "next" -> EditorInfo.IME_ACTION_NEXT
1123
+ "search" -> EditorInfo.IME_ACTION_SEARCH
1124
+ "send" -> EditorInfo.IME_ACTION_SEND
1125
+ else -> EditorInfo.IME_ACTION_NONE
1126
+ }
1127
+
816
1128
  private fun answerAPI(screen: Screen, message: JSONObject, payload: JSONObject) {
817
1129
  val id = message.optString("id")
818
1130
  if (id.isBlank()) return
@@ -974,6 +1286,12 @@ class MainActivity : AppCompatActivity() {
974
1286
  }
975
1287
  releaseImages(released)
976
1288
  screen.handlers.keys.removeAll(released)
1289
+ screen.scrollHandlers.keys.removeAll(released)
1290
+ screen.scrollBeginHandlers.keys.removeAll(released)
1291
+ screen.scrollEndHandlers.keys.removeAll(released)
1292
+ screen.focusHandlers.keys.removeAll(released)
1293
+ screen.blurHandlers.keys.removeAll(released)
1294
+ screen.submitHandlers.keys.removeAll(released)
977
1295
  }
978
1296
 
979
1297
  private fun closeScreen(screen: Screen) {
@@ -984,6 +1302,12 @@ class MainActivity : AppCompatActivity() {
984
1302
  releaseViewState(screen, retainedViews(screen).toList())
985
1303
  screen.controls.clear()
986
1304
  screen.handlers.clear()
1305
+ screen.scrollHandlers.clear()
1306
+ screen.scrollBeginHandlers.clear()
1307
+ screen.scrollEndHandlers.clear()
1308
+ screen.focusHandlers.clear()
1309
+ screen.blurHandlers.clear()
1310
+ screen.submitHandlers.clear()
987
1311
  screen.flatListRows.clear()
988
1312
  screen.flatListOwners.clear()
989
1313
  screen.flatListOwnerByView.clear()
package/dist/cli.js CHANGED
@@ -3303,7 +3303,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3303
3303
  `);
3304
3304
  }
3305
3305
  // package.json
3306
- var version = "0.0.119";
3306
+ var version = "0.0.121";
3307
3307
 
3308
3308
  // bin/cli.ts
3309
3309
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
@@ -285,11 +285,20 @@ export interface BaseProps {
285
285
  accessibilityLabel?: string;
286
286
  /** Accessibility hint */
287
287
  accessibilityHint?: string;
288
+ /** Accessibility value announced by the native host */
289
+ accessibilityValue?: string | number;
290
+ /** Accessibility state exposed to assistive technologies */
291
+ accessibilityState?: AccessibilityState;
288
292
  /** Accessibility role */
289
293
  accessibilityRole?: 'none' | 'button' | 'link' | 'image' | 'text' | 'header' | 'search' | 'menu';
290
294
  /** Children elements */
291
295
  children?: any;
292
296
  }
297
+ export interface AccessibilityState {
298
+ disabled?: boolean;
299
+ selected?: boolean;
300
+ checked?: boolean;
301
+ }
293
302
  /**
294
303
  * View component props.
295
304
  */
package/dist/index.cjs CHANGED
@@ -310,7 +310,7 @@ function craftBinaryNotFoundMessage(triedPath) {
310
310
  `);
311
311
  }
312
312
  // package.json
313
- var version = "0.0.119";
313
+ var version = "0.0.121";
314
314
 
315
315
  // src/sdk-version.ts
316
316
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -58,7 +58,7 @@ function craftBinaryNotFoundMessage(triedPath) {
58
58
  `);
59
59
  }
60
60
  // package.json
61
- var version = "0.0.119";
61
+ var version = "0.0.121";
62
62
 
63
63
  // src/sdk-version.ts
64
64
  var SDK_VERSION = version;
@@ -85,6 +85,13 @@ export interface CraftConfig {
85
85
  associatedDomains?: string[];
86
86
  appGroups?: string[];
87
87
  appIconPath?: string;
88
+ /**
89
+ * Whether the app uses encryption beyond the exempt kinds (HTTPS, the
90
+ * system's own). Written as `ITSAppUsesNonExemptEncryption`, so App Store
91
+ * Connect does not ask the export-compliance question on every build.
92
+ * Default false: a WebView app that talks HTTPS is exempt.
93
+ */
94
+ usesNonExemptEncryption?: boolean;
88
95
  privacy?: CraftPrivacyManifest;
89
96
  orientations?: Array<'portrait' | 'landscape-left' | 'landscape-right' | 'portrait-upside-down'>;
90
97
  deviceFamilies?: Array<'iphone' | 'ipad'>;
@@ -193,6 +200,11 @@ export declare function renderWatchEntitlements(config: CraftConfig): string;
193
200
  */
194
201
  export declare function renderRuntimeSettings(): string;
195
202
  export declare function renderPrivacyManifest(config: CraftConfig): string;
203
+ /**
204
+ * Whether a PNG carries an alpha channel, from its header: colour type 4
205
+ * (grey + alpha) or 6 (RGB + alpha), or a tRNS chunk before the image data.
206
+ */
207
+ export declare function pngHasAlpha(bytes: Uint8Array): boolean;
196
208
  /** Replace the bundled web application atomically so removed assets cannot linger. */
197
209
  export declare function syncWebAssets(source: string, output: string): void;
198
210
  /**