craft-native 0.0.112 → 0.0.115
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/android/src/index.js +6 -0
- package/dist/android/templates/CraftNativeFlatList.kt.template +181 -0
- package/dist/android/templates/CraftNativeMutation.kt.template +335 -0
- package/dist/android/templates/MainActivityNative.kt.template +493 -42
- package/dist/android/templates/build.gradle.kts.app.template +1 -0
- package/dist/cli.js +1 -1
- package/dist/index.cjs +8 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +8 -1
- package/dist/ios/src/index.js +2 -0
- package/dist/ios/templates/CraftNativeFlatList.swift +323 -0
- package/dist/ios/templates/CraftNativeMutation.swift +365 -0
- package/dist/ios/templates/CraftNativeScreen.swift +460 -31
- package/dist/types.d.ts +81 -1
- package/package.json +1 -1
|
@@ -3,7 +3,11 @@ package {{PACKAGE_NAME}}
|
|
|
3
3
|
import android.content.ClipData
|
|
4
4
|
import android.content.ClipboardManager
|
|
5
5
|
import android.content.Context
|
|
6
|
+
import android.graphics.BitmapFactory
|
|
6
7
|
import android.graphics.Color
|
|
8
|
+
import android.graphics.Paint
|
|
9
|
+
import android.graphics.Typeface
|
|
10
|
+
import android.graphics.drawable.GradientDrawable
|
|
7
11
|
import android.os.Build
|
|
8
12
|
import android.os.Bundle
|
|
9
13
|
import android.os.VibrationEffect
|
|
@@ -11,15 +15,24 @@ import android.os.Vibrator
|
|
|
11
15
|
import android.text.Editable
|
|
12
16
|
import android.text.TextWatcher
|
|
13
17
|
import android.util.Log
|
|
18
|
+
import android.util.Base64
|
|
19
|
+
import android.util.TypedValue
|
|
20
|
+
import android.view.Gravity
|
|
14
21
|
import android.view.View
|
|
15
22
|
import android.view.ViewGroup
|
|
16
23
|
import android.widget.Button
|
|
17
24
|
import android.widget.EditText
|
|
18
25
|
import android.widget.FrameLayout
|
|
26
|
+
import android.widget.HorizontalScrollView
|
|
27
|
+
import android.widget.ImageView
|
|
19
28
|
import android.widget.LinearLayout
|
|
29
|
+
import android.widget.ScrollView
|
|
20
30
|
import android.widget.TextView
|
|
21
31
|
import androidx.appcompat.app.AppCompatActivity
|
|
22
32
|
import androidx.core.content.ContextCompat
|
|
33
|
+
import androidx.core.view.AccessibilityDelegateCompat
|
|
34
|
+
import androidx.core.view.ViewCompat
|
|
35
|
+
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
|
|
23
36
|
import androidx.javascriptengine.JavaScriptIsolate
|
|
24
37
|
import androidx.javascriptengine.JavaScriptConsoleCallback
|
|
25
38
|
import androidx.javascriptengine.JavaScriptSandbox
|
|
@@ -28,8 +41,36 @@ import androidx.javascriptengine.MessagePort
|
|
|
28
41
|
import androidx.javascriptengine.MessagePortClient
|
|
29
42
|
import org.json.JSONArray
|
|
30
43
|
import org.json.JSONObject
|
|
44
|
+
import java.net.URL
|
|
45
|
+
import java.util.concurrent.Executors
|
|
46
|
+
import java.util.concurrent.Future
|
|
31
47
|
import java.util.UUID
|
|
32
48
|
|
|
49
|
+
private class CraftNativeScrollView(context: Context) : FrameLayout(context) {
|
|
50
|
+
val content = LinearLayout(context)
|
|
51
|
+
private val vertical = ScrollView(context)
|
|
52
|
+
private val horizontal = HorizontalScrollView(context)
|
|
53
|
+
private var horizontalMode: Boolean? = null
|
|
54
|
+
|
|
55
|
+
init { setHorizontal(false) }
|
|
56
|
+
|
|
57
|
+
fun setHorizontal(value: Boolean) {
|
|
58
|
+
if (horizontalMode == value) return
|
|
59
|
+
horizontalMode = value
|
|
60
|
+
(content.parent as? ViewGroup)?.removeView(content)
|
|
61
|
+
removeAllViews()
|
|
62
|
+
val scroller: ViewGroup = if (value) horizontal else vertical
|
|
63
|
+
scroller.removeAllViews()
|
|
64
|
+
content.orientation = if (value) LinearLayout.HORIZONTAL else LinearLayout.VERTICAL
|
|
65
|
+
scroller.addView(content, if (value)
|
|
66
|
+
ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT)
|
|
67
|
+
else ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))
|
|
68
|
+
addView(scroller, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private class CraftNativeFlexSpacer(context: Context) : View(context)
|
|
73
|
+
|
|
33
74
|
/** WebView-free host for a compiled stx-native route bundle. */
|
|
34
75
|
class MainActivity : AppCompatActivity() {
|
|
35
76
|
private class Screen(
|
|
@@ -42,6 +83,10 @@ class MainActivity : AppCompatActivity() {
|
|
|
42
83
|
val drafts = mutableMapOf<String, String>()
|
|
43
84
|
val controls = mutableMapOf<String, View>()
|
|
44
85
|
val handlers = mutableMapOf<View, String>()
|
|
86
|
+
val flatListRows = mutableMapOf<CraftNativeFlatList, MutableMap<String, MutableMap<String, View>>>()
|
|
87
|
+
val flatListOwners = mutableMapOf<String, String>()
|
|
88
|
+
val flatListOwnerByView = mutableMapOf<CraftNativeFlatList, String>()
|
|
89
|
+
val mutations = CraftNativeMutationDocument()
|
|
45
90
|
var active = true
|
|
46
91
|
|
|
47
92
|
fun close() {
|
|
@@ -58,6 +103,10 @@ class MainActivity : AppCompatActivity() {
|
|
|
58
103
|
private var hapticsEnabled = false
|
|
59
104
|
private var defaultTextColor = Color.WHITE
|
|
60
105
|
private var useMessagePorts = false
|
|
106
|
+
private val imageExecutor = Executors.newFixedThreadPool(2)
|
|
107
|
+
private val imageSources = mutableMapOf<ImageView, String>()
|
|
108
|
+
private val imageJobs = mutableMapOf<ImageView, Future<*>>()
|
|
109
|
+
private val imageErrors = mutableMapOf<ImageView, String>()
|
|
61
110
|
|
|
62
111
|
override fun onCreate(savedInstanceState: Bundle?) {
|
|
63
112
|
super.onCreate(savedInstanceState)
|
|
@@ -145,6 +194,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
145
194
|
const port = await android.getNamedPort('craft');
|
|
146
195
|
$routePrelude
|
|
147
196
|
globalThis.__stxNativeBridge = {
|
|
197
|
+
mutationProtocolVersion: 1,
|
|
148
198
|
postMessage: function(value) { port.postMessage(value); },
|
|
149
199
|
onMessage: function(callback) { port.onmessage = function(event) { callback(event.data); }; }
|
|
150
200
|
};
|
|
@@ -152,6 +202,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
152
202
|
""".trimIndent() else """
|
|
153
203
|
$routePrelude
|
|
154
204
|
globalThis.__stxNativeBridge = {
|
|
205
|
+
mutationProtocolVersion: 1,
|
|
155
206
|
postMessage: function(value) { console.log(${JSONObject.quote(prefix)} + value); },
|
|
156
207
|
onMessage: function(callback) { globalThis.__stxNativeCallback = callback; }
|
|
157
208
|
};
|
|
@@ -186,11 +237,34 @@ class MainActivity : AppCompatActivity() {
|
|
|
186
237
|
val payload = message.optJSONObject("payload") ?: JSONObject()
|
|
187
238
|
when (message.optString("type")) {
|
|
188
239
|
"RENDER" -> payload.optJSONObject("document")?.let { render(screen, it) }
|
|
240
|
+
"MUTATE" -> try {
|
|
241
|
+
val result = applyMutation(screen, payload)
|
|
242
|
+
send(screen, "MUTATION_ACK", JSONObject()
|
|
243
|
+
.put("batchId", result.batchId)
|
|
244
|
+
.put("revision", result.revision)
|
|
245
|
+
.put("version", CRAFT_NATIVE_MUTATION_PROTOCOL_VERSION))
|
|
246
|
+
}
|
|
247
|
+
catch (failure: CraftNativeMutationFailure) {
|
|
248
|
+
val error = JSONObject()
|
|
249
|
+
.put("batchId", payload.optString("batchId"))
|
|
250
|
+
.put("code", failure.code)
|
|
251
|
+
.put("message", failure.message)
|
|
252
|
+
.put("version", CRAFT_NATIVE_MUTATION_PROTOCOL_VERSION)
|
|
253
|
+
failure.operationIndex?.let { error.put("operationIndex", it) }
|
|
254
|
+
send(screen, "MUTATION_ERROR", error)
|
|
255
|
+
}
|
|
256
|
+
catch (_: Exception) {
|
|
257
|
+
send(screen, "MUTATION_ERROR", JSONObject()
|
|
258
|
+
.put("batchId", payload.optString("batchId"))
|
|
259
|
+
.put("code", "INVALID_BATCH")
|
|
260
|
+
.put("message", "mutation batch could not be applied")
|
|
261
|
+
.put("version", CRAFT_NATIVE_MUTATION_PROTOCOL_VERSION))
|
|
262
|
+
}
|
|
189
263
|
"NAVIGATE", "NAVIGATE_REPLACE" -> {
|
|
190
264
|
if (screens.lastOrNull() !== screen) return
|
|
191
265
|
val route = payload.optString("screen").takeIf { it.isNotBlank() } ?: return
|
|
192
266
|
if (message.optString("type") == "NAVIGATE_REPLACE") {
|
|
193
|
-
screens.removeAt(screens.lastIndex)
|
|
267
|
+
closeScreen(screens.removeAt(screens.lastIndex))
|
|
194
268
|
}
|
|
195
269
|
openScreen(route, payload.optJSONObject("params") ?: JSONObject())
|
|
196
270
|
}
|
|
@@ -200,6 +274,30 @@ class MainActivity : AppCompatActivity() {
|
|
|
200
274
|
}
|
|
201
275
|
|
|
202
276
|
private fun render(screen: Screen, document: JSONObject) {
|
|
277
|
+
screen.mutations.replace(document)
|
|
278
|
+
renderCommitted(screen, document)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private fun applyMutation(screen: Screen, payload: JSONObject): CraftNativeMutationResult {
|
|
282
|
+
val result = screen.mutations.apply(payload)
|
|
283
|
+
if (!result.requiresFullRender && applyTargetedUpdates(screen, result.affectedNodeIds)) return result
|
|
284
|
+
result.document?.let { renderCommitted(screen, it) } ?: clearRenderedTree(screen)
|
|
285
|
+
return result
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private fun applyTargetedUpdates(screen: Screen, ids: List<String>): Boolean {
|
|
289
|
+
val applied = mutableSetOf<String>()
|
|
290
|
+
for (id in ids) {
|
|
291
|
+
val target = screen.flatListOwners[id] ?: id
|
|
292
|
+
if (!applied.add(target)) continue
|
|
293
|
+
val document = screen.mutations.node(target) ?: return false
|
|
294
|
+
if (!screen.controls.containsKey("id:$target")) return false
|
|
295
|
+
makeView(screen, document, target, screen.controls)
|
|
296
|
+
}
|
|
297
|
+
return true
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private fun renderCommitted(screen: Screen, document: JSONObject) {
|
|
203
301
|
// Keep live controls (especially the focused EditText) across renders.
|
|
204
302
|
val next = mutableMapOf<String, View>()
|
|
205
303
|
val tree = makeView(screen, document, "root", next)
|
|
@@ -208,26 +306,44 @@ class MainActivity : AppCompatActivity() {
|
|
|
208
306
|
screen.view.removeAllViews()
|
|
209
307
|
screen.view.addView(tree)
|
|
210
308
|
}
|
|
309
|
+
releaseViewState(screen, screen.controls.values.filter { it !in next.values })
|
|
211
310
|
screen.controls.clear()
|
|
212
311
|
screen.controls.putAll(next)
|
|
213
|
-
screen.handlers.keys.retainAll(
|
|
312
|
+
screen.handlers.keys.retainAll(retainedViews(screen))
|
|
214
313
|
}
|
|
215
314
|
|
|
216
|
-
private fun
|
|
315
|
+
private fun clearRenderedTree(screen: Screen) {
|
|
316
|
+
releaseViewState(screen, retainedViews(screen).toList())
|
|
317
|
+
screen.view.removeAllViews()
|
|
318
|
+
screen.controls.clear()
|
|
319
|
+
screen.handlers.clear()
|
|
320
|
+
screen.flatListRows.clear()
|
|
321
|
+
screen.flatListOwners.clear()
|
|
322
|
+
screen.flatListOwnerByView.clear()
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private fun makeView(
|
|
326
|
+
screen: Screen,
|
|
327
|
+
node: JSONObject,
|
|
328
|
+
path: String,
|
|
329
|
+
next: MutableMap<String, View>,
|
|
330
|
+
previousControls: Map<String, View> = screen.controls,
|
|
331
|
+
): View {
|
|
217
332
|
val type = node.optString("type", "View")
|
|
218
333
|
val props = node.optJSONObject("props") ?: JSONObject()
|
|
219
334
|
val style = node.optJSONObject("style") ?: JSONObject()
|
|
220
335
|
val events = node.optJSONObject("events") ?: JSONObject()
|
|
221
336
|
val children = node.optJSONArray("children") ?: JSONArray()
|
|
337
|
+
val protocolId = node.optString("id").takeIf { it.isNotBlank() }
|
|
222
338
|
val explicitKey = listOf(node.optString("key"), props.optString("key"), props.optString("testID"))
|
|
223
339
|
.firstOrNull { it.isNotBlank() }
|
|
224
|
-
val identity =
|
|
225
|
-
|
|
340
|
+
val identity = protocolId?.let { "id:$it" }
|
|
341
|
+
?: explicitKey?.let { "${path.substringBeforeLast('.', "")}/$it" }
|
|
342
|
+
?: path
|
|
343
|
+
val previous = previousControls[identity]
|
|
226
344
|
val control = when (type) {
|
|
227
345
|
"Text" -> ((previous as? TextView)?.takeUnless { it is Button || it is EditText } ?: TextView(this)).apply {
|
|
228
|
-
|
|
229
|
-
setTextColor(color(style.optString("color"), defaultTextColor))
|
|
230
|
-
if (style.has("fontSize")) textSize = style.optDouble("fontSize").toFloat()
|
|
346
|
+
configureText(this, childText(children), style)
|
|
231
347
|
}
|
|
232
348
|
"Button" -> (previous as? Button ?: Button(this)).apply {
|
|
233
349
|
text = props.optString("title").ifBlank { childText(children) }
|
|
@@ -257,46 +373,63 @@ class MainActivity : AppCompatActivity() {
|
|
|
257
373
|
override fun afterTextChanged(s: Editable?) {}
|
|
258
374
|
})
|
|
259
375
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
dp(style.optDouble("paddingTop", padding)),
|
|
267
|
-
dp(style.optDouble("paddingRight", padding)),
|
|
268
|
-
dp(style.optDouble("paddingBottom", padding))
|
|
269
|
-
)
|
|
270
|
-
val desired = mutableListOf<View>()
|
|
271
|
-
for (index in 0 until children.length()) {
|
|
272
|
-
val child = children.opt(index)
|
|
273
|
-
val childView = when (child) {
|
|
274
|
-
is JSONObject -> makeView(screen, child, "$path.$index", next)
|
|
275
|
-
is String -> TextView(this@MainActivity).apply {
|
|
276
|
-
text = child
|
|
277
|
-
setTextColor(defaultTextColor)
|
|
278
|
-
}
|
|
279
|
-
else -> null
|
|
280
|
-
}
|
|
281
|
-
if (childView != null) desired.add(childView)
|
|
376
|
+
"Image" -> (previous as? ImageView ?: ImageView(this)).apply {
|
|
377
|
+
scaleType = when (style.optString("resizeMode").ifBlank { props.optString("resizeMode") }) {
|
|
378
|
+
"cover" -> ImageView.ScaleType.CENTER_CROP
|
|
379
|
+
"stretch" -> ImageView.ScaleType.FIT_XY
|
|
380
|
+
"center" -> ImageView.ScaleType.CENTER
|
|
381
|
+
else -> ImageView.ScaleType.CENTER_INSIDE
|
|
282
382
|
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
383
|
+
adjustViewBounds = false
|
|
384
|
+
updateImage(this, props.opt("source"))
|
|
385
|
+
}
|
|
386
|
+
"ScrollView" -> (previous as? CraftNativeScrollView ?: CraftNativeScrollView(this)).apply {
|
|
387
|
+
val horizontal = props.optBoolean("horizontal", false) || style.optString("flexDirection") == "row"
|
|
388
|
+
setHorizontal(horizontal)
|
|
389
|
+
configureContainer(content, style)
|
|
390
|
+
reconcileChildren(screen, content, children, path, next, style, previousControls)
|
|
391
|
+
}
|
|
392
|
+
"FlatList" -> (previous as? CraftNativeFlatList ?: CraftNativeFlatList(this)).apply {
|
|
393
|
+
reconcileFlatList(screen, this, children, path, protocolId ?: path, props, events)
|
|
394
|
+
}
|
|
395
|
+
else -> (previous as? LinearLayout ?: LinearLayout(this)).apply {
|
|
396
|
+
configureContainer(this, style)
|
|
397
|
+
reconcileChildren(screen, this, children, path, next, style, previousControls)
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if (type != "Button" && type != "TextInput") {
|
|
401
|
+
val handler = events.optString("onPress").ifBlank { events.optString("onClick") }
|
|
402
|
+
if (handler.isNotBlank()) {
|
|
403
|
+
screen.handlers[control] = handler
|
|
404
|
+
control.isClickable = true
|
|
405
|
+
control.setOnClickListener {
|
|
406
|
+
val current = screen.handlers[control] ?: return@setOnClickListener
|
|
407
|
+
send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
|
|
289
408
|
}
|
|
290
|
-
|
|
409
|
+
} else {
|
|
410
|
+
screen.handlers.remove(control)
|
|
411
|
+
control.isClickable = false
|
|
412
|
+
control.setOnClickListener(null)
|
|
291
413
|
}
|
|
292
414
|
}
|
|
293
|
-
control
|
|
415
|
+
applyAccessibility(control, props, type, explicitKey ?: protocolId ?: path)
|
|
416
|
+
if (control is ImageView) imageErrors[control]?.let { control.contentDescription = "${control.contentDescription}, $it" }
|
|
294
417
|
next[identity] = control
|
|
295
|
-
|
|
418
|
+
applyViewStyle(control, style)
|
|
296
419
|
control.layoutParams = LinearLayout.LayoutParams(
|
|
297
420
|
if (style.has("width")) dp(style.optDouble("width")) else ViewGroup.LayoutParams.MATCH_PARENT,
|
|
298
421
|
if (style.has("height")) dp(style.optDouble("height")) else ViewGroup.LayoutParams.WRAP_CONTENT
|
|
299
|
-
)
|
|
422
|
+
).apply {
|
|
423
|
+
val margin = style.optDouble("margin", 0.0)
|
|
424
|
+
val horizontal = style.optDouble("marginHorizontal", margin)
|
|
425
|
+
val vertical = style.optDouble("marginVertical", margin)
|
|
426
|
+
setMargins(
|
|
427
|
+
dp(style.optDouble("marginLeft", horizontal)),
|
|
428
|
+
dp(style.optDouble("marginTop", vertical)),
|
|
429
|
+
dp(style.optDouble("marginRight", horizontal)),
|
|
430
|
+
dp(style.optDouble("marginBottom", vertical))
|
|
431
|
+
)
|
|
432
|
+
}
|
|
300
433
|
return control
|
|
301
434
|
}
|
|
302
435
|
|
|
@@ -307,6 +440,287 @@ class MainActivity : AppCompatActivity() {
|
|
|
307
440
|
}
|
|
308
441
|
}
|
|
309
442
|
|
|
443
|
+
private fun configureContainer(layout: LinearLayout, style: JSONObject) {
|
|
444
|
+
val direction = style.optString("flexDirection")
|
|
445
|
+
layout.orientation = if (direction == "row" || direction == "row-reverse")
|
|
446
|
+
LinearLayout.HORIZONTAL else LinearLayout.VERTICAL
|
|
447
|
+
val cross = when (style.optString("alignItems")) {
|
|
448
|
+
"center" -> if (layout.orientation == LinearLayout.HORIZONTAL) Gravity.CENTER_VERTICAL else Gravity.CENTER_HORIZONTAL
|
|
449
|
+
"flex-end" -> if (layout.orientation == LinearLayout.HORIZONTAL) Gravity.BOTTOM else Gravity.END
|
|
450
|
+
else -> if (layout.orientation == LinearLayout.HORIZONTAL) Gravity.TOP else Gravity.START
|
|
451
|
+
}
|
|
452
|
+
layout.gravity = cross
|
|
453
|
+
val padding = style.optDouble("padding", 0.0)
|
|
454
|
+
val horizontal = style.optDouble("paddingHorizontal", padding)
|
|
455
|
+
val vertical = style.optDouble("paddingVertical", padding)
|
|
456
|
+
layout.setPadding(
|
|
457
|
+
dp(style.optDouble("paddingLeft", horizontal)),
|
|
458
|
+
dp(style.optDouble("paddingTop", vertical)),
|
|
459
|
+
dp(style.optDouble("paddingRight", horizontal)),
|
|
460
|
+
dp(style.optDouble("paddingBottom", vertical))
|
|
461
|
+
)
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
private fun reconcileChildren(
|
|
465
|
+
screen: Screen,
|
|
466
|
+
layout: LinearLayout,
|
|
467
|
+
children: JSONArray,
|
|
468
|
+
path: String,
|
|
469
|
+
next: MutableMap<String, View>,
|
|
470
|
+
style: JSONObject,
|
|
471
|
+
previousControls: Map<String, View>,
|
|
472
|
+
) {
|
|
473
|
+
for (index in layout.childCount - 1 downTo 0) {
|
|
474
|
+
if (layout.getChildAt(index) is CraftNativeFlexSpacer) layout.removeViewAt(index)
|
|
475
|
+
}
|
|
476
|
+
val desired = mutableListOf<View>()
|
|
477
|
+
for (index in 0 until children.length()) {
|
|
478
|
+
val child = children.opt(index)
|
|
479
|
+
val childView = when (child) {
|
|
480
|
+
is JSONObject -> makeView(screen, child, "$path.$index", next, previousControls)
|
|
481
|
+
is String -> TextView(this).apply { text = child; setTextColor(defaultTextColor) }
|
|
482
|
+
else -> null
|
|
483
|
+
}
|
|
484
|
+
if (childView != null) desired.add(childView)
|
|
485
|
+
}
|
|
486
|
+
if (style.optString("flexDirection").endsWith("-reverse")) desired.reverse()
|
|
487
|
+
val desiredSet = desired.toSet()
|
|
488
|
+
for ((index, childView) in desired.withIndex()) {
|
|
489
|
+
if (layout.getChildAt(index) !== childView) {
|
|
490
|
+
if (index < layout.childCount && layout.getChildAt(index) !in desiredSet) layout.removeViewAt(index)
|
|
491
|
+
(childView.parent as? ViewGroup)?.removeView(childView)
|
|
492
|
+
layout.addView(childView, index)
|
|
493
|
+
}
|
|
494
|
+
val gapName = if (layout.orientation == LinearLayout.HORIZONTAL) "columnGap" else "rowGap"
|
|
495
|
+
val gap = dp(style.optDouble(gapName, style.optDouble("gap", 0.0)))
|
|
496
|
+
val params = childView.layoutParams as? LinearLayout.LayoutParams
|
|
497
|
+
?: LinearLayout.LayoutParams(childView.layoutParams?.width ?: -1, childView.layoutParams?.height ?: -2)
|
|
498
|
+
params.leftMargin = if (layout.orientation == LinearLayout.HORIZONTAL && index > 0) gap else 0
|
|
499
|
+
params.topMargin = if (layout.orientation == LinearLayout.VERTICAL && index > 0) gap else 0
|
|
500
|
+
childView.layoutParams = params
|
|
501
|
+
}
|
|
502
|
+
while (layout.childCount > desired.size) layout.removeViewAt(layout.childCount - 1)
|
|
503
|
+
addJustificationSpacers(layout, style.optString("justifyContent"))
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
private fun reconcileFlatList(
|
|
507
|
+
screen: Screen,
|
|
508
|
+
list: CraftNativeFlatList,
|
|
509
|
+
children: JSONArray,
|
|
510
|
+
path: String,
|
|
511
|
+
owner: String,
|
|
512
|
+
props: JSONObject,
|
|
513
|
+
events: JSONObject,
|
|
514
|
+
) {
|
|
515
|
+
screen.flatListOwners.entries.removeAll { it.value == owner }
|
|
516
|
+
screen.flatListOwnerByView[list] = owner
|
|
517
|
+
val nodes = (0 until children.length()).mapNotNull { children.optJSONObject(it) }
|
|
518
|
+
nodes.forEach { registerFlatListOwnership(screen, it, owner) }
|
|
519
|
+
list.apply(
|
|
520
|
+
nodes = nodes,
|
|
521
|
+
horizontal = props.optBoolean("horizontal", false),
|
|
522
|
+
columns = props.optInt("numColumns", 1),
|
|
523
|
+
inverted = props.optBoolean("inverted", false),
|
|
524
|
+
endReachedThreshold = when {
|
|
525
|
+
props.has("onEndReachedThreshold") -> props.optDouble("onEndReachedThreshold", 0.1)
|
|
526
|
+
else -> props.optDouble("threshold", 0.1)
|
|
527
|
+
},
|
|
528
|
+
renderItem = { node, itemIdentity, _ ->
|
|
529
|
+
val rows = screen.flatListRows.getOrPut(list) { mutableMapOf() }
|
|
530
|
+
val previous = rows[itemIdentity].orEmpty()
|
|
531
|
+
val next = mutableMapOf<String, View>()
|
|
532
|
+
val view = makeView(screen, node, "$path.$itemIdentity", next, previous)
|
|
533
|
+
releaseViewState(screen, previous.values.filter { it !in next.values })
|
|
534
|
+
rows[itemIdentity] = next
|
|
535
|
+
view
|
|
536
|
+
},
|
|
537
|
+
recycleItem = { itemIdentity ->
|
|
538
|
+
val controls = screen.flatListRows[list]?.remove(itemIdentity).orEmpty()
|
|
539
|
+
releaseViewState(screen, controls.values.toList())
|
|
540
|
+
},
|
|
541
|
+
endReached = events.optString("onEndReached").takeIf { it.isNotBlank() }?.let { handler ->
|
|
542
|
+
{ send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", JSONObject())) }
|
|
543
|
+
},
|
|
544
|
+
)
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
private fun registerFlatListOwnership(screen: Screen, node: JSONObject, owner: String) {
|
|
548
|
+
val props = node.optJSONObject("props") ?: JSONObject()
|
|
549
|
+
listOf(node.optString("id"), node.optString("key"), props.optString("key"), props.optString("testID"))
|
|
550
|
+
.filter { it.isNotBlank() }
|
|
551
|
+
.forEach { screen.flatListOwners[it] = owner }
|
|
552
|
+
val children = node.optJSONArray("children") ?: JSONArray()
|
|
553
|
+
for (index in 0 until children.length()) {
|
|
554
|
+
children.optJSONObject(index)?.let { registerFlatListOwnership(screen, it, owner) }
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
private fun addJustificationSpacers(layout: LinearLayout, value: String) {
|
|
559
|
+
if (layout.childCount == 0 || value == "flex-start" || value.isBlank()) return
|
|
560
|
+
fun spacer(weight: Float): CraftNativeFlexSpacer = CraftNativeFlexSpacer(this).apply {
|
|
561
|
+
layoutParams = if (layout.orientation == LinearLayout.HORIZONTAL)
|
|
562
|
+
LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, weight)
|
|
563
|
+
else LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, weight)
|
|
564
|
+
}
|
|
565
|
+
when (value) {
|
|
566
|
+
"flex-end" -> layout.addView(spacer(1f), 0)
|
|
567
|
+
"center" -> {
|
|
568
|
+
layout.addView(spacer(1f), 0)
|
|
569
|
+
layout.addView(spacer(1f))
|
|
570
|
+
}
|
|
571
|
+
"space-between" -> for (index in layout.childCount - 1 downTo 1) layout.addView(spacer(1f), index)
|
|
572
|
+
"space-around", "space-evenly" -> {
|
|
573
|
+
val edgeWeight = if (value == "space-around") 0.5f else 1f
|
|
574
|
+
for (index in layout.childCount - 1 downTo 1) layout.addView(spacer(1f), index)
|
|
575
|
+
layout.addView(spacer(edgeWeight), 0)
|
|
576
|
+
layout.addView(spacer(edgeWeight))
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
private fun applyViewStyle(view: View, style: JSONObject) {
|
|
582
|
+
view.alpha = style.optDouble("opacity", 1.0).toFloat()
|
|
583
|
+
view.visibility = if (style.optString("display") == "none") View.GONE else View.VISIBLE
|
|
584
|
+
view.elevation = dp(style.optDouble("elevation", 0.0)).toFloat()
|
|
585
|
+
val background = style.has("backgroundColor")
|
|
586
|
+
val border = style.optDouble("borderWidth", 0.0)
|
|
587
|
+
val radius = style.optDouble("borderRadius", 0.0)
|
|
588
|
+
if (background || border > 0 || radius > 0) {
|
|
589
|
+
view.background = GradientDrawable().apply {
|
|
590
|
+
setColor(color(style.optString("backgroundColor"), Color.TRANSPARENT))
|
|
591
|
+
cornerRadius = dp(radius).toFloat()
|
|
592
|
+
if (border > 0) setStroke(dp(border), color(style.optString("borderColor"), Color.TRANSPARENT))
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
view.clipToOutline = style.optString("overflow") == "hidden"
|
|
596
|
+
if (view is ViewGroup) view.clipChildren = style.optString("overflow") == "hidden"
|
|
597
|
+
if (view is ImageView) {
|
|
598
|
+
if (style.has("tintColor")) view.setColorFilter(color(style.optString("tintColor"), Color.TRANSPARENT))
|
|
599
|
+
else view.clearColorFilter()
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
private fun configureText(view: TextView, value: String, style: JSONObject) {
|
|
604
|
+
view.text = when (style.optString("textTransform")) {
|
|
605
|
+
"uppercase" -> value.uppercase()
|
|
606
|
+
"lowercase" -> value.lowercase()
|
|
607
|
+
"capitalize" -> value.split(' ').joinToString(" ") { word -> word.replaceFirstChar(Char::uppercase) }
|
|
608
|
+
else -> value
|
|
609
|
+
}
|
|
610
|
+
view.setTextColor(color(style.optString("color"), defaultTextColor))
|
|
611
|
+
view.setTextSize(TypedValue.COMPLEX_UNIT_SP, style.optDouble("fontSize", 14.0).toFloat())
|
|
612
|
+
val weight = when (style.optString("fontWeight")) {
|
|
613
|
+
"bold" -> 700
|
|
614
|
+
"100", "200", "300", "400", "500", "600", "700", "800", "900" -> style.optInt("fontWeight")
|
|
615
|
+
else -> 400
|
|
616
|
+
}
|
|
617
|
+
val italic = style.optString("fontStyle") == "italic"
|
|
618
|
+
val family = style.optString("fontFamily").takeIf { it.isNotBlank() }
|
|
619
|
+
view.typeface = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
|
620
|
+
Typeface.create(Typeface.create(family, Typeface.NORMAL), weight, italic)
|
|
621
|
+
} else Typeface.create(family, (if (weight >= 600) Typeface.BOLD else Typeface.NORMAL) or
|
|
622
|
+
(if (italic) Typeface.ITALIC else Typeface.NORMAL))
|
|
623
|
+
view.textAlignment = when (style.optString("textAlign")) {
|
|
624
|
+
"left" -> View.TEXT_ALIGNMENT_TEXT_START
|
|
625
|
+
"right" -> View.TEXT_ALIGNMENT_TEXT_END
|
|
626
|
+
"center" -> View.TEXT_ALIGNMENT_CENTER
|
|
627
|
+
"justify" -> View.TEXT_ALIGNMENT_VIEW_START
|
|
628
|
+
else -> View.TEXT_ALIGNMENT_INHERIT
|
|
629
|
+
}
|
|
630
|
+
view.letterSpacing = if (style.has("letterSpacing") && view.textSize > 0)
|
|
631
|
+
dp(style.optDouble("letterSpacing")).toFloat() / view.textSize else 0f
|
|
632
|
+
view.setLineSpacing(
|
|
633
|
+
if (style.has("lineHeight")) dp(style.optDouble("lineHeight")) - view.textSize else 0f,
|
|
634
|
+
1f
|
|
635
|
+
)
|
|
636
|
+
view.paintFlags = view.paintFlags and Paint.UNDERLINE_TEXT_FLAG.inv() and Paint.STRIKE_THRU_TEXT_FLAG.inv()
|
|
637
|
+
when (style.optString("textDecorationLine")) {
|
|
638
|
+
"underline" -> view.paintFlags = view.paintFlags or Paint.UNDERLINE_TEXT_FLAG
|
|
639
|
+
"line-through" -> view.paintFlags = view.paintFlags or Paint.STRIKE_THRU_TEXT_FLAG
|
|
640
|
+
"underline line-through" -> view.paintFlags = view.paintFlags or
|
|
641
|
+
Paint.UNDERLINE_TEXT_FLAG or Paint.STRIKE_THRU_TEXT_FLAG
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
private fun updateImage(view: ImageView, source: Any?) {
|
|
646
|
+
val uri = when (source) {
|
|
647
|
+
is String -> source
|
|
648
|
+
is JSONObject -> source.optString("uri")
|
|
649
|
+
else -> ""
|
|
650
|
+
}
|
|
651
|
+
if (uri.isBlank()) {
|
|
652
|
+
imageJobs.remove(view)?.cancel(true)
|
|
653
|
+
imageSources.remove(view)
|
|
654
|
+
imageErrors[view] = "Image source is missing"
|
|
655
|
+
view.setImageDrawable(null)
|
|
656
|
+
return
|
|
657
|
+
}
|
|
658
|
+
if (imageSources[view] == uri) return
|
|
659
|
+
imageJobs.remove(view)?.cancel(true)
|
|
660
|
+
imageSources[view] = uri
|
|
661
|
+
imageErrors.remove(view)
|
|
662
|
+
view.setImageDrawable(null)
|
|
663
|
+
|
|
664
|
+
if (uri.startsWith("data:image/") && uri.contains(',')) {
|
|
665
|
+
val bytes = runCatching { Base64.decode(uri.substringAfter(','), Base64.DEFAULT) }.getOrNull()
|
|
666
|
+
val bitmap = bytes?.let { BitmapFactory.decodeByteArray(it, 0, it.size) }
|
|
667
|
+
if (bitmap != null) view.setImageBitmap(bitmap) else imageFailure(view, uri, "Image data is invalid")
|
|
668
|
+
return
|
|
669
|
+
}
|
|
670
|
+
if (!uri.contains(':')) {
|
|
671
|
+
val bitmap = runCatching { assets.open(uri).use { BitmapFactory.decodeStream(it) } }.getOrNull()
|
|
672
|
+
if (bitmap != null) view.setImageBitmap(bitmap) else imageFailure(view, uri, "Bundled image was not found")
|
|
673
|
+
return
|
|
674
|
+
}
|
|
675
|
+
if (!uri.startsWith("https://")) {
|
|
676
|
+
imageFailure(view, uri, "Unsupported image source")
|
|
677
|
+
return
|
|
678
|
+
}
|
|
679
|
+
imageJobs[view] = imageExecutor.submit {
|
|
680
|
+
val bitmap = runCatching {
|
|
681
|
+
val connection = URL(uri).openConnection().apply {
|
|
682
|
+
connectTimeout = 10_000
|
|
683
|
+
readTimeout = 10_000
|
|
684
|
+
}
|
|
685
|
+
connection.getInputStream().use(BitmapFactory::decodeStream)
|
|
686
|
+
}.getOrNull()
|
|
687
|
+
runOnUiThread {
|
|
688
|
+
if (imageSources[view] != uri) return@runOnUiThread
|
|
689
|
+
imageJobs.remove(view)
|
|
690
|
+
if (bitmap != null) view.setImageBitmap(bitmap) else imageFailure(view, uri, "Image download failed")
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
private fun imageFailure(view: ImageView, uri: String, message: String) {
|
|
696
|
+
imageErrors[view] = message
|
|
697
|
+
view.setImageDrawable(null)
|
|
698
|
+
view.contentDescription = "${view.tag ?: "Image"}, $message"
|
|
699
|
+
Log.e(TAG, "$message: $uri")
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
private fun applyAccessibility(view: View, props: JSONObject, type: String, fallbackLabel: String) {
|
|
703
|
+
view.tag = fallbackLabel
|
|
704
|
+
view.contentDescription = props.optString("accessibilityLabel").ifBlank { fallbackLabel }
|
|
705
|
+
props.optString("accessibilityHint").takeIf { it.isNotBlank() }?.let { view.tooltipText = it }
|
|
706
|
+
val role = props.optString("accessibilityRole").ifBlank { type.lowercase() }
|
|
707
|
+
ViewCompat.setAccessibilityDelegate(view, object : AccessibilityDelegateCompat() {
|
|
708
|
+
override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfoCompat) {
|
|
709
|
+
super.onInitializeAccessibilityNodeInfo(host, info)
|
|
710
|
+
info.className = when (role) {
|
|
711
|
+
"button" -> Button::class.java.name
|
|
712
|
+
"image" -> ImageView::class.java.name
|
|
713
|
+
"search" -> EditText::class.java.name
|
|
714
|
+
"scrollbar" -> ScrollView::class.java.name
|
|
715
|
+
else -> host.javaClass.name
|
|
716
|
+
}
|
|
717
|
+
info.isHeading = role == "header"
|
|
718
|
+
info.roleDescription = role.takeIf { it !in setOf("view", "text", "textinput", "scrollview") }
|
|
719
|
+
}
|
|
720
|
+
})
|
|
721
|
+
ViewCompat.setImportantForAccessibility(view, ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_YES)
|
|
722
|
+
}
|
|
723
|
+
|
|
310
724
|
private fun dp(value: Double) = (value * resources.displayMetrics.density).toInt()
|
|
311
725
|
private fun color(raw: String, fallback: Int) = runCatching { Color.parseColor(raw) }.getOrDefault(fallback)
|
|
312
726
|
|
|
@@ -358,13 +772,47 @@ class MainActivity : AppCompatActivity() {
|
|
|
358
772
|
|
|
359
773
|
private fun back() {
|
|
360
774
|
if (screens.size > 1) {
|
|
361
|
-
screens.removeAt(screens.lastIndex)
|
|
775
|
+
closeScreen(screens.removeAt(screens.lastIndex))
|
|
362
776
|
show(screens.last())
|
|
363
777
|
} else {
|
|
364
778
|
finish()
|
|
365
779
|
}
|
|
366
780
|
}
|
|
367
781
|
|
|
782
|
+
private fun releaseImages(views: Collection<View>) {
|
|
783
|
+
views.filterIsInstance<ImageView>().forEach { image ->
|
|
784
|
+
imageJobs.remove(image)?.cancel(true)
|
|
785
|
+
imageSources.remove(image)
|
|
786
|
+
imageErrors.remove(image)
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
private fun retainedViews(screen: Screen): MutableSet<View> = screen.controls.values.toMutableSet().apply {
|
|
791
|
+
screen.flatListRows.values.forEach { rows -> rows.values.forEach { addAll(it.values) } }
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
private fun releaseViewState(screen: Screen, views: Collection<View>) {
|
|
795
|
+
val released = views.toSet()
|
|
796
|
+
released.filterIsInstance<CraftNativeFlatList>().forEach { list ->
|
|
797
|
+
val owner = screen.flatListOwnerByView.remove(list)
|
|
798
|
+
list.discardAll()
|
|
799
|
+
screen.flatListRows.remove(list)?.values?.forEach { releaseViewState(screen, it.values) }
|
|
800
|
+
if (owner != null) screen.flatListOwners.entries.removeAll { it.value == owner }
|
|
801
|
+
}
|
|
802
|
+
releaseImages(released)
|
|
803
|
+
screen.handlers.keys.removeAll(released)
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
private fun closeScreen(screen: Screen) {
|
|
807
|
+
releaseViewState(screen, retainedViews(screen).toList())
|
|
808
|
+
screen.controls.clear()
|
|
809
|
+
screen.handlers.clear()
|
|
810
|
+
screen.flatListRows.clear()
|
|
811
|
+
screen.flatListOwners.clear()
|
|
812
|
+
screen.flatListOwnerByView.clear()
|
|
813
|
+
screen.close()
|
|
814
|
+
}
|
|
815
|
+
|
|
368
816
|
@Deprecated("Uses the activity back callback for native route navigation")
|
|
369
817
|
override fun onBackPressed() = back()
|
|
370
818
|
|
|
@@ -380,8 +828,11 @@ class MainActivity : AppCompatActivity() {
|
|
|
380
828
|
}
|
|
381
829
|
|
|
382
830
|
override fun onDestroy() {
|
|
383
|
-
screens.forEach { it
|
|
831
|
+
screens.forEach { closeScreen(it) }
|
|
384
832
|
screens.clear()
|
|
833
|
+
imageJobs.values.forEach { it.cancel(true) }
|
|
834
|
+
imageJobs.clear()
|
|
835
|
+
imageExecutor.shutdownNow()
|
|
385
836
|
sandbox?.close()
|
|
386
837
|
super.onDestroy()
|
|
387
838
|
}
|