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

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 +163 -26
  13. package/android/src/main/res/values/native_scroll_coordinator_ids.xml +4 -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 +227 -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
@@ -522,6 +602,8 @@ class NativeListView(
522
602
 
523
603
  override fun onAttachedToWindow() {
524
604
  super.onAttachedToWindow()
605
+ recyclerView.setTag(R.id.onekey_native_scroll_coordinator_offset_listener, coordinatorOffsetListener)
606
+ emitScrollPositionThresholdIfNeeded()
525
607
  startStickyHeaderTracking()
526
608
  updateSectionIndexAttachment()
527
609
  val first = config?.items?.firstOrNull()
@@ -531,6 +613,9 @@ class NativeListView(
531
613
  }
532
614
 
533
615
  override fun onDetachedFromWindow() {
616
+ if (recyclerView.getTag(R.id.onekey_native_scroll_coordinator_offset_listener) === coordinatorOffsetListener) {
617
+ recyclerView.setTag(R.id.onekey_native_scroll_coordinator_offset_listener, null)
618
+ }
534
619
  stopStickyHeaderTracking()
535
620
  stopSectionIndexAttachmentTracking()
536
621
  scheduleSectionIndexAttachmentToList()
@@ -562,6 +647,7 @@ class NativeListView(
562
647
  }
563
648
  }.toSet()
564
649
  config = next
650
+ updateContainerSlots()
565
651
  usesSelectorSourceScale = next.items.any { it.usesSelectorSourceScale }
566
652
  adapter.usesSelectorSourceScale = usesSelectorSourceScale
567
653
  adapter.theme = next.theme
@@ -592,13 +678,14 @@ class NativeListView(
592
678
  previous?.items?.firstOrNull()?.type == "market" &&
593
679
  next.items.firstOrNull()?.type == "market" &&
594
680
  previous.items.firstOrNull()?.key != next.items.firstOrNull()?.key &&
595
- layoutManager.findFirstVisibleItemPosition() == 0
681
+ rowPositionForScroll(layoutManager.findFirstVisibleItemPosition()) == 0
596
682
  ) {
597
- layoutManager.findViewByPosition(0)?.let {
683
+ layoutManager.findViewByPosition(scrollPositionForRow(0))?.let {
598
684
  layoutManager.getDecoratedTop(it) - recyclerView.paddingTop
599
685
  }
600
686
  } else null
601
687
  config = next
688
+ updateContainerSlots()
602
689
  usesSelectorSourceScale = next.items.any { it.usesSelectorSourceScale }
603
690
  adapter.usesSelectorSourceScale = usesSelectorSourceScale
604
691
  if (previous?.generation != next.generation) endReachedGeneration = null
@@ -612,7 +699,7 @@ class NativeListView(
612
699
  updateLayout(next)
613
700
  adapter.submitList(next.items) {
614
701
  if (marketStartOffset != null) {
615
- layoutManager.scrollToPositionWithOffset(0, marketStartOffset)
702
+ layoutManager.scrollToPositionWithOffset(scrollPositionForRow(0), marketStartOffset)
616
703
  }
617
704
  relayoutContents(preserveMarketPaginationAnchor)
618
705
  performPendingScrollIfNeeded()
@@ -621,16 +708,17 @@ class NativeListView(
621
708
  if (next.items.isNotEmpty()) checkEndReached()
622
709
  }
623
710
  refreshLayout.isEnabled = next.pullToRefresh
711
+ refreshLayout.setDistanceToTriggerSync(next.refreshTriggerDistance?.let { (it * density).roundToInt() } ?: defaultRefreshTriggerDistancePx)
624
712
  refreshLayout.isRefreshing = next.refreshing
625
713
  bindFooter(next)
626
714
  updateReordering(next)
627
715
  }
628
716
 
629
717
  private fun captureVisibleMarketAnchor(): VisibleMarketAnchor? {
630
- val position = layoutManager.findFirstVisibleItemPosition()
718
+ val position = rowPositionForScroll(layoutManager.findFirstVisibleItemPosition()).coerceAtLeast(0)
631
719
  if (position == RecyclerView.NO_POSITION) return null
632
720
  val item = adapter.itemAt(position) ?: return null
633
- val view = layoutManager.findViewByPosition(position) ?: return null
721
+ val view = layoutManager.findViewByPosition(scrollPositionForRow(position)) ?: return null
634
722
  return VisibleMarketAnchor(
635
723
  key = item.key,
636
724
  offset = layoutManager.getDecoratedTop(view) - recyclerView.paddingTop,
@@ -686,6 +774,7 @@ class NativeListView(
686
774
  previous.rowPressToggles != next.rowPressToggles ||
687
775
  previous.reorderable != next.reorderable ||
688
776
  previous.pullToRefresh != next.pullToRefresh ||
777
+ previous.refreshTriggerDistance != next.refreshTriggerDistance ||
689
778
  previous.refreshing != next.refreshing ||
690
779
  previous.loadMore != next.loadMore ||
691
780
  previous.endReachedThreshold != next.endReachedThreshold ||
@@ -837,6 +926,7 @@ class NativeListView(
837
926
  if (!marketQuoteOnly) invalidateActionAnchor("snapshot")
838
927
  val next = current.copy(items = nextItems, selectedKeys = selected)
839
928
  config = next
929
+ updateContainerSlots()
840
930
  usesSelectorSourceScale = next.items.any { it.usesSelectorSourceScale }
841
931
  adapter.usesSelectorSourceScale = usesSelectorSourceScale
842
932
  adapter.selectedKeys = selected
@@ -940,7 +1030,32 @@ class NativeListView(
940
1030
  true
941
1031
  }
942
1032
  is ScrollRequest.End -> {
943
- if (current.items.isNotEmpty()) {
1033
+ val finalSlotHeight = when {
1034
+ footerSlotAdapter.itemCount > 0 -> slotHeights[2]
1035
+ emptySlotAdapter.itemCount > 0 -> slotHeights[1]
1036
+ adapter.itemCount == 0 && headerSlotAdapter.itemCount > 0 -> slotHeights[0]
1037
+ else -> null
1038
+ }
1039
+ if (finalSlotHeight != null) {
1040
+ val target = scrollContentAdapter.itemCount - 1
1041
+ if (request.animated) {
1042
+ val manager = layoutManager
1043
+ val scroller = object : LinearSmoothScroller(context) {
1044
+ override fun calculateDyToMakeVisible(view: View, snapPreference: Int): Int =
1045
+ nativeListEndAlignmentOffset(
1046
+ manager.height - manager.paddingBottom,
1047
+ manager.getDecoratedBottom(view),
1048
+ )
1049
+ }
1050
+ scroller.targetPosition = target
1051
+ layoutManager.startSmoothScroll(scroller)
1052
+ } else {
1053
+ layoutManager.scrollToPositionWithOffset(
1054
+ target,
1055
+ nativeListEndAlignmentOffset(viewportLength(layoutManager), finalSlotHeight),
1056
+ )
1057
+ }
1058
+ } else if (current.items.isNotEmpty()) {
944
1059
  performIndexScroll(
945
1060
  current.items.lastIndex,
946
1061
  request.animated,
@@ -962,7 +1077,7 @@ class NativeListView(
962
1077
  viewOffset: Double,
963
1078
  ) {
964
1079
  val manager = recyclerView.layoutManager as? LinearLayoutManager ?: return
965
- val visibleView = manager.findViewByPosition(index)
1080
+ val visibleView = manager.findViewByPosition(scrollPositionForRow(index))
966
1081
  val viewPosition = if (alignment == "nearest") {
967
1082
  val viewportStart = if (manager.orientation == RecyclerView.VERTICAL) {
968
1083
  manager.paddingTop
@@ -983,7 +1098,7 @@ class NativeListView(
983
1098
  else -> return
984
1099
  }
985
1100
  } else {
986
- val firstVisible = manager.findFirstVisibleItemPosition()
1101
+ val firstVisible = rowPositionForScroll(manager.findFirstVisibleItemPosition())
987
1102
  if (firstVisible != RecyclerView.NO_POSITION && index < firstVisible) 0.0 else 1.0
988
1103
  }
989
1104
  } else {
@@ -1010,13 +1125,13 @@ class NativeListView(
1010
1125
  return smoothAlignmentDelta(manager, view, viewPosition, offsetPx)
1011
1126
  }
1012
1127
  }
1013
- smoothScroller.targetPosition = index
1128
+ smoothScroller.targetPosition = scrollPositionForRow(index)
1014
1129
  manager.startSmoothScroll(smoothScroller)
1015
1130
  return
1016
1131
  }
1017
1132
 
1018
1133
  val provisionalOffset = (viewPosition * viewportLength(manager)).roundToInt() + offsetPx
1019
- manager.scrollToPositionWithOffset(index, provisionalOffset)
1134
+ manager.scrollToPositionWithOffset(scrollPositionForRow(index), provisionalOffset)
1020
1135
  relayoutRecyclerView()
1021
1136
  alignAfterLayout(index, viewPosition, offsetPx)
1022
1137
  }
@@ -1024,6 +1139,9 @@ class NativeListView(
1024
1139
  private fun performOffsetScroll(offset: Double, animated: Boolean) {
1025
1140
  val manager = recyclerView.layoutManager as? LinearLayoutManager ?: return
1026
1141
  val target = (offset * density).roundToInt().coerceAtLeast(0)
1142
+ if (!animated) {
1143
+ logicalScrollDistancePx = target
1144
+ }
1027
1145
  if (!animated) {
1028
1146
  recyclerView.stopScroll()
1029
1147
  manager.scrollToPositionWithOffset(0, -target)
@@ -1055,7 +1173,7 @@ class NativeListView(
1055
1173
  ) {
1056
1174
  recyclerView.post {
1057
1175
  val manager = recyclerView.layoutManager as? LinearLayoutManager ?: return@post
1058
- val view = manager.findViewByPosition(index)
1176
+ val view = manager.findViewByPosition(scrollPositionForRow(index))
1059
1177
  if (view == null) {
1060
1178
  if (attemptsRemaining > 0) {
1061
1179
  alignAfterLayout(index, viewPosition, viewOffset, attemptsRemaining - 1)
@@ -1166,6 +1284,7 @@ class NativeListView(
1166
1284
  fun dispose() {
1167
1285
  if (disposed) return
1168
1286
  disposed = true
1287
+ onScrollPositionThresholdChange = null
1169
1288
  if (!isAttachedToWindow) disposeResources()
1170
1289
  }
1171
1290
 
@@ -1234,11 +1353,11 @@ class NativeListView(
1234
1353
  stickyHeaderHost.visibility = GONE
1235
1354
  return
1236
1355
  }
1237
- val first = layoutManager.findFirstVisibleItemPosition()
1356
+ val first = rowPositionForScroll(layoutManager.findFirstVisibleItemPosition())
1238
1357
  var position = first
1239
1358
  while (position >= 0 && adapter.itemAt(position)?.let(::isStickySectionHeader) != true) position--
1240
1359
  val item = adapter.itemAt(position)
1241
- if (item == null || first == RecyclerView.NO_POSITION || (layoutManager.findViewByPosition(position)?.top ?: -1) > 0) {
1360
+ if (item == null || first == RecyclerView.NO_POSITION || (layoutManager.findViewByPosition(scrollPositionForRow(position))?.top ?: -1) > 0) {
1242
1361
  stickyHeaderHost.visibility = GONE
1243
1362
  return
1244
1363
  }
@@ -1267,7 +1386,7 @@ class NativeListView(
1267
1386
  var nextTop = Int.MAX_VALUE
1268
1387
  for (index in 0 until recyclerView.childCount) {
1269
1388
  val child = recyclerView.getChildAt(index)
1270
- val childPosition = recyclerView.getChildAdapterPosition(child)
1389
+ val childPosition = rowPositionForScroll(recyclerView.getChildAdapterPosition(child))
1271
1390
  if (childPosition > position && adapter.itemAt(childPosition)?.let(::isStickySectionHeader) == true) nextTop = minOf(nextTop, child.top)
1272
1391
  }
1273
1392
  stickyHeaderHost.translationY = minOf(0, nextTop - height).toFloat()
@@ -1501,7 +1620,7 @@ class NativeListView(
1501
1620
 
1502
1621
  private fun syncSectionIndexToVisibleRows() {
1503
1622
  if (sectionIndexScrubbing || sectionIndexProgrammaticScroll || sectionIndexEntries.isEmpty()) return
1504
- val firstVisible = layoutManager.findFirstVisibleItemPosition()
1623
+ val firstVisible = rowPositionForScroll(layoutManager.findFirstVisibleItemPosition())
1505
1624
  if (firstVisible == RecyclerView.NO_POSITION) return
1506
1625
  sectionIndexView.setActiveIndex(
1507
1626
  sectionIndexEntries.indexOfLast { it.position <= firstVisible }.takeIf { it >= 0 },
@@ -1866,6 +1985,7 @@ class NativeListView(
1866
1985
  }
1867
1986
 
1868
1987
  override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int {
1988
+ if (viewHolder !is NativeListViewHolder) return makeMovementFlags(0, 0)
1869
1989
  val item = adapter.itemAt(viewHolder.bindingAdapterPosition)
1870
1990
  if (item == null || !item.isReorderable) return makeMovementFlags(0, 0)
1871
1991
  val dragFlags = if (next.orientation == "horizontal") ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT else ItemTouchHelper.UP or ItemTouchHelper.DOWN
@@ -1877,6 +1997,7 @@ class NativeListView(
1877
1997
  source: RecyclerView.ViewHolder,
1878
1998
  target: RecyclerView.ViewHolder,
1879
1999
  ): Boolean {
2000
+ if (source !is NativeListViewHolder || target !is NativeListViewHolder) return false
1880
2001
  val from = source.bindingAdapterPosition
1881
2002
  val to = target.bindingAdapterPosition
1882
2003
  val base = pendingReorder ?: adapter.currentList
@@ -2088,7 +2209,7 @@ class NativeListView(
2088
2209
  ?.takeIf { it != RecyclerView.NO_POSITION }
2089
2210
  ?: destinationPosition
2090
2211
  val destinationRow = recyclerView
2091
- .findViewHolderForAdapterPosition(resolvedPosition)
2212
+ .findViewHolderForAdapterPosition(scrollPositionForRow(resolvedPosition))
2092
2213
  ?.let { it as? NativeListViewHolder }
2093
2214
  ?.rowView
2094
2215
  val groupRow = destinationRow?.takeIf {
@@ -2251,8 +2372,20 @@ class NativeListView(
2251
2372
  Choreographer.getInstance().postFrameCallback {
2252
2373
  visibleEventScheduled = false
2253
2374
  val manager = recyclerView.layoutManager as? LinearLayoutManager ?: return@postFrameCallback
2254
- val first = manager.findFirstVisibleItemPosition()
2255
- val last = manager.findLastVisibleItemPosition()
2375
+ val visibleRows = (0 until recyclerView.childCount).mapNotNull { index ->
2376
+ val child = recyclerView.getChildAt(index)
2377
+ val holder = recyclerView.getChildViewHolder(child) as? NativeListViewHolder ?: return@mapNotNull null
2378
+ val visible = if (manager.orientation == RecyclerView.VERTICAL) {
2379
+ manager.getDecoratedBottom(child) > recyclerView.paddingTop &&
2380
+ manager.getDecoratedTop(child) < recyclerView.height - recyclerView.paddingBottom
2381
+ } else {
2382
+ manager.getDecoratedRight(child) > recyclerView.paddingLeft &&
2383
+ manager.getDecoratedLeft(child) < recyclerView.width - recyclerView.paddingRight
2384
+ }
2385
+ holder.bindingAdapterPosition.takeIf { visible && it != RecyclerView.NO_POSITION }
2386
+ }
2387
+ val first = visibleRows.minOrNull() ?: RecyclerView.NO_POSITION
2388
+ val last = visibleRows.maxOrNull() ?: RecyclerView.NO_POSITION
2256
2389
  val firstKey = adapter.itemAt(first)?.key
2257
2390
  val lastKey = adapter.itemAt(last)?.key
2258
2391
  val signature = "$first:$last:${firstKey.orEmpty()}:${lastKey.orEmpty()}"
@@ -2271,7 +2404,7 @@ class NativeListView(
2271
2404
  val current = config ?: return
2272
2405
  if (!current.loadMore || current.generation == endReachedGeneration || adapter.itemCount == 0) return
2273
2406
  val manager = recyclerView.layoutManager as? LinearLayoutManager ?: return
2274
- val lastVisible = manager.findLastVisibleItemPosition()
2407
+ val lastVisible = rowPositionForScroll(manager.findLastVisibleItemPosition())
2275
2408
  val thresholdItems = max(1, ceil(adapter.itemCount * current.endReachedThreshold).toInt())
2276
2409
  if (lastVisible >= adapter.itemCount - thresholdItems) {
2277
2410
  endReachedGeneration = current.generation
@@ -2311,7 +2444,7 @@ class NativeListView(
2311
2444
  adapter.currentList.indexOfFirst { it.key == currentAnchor.key }
2312
2445
  } ?: RecyclerView.NO_POSITION
2313
2446
  if (anchor != null && anchorIndex >= 0) {
2314
- layoutManager.scrollToPositionWithOffset(anchorIndex, anchor.offset)
2447
+ layoutManager.scrollToPositionWithOffset(scrollPositionForRow(anchorIndex), anchor.offset)
2315
2448
  }
2316
2449
  forceLayout()
2317
2450
  measure(
@@ -2473,7 +2606,11 @@ private class ReorderPlaceholderDecoration(
2473
2606
  val isWalletSidebar =
2474
2607
  item.type == "identity" && item.json.optString("presentation") == "walletSidebar"
2475
2608
  if (!isWalletSidebar && item.type != "walletGroup") return
2476
- val view = parent.findViewHolderForAdapterPosition(position)?.itemView ?: return
2609
+ val view = (0 until parent.childCount).asSequence().map(parent::getChildAt)
2610
+ .firstOrNull { child ->
2611
+ val holder = parent.getChildViewHolder(child)
2612
+ holder is NativeListViewHolder && holder.bindingAdapterPosition == position
2613
+ } ?: return
2477
2614
  bounds.set(
2478
2615
  (view.left + insetPx).toFloat(),
2479
2616
  view.top.toFloat(),
@@ -2744,7 +2881,7 @@ private class ItemSpacingDecoration(
2744
2881
  parent: RecyclerView,
2745
2882
  state: RecyclerView.State,
2746
2883
  ) {
2747
- if (spacing <= 0) return
2884
+ if (spacing <= 0 || parent.getChildViewHolder(view) !is NativeListViewHolder) return
2748
2885
  val horizontal = (parent.layoutManager as? LinearLayoutManager)?.orientation == RecyclerView.HORIZONTAL
2749
2886
  val item = view.tag as? NativeListItem
2750
2887
  val sourceWallet = item?.type == "identity" && item.json.optString("presentation") == "walletSidebar" && item.hasExplicitHeight
@@ -2766,7 +2903,7 @@ private class SelectorBackgroundDecoration(private val adapter: NativeListAdapte
2766
2903
  override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
2767
2904
  for (index in 0 until parent.childCount) {
2768
2905
  val child = parent.getChildAt(index)
2769
- val item = adapter.itemAt(parent.getChildAdapterPosition(child)) ?: continue
2906
+ val item = adapter.itemAt(parent.getChildViewHolder(child).takeIf { it is NativeListViewHolder }?.bindingAdapterPosition ?: RecyclerView.NO_POSITION) ?: continue
2770
2907
  if (!item.json.optBoolean("backgroundFullWidth", false)) continue
2771
2908
  val color = resolveNativeListSelectorBackgroundColor(
2772
2909
  rowType = item.type,
@@ -0,0 +1,4 @@
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
+ </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