@craft-native/android 0.0.204 → 0.0.206
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/README.md +24 -8
- package/dist/index.js +91 -1
- package/dist/index.js.map +3 -3
- package/package.json +1 -1
- package/templates/CraftNativeFlatList.kt.template +117 -0
- package/templates/CraftNativeRefreshableFlatList.kt.template +7 -0
- package/templates/CraftNativeShell.kt.template +42 -10
- package/templates/MainActivityNative.kt.template +628 -99
|
@@ -8,6 +8,7 @@ import androidx.recyclerview.widget.DiffUtil
|
|
|
8
8
|
import androidx.recyclerview.widget.GridLayoutManager
|
|
9
9
|
import androidx.recyclerview.widget.LinearLayoutManager
|
|
10
10
|
import androidx.recyclerview.widget.ListAdapter
|
|
11
|
+
import androidx.recyclerview.widget.PagerSnapHelper
|
|
11
12
|
import androidx.recyclerview.widget.RecyclerView
|
|
12
13
|
import org.json.JSONObject
|
|
13
14
|
import kotlin.math.ceil
|
|
@@ -101,6 +102,14 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
|
|
|
101
102
|
private var dataContentSignature = ""
|
|
102
103
|
private var horizontal = false
|
|
103
104
|
private var columns = 1
|
|
105
|
+
private var bounces = true
|
|
106
|
+
private var alwaysBounceVertical = true
|
|
107
|
+
private var alwaysBounceHorizontal = true
|
|
108
|
+
private var scrollEnabled = true
|
|
109
|
+
private var pagingEnabled = false
|
|
110
|
+
private val pagerSnapHelper = PagerSnapHelper()
|
|
111
|
+
private var scrollTargetKey: String? = null
|
|
112
|
+
private var pendingScrollTarget: Pair<String, Boolean>? = null
|
|
104
113
|
private var keyboardShouldPersistTaps = "never"
|
|
105
114
|
private var submissionGeneration = 0
|
|
106
115
|
private var isSubmittingList = false
|
|
@@ -117,6 +126,53 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
|
|
|
117
126
|
LayoutParams.WRAP_CONTENT,
|
|
118
127
|
)
|
|
119
128
|
|
|
129
|
+
fun setScrollIndicators(vertical: Boolean, horizontal: Boolean) {
|
|
130
|
+
isVerticalScrollBarEnabled = vertical
|
|
131
|
+
isHorizontalScrollBarEnabled = horizontal
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
fun setScrollEnabled(enabled: Boolean) {
|
|
135
|
+
scrollEnabled = enabled
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
val isScrollEnabledForTest: Boolean
|
|
139
|
+
get() = scrollEnabled
|
|
140
|
+
|
|
141
|
+
fun setPagingEnabled(enabled: Boolean) {
|
|
142
|
+
if (pagingEnabled == enabled) return
|
|
143
|
+
pagingEnabled = enabled
|
|
144
|
+
if (enabled) pagerSnapHelper.attachToRecyclerView(this)
|
|
145
|
+
else pagerSnapHelper.attachToRecyclerView(null)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
val isPagingEnabledForTest: Boolean
|
|
149
|
+
get() = pagingEnabled
|
|
150
|
+
|
|
151
|
+
val isScrollTargetPendingForTest: Boolean
|
|
152
|
+
get() = pendingScrollTarget != null
|
|
153
|
+
|
|
154
|
+
override fun onInterceptTouchEvent(event: android.view.MotionEvent): Boolean {
|
|
155
|
+
if (!scrollEnabled) return false
|
|
156
|
+
return super.onInterceptTouchEvent(event)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
override fun onTouchEvent(event: android.view.MotionEvent): Boolean {
|
|
160
|
+
if (!scrollEnabled) return false
|
|
161
|
+
return super.onTouchEvent(event)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
fun setBounceOptions(enabled: Boolean, vertical: Boolean, horizontal: Boolean) {
|
|
165
|
+
bounces = enabled
|
|
166
|
+
alwaysBounceVertical = vertical
|
|
167
|
+
alwaysBounceHorizontal = horizontal
|
|
168
|
+
applyBounceMode()
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private fun applyBounceMode() {
|
|
172
|
+
val axisEnabled = if (this.horizontal) alwaysBounceHorizontal else alwaysBounceVertical
|
|
173
|
+
overScrollMode = if (bounces && axisEnabled) View.OVER_SCROLL_ALWAYS else View.OVER_SCROLL_NEVER
|
|
174
|
+
}
|
|
175
|
+
|
|
120
176
|
private fun updateVisibleHolderLayoutParams() {
|
|
121
177
|
for (index in 0 until childCount) {
|
|
122
178
|
(getChildViewHolder(getChildAt(index)) as? Holder)?.host?.layoutParams = holderLayoutParams()
|
|
@@ -125,6 +181,7 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
|
|
|
125
181
|
|
|
126
182
|
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
|
127
183
|
super.onLayout(changed, left, top, right, bottom)
|
|
184
|
+
scrollToPendingTarget(0)
|
|
128
185
|
onLayoutChanged?.invoke()
|
|
129
186
|
}
|
|
130
187
|
|
|
@@ -246,6 +303,7 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
|
|
|
246
303
|
val retainedState = if (layoutChanged) null else layoutManager?.onSaveInstanceState()
|
|
247
304
|
this.horizontal = horizontal
|
|
248
305
|
this.columns = nextColumns
|
|
306
|
+
applyBounceMode()
|
|
249
307
|
if (layoutChanged || layoutManager == null) {
|
|
250
308
|
configureLayoutManager()
|
|
251
309
|
updateVisibleHolderLayoutParams()
|
|
@@ -280,10 +338,44 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
|
|
|
280
338
|
pendingThemeRefresh = false
|
|
281
339
|
refreshThemeDefaults()
|
|
282
340
|
}
|
|
341
|
+
scrollToPendingTarget(0)
|
|
283
342
|
evaluateEndReached()
|
|
284
343
|
}
|
|
285
344
|
}
|
|
286
345
|
|
|
346
|
+
/** Scroll to a virtualized row identified by its native testID, id, or key. */
|
|
347
|
+
fun setScrollTarget(value: Any?) {
|
|
348
|
+
val target: String
|
|
349
|
+
val animated: Boolean
|
|
350
|
+
val key: String
|
|
351
|
+
when (value) {
|
|
352
|
+
is String -> {
|
|
353
|
+
target = value.trim()
|
|
354
|
+
animated = true
|
|
355
|
+
key = target
|
|
356
|
+
}
|
|
357
|
+
is JSONObject -> {
|
|
358
|
+
target = craftNativeOptionalString(value.opt("id")).orEmpty().trim()
|
|
359
|
+
animated = value.optBoolean("animated", true)
|
|
360
|
+
key = value.toString()
|
|
361
|
+
}
|
|
362
|
+
else -> {
|
|
363
|
+
scrollTargetKey = null
|
|
364
|
+
pendingScrollTarget = null
|
|
365
|
+
return
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (target.isBlank()) {
|
|
369
|
+
scrollTargetKey = null
|
|
370
|
+
pendingScrollTarget = null
|
|
371
|
+
return
|
|
372
|
+
}
|
|
373
|
+
if (key == scrollTargetKey) return
|
|
374
|
+
scrollTargetKey = key
|
|
375
|
+
pendingScrollTarget = target to animated
|
|
376
|
+
post { scrollToPendingTarget(0) }
|
|
377
|
+
}
|
|
378
|
+
|
|
287
379
|
fun discardAll() {
|
|
288
380
|
listAdapter.currentList.forEach { recycleItem?.invoke(it.identity) }
|
|
289
381
|
val generation = ++submissionGeneration
|
|
@@ -295,6 +387,9 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
|
|
|
295
387
|
renderItem = null
|
|
296
388
|
recycleItem = null
|
|
297
389
|
endReached = null
|
|
390
|
+
setPagingEnabled(false)
|
|
391
|
+
scrollTargetKey = null
|
|
392
|
+
pendingScrollTarget = null
|
|
298
393
|
dataContentSignature = ""
|
|
299
394
|
onScrollEvent = null
|
|
300
395
|
onScrollBeginDrag = null
|
|
@@ -325,6 +420,28 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
|
|
|
325
420
|
}
|
|
326
421
|
}
|
|
327
422
|
|
|
423
|
+
private fun targetPosition(id: String): Int? = listAdapter.currentList.indexOfFirst { item ->
|
|
424
|
+
val props = item.node.optJSONObject("props")
|
|
425
|
+
listOf(
|
|
426
|
+
props?.optString("testID"),
|
|
427
|
+
props?.optString("id"),
|
|
428
|
+
item.node.optString("id"),
|
|
429
|
+
item.identity,
|
|
430
|
+
).any { it == id }
|
|
431
|
+
}.takeIf { it >= 0 }
|
|
432
|
+
|
|
433
|
+
private fun scrollToPendingTarget(attempt: Int) {
|
|
434
|
+
val target = pendingScrollTarget ?: return
|
|
435
|
+
val position = targetPosition(target.first)
|
|
436
|
+
if (position == null || layoutManager == null || width <= 0 || height <= 0) {
|
|
437
|
+
if (attempt < 4) postDelayed({ scrollToPendingTarget(attempt + 1) }, 16L)
|
|
438
|
+
else if (!isSubmittingList) pendingScrollTarget = null
|
|
439
|
+
return
|
|
440
|
+
}
|
|
441
|
+
pendingScrollTarget = null
|
|
442
|
+
if (target.second) smoothScrollToPosition(position) else scrollToPosition(position)
|
|
443
|
+
}
|
|
444
|
+
|
|
328
445
|
private fun evaluateEndReached() {
|
|
329
446
|
if (isSubmittingList) return
|
|
330
447
|
val manager = layoutManager as? LinearLayoutManager ?: return
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
package {{PACKAGE_NAME}}
|
|
2
2
|
|
|
3
3
|
import android.content.Context
|
|
4
|
+
import org.json.JSONObject
|
|
4
5
|
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
|
5
6
|
|
|
6
7
|
/** Swipe-to-refresh host for a native FlatList. */
|
|
@@ -21,6 +22,12 @@ internal class CraftNativeRefreshableFlatList(context: Context) : SwipeRefreshLa
|
|
|
21
22
|
if (handler == null) isRefreshing = false
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
fun setRefreshAppearance(props: JSONObject, fallback: Int) {
|
|
26
|
+
val tint = props.optString("tintColor").takeIf { it.isNotBlank() }
|
|
27
|
+
val resolved = tint?.let { runCatching { craftNativeColor(it) }.getOrNull() } ?: fallback
|
|
28
|
+
setColorSchemeColors(resolved)
|
|
29
|
+
}
|
|
30
|
+
|
|
24
31
|
override fun onDetachedFromWindow() {
|
|
25
32
|
refreshHandler = null
|
|
26
33
|
isRefreshing = false
|
|
@@ -115,13 +115,15 @@ internal class CraftNativeRoutes(config: JSONObject) {
|
|
|
115
115
|
val site: Uri? = config.optString("devServerURL").takeIf { it.isNotBlank() }?.let(Uri::parse)
|
|
116
116
|
val siteOrigin: String? = site?.let { uri ->
|
|
117
117
|
val port = if (uri.port > 0) ":${uri.port}" else ""
|
|
118
|
-
"${uri.scheme}://${uri.host}$port"
|
|
118
|
+
"${uri.scheme?.lowercase()}://${uri.host?.lowercase()}$port"
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
/** Where the app opens: the site's path when a screen draws it, else the first tab. */
|
|
122
|
-
val initialPath: String = site?.
|
|
123
|
-
|
|
124
|
-
?: "
|
|
122
|
+
val initialPath: String = site?.let { uri ->
|
|
123
|
+
val path = uri.encodedPath?.takeIf { it.isNotBlank() } ?: "/"
|
|
124
|
+
val query = uri.encodedQuery?.takeIf { it.isNotBlank() }?.let { "?$it" } ?: ""
|
|
125
|
+
(path + query).takeIf { match(it) != null }
|
|
126
|
+
} ?: tabs.firstOrNull()?.id ?: "/"
|
|
125
127
|
|
|
126
128
|
val isEmpty: Boolean get() = routes.isEmpty()
|
|
127
129
|
|
|
@@ -148,7 +150,7 @@ internal class CraftNativeRoutes(config: JSONObject) {
|
|
|
148
150
|
}
|
|
149
151
|
val origin = siteOrigin ?: return null
|
|
150
152
|
val port = if (uri.port > 0) ":${uri.port}" else ""
|
|
151
|
-
if ("${
|
|
153
|
+
if ("${scheme}://${uri.host?.lowercase()}$port" != origin) return null
|
|
152
154
|
return (uri.encodedPath ?: "/") + (uri.encodedQuery?.let { "?$it" } ?: "")
|
|
153
155
|
}
|
|
154
156
|
|
|
@@ -161,7 +163,7 @@ internal class CraftNativeRoutes(config: JSONObject) {
|
|
|
161
163
|
val params = bind(route.segments, parts) ?: continue
|
|
162
164
|
if (query.isNotBlank()) {
|
|
163
165
|
query.split('&').filter { it.isNotBlank() }.forEach { pair ->
|
|
164
|
-
val key = decode(pair.substringBefore('='))
|
|
166
|
+
val key = Uri.decode(pair.substringBefore('='))
|
|
165
167
|
if (key.isNotBlank() && !params.has(key)) params.put(key, decode(pair.substringAfter('=', "").replace('+', ' ')))
|
|
166
168
|
}
|
|
167
169
|
}
|
|
@@ -181,7 +183,7 @@ internal class CraftNativeRoutes(config: JSONObject) {
|
|
|
181
183
|
if (index >= parts.size) return null
|
|
182
184
|
if (expected.startsWith(":")) {
|
|
183
185
|
if (parts[index].isBlank()) return null
|
|
184
|
-
params.put(expected.removePrefix(":"), decode(parts[index]))
|
|
186
|
+
params.put(expected.removePrefix(":"), Uri.decode(parts[index]))
|
|
185
187
|
} else if (expected != parts[index]) return null
|
|
186
188
|
}
|
|
187
189
|
return if (pattern.size == parts.size) params else null
|
|
@@ -580,7 +582,9 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
|
|
|
580
582
|
private val tabIconText = mutableMapOf<String, String>()
|
|
581
583
|
private val tabImageURLs = mutableMapOf<String, String>()
|
|
582
584
|
private val tabImages = mutableMapOf<String, android.graphics.drawable.Drawable>()
|
|
585
|
+
private val navButtonSpecs = mutableMapOf<TextView, JSONObject>()
|
|
583
586
|
private val density = resources.displayMetrics.density
|
|
587
|
+
private var screenBackgroundColor: Int? = null
|
|
584
588
|
private var selectedTab = 0
|
|
585
589
|
private var swipeTracking = false
|
|
586
590
|
private var swipeIntercepted = false
|
|
@@ -710,6 +714,10 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
|
|
|
710
714
|
|
|
711
715
|
/** Draws `options` (a screen's `setOptions`) for a screen `depth` deep in its stack. */
|
|
712
716
|
fun apply(options: JSONObject, depth: Int, fallbackTitle: String?) {
|
|
717
|
+
screenBackgroundColor = options.opt("backgroundColor")
|
|
718
|
+
?.takeUnless { it == JSONObject.NULL }
|
|
719
|
+
?.let { craftNativeOptionalString(it) }
|
|
720
|
+
?.let { runCatching { craftNativeColor(it) }.getOrNull() }
|
|
713
721
|
options.optJSONObject("tabItem")?.let(::updateTab)
|
|
714
722
|
val hidden = options.optBoolean("hidden", false)
|
|
715
723
|
val large = options.optBoolean("largeTitle", false) && depth == 0
|
|
@@ -722,6 +730,7 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
|
|
|
722
730
|
largeTitleView.visibility = if (!hidden && large && title.isNotBlank()) View.VISIBLE else View.GONE
|
|
723
731
|
buttons.removeAllViews()
|
|
724
732
|
leadingButtons.removeAllViews()
|
|
733
|
+
navButtonSpecs.clear()
|
|
725
734
|
val leading = options.optJSONArray("leftButtons") ?: JSONArray()
|
|
726
735
|
for (index in 0 until leading.length()) {
|
|
727
736
|
val button = leading.optJSONObject(index) ?: continue
|
|
@@ -735,6 +744,7 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
|
|
|
735
744
|
val tabBarHidden = options.optBoolean("tabBarHidden", false) || depth > 0 && options.optBoolean("tabBarHiddenWhenPushed", false)
|
|
736
745
|
tabBar.visibility = if (tabs.isEmpty() || tabBarHidden) View.GONE else View.VISIBLE
|
|
737
746
|
tabSwipeEnabled = options.optBoolean("tabSwipeEnabled", true)
|
|
747
|
+
refreshColors()
|
|
738
748
|
}
|
|
739
749
|
|
|
740
750
|
fun selectTab(index: Int) {
|
|
@@ -780,13 +790,14 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
|
|
|
780
790
|
|
|
781
791
|
/** Light or dark changed: the bars follow the screen's background. */
|
|
782
792
|
fun refreshColors() {
|
|
783
|
-
val background = ContextCompat.getColor(context, R.color.background)
|
|
793
|
+
val background = screenBackgroundColor ?: ContextCompat.getColor(context, R.color.background)
|
|
784
794
|
val dark = Color.luminance(background) < 0.5
|
|
785
795
|
val text = if (dark) Color.WHITE else Color.BLACK
|
|
786
796
|
val muted = if (dark) Color.parseColor("#8E8E93") else Color.parseColor("#6C6C70")
|
|
787
797
|
val accent = accentColor(dark)
|
|
788
798
|
setBackgroundColor(background)
|
|
789
799
|
bar.setBackgroundColor(background)
|
|
800
|
+
largeTitleView.setBackgroundColor(background)
|
|
790
801
|
titleView.setTextColor(text)
|
|
791
802
|
largeTitleView.setTextColor(text)
|
|
792
803
|
backButton.imageTintList = ColorStateList.valueOf(accent)
|
|
@@ -806,6 +817,7 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
|
|
|
806
817
|
icon.foreground = vector
|
|
807
818
|
icon.text = if (vector != null) "" else number ?: CraftNativeSymbols.glyph(tabs[index].symbol)
|
|
808
819
|
}
|
|
820
|
+
navButtonSpecs.forEach { (view, spec) -> applyNavButtonTint(view, spec, dark) }
|
|
809
821
|
}
|
|
810
822
|
|
|
811
823
|
private fun navButton(button: JSONObject): View {
|
|
@@ -814,8 +826,7 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
|
|
|
814
826
|
val symbol = button.optString("symbol")
|
|
815
827
|
val view = TextView(context)
|
|
816
828
|
val dark = Color.luminance(ContextCompat.getColor(context, R.color.background)) < 0.5
|
|
817
|
-
val
|
|
818
|
-
val tint = button.optString("color").takeIf { it.isNotBlank() }?.let { runCatching { craftNativeColor(it) }.getOrNull() } ?: defaultTint
|
|
829
|
+
val tint = navButtonTint(button, dark)
|
|
819
830
|
val vector = if (symbol.isNotBlank()) CraftNativeIcons.drawable(context, null, symbol, tint) else null
|
|
820
831
|
view.text = if (vector != null) "" else if (symbol.isNotBlank()) CraftNativeSymbols.glyph(symbol) else title
|
|
821
832
|
view.textSize = if (symbol.isNotBlank()) 22f else 16f
|
|
@@ -829,6 +840,7 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
|
|
|
829
840
|
view.background = rippleBackground()
|
|
830
841
|
view.contentDescription = button.optString("accessibilityLabel").ifBlank { title.ifBlank { symbol } }
|
|
831
842
|
view.setTextColor(tint)
|
|
843
|
+
navButtonSpecs[view] = button
|
|
832
844
|
val badge = button.optInt("badge", 0)
|
|
833
845
|
if (badge > 0) {
|
|
834
846
|
view.text = "${view.text}${if (button.optString("badgeStyle") == "dot") " •" else " $badge"}"
|
|
@@ -838,6 +850,26 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
|
|
|
838
850
|
return view
|
|
839
851
|
}
|
|
840
852
|
|
|
853
|
+
private fun navButtonTint(button: JSONObject, dark: Boolean): Int {
|
|
854
|
+
val defaultTint = if (button.optString("variant") == "neutral") {
|
|
855
|
+
if (dark) Color.WHITE else Color.parseColor("#1C1C1E")
|
|
856
|
+
} else accentColor(dark)
|
|
857
|
+
return button.optString("color").takeIf { it.isNotBlank() }?.let {
|
|
858
|
+
runCatching { craftNativeColor(it) }.getOrNull()
|
|
859
|
+
} ?: defaultTint
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
private fun applyNavButtonTint(view: TextView, button: JSONObject, dark: Boolean) {
|
|
863
|
+
val tint = navButtonTint(button, dark)
|
|
864
|
+
view.setTextColor(tint)
|
|
865
|
+
val symbol = button.optString("symbol")
|
|
866
|
+
if (symbol.isNotBlank()) {
|
|
867
|
+
CraftNativeIcons.drawable(context, null, symbol, tint)?.let {
|
|
868
|
+
view.foreground = android.graphics.drawable.InsetDrawable(it, dp(10), dp(15), dp(10), dp(15))
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
841
873
|
private fun accentColor(dark: Boolean): Int = if (dark) Color.parseColor("#0A84FF") else Color.parseColor("#007AFF")
|
|
842
874
|
|
|
843
875
|
private fun rippleBackground(): android.graphics.drawable.Drawable? {
|