@onekeyfe/react-native-native-list 3.0.157-alpha.254 → 3.0.157-alpha.258

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.
Files changed (87) hide show
  1. package/android/build.gradle +7 -0
  2. package/android/src/main/java/com/onekey/nativelist/HybridNativeList.kt +20 -0
  3. package/android/src/main/java/com/onekey/nativelist/NativeListActivityDetailsView.kt +179 -0
  4. package/android/src/main/java/com/onekey/nativelist/NativeListActivityRenderer.kt +23 -2
  5. package/android/src/main/java/com/onekey/nativelist/NativeListContainerSlotAdapter.kt +53 -0
  6. package/android/src/main/java/com/onekey/nativelist/NativeListEndAlignment.kt +5 -0
  7. package/android/src/main/java/com/onekey/nativelist/NativeListMediaPreviewSlot.kt +215 -0
  8. package/android/src/main/java/com/onekey/nativelist/NativeListMediaTileRenderer.kt +15 -6
  9. package/android/src/main/java/com/onekey/nativelist/NativeListModels.kt +3 -1
  10. package/android/src/main/java/com/onekey/nativelist/NativeListScrollPositionTracker.kt +29 -0
  11. package/android/src/main/java/com/onekey/nativelist/NativeListSectionHeaderRenderer.kt +16 -1
  12. package/android/src/main/java/com/onekey/nativelist/NativeListView.kt +165 -26
  13. package/android/src/main/res/values/native_scroll_coordinator_ids.xml +5 -0
  14. package/android/src/test/java/com/margelo/nitro/nativelist/NativeListEndAlignmentTest.kt +34 -0
  15. package/android/src/test/java/com/margelo/nitro/nativelist/NativeListScrollPositionTrackerTest.kt +28 -0
  16. package/docs/DESIGN.md +4 -2
  17. package/docs/ROW_TEMPLATES.md +45 -0
  18. package/docs/SPEC.md +241 -1
  19. package/docs/STYLE_SPEC.md +8 -0
  20. package/ios/HybridNativeList.swift +30 -0
  21. package/ios/NativeListActivityDetailsView.swift +229 -0
  22. package/ios/NativeListActivityRenderer.swift +24 -2
  23. package/ios/NativeListMediaPreviewSlot.swift +199 -0
  24. package/ios/NativeListMediaTileRenderer.swift +10 -4
  25. package/ios/NativeListModels.swift +4 -1
  26. package/ios/NativeListScrollPositionTracker.swift +30 -0
  27. package/ios/NativeListSectionHeaderRenderer.swift +27 -1
  28. package/ios/RNCNativeListView.swift +159 -4
  29. package/ios/tests/NativeListActivityPresentationTests.swift +43 -0
  30. package/ios/tests/NativeListLifecycleTests.swift +123 -0
  31. package/ios/tests/NativeListMediaVisibilityTests.swift +42 -0
  32. package/ios/tests/NativeListScrollPositionTrackerTests.swift +25 -0
  33. package/lib/module/NativeList.js +56 -3
  34. package/lib/module/NativeList.web.js +5 -0
  35. package/lib/module/validation.js +64 -4
  36. package/lib/nitrogen/generated/android/c++/JFunc_void_bool.hpp +75 -0
  37. package/lib/nitrogen/generated/android/c++/JHybridNativeListSpec.cpp +39 -3
  38. package/lib/nitrogen/generated/android/c++/JHybridNativeListSpec.hpp +6 -0
  39. package/lib/nitrogen/generated/android/c++/views/JHybridNativeListStateUpdater.cpp +15 -0
  40. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/Func_void_bool.kt +78 -0
  41. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/HybridNativeListSpec.kt +26 -0
  42. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/views/HybridNativeListManager.kt +24 -9
  43. package/lib/nitrogen/generated/android/nativelistOnLoad.cpp +2 -0
  44. package/lib/nitrogen/generated/ios/NativeListModule-Swift-Cxx-Bridge.cpp +8 -0
  45. package/lib/nitrogen/generated/ios/NativeListModule-Swift-Cxx-Bridge.hpp +37 -0
  46. package/lib/nitrogen/generated/ios/c++/HybridNativeListSpecSwift.hpp +23 -2
  47. package/lib/nitrogen/generated/ios/c++/views/HybridNativeListComponent.mm +32 -0
  48. package/lib/nitrogen/generated/ios/swift/Func_void_bool.swift +46 -0
  49. package/lib/nitrogen/generated/ios/swift/HybridNativeListSpec.swift +3 -0
  50. package/lib/nitrogen/generated/ios/swift/HybridNativeListSpec_cxx.swift +54 -0
  51. package/lib/nitrogen/generated/shared/c++/HybridNativeListSpec.cpp +6 -0
  52. package/lib/nitrogen/generated/shared/c++/HybridNativeListSpec.hpp +8 -2
  53. package/lib/nitrogen/generated/shared/c++/views/HybridNativeListComponent.cpp +6 -0
  54. package/lib/nitrogen/generated/shared/c++/views/HybridNativeListComponent.hpp +11 -2
  55. package/lib/nitrogen/generated/shared/json/NativeListConfig.json +3 -0
  56. package/lib/typescript/src/NativeList.nitro.d.ts +3 -0
  57. package/lib/typescript/src/NativeList.types.d.ts +14 -1
  58. package/lib/typescript/src/models.d.ts +33 -5
  59. package/nitrogen/generated/android/c++/JFunc_void_bool.hpp +75 -0
  60. package/nitrogen/generated/android/c++/JHybridNativeListSpec.cpp +39 -3
  61. package/nitrogen/generated/android/c++/JHybridNativeListSpec.hpp +6 -0
  62. package/nitrogen/generated/android/c++/views/JHybridNativeListStateUpdater.cpp +15 -0
  63. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/Func_void_bool.kt +78 -0
  64. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/HybridNativeListSpec.kt +26 -0
  65. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/views/HybridNativeListManager.kt +24 -9
  66. package/nitrogen/generated/android/nativelistOnLoad.cpp +2 -0
  67. package/nitrogen/generated/ios/NativeListModule-Swift-Cxx-Bridge.cpp +8 -0
  68. package/nitrogen/generated/ios/NativeListModule-Swift-Cxx-Bridge.hpp +37 -0
  69. package/nitrogen/generated/ios/c++/HybridNativeListSpecSwift.hpp +23 -2
  70. package/nitrogen/generated/ios/c++/views/HybridNativeListComponent.mm +32 -0
  71. package/nitrogen/generated/ios/swift/Func_void_bool.swift +46 -0
  72. package/nitrogen/generated/ios/swift/HybridNativeListSpec.swift +3 -0
  73. package/nitrogen/generated/ios/swift/HybridNativeListSpec_cxx.swift +54 -0
  74. package/nitrogen/generated/shared/c++/HybridNativeListSpec.cpp +6 -0
  75. package/nitrogen/generated/shared/c++/HybridNativeListSpec.hpp +8 -2
  76. package/nitrogen/generated/shared/c++/views/HybridNativeListComponent.cpp +6 -0
  77. package/nitrogen/generated/shared/c++/views/HybridNativeListComponent.hpp +11 -2
  78. package/nitrogen/generated/shared/json/NativeListConfig.json +3 -0
  79. package/package.json +6 -5
  80. package/scripts/enable-container-slots.mjs +58 -0
  81. package/scripts/enable-container-slots.test.mjs +58 -0
  82. package/src/NativeList.nitro.ts +3 -0
  83. package/src/NativeList.tsx +84 -2
  84. package/src/NativeList.types.ts +14 -1
  85. package/src/NativeList.web.tsx +5 -0
  86. package/src/models.ts +38 -1
  87. package/src/validation.ts +146 -4
@@ -0,0 +1,29 @@
1
+ package com.margelo.nitro.nativelist
2
+
3
+ /** A per-view hysteresis observer. Null means no bridge event is needed. */
4
+ internal class NativeListScrollPositionTracker {
5
+ private var start: Double? = null
6
+ private var end: Double? = null
7
+ private var previous: Boolean? = null
8
+
9
+ fun configure(start: Double?, end: Double?) {
10
+ this.start = null
11
+ this.end = null
12
+ previous = null
13
+ if (start == null || end == null || !start.isFinite() || !end.isFinite() || start < 0 || end <= start) return
14
+ this.start = start
15
+ this.end = end
16
+ }
17
+
18
+ fun resetDelivery() { previous = null }
19
+
20
+ fun update(offset: Double): Boolean? {
21
+ val start = start ?: return null
22
+ val end = end ?: return null
23
+ if (!offset.isFinite()) return null
24
+ val next = if (previous == true) offset > start else offset >= end
25
+ if (previous == next) return null
26
+ previous = next
27
+ return next
28
+ }
29
+ }
@@ -1,5 +1,6 @@
1
1
  package com.margelo.nitro.nativelist
2
2
 
3
+ import android.content.res.ColorStateList
3
4
  import android.graphics.Paint
4
5
  import android.graphics.drawable.GradientDrawable
5
6
  import android.text.Spannable
@@ -12,6 +13,7 @@ import android.view.Gravity
12
13
  import android.view.View
13
14
  import android.view.ViewGroup
14
15
  import android.widget.LinearLayout
16
+ import android.widget.ProgressBar
15
17
  import android.widget.TextView
16
18
  import androidx.core.widget.TextViewCompat
17
19
  import com.facebook.react.uimanager.ThemedReactContext
@@ -24,6 +26,10 @@ internal class NativeListSectionHeaderRowView(context: ThemedReactContext) :
24
26
  private val mainColumn = LinearLayout(context)
25
27
  private val titleLine = PackedTitleLineLayout(context)
26
28
  private val title = DottedUnderlineTextView(context)
29
+ private val titleSpinner = ProgressBar(context, null, android.R.attr.progressBarStyleSmall).apply {
30
+ isIndeterminate = true
31
+ importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
32
+ }
27
33
  private val subtitle = NativeListTextView(context)
28
34
  private val trailingColumn = LinearLayout(context)
29
35
  private val trailingViews = listOf(NativeListTextView(context))
@@ -64,7 +70,8 @@ internal class NativeListSectionHeaderRowView(context: ThemedReactContext) :
64
70
  layout == "table" -> 28
65
71
  item.json.optString("variant") == "summary" -> 80
66
72
  item.json.optString("variant") == "gallery" -> 32
67
- item.json.optString("variant") == "history" -> 16
73
+ item.json.optString("variant") == "history" ->
74
+ if (item.json.optBoolean("titleLoading")) 20 else 16
68
75
  item.json.optString("value").isNotEmpty() && item.json.optJSONObject("checkbox") != null -> 40
69
76
  else -> 36
70
77
  }
@@ -284,6 +291,7 @@ internal class NativeListSectionHeaderRowView(context: ThemedReactContext) :
284
291
  }
285
292
  }
286
293
  applyTextStyle(item)
294
+ titleSpinner.indeterminateTintList = ColorStateList.valueOf(title.currentTextColor)
287
295
  style
288
296
  .optJSONObject("container")
289
297
  ?.optString("contentVerticalAlignment")
@@ -403,6 +411,7 @@ internal class NativeListSectionHeaderRowView(context: ThemedReactContext) :
403
411
  override fun recycleContent() {
404
412
  checkboxControl.reset()
405
413
  currentItem = null
414
+ titleSpinner.visibility = GONE
406
415
  }
407
416
 
408
417
  override fun disposeContent() {
@@ -437,6 +446,12 @@ internal class NativeListSectionHeaderRowView(context: ThemedReactContext) :
437
446
  // Linear/sectioned snapshots reserve the ListItem mx=8 at RecyclerView
438
447
  // level, so header-local insets below are source px minus that outer inset.
439
448
  val headerHorizontalInset = 12
449
+ if (item.json.optBoolean("titleLoading")) {
450
+ titleSpinner.visibility = VISIBLE
451
+ addView(titleSpinner, LayoutParams(dp(20), dp(20)).apply { marginEnd = dp(8) })
452
+ } else {
453
+ titleSpinner.visibility = GONE
454
+ }
440
455
  addView(mainColumn, weighted())
441
456
  showText(title, item.json.optString("title"), 1)
442
457
  if (hasDottedTitle) {
@@ -31,6 +31,7 @@ import android.widget.SeekBar
31
31
  import android.widget.TextView
32
32
  import androidx.core.view.ViewCompat
33
33
  import androidx.core.view.WindowInsetsCompat
34
+ import androidx.recyclerview.widget.ConcatAdapter
34
35
  import androidx.recyclerview.widget.GridLayoutManager
35
36
  import androidx.recyclerview.widget.ItemTouchHelper
36
37
  import androidx.recyclerview.widget.LinearLayoutManager
@@ -111,6 +112,37 @@ class NativeListView(
111
112
  data class End(val animated: Boolean) : ScrollRequest()
112
113
  }
113
114
 
115
+ private val coordinatorOffsetListener = Runnable { emitScrollPositionThresholdIfNeeded() }
116
+ private var logicalScrollDistancePx = 0
117
+ private val scrollPositionTracker = NativeListScrollPositionTracker()
118
+ var onScrollPositionThresholdChange: ((Boolean) -> Unit)? = null
119
+ set(value) {
120
+ field = value
121
+ scrollPositionTracker.resetDelivery()
122
+ emitScrollPositionThresholdIfNeeded()
123
+ }
124
+
125
+ fun setScrollPositionThresholdsJson(json: String) {
126
+ val config = runCatching { JSONObject(json) }.getOrNull()
127
+ scrollPositionTracker.configure(config?.optDouble("start"), config?.optDouble("end"))
128
+ emitScrollPositionThresholdIfNeeded()
129
+ }
130
+
131
+ private fun emitScrollPositionThresholdIfNeeded() {
132
+ if (disposed) return
133
+ val callback = onScrollPositionThresholdChange ?: return
134
+ if (layoutManager.findFirstVisibleItemPosition() == 0) {
135
+ layoutManager.findViewByPosition(0)?.let { first ->
136
+ logicalScrollDistancePx = (recyclerView.paddingTop - layoutManager.getDecoratedTop(first)).coerceAtLeast(0)
137
+ }
138
+ }
139
+ val contributions = recyclerView.getTag(R.id.onekey_native_scroll_coordinator_consumed_offsets) as? Map<*, *>
140
+ val parentDistance = contributions?.values?.sumOf { (it as? Int)?.coerceAtLeast(0)?.toLong() ?: 0L } ?: 0L
141
+ val distance = logicalScrollDistancePx.toLong() + parentDistance
142
+ val next = scrollPositionTracker.update(distance.toDouble() / density) ?: return
143
+ callback(next)
144
+ }
145
+
114
146
  var onRowAction: ((String) -> Unit)? = null
115
147
  var onActionAnchorInvalidated: ((String) -> Unit)? = null
116
148
  var onSelectionDelta: ((String) -> Unit)? = null
@@ -125,9 +157,53 @@ class NativeListView(
125
157
  private var configuredBottomPaddingPx = 0
126
158
  private val refreshLayout = SwipeRefreshLayout(context)
127
159
  private val refreshIndicatorTravelPx = refreshLayout.progressViewEndOffset
160
+ private val defaultRefreshTriggerDistancePx = (64 * resources.displayMetrics.density).roundToInt()
128
161
  private var refreshIndicatorOffsetPx = 0
129
162
  private val contentContainer = FrameLayout(context)
130
163
  private val adapter = NativeListAdapter(reactContext)
164
+ private val containerSlots = mutableListOf<View>()
165
+ private val slotHeights = IntArray(3)
166
+ private val headerSlotAdapter = NativeListContainerSlotAdapter()
167
+ private val emptySlotAdapter = NativeListContainerSlotAdapter()
168
+ private val footerSlotAdapter = NativeListContainerSlotAdapter()
169
+ private val scrollContentAdapter = ConcatAdapter(headerSlotAdapter, adapter, emptySlotAdapter, footerSlotAdapter)
170
+
171
+ fun mountContainerSlot(view: View, index: Int) {
172
+ containerSlots.add(index.coerceIn(0, containerSlots.size), view)
173
+ updateContainerSlots()
174
+ }
175
+
176
+ fun containerSlotCount(): Int = containerSlots.size
177
+ fun containerSlotAt(index: Int): View = containerSlots[index]
178
+ fun unmountContainerSlot(index: Int) {
179
+ val view = containerSlots.removeAt(index)
180
+ (view.parent as? ViewGroup)?.removeView(view)
181
+ updateContainerSlots()
182
+ }
183
+
184
+ fun setContainerSlotHeightsJson(json: String) {
185
+ val values = runCatching { JSONArray(json) }.getOrNull() ?: return
186
+ if (values.length() != 3) return
187
+ for (index in 0..2) {
188
+ val value = values.optDouble(index)
189
+ if (!value.isFinite() || value < 0) return
190
+ }
191
+ for (index in 0..2) slotHeights[index] = (values.getDouble(index) * density).roundToInt()
192
+ updateContainerSlots()
193
+ }
194
+
195
+ private fun updateContainerSlots() {
196
+ headerSlotAdapter.update(containerSlots.getOrNull(0), slotHeights[0], true)
197
+ emptySlotAdapter.update(containerSlots.getOrNull(1), slotHeights[1], config?.items?.isEmpty() != false)
198
+ footerSlotAdapter.update(containerSlots.getOrNull(2), slotHeights[2], true)
199
+ layoutManager.spanSizeLookup.invalidateSpanIndexCache()
200
+ recyclerView.requestLayout()
201
+ }
202
+
203
+ private fun scrollPositionForRow(index: Int): Int = index + headerSlotAdapter.itemCount
204
+ private fun rowPositionForScroll(index: Int): Int =
205
+ if (index == RecyclerView.NO_POSITION) RecyclerView.NO_POSITION else index - headerSlotAdapter.itemCount
206
+
131
207
  private val layoutManager = NativeListGridLayoutManager(context, 1)
132
208
  // OneKey patch: vertical lists retain vertical drags and leave horizontal drags to a parent pager.
133
209
  private val pagerGestureTouchSlop = ViewConfiguration.get(context).scaledTouchSlop
@@ -333,7 +409,7 @@ class NativeListView(
333
409
 
334
410
  init {
335
411
  orientation = VERTICAL
336
- recyclerView.adapter = adapter
412
+ recyclerView.adapter = scrollContentAdapter
337
413
  recyclerView.layoutManager = layoutManager
338
414
  recyclerView.itemAnimator = null
339
415
  recyclerView.addOnItemTouchListener(keyboardTapTouchListener)
@@ -345,7 +421,9 @@ class NativeListView(
345
421
  layoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
346
422
  override fun getSpanSize(position: Int): Int {
347
423
  if (config?.layout != "grid") return 1
348
- val type = adapter.itemAt(position)?.type
424
+ val rowPosition = rowPositionForScroll(position)
425
+ if (rowPosition !in 0 until adapter.itemCount) return layoutManager.spanCount
426
+ val type = adapter.itemAt(rowPosition)?.type
349
427
  return if (type == "sectionHeader" || type == "system" || type == "action") {
350
428
  layoutManager.spanCount
351
429
  } else {
@@ -443,6 +521,8 @@ class NativeListView(
443
521
  }
444
522
 
445
523
  override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
524
+ logicalScrollDistancePx = (logicalScrollDistancePx + dy).coerceAtLeast(0)
525
+ emitScrollPositionThresholdIfNeeded()
446
526
  if (dx != 0 || dy != 0) invalidateActionAnchor("scroll")
447
527
  syncSectionIndexToVisibleRows()
448
528
  scheduleVisibleEvent()
@@ -469,7 +549,7 @@ class NativeListView(
469
549
 
470
550
  private fun keyboardTapIsHandled(recyclerView: RecyclerView, event: MotionEvent): Boolean {
471
551
  val child = recyclerView.findChildViewUnder(event.x, event.y) ?: return false
472
- val position = recyclerView.getChildAdapterPosition(child)
552
+ val position = rowPositionForScroll(recyclerView.getChildAdapterPosition(child))
473
553
  val item = adapter.itemAt(position) ?: return false
474
554
  val clickable = clickableDescendantAt(child, event.rawX.toInt(), event.rawY.toInt())
475
555
  val accessoryHandlesTap = clickable != null && clickable !== child
@@ -512,6 +592,8 @@ class NativeListView(
512
592
  }
513
593
 
514
594
  private fun updateRefreshIndicatorOffset(offset: Int) {
595
+ // Let an ancestor render above its shared header without guessing caller offsets.
596
+ refreshLayout.setTag(R.id.onekey_native_scroll_coordinator_refresh_inset, offset)
515
597
  if (refreshIndicatorOffsetPx == offset) return
516
598
  refreshIndicatorOffsetPx = offset
517
599
  val refreshing = refreshLayout.isRefreshing
@@ -522,6 +604,8 @@ class NativeListView(
522
604
 
523
605
  override fun onAttachedToWindow() {
524
606
  super.onAttachedToWindow()
607
+ recyclerView.setTag(R.id.onekey_native_scroll_coordinator_offset_listener, coordinatorOffsetListener)
608
+ emitScrollPositionThresholdIfNeeded()
525
609
  startStickyHeaderTracking()
526
610
  updateSectionIndexAttachment()
527
611
  val first = config?.items?.firstOrNull()
@@ -531,6 +615,9 @@ class NativeListView(
531
615
  }
532
616
 
533
617
  override fun onDetachedFromWindow() {
618
+ if (recyclerView.getTag(R.id.onekey_native_scroll_coordinator_offset_listener) === coordinatorOffsetListener) {
619
+ recyclerView.setTag(R.id.onekey_native_scroll_coordinator_offset_listener, null)
620
+ }
534
621
  stopStickyHeaderTracking()
535
622
  stopSectionIndexAttachmentTracking()
536
623
  scheduleSectionIndexAttachmentToList()
@@ -562,6 +649,7 @@ class NativeListView(
562
649
  }
563
650
  }.toSet()
564
651
  config = next
652
+ updateContainerSlots()
565
653
  usesSelectorSourceScale = next.items.any { it.usesSelectorSourceScale }
566
654
  adapter.usesSelectorSourceScale = usesSelectorSourceScale
567
655
  adapter.theme = next.theme
@@ -592,13 +680,14 @@ class NativeListView(
592
680
  previous?.items?.firstOrNull()?.type == "market" &&
593
681
  next.items.firstOrNull()?.type == "market" &&
594
682
  previous.items.firstOrNull()?.key != next.items.firstOrNull()?.key &&
595
- layoutManager.findFirstVisibleItemPosition() == 0
683
+ rowPositionForScroll(layoutManager.findFirstVisibleItemPosition()) == 0
596
684
  ) {
597
- layoutManager.findViewByPosition(0)?.let {
685
+ layoutManager.findViewByPosition(scrollPositionForRow(0))?.let {
598
686
  layoutManager.getDecoratedTop(it) - recyclerView.paddingTop
599
687
  }
600
688
  } else null
601
689
  config = next
690
+ updateContainerSlots()
602
691
  usesSelectorSourceScale = next.items.any { it.usesSelectorSourceScale }
603
692
  adapter.usesSelectorSourceScale = usesSelectorSourceScale
604
693
  if (previous?.generation != next.generation) endReachedGeneration = null
@@ -612,7 +701,7 @@ class NativeListView(
612
701
  updateLayout(next)
613
702
  adapter.submitList(next.items) {
614
703
  if (marketStartOffset != null) {
615
- layoutManager.scrollToPositionWithOffset(0, marketStartOffset)
704
+ layoutManager.scrollToPositionWithOffset(scrollPositionForRow(0), marketStartOffset)
616
705
  }
617
706
  relayoutContents(preserveMarketPaginationAnchor)
618
707
  performPendingScrollIfNeeded()
@@ -621,16 +710,17 @@ class NativeListView(
621
710
  if (next.items.isNotEmpty()) checkEndReached()
622
711
  }
623
712
  refreshLayout.isEnabled = next.pullToRefresh
713
+ refreshLayout.setDistanceToTriggerSync(next.refreshTriggerDistance?.let { (it * density).roundToInt() } ?: defaultRefreshTriggerDistancePx)
624
714
  refreshLayout.isRefreshing = next.refreshing
625
715
  bindFooter(next)
626
716
  updateReordering(next)
627
717
  }
628
718
 
629
719
  private fun captureVisibleMarketAnchor(): VisibleMarketAnchor? {
630
- val position = layoutManager.findFirstVisibleItemPosition()
720
+ val position = rowPositionForScroll(layoutManager.findFirstVisibleItemPosition()).coerceAtLeast(0)
631
721
  if (position == RecyclerView.NO_POSITION) return null
632
722
  val item = adapter.itemAt(position) ?: return null
633
- val view = layoutManager.findViewByPosition(position) ?: return null
723
+ val view = layoutManager.findViewByPosition(scrollPositionForRow(position)) ?: return null
634
724
  return VisibleMarketAnchor(
635
725
  key = item.key,
636
726
  offset = layoutManager.getDecoratedTop(view) - recyclerView.paddingTop,
@@ -686,6 +776,7 @@ class NativeListView(
686
776
  previous.rowPressToggles != next.rowPressToggles ||
687
777
  previous.reorderable != next.reorderable ||
688
778
  previous.pullToRefresh != next.pullToRefresh ||
779
+ previous.refreshTriggerDistance != next.refreshTriggerDistance ||
689
780
  previous.refreshing != next.refreshing ||
690
781
  previous.loadMore != next.loadMore ||
691
782
  previous.endReachedThreshold != next.endReachedThreshold ||
@@ -837,6 +928,7 @@ class NativeListView(
837
928
  if (!marketQuoteOnly) invalidateActionAnchor("snapshot")
838
929
  val next = current.copy(items = nextItems, selectedKeys = selected)
839
930
  config = next
931
+ updateContainerSlots()
840
932
  usesSelectorSourceScale = next.items.any { it.usesSelectorSourceScale }
841
933
  adapter.usesSelectorSourceScale = usesSelectorSourceScale
842
934
  adapter.selectedKeys = selected
@@ -940,7 +1032,32 @@ class NativeListView(
940
1032
  true
941
1033
  }
942
1034
  is ScrollRequest.End -> {
943
- if (current.items.isNotEmpty()) {
1035
+ val finalSlotHeight = when {
1036
+ footerSlotAdapter.itemCount > 0 -> slotHeights[2]
1037
+ emptySlotAdapter.itemCount > 0 -> slotHeights[1]
1038
+ adapter.itemCount == 0 && headerSlotAdapter.itemCount > 0 -> slotHeights[0]
1039
+ else -> null
1040
+ }
1041
+ if (finalSlotHeight != null) {
1042
+ val target = scrollContentAdapter.itemCount - 1
1043
+ if (request.animated) {
1044
+ val manager = layoutManager
1045
+ val scroller = object : LinearSmoothScroller(context) {
1046
+ override fun calculateDyToMakeVisible(view: View, snapPreference: Int): Int =
1047
+ nativeListEndAlignmentOffset(
1048
+ manager.height - manager.paddingBottom,
1049
+ manager.getDecoratedBottom(view),
1050
+ )
1051
+ }
1052
+ scroller.targetPosition = target
1053
+ layoutManager.startSmoothScroll(scroller)
1054
+ } else {
1055
+ layoutManager.scrollToPositionWithOffset(
1056
+ target,
1057
+ nativeListEndAlignmentOffset(viewportLength(layoutManager), finalSlotHeight),
1058
+ )
1059
+ }
1060
+ } else if (current.items.isNotEmpty()) {
944
1061
  performIndexScroll(
945
1062
  current.items.lastIndex,
946
1063
  request.animated,
@@ -962,7 +1079,7 @@ class NativeListView(
962
1079
  viewOffset: Double,
963
1080
  ) {
964
1081
  val manager = recyclerView.layoutManager as? LinearLayoutManager ?: return
965
- val visibleView = manager.findViewByPosition(index)
1082
+ val visibleView = manager.findViewByPosition(scrollPositionForRow(index))
966
1083
  val viewPosition = if (alignment == "nearest") {
967
1084
  val viewportStart = if (manager.orientation == RecyclerView.VERTICAL) {
968
1085
  manager.paddingTop
@@ -983,7 +1100,7 @@ class NativeListView(
983
1100
  else -> return
984
1101
  }
985
1102
  } else {
986
- val firstVisible = manager.findFirstVisibleItemPosition()
1103
+ val firstVisible = rowPositionForScroll(manager.findFirstVisibleItemPosition())
987
1104
  if (firstVisible != RecyclerView.NO_POSITION && index < firstVisible) 0.0 else 1.0
988
1105
  }
989
1106
  } else {
@@ -1010,13 +1127,13 @@ class NativeListView(
1010
1127
  return smoothAlignmentDelta(manager, view, viewPosition, offsetPx)
1011
1128
  }
1012
1129
  }
1013
- smoothScroller.targetPosition = index
1130
+ smoothScroller.targetPosition = scrollPositionForRow(index)
1014
1131
  manager.startSmoothScroll(smoothScroller)
1015
1132
  return
1016
1133
  }
1017
1134
 
1018
1135
  val provisionalOffset = (viewPosition * viewportLength(manager)).roundToInt() + offsetPx
1019
- manager.scrollToPositionWithOffset(index, provisionalOffset)
1136
+ manager.scrollToPositionWithOffset(scrollPositionForRow(index), provisionalOffset)
1020
1137
  relayoutRecyclerView()
1021
1138
  alignAfterLayout(index, viewPosition, offsetPx)
1022
1139
  }
@@ -1024,6 +1141,9 @@ class NativeListView(
1024
1141
  private fun performOffsetScroll(offset: Double, animated: Boolean) {
1025
1142
  val manager = recyclerView.layoutManager as? LinearLayoutManager ?: return
1026
1143
  val target = (offset * density).roundToInt().coerceAtLeast(0)
1144
+ if (!animated) {
1145
+ logicalScrollDistancePx = target
1146
+ }
1027
1147
  if (!animated) {
1028
1148
  recyclerView.stopScroll()
1029
1149
  manager.scrollToPositionWithOffset(0, -target)
@@ -1055,7 +1175,7 @@ class NativeListView(
1055
1175
  ) {
1056
1176
  recyclerView.post {
1057
1177
  val manager = recyclerView.layoutManager as? LinearLayoutManager ?: return@post
1058
- val view = manager.findViewByPosition(index)
1178
+ val view = manager.findViewByPosition(scrollPositionForRow(index))
1059
1179
  if (view == null) {
1060
1180
  if (attemptsRemaining > 0) {
1061
1181
  alignAfterLayout(index, viewPosition, viewOffset, attemptsRemaining - 1)
@@ -1166,6 +1286,7 @@ class NativeListView(
1166
1286
  fun dispose() {
1167
1287
  if (disposed) return
1168
1288
  disposed = true
1289
+ onScrollPositionThresholdChange = null
1169
1290
  if (!isAttachedToWindow) disposeResources()
1170
1291
  }
1171
1292
 
@@ -1234,11 +1355,11 @@ class NativeListView(
1234
1355
  stickyHeaderHost.visibility = GONE
1235
1356
  return
1236
1357
  }
1237
- val first = layoutManager.findFirstVisibleItemPosition()
1358
+ val first = rowPositionForScroll(layoutManager.findFirstVisibleItemPosition())
1238
1359
  var position = first
1239
1360
  while (position >= 0 && adapter.itemAt(position)?.let(::isStickySectionHeader) != true) position--
1240
1361
  val item = adapter.itemAt(position)
1241
- if (item == null || first == RecyclerView.NO_POSITION || (layoutManager.findViewByPosition(position)?.top ?: -1) > 0) {
1362
+ if (item == null || first == RecyclerView.NO_POSITION || (layoutManager.findViewByPosition(scrollPositionForRow(position))?.top ?: -1) > 0) {
1242
1363
  stickyHeaderHost.visibility = GONE
1243
1364
  return
1244
1365
  }
@@ -1267,7 +1388,7 @@ class NativeListView(
1267
1388
  var nextTop = Int.MAX_VALUE
1268
1389
  for (index in 0 until recyclerView.childCount) {
1269
1390
  val child = recyclerView.getChildAt(index)
1270
- val childPosition = recyclerView.getChildAdapterPosition(child)
1391
+ val childPosition = rowPositionForScroll(recyclerView.getChildAdapterPosition(child))
1271
1392
  if (childPosition > position && adapter.itemAt(childPosition)?.let(::isStickySectionHeader) == true) nextTop = minOf(nextTop, child.top)
1272
1393
  }
1273
1394
  stickyHeaderHost.translationY = minOf(0, nextTop - height).toFloat()
@@ -1501,7 +1622,7 @@ class NativeListView(
1501
1622
 
1502
1623
  private fun syncSectionIndexToVisibleRows() {
1503
1624
  if (sectionIndexScrubbing || sectionIndexProgrammaticScroll || sectionIndexEntries.isEmpty()) return
1504
- val firstVisible = layoutManager.findFirstVisibleItemPosition()
1625
+ val firstVisible = rowPositionForScroll(layoutManager.findFirstVisibleItemPosition())
1505
1626
  if (firstVisible == RecyclerView.NO_POSITION) return
1506
1627
  sectionIndexView.setActiveIndex(
1507
1628
  sectionIndexEntries.indexOfLast { it.position <= firstVisible }.takeIf { it >= 0 },
@@ -1866,6 +1987,7 @@ class NativeListView(
1866
1987
  }
1867
1988
 
1868
1989
  override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int {
1990
+ if (viewHolder !is NativeListViewHolder) return makeMovementFlags(0, 0)
1869
1991
  val item = adapter.itemAt(viewHolder.bindingAdapterPosition)
1870
1992
  if (item == null || !item.isReorderable) return makeMovementFlags(0, 0)
1871
1993
  val dragFlags = if (next.orientation == "horizontal") ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT else ItemTouchHelper.UP or ItemTouchHelper.DOWN
@@ -1877,6 +1999,7 @@ class NativeListView(
1877
1999
  source: RecyclerView.ViewHolder,
1878
2000
  target: RecyclerView.ViewHolder,
1879
2001
  ): Boolean {
2002
+ if (source !is NativeListViewHolder || target !is NativeListViewHolder) return false
1880
2003
  val from = source.bindingAdapterPosition
1881
2004
  val to = target.bindingAdapterPosition
1882
2005
  val base = pendingReorder ?: adapter.currentList
@@ -2088,7 +2211,7 @@ class NativeListView(
2088
2211
  ?.takeIf { it != RecyclerView.NO_POSITION }
2089
2212
  ?: destinationPosition
2090
2213
  val destinationRow = recyclerView
2091
- .findViewHolderForAdapterPosition(resolvedPosition)
2214
+ .findViewHolderForAdapterPosition(scrollPositionForRow(resolvedPosition))
2092
2215
  ?.let { it as? NativeListViewHolder }
2093
2216
  ?.rowView
2094
2217
  val groupRow = destinationRow?.takeIf {
@@ -2251,8 +2374,20 @@ class NativeListView(
2251
2374
  Choreographer.getInstance().postFrameCallback {
2252
2375
  visibleEventScheduled = false
2253
2376
  val manager = recyclerView.layoutManager as? LinearLayoutManager ?: return@postFrameCallback
2254
- val first = manager.findFirstVisibleItemPosition()
2255
- val last = manager.findLastVisibleItemPosition()
2377
+ val visibleRows = (0 until recyclerView.childCount).mapNotNull { index ->
2378
+ val child = recyclerView.getChildAt(index)
2379
+ val holder = recyclerView.getChildViewHolder(child) as? NativeListViewHolder ?: return@mapNotNull null
2380
+ val visible = if (manager.orientation == RecyclerView.VERTICAL) {
2381
+ manager.getDecoratedBottom(child) > recyclerView.paddingTop &&
2382
+ manager.getDecoratedTop(child) < recyclerView.height - recyclerView.paddingBottom
2383
+ } else {
2384
+ manager.getDecoratedRight(child) > recyclerView.paddingLeft &&
2385
+ manager.getDecoratedLeft(child) < recyclerView.width - recyclerView.paddingRight
2386
+ }
2387
+ holder.bindingAdapterPosition.takeIf { visible && it != RecyclerView.NO_POSITION }
2388
+ }
2389
+ val first = visibleRows.minOrNull() ?: RecyclerView.NO_POSITION
2390
+ val last = visibleRows.maxOrNull() ?: RecyclerView.NO_POSITION
2256
2391
  val firstKey = adapter.itemAt(first)?.key
2257
2392
  val lastKey = adapter.itemAt(last)?.key
2258
2393
  val signature = "$first:$last:${firstKey.orEmpty()}:${lastKey.orEmpty()}"
@@ -2271,7 +2406,7 @@ class NativeListView(
2271
2406
  val current = config ?: return
2272
2407
  if (!current.loadMore || current.generation == endReachedGeneration || adapter.itemCount == 0) return
2273
2408
  val manager = recyclerView.layoutManager as? LinearLayoutManager ?: return
2274
- val lastVisible = manager.findLastVisibleItemPosition()
2409
+ val lastVisible = rowPositionForScroll(manager.findLastVisibleItemPosition())
2275
2410
  val thresholdItems = max(1, ceil(adapter.itemCount * current.endReachedThreshold).toInt())
2276
2411
  if (lastVisible >= adapter.itemCount - thresholdItems) {
2277
2412
  endReachedGeneration = current.generation
@@ -2311,7 +2446,7 @@ class NativeListView(
2311
2446
  adapter.currentList.indexOfFirst { it.key == currentAnchor.key }
2312
2447
  } ?: RecyclerView.NO_POSITION
2313
2448
  if (anchor != null && anchorIndex >= 0) {
2314
- layoutManager.scrollToPositionWithOffset(anchorIndex, anchor.offset)
2449
+ layoutManager.scrollToPositionWithOffset(scrollPositionForRow(anchorIndex), anchor.offset)
2315
2450
  }
2316
2451
  forceLayout()
2317
2452
  measure(
@@ -2473,7 +2608,11 @@ private class ReorderPlaceholderDecoration(
2473
2608
  val isWalletSidebar =
2474
2609
  item.type == "identity" && item.json.optString("presentation") == "walletSidebar"
2475
2610
  if (!isWalletSidebar && item.type != "walletGroup") return
2476
- val view = parent.findViewHolderForAdapterPosition(position)?.itemView ?: return
2611
+ val view = (0 until parent.childCount).asSequence().map(parent::getChildAt)
2612
+ .firstOrNull { child ->
2613
+ val holder = parent.getChildViewHolder(child)
2614
+ holder is NativeListViewHolder && holder.bindingAdapterPosition == position
2615
+ } ?: return
2477
2616
  bounds.set(
2478
2617
  (view.left + insetPx).toFloat(),
2479
2618
  view.top.toFloat(),
@@ -2744,7 +2883,7 @@ private class ItemSpacingDecoration(
2744
2883
  parent: RecyclerView,
2745
2884
  state: RecyclerView.State,
2746
2885
  ) {
2747
- if (spacing <= 0) return
2886
+ if (spacing <= 0 || parent.getChildViewHolder(view) !is NativeListViewHolder) return
2748
2887
  val horizontal = (parent.layoutManager as? LinearLayoutManager)?.orientation == RecyclerView.HORIZONTAL
2749
2888
  val item = view.tag as? NativeListItem
2750
2889
  val sourceWallet = item?.type == "identity" && item.json.optString("presentation") == "walletSidebar" && item.hasExplicitHeight
@@ -2766,7 +2905,7 @@ private class SelectorBackgroundDecoration(private val adapter: NativeListAdapte
2766
2905
  override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
2767
2906
  for (index in 0 until parent.childCount) {
2768
2907
  val child = parent.getChildAt(index)
2769
- val item = adapter.itemAt(parent.getChildAdapterPosition(child)) ?: continue
2908
+ val item = adapter.itemAt(parent.getChildViewHolder(child).takeIf { it is NativeListViewHolder }?.bindingAdapterPosition ?: RecyclerView.NO_POSITION) ?: continue
2770
2909
  if (!item.json.optBoolean("backgroundFullWidth", false)) continue
2771
2910
  val color = resolveNativeListSelectorBackgroundColor(
2772
2911
  rowType = item.type,
@@ -0,0 +1,5 @@
1
+ <resources>
2
+ <item name="onekey_native_scroll_coordinator_consumed_offsets" type="id" />
3
+ <item name="onekey_native_scroll_coordinator_offset_listener" type="id" />
4
+ <item name="onekey_native_scroll_coordinator_refresh_inset" type="id" />
5
+ </resources>
@@ -0,0 +1,34 @@
1
+ package com.margelo.nitro.nativelist
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Test
5
+
6
+ class NativeListEndAlignmentTest {
7
+ @Test fun tallFooterEndsAtViewportBottom() {
8
+ val viewportHeight = 600
9
+ val footerHeight = 1000
10
+ val start = nativeListEndAlignmentOffset(viewportHeight, footerHeight)
11
+ assertEquals(-400, start)
12
+ assertEquals(viewportHeight, start + footerHeight)
13
+ }
14
+
15
+ @Test fun tallEmptySlotUsesTheSameEndAlignmentWithPadding() {
16
+ val viewportTop = 80
17
+ val viewportBottom = 680
18
+ val emptyHeight = 900
19
+ val start = viewportTop + nativeListEndAlignmentOffset(viewportBottom - viewportTop, emptyHeight)
20
+ assertEquals(viewportBottom, start + emptyHeight)
21
+ }
22
+
23
+ @Test fun animatedCorrectionMovesDecoratedBottomToViewportBottom() {
24
+ val decoratedBottom = 1240
25
+ val viewportBottom = 680
26
+ val correction = nativeListEndAlignmentOffset(viewportBottom, decoratedBottom)
27
+ assertEquals(-560, correction)
28
+ assertEquals(viewportBottom, decoratedBottom + correction)
29
+ }
30
+
31
+ @Test fun shortFooterRetainsBottomAlignment() {
32
+ assertEquals(480, nativeListEndAlignmentOffset(600, 120))
33
+ }
34
+ }
@@ -0,0 +1,28 @@
1
+ package com.margelo.nitro.nativelist
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertNull
5
+ import org.junit.Test
6
+
7
+ class NativeListScrollPositionTrackerTest {
8
+ @Test fun hysteresisAndDisabledLifecycle() {
9
+ val tracker = NativeListScrollPositionTracker()
10
+ assertNull(tracker.update(200.0))
11
+ tracker.configure(48.0, 160.0)
12
+ assertEquals(false, tracker.update(0.0))
13
+ assertNull(tracker.update(159.0))
14
+ assertEquals(true, tracker.update(160.0))
15
+ assertNull(tracker.update(100.0))
16
+ assertEquals(false, tracker.update(48.0))
17
+ assertNull(tracker.update(49.0))
18
+ tracker.configure(null, null)
19
+ assertNull(tracker.update(200.0))
20
+ tracker.configure(48.0, 160.0)
21
+ assertEquals(true, tracker.update(200.0))
22
+ tracker.resetDelivery()
23
+ assertEquals(true, tracker.update(200.0))
24
+ assertNull(tracker.update(Double.NaN))
25
+ tracker.configure(48.0, 48.0)
26
+ assertNull(tracker.update(48.0))
27
+ }
28
+ }
package/docs/DESIGN.md CHANGED
@@ -43,8 +43,10 @@ applying them.
43
43
  Section headers and scrolling footer/status rows use the flattened row stream.
44
44
  `fixedFooter` is a separate descriptor outside that stream, and `emptyState` is
45
45
  used when content is empty; both reuse row binders and currently accept only
46
- `action` or `system` through the public API. A generic public list-header slot is
47
- not yet exposed. `groupId` and `groupPosition` preserve continuous card geometry
46
+ `action` or `system` through the public API. Native vertical lists additionally
47
+ expose `listHeader`, `listEmpty`, and `listFooter` React container slots. These
48
+ three fixed roots remain outside row identity and scroll with the native list;
49
+ see SPEC.md for mounting, index translation, measurement and platform limits. `groupId` and `groupPosition` preserve continuous card geometry
48
50
  without nesting lists inside cells. These placement/grouping rules belong to the
49
51
  container even when the displayed content uses an existing row template.
50
52