@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.
@@ -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?.path?.takeIf { it.isNotBlank() && match(it) != null }
123
- ?: tabs.firstOrNull()?.id
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 ("${uri.scheme}://${uri.host}$port" != origin) return null
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 defaultTint = if (button.optString("variant") == "neutral") { if (dark) Color.WHITE else Color.parseColor("#1C1C1E") } else accentColor(dark)
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? {