@onekeyfe/react-native-native-list 3.0.173 → 3.0.175

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.
@@ -44,6 +44,7 @@ import org.json.JSONArray
44
44
  import org.json.JSONObject
45
45
  import java.lang.ref.WeakReference
46
46
  import java.util.UUID
47
+ import java.util.function.BiConsumer
47
48
  import kotlin.math.abs
48
49
  import kotlin.math.ceil
49
50
  import kotlin.math.cos
@@ -60,6 +61,15 @@ private class NativeListGridLayoutManager(
60
61
  ) : GridLayoutManager(context, spanCount) {
61
62
  var onChildLaidOut: ((View, Int) -> Unit)? = null
62
63
  var onLayoutPassCompleted: (() -> Unit)? = null
64
+ var onBeforeDataLayout: (() -> Unit)? = null
65
+ var onDiagnosticLayout: ((String, RecyclerView.State) -> Unit)? = null
66
+
67
+ override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) {
68
+ onDiagnosticLayout?.invoke("layout-children-before", state)
69
+ if (!state.isPreLayout) onBeforeDataLayout?.invoke()
70
+ super.onLayoutChildren(recycler, state)
71
+ onDiagnosticLayout?.invoke("layout-children-after", state)
72
+ }
63
73
 
64
74
  // Scroll fills and layout passes both place children here, including rows that a
65
75
  // positional jump lays out and immediately leaves offscreen.
@@ -70,6 +80,7 @@ private class NativeListGridLayoutManager(
70
80
 
71
81
  override fun onLayoutCompleted(state: RecyclerView.State) {
72
82
  super.onLayoutCompleted(state)
83
+ onDiagnosticLayout?.invoke("layout-completed", state)
73
84
  onLayoutPassCompleted?.invoke()
74
85
  }
75
86
 
@@ -192,6 +203,46 @@ class NativeListView(
192
203
  private val footerSlotAdapter = NativeListContainerSlotAdapter()
193
204
  private val scrollContentAdapter = ConcatAdapter(headerSlotAdapter, adapter, emptySlotAdapter, footerSlotAdapter)
194
205
 
206
+ private val homeOffsetDiagnosticEnabled =
207
+ context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE != 0
208
+ private var homeOffsetDiagnosticSequence = 0L
209
+
210
+ private fun homeOffsetDiagnostic(phase: String, state: RecyclerView.State? = null) {
211
+ if (!homeOffsetDiagnosticEnabled) return
212
+ try {
213
+ val first = layoutManager.findFirstVisibleItemPosition()
214
+ val firstView = layoutManager.findViewByPosition(first)
215
+ val holder = firstView?.let { recyclerView.getChildViewHolder(it) }
216
+ val lastView = layoutManager.findViewByPosition(layoutManager.findLastVisibleItemPosition())
217
+ val lastHolder = lastView?.let { recyclerView.getChildViewHolder(it) }
218
+ fun adapterOrdinal(value: RecyclerView.Adapter<*>?): Int = when (value) {
219
+ headerSlotAdapter -> 0
220
+ adapter -> 1
221
+ emptySlotAdapter -> 2
222
+ footerSlotAdapter -> 3
223
+ else -> -1
224
+ }
225
+ val focused = recyclerView.findFocus()
226
+ val focusChild = focused?.let { recyclerView.findContainingItemView(it) }
227
+ val focusHolder = focusChild?.let { recyclerView.getChildViewHolder(it) }
228
+ OneKeyLog.debug("NativeList", "[HOME-OFFSET-DIAG] listPhase=$phase " +
229
+ "seq=${++homeOffsetDiagnosticSequence} wallMs=${System.currentTimeMillis()} monoMs=${SystemClock.uptimeMillis()} " +
230
+ "list=${System.identityHashCode(this)} view=${System.identityHashCode(recyclerView)} tag=$id " +
231
+ "generation=${config?.generation} configuredCount=${config?.items?.size} " +
232
+ "counts=${headerSlotAdapter.itemCount},${adapter.itemCount},${emptySlotAdapter.itemCount},${footerSlotAdapter.itemCount} " +
233
+ "total=${scrollContentAdapter.itemCount} pendingCommand=${pendingScrollRequest?.javaClass?.simpleName ?: "none"} " +
234
+ "stateCount=${state?.itemCount} preLayout=${state?.isPreLayout} structureChanged=${state?.didStructureChange()} predictive=${state?.willRunPredictiveAnimations()} " +
235
+ "first=$first last=${layoutManager.findLastVisibleItemPosition()} " +
236
+ "adapterOrdinal=${adapterOrdinal(holder?.bindingAdapter)} layout=${holder?.layoutPosition} absolute=${holder?.absoluteAdapterPosition} binding=${holder?.bindingAdapterPosition} lastAdapterOrdinal=${adapterOrdinal(lastHolder?.bindingAdapter)} lastLayout=${lastHolder?.layoutPosition} lastAbsolute=${lastHolder?.absoluteAdapterPosition} lastBinding=${lastHolder?.bindingAdapterPosition} " +
237
+ "anchorTop=${firstView?.let(layoutManager::getDecoratedTop)} paddingTop=${recyclerView.paddingTop} " +
238
+ "childHeight=${firstView?.height} height=${recyclerView.height} range=${recyclerView.computeVerticalScrollRange()} extent=${recyclerView.computeVerticalScrollExtent()} " +
239
+ "canUp=${recyclerView.canScrollVertically(-1)} canDown=${recyclerView.canScrollVertically(1)} " +
240
+ "focusClass=${focused?.javaClass?.simpleName ?: "none"} focusId=${focused?.id} focusAdapterOrdinal=${adapterOrdinal(focusHolder?.bindingAdapter)} focusLayout=${focusHolder?.layoutPosition} focusPosition=${focusHolder?.absoluteAdapterPosition} focusBinding=${focusHolder?.bindingAdapterPosition}")
241
+ } catch (_: Exception) {
242
+ // Diagnostic reads must not affect layout or command delivery.
243
+ }
244
+ }
245
+
195
246
  fun mountContainerSlot(view: View, index: Int) {
196
247
  containerSlots.add(index.coerceIn(0, containerSlots.size), view)
197
248
  updateContainerSlots()
@@ -243,9 +294,11 @@ class NativeListView(
243
294
  }
244
295
 
245
296
  private fun updateContainerSlots() {
297
+ homeOffsetDiagnostic("slots-before")
246
298
  headerSlotAdapter.update(containerSlots.getOrNull(0), slotHeights[0], true)
247
299
  emptySlotAdapter.update(containerSlots.getOrNull(1), slotHeights[1], config?.items?.isEmpty() != false)
248
300
  footerSlotAdapter.update(containerSlots.getOrNull(2), slotHeights[2], true)
301
+ homeOffsetDiagnostic("slots-after")
249
302
  layoutManager.spanSizeLookup.invalidateSpanIndexCache()
250
303
  recyclerView.requestLayout()
251
304
  }
@@ -456,6 +509,7 @@ class NativeListView(
456
509
  }
457
510
  private val stickyPreDraw = android.view.ViewTreeObserver.OnPreDrawListener {
458
511
  updateStickyHeader()
512
+ finishScrollCommandIfSettled()
459
513
  true
460
514
  }
461
515
  // Registered only while attached, on the captured window observer, so detach
@@ -493,6 +547,14 @@ class NativeListView(
493
547
  true
494
548
  }
495
549
  private var pendingScrollRequest: ScrollRequest? = null
550
+ private var acceptedScrollRequest: ScrollRequest? = null
551
+ private var executingScrollCommand = false
552
+ private var dispatchingScrollRequest: ScrollRequest? = null
553
+ private var commandLayoutRequest: ScrollRequest? = null
554
+ private var commandAlignmentRequest: ScrollRequest? = null
555
+ private var commandFinishDiagnosticReason: String? = null
556
+ private var readingIntentEpoch = 0L
557
+ private var pendingBodyStartEpoch: Long? = null
496
558
  private val actionAnchorInstanceId = UUID.randomUUID().toString()
497
559
  private var actionAnchorCounter = 0L
498
560
  private var actionAnchor: ActionAnchorRecord? = null
@@ -514,10 +576,20 @@ class NativeListView(
514
576
  // OneKey patch: full-width selector header backgrounds do not change row content insets.
515
577
  recyclerView.addItemDecoration(SelectorBackgroundDecoration(adapter))
516
578
  recyclerView.addItemDecoration(ContainerSlotInsetDecoration { slotInsetPx })
579
+ if (homeOffsetDiagnosticEnabled) layoutManager.onDiagnosticLayout = ::homeOffsetDiagnostic
580
+ layoutManager.onBeforeDataLayout = {
581
+ if (pendingBodyStartEpoch == readingIntentEpoch && adapter.itemCount > 0 && pendingScrollRequest == null) {
582
+ layoutManager.scrollToPositionWithOffset(0, 0)
583
+ pendingBodyStartEpoch = null
584
+ homeOffsetDiagnostic("first-fill-body-start")
585
+ }
586
+ }
517
587
  layoutManager.onChildLaidOut = ::recordLaidOutRowExtent
518
588
  layoutManager.onLayoutPassCompleted = {
519
589
  emitScrollPositionThresholdIfNeeded(resync = true)
520
590
  if (containerSlotRelayoutPending) scheduleContainerSlotRelayout()
591
+ if (commandLayoutRequest === acceptedScrollRequest) commandLayoutRequest = null
592
+ finishScrollCommandIfSettled()
521
593
  }
522
594
  recyclerView.setHasFixedSize(false)
523
595
  layoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
@@ -607,6 +679,11 @@ class NativeListView(
607
679
 
608
680
  recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
609
681
  override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
682
+ if (newState == RecyclerView.SCROLL_STATE_DRAGGING) {
683
+ cancelExecutingScrollCommand()
684
+ readingIntentEpoch++
685
+ pendingBodyStartEpoch = null
686
+ }
610
687
  when (newState) {
611
688
  RecyclerView.SCROLL_STATE_DRAGGING -> {
612
689
  sectionIndexProgrammaticScroll = false
@@ -618,7 +695,10 @@ class NativeListView(
618
695
  dismissKeyboard()
619
696
  }
620
697
  }
621
- RecyclerView.SCROLL_STATE_IDLE -> sectionIndexProgrammaticScroll = false
698
+ RecyclerView.SCROLL_STATE_IDLE -> {
699
+ sectionIndexProgrammaticScroll = false
700
+ finishScrollCommandIfSettled()
701
+ }
622
702
  }
623
703
  }
624
704
 
@@ -709,6 +789,8 @@ class NativeListView(
709
789
  super.onAttachedToWindow()
710
790
  if (containerSlotRelayoutPending) scheduleContainerSlotRelayout()
711
791
  recyclerView.setTag(R.id.onekey_native_scroll_coordinator_offset_listener, coordinatorOffsetListener)
792
+ val cancelTag = R.id.onekey_native_scroll_coordinator_command_cancel_listener
793
+ if (recyclerView.getTag(cancelTag) == null) recyclerView.setTag(cancelTag, coordinatorCommandCancelListener)
712
794
  emitScrollPositionThresholdIfNeeded()
713
795
  startStickyHeaderTracking()
714
796
  updateSectionIndexAttachment()
@@ -719,6 +801,9 @@ class NativeListView(
719
801
  }
720
802
 
721
803
  override fun onDetachedFromWindow() {
804
+ val cancelTag = R.id.onekey_native_scroll_coordinator_command_cancel_listener
805
+ if (recyclerView.getTag(cancelTag) === coordinatorCommandCancelListener) recyclerView.setTag(cancelTag, null)
806
+ cancelExecutingScrollCommand()
722
807
  if (recyclerView.getTag(R.id.onekey_native_scroll_coordinator_offset_listener) === coordinatorOffsetListener) {
723
808
  recyclerView.setTag(R.id.onekey_native_scroll_coordinator_offset_listener, null)
724
809
  }
@@ -736,8 +821,17 @@ class NativeListView(
736
821
  } catch (_: Exception) {
737
822
  return
738
823
  }
824
+ homeOffsetDiagnostic("snapshot-before-nextCount=${next.items.size}")
739
825
  invalidateActionAnchor("snapshot")
740
826
  val previous = config
827
+ if (previous?.items?.isEmpty() == true && next.items.isNotEmpty() && adapter.itemCount == 0 &&
828
+ pendingScrollRequest == null && recyclerView.scrollState != RecyclerView.SCROLL_STATE_DRAGGING &&
829
+ !recyclerView.canScrollVertically(-1) && layoutManager.findFirstVisibleItemPosition() == 0) {
830
+ val first = layoutManager.findViewByPosition(0)
831
+ if (first != null && kotlin.math.abs(layoutManager.getDecoratedTop(first) - recyclerView.paddingTop) <= 1) {
832
+ pendingBodyStartEpoch = readingIntentEpoch
833
+ }
834
+ }
741
835
  if (previous != null && canApplyStableContentUpdate(previous, next)) {
742
836
  // OneKey patch: authorize a diff payload only against this exact validated baseline.
743
837
  next.items.forEachIndexed { index, item ->
@@ -765,6 +859,7 @@ class NativeListView(
765
859
  // outside the adapter, so a listStyle-only snapshot must rebind it here.
766
860
  if (previous.listStyle?.toString() != next.listStyle?.toString()) bindFooter(next)
767
861
  adapter.submitList(next.items) {
862
+ homeOffsetDiagnostic("snapshot-stable-submit-commit")
768
863
  // OneKey patch: DiffUtil has dispatched its payloads; release the old serialized rows.
769
864
  next.items.forEach { it.selectionUpdateFromContent = null }
770
865
  recyclerView.post {
@@ -805,6 +900,7 @@ class NativeListView(
805
900
  configureSectionIndex(next)
806
901
  updateLayout(next)
807
902
  adapter.submitList(next.items) {
903
+ homeOffsetDiagnostic("snapshot-submit-commit-marketAnchor=${marketStartOffset != null}")
808
904
  if (marketStartOffset != null) {
809
905
  layoutManager.scrollToPositionWithOffset(scrollPositionForRow(0), marketStartOffset)
810
906
  }
@@ -1089,9 +1185,90 @@ class NativeListView(
1089
1185
  requestScroll(ScrollRequest.End(animated))
1090
1186
  }
1091
1187
 
1188
+ private fun acceptScrollRequest(request: ScrollRequest) {
1189
+ if (acceptedScrollRequest === request) return
1190
+ val current = config ?: return
1191
+ val valid = when (request) {
1192
+ is ScrollRequest.Key -> current.items.any { it.key == request.key } && request.viewPosition.isFinite() && request.viewOffset.isFinite()
1193
+ is ScrollRequest.Index -> request.index in current.items.indices && request.viewPosition.isFinite() && request.viewOffset.isFinite()
1194
+ is ScrollRequest.Offset -> request.offset.isFinite()
1195
+ is ScrollRequest.End -> true
1196
+ }
1197
+ if (!valid) return
1198
+ executingScrollCommand = false
1199
+ dispatchingScrollRequest = null
1200
+ commandLayoutRequest = null
1201
+ commandAlignmentRequest = null
1202
+ acceptedScrollRequest = request
1203
+ commandFinishDiagnosticReason = null
1204
+ readingIntentEpoch++
1205
+ pendingBodyStartEpoch = null
1206
+ val explicitTop = when (request) {
1207
+ is ScrollRequest.Offset -> request.offset <= 0
1208
+ is ScrollRequest.Index -> request.index == 0 && request.alignment == "start" && request.viewOffset == 0.0 && request.viewPosition == 0.0
1209
+ is ScrollRequest.Key -> current.items.firstOrNull()?.key == request.key && request.alignment == "start" && request.viewOffset == 0.0 && request.viewPosition == 0.0
1210
+ is ScrollRequest.End -> false
1211
+ }
1212
+ notifyScrollCommand(explicitTop, 0)
1213
+ }
1214
+
1215
+ private fun notifyScrollCommand(explicitTop: Boolean, phase: Int) {
1216
+ @Suppress("UNCHECKED_CAST")
1217
+ val listeners = recyclerView.getTag(R.id.onekey_native_scroll_coordinator_command_listener) as? Map<Any, BiConsumer<Boolean, Int>>
1218
+ listeners?.values?.toList()?.forEach { it.accept(explicitTop, phase) }
1219
+ }
1220
+
1221
+ private val coordinatorCommandCancelListener = Runnable {
1222
+ if (disposed) return@Runnable
1223
+ pendingScrollRequest = null
1224
+ readingIntentEpoch++
1225
+ pendingBodyStartEpoch = null
1226
+ cancelExecutingScrollCommand()
1227
+ recyclerView.stopScroll()
1228
+ }
1229
+
1230
+ private fun cancelExecutingScrollCommand() {
1231
+ val request = acceptedScrollRequest
1232
+ val wasExecuting = executingScrollCommand
1233
+ executingScrollCommand = false
1234
+ acceptedScrollRequest = null
1235
+ dispatchingScrollRequest = null
1236
+ commandLayoutRequest = null
1237
+ commandAlignmentRequest = null
1238
+ if (request != null && pendingScrollRequest === request) pendingScrollRequest = null
1239
+ if (wasExecuting) notifyScrollCommand(false, 2)
1240
+ }
1241
+
1242
+ private fun finishScrollCommandIfSettled() {
1243
+ val request = acceptedScrollRequest ?: return
1244
+ if (homeOffsetDiagnosticEnabled && executingScrollCommand) {
1245
+ val reason = "pendingOwn=${pendingScrollRequest === request}-dispatchOwn=${dispatchingScrollRequest === request}" +
1246
+ "-layoutOwn=${commandLayoutRequest === request}-alignOwn=${commandAlignmentRequest === request}" +
1247
+ "-computing=${recyclerView.isComputingLayout}-scrollState=${recyclerView.scrollState}-smooth=${layoutManager.isSmoothScrolling}"
1248
+ if (commandFinishDiagnosticReason != reason) {
1249
+ commandFinishDiagnosticReason = reason
1250
+ homeOffsetDiagnostic("command-finish-gates-$reason")
1251
+ }
1252
+ }
1253
+ if (
1254
+ !executingScrollCommand || pendingScrollRequest === request ||
1255
+ dispatchingScrollRequest === request || commandLayoutRequest === request ||
1256
+ commandAlignmentRequest === request || recyclerView.isComputingLayout ||
1257
+ recyclerView.scrollState != RecyclerView.SCROLL_STATE_IDLE || layoutManager.isSmoothScrolling
1258
+ ) return
1259
+ executingScrollCommand = false
1260
+ commandLayoutRequest = null
1261
+ commandAlignmentRequest = null
1262
+ notifyScrollCommand(false, 2)
1263
+ }
1264
+
1092
1265
  private fun requestScroll(request: ScrollRequest) {
1266
+ acceptScrollRequest(request)
1267
+ if (config != null && acceptedScrollRequest !== request) return
1268
+ homeOffsetDiagnostic("command-request-kind=${request.javaClass.simpleName}")
1093
1269
  if (performScroll(request)) {
1094
1270
  pendingScrollRequest = null
1271
+ finishScrollCommandIfSettled()
1095
1272
  } else {
1096
1273
  pendingScrollRequest = request
1097
1274
  }
@@ -1099,84 +1276,109 @@ class NativeListView(
1099
1276
 
1100
1277
  private fun performPendingScrollIfNeeded() {
1101
1278
  val request = pendingScrollRequest ?: return
1102
- if (performScroll(request)) pendingScrollRequest = null
1279
+ acceptScrollRequest(request)
1280
+ if (config != null && acceptedScrollRequest !== request) {
1281
+ if (pendingScrollRequest === request) pendingScrollRequest = null
1282
+ return
1283
+ }
1284
+ homeOffsetDiagnostic("command-pending-kind=${request.javaClass.simpleName}")
1285
+ if (performScroll(request)) {
1286
+ pendingScrollRequest = null
1287
+ finishScrollCommandIfSettled()
1288
+ }
1103
1289
  }
1104
1290
 
1105
1291
  private fun performScroll(request: ScrollRequest): Boolean {
1292
+ homeOffsetDiagnostic("command-perform-kind=${request.javaClass.simpleName}")
1106
1293
  val current = config ?: return false
1107
1294
  if (adapter.itemCount != current.items.size) return false
1108
1295
  if (recyclerView.width <= 0 || recyclerView.height <= 0) return false
1109
- return when (request) {
1110
- is ScrollRequest.Key -> {
1111
- val index = current.items.indexOfFirst { it.key == request.key }
1112
- if (index >= 0) {
1113
- performIndexScroll(
1114
- index,
1115
- request.animated,
1116
- request.alignment,
1117
- request.viewPosition,
1118
- request.viewOffset,
1119
- )
1296
+ if (acceptedScrollRequest === request) {
1297
+ dispatchingScrollRequest = request
1298
+ executingScrollCommand = true
1299
+ notifyScrollCommand(false, 1)
1300
+ }
1301
+ try {
1302
+ return when (request) {
1303
+ is ScrollRequest.Key -> {
1304
+ val index = current.items.indexOfFirst { it.key == request.key }
1305
+ if (index >= 0) {
1306
+ performIndexScroll(
1307
+ request,
1308
+ index,
1309
+ request.animated,
1310
+ request.alignment,
1311
+ request.viewPosition,
1312
+ request.viewOffset,
1313
+ )
1314
+ }
1315
+ true
1120
1316
  }
1121
- true
1122
- }
1123
- is ScrollRequest.Index -> {
1124
- if (request.index in current.items.indices) {
1125
- performIndexScroll(
1126
- request.index,
1127
- request.animated,
1128
- request.alignment,
1129
- request.viewPosition,
1130
- request.viewOffset,
1131
- )
1317
+ is ScrollRequest.Index -> {
1318
+ if (request.index in current.items.indices) {
1319
+ performIndexScroll(
1320
+ request,
1321
+ request.index,
1322
+ request.animated,
1323
+ request.alignment,
1324
+ request.viewPosition,
1325
+ request.viewOffset,
1326
+ )
1327
+ }
1328
+ true
1132
1329
  }
1133
- true
1134
- }
1135
- is ScrollRequest.Offset -> {
1136
- performOffsetScroll(request.offset, request.animated)
1137
- true
1138
- }
1139
- is ScrollRequest.End -> {
1140
- val finalSlotHeight = when {
1141
- footerSlotAdapter.itemCount > 0 -> slotHeights[2]
1142
- emptySlotAdapter.itemCount > 0 -> slotHeights[1]
1143
- adapter.itemCount == 0 && headerSlotAdapter.itemCount > 0 -> slotHeights[0]
1144
- else -> null
1330
+ is ScrollRequest.Offset -> {
1331
+ performOffsetScroll(request, request.offset, request.animated)
1332
+ true
1145
1333
  }
1146
- if (finalSlotHeight != null) {
1147
- val target = scrollContentAdapter.itemCount - 1
1148
- if (request.animated) {
1149
- val manager = layoutManager
1150
- val scroller = object : LinearSmoothScroller(context) {
1151
- override fun calculateDyToMakeVisible(view: View, snapPreference: Int): Int =
1152
- nativeListEndAlignmentOffset(
1153
- manager.height - manager.paddingBottom,
1154
- manager.getDecoratedBottom(view),
1155
- )
1334
+ is ScrollRequest.End -> {
1335
+ val finalSlotHeight = when {
1336
+ footerSlotAdapter.itemCount > 0 -> slotHeights[2]
1337
+ emptySlotAdapter.itemCount > 0 -> slotHeights[1]
1338
+ adapter.itemCount == 0 && headerSlotAdapter.itemCount > 0 -> slotHeights[0]
1339
+ else -> null
1340
+ }
1341
+ if (finalSlotHeight != null) {
1342
+ val target = scrollContentAdapter.itemCount - 1
1343
+ if (request.animated) {
1344
+ val manager = layoutManager
1345
+ val scroller = object : LinearSmoothScroller(context) {
1346
+ override fun calculateDyToMakeVisible(view: View, snapPreference: Int): Int =
1347
+ nativeListEndAlignmentOffset(
1348
+ manager.height - manager.paddingBottom,
1349
+ manager.getDecoratedBottom(view),
1350
+ )
1351
+ }
1352
+ scroller.targetPosition = target
1353
+ layoutManager.startSmoothScroll(scroller)
1354
+ } else {
1355
+ if (acceptedScrollRequest === request) commandLayoutRequest = request
1356
+ layoutManager.scrollToPositionWithOffset(
1357
+ target,
1358
+ nativeListEndAlignmentOffset(viewportLength(layoutManager), finalSlotHeight),
1359
+ )
1360
+ relayoutRecyclerView()
1156
1361
  }
1157
- scroller.targetPosition = target
1158
- layoutManager.startSmoothScroll(scroller)
1159
- } else {
1160
- layoutManager.scrollToPositionWithOffset(
1161
- target,
1162
- nativeListEndAlignmentOffset(viewportLength(layoutManager), finalSlotHeight),
1362
+ } else if (current.items.isNotEmpty()) {
1363
+ performIndexScroll(
1364
+ request,
1365
+ current.items.lastIndex,
1366
+ request.animated,
1367
+ "end",
1368
+ 1.0,
1369
+ 0.0,
1163
1370
  )
1164
1371
  }
1165
- } else if (current.items.isNotEmpty()) {
1166
- performIndexScroll(
1167
- current.items.lastIndex,
1168
- request.animated,
1169
- "end",
1170
- 1.0,
1171
- 0.0,
1172
- )
1372
+ true
1173
1373
  }
1174
- true
1175
1374
  }
1375
+ } finally {
1376
+ if (dispatchingScrollRequest === request) dispatchingScrollRequest = null
1176
1377
  }
1177
1378
  }
1178
1379
 
1179
1380
  private fun performIndexScroll(
1381
+ request: ScrollRequest,
1180
1382
  index: Int,
1181
1383
  animated: Boolean,
1182
1384
  alignment: String,
@@ -1241,12 +1443,16 @@ class NativeListView(
1241
1443
  val layoutOffset = nativeListProvisionalStart(
1242
1444
  viewportLength(manager), viewPosition, offsetPx, nearest,
1243
1445
  )
1446
+ if (acceptedScrollRequest === request) {
1447
+ commandLayoutRequest = request
1448
+ commandAlignmentRequest = request
1449
+ }
1244
1450
  manager.scrollToPositionWithOffset(scrollPositionForRow(index), layoutOffset)
1245
1451
  relayoutRecyclerView()
1246
- alignAfterLayout(index, viewPosition, offsetPx, nearest)
1452
+ alignAfterLayout(request, index, viewPosition, offsetPx, nearest)
1247
1453
  }
1248
1454
 
1249
- private fun performOffsetScroll(offset: Double, animated: Boolean) {
1455
+ private fun performOffsetScroll(request: ScrollRequest, offset: Double, animated: Boolean) {
1250
1456
  val manager = recyclerView.layoutManager as? LinearLayoutManager ?: return
1251
1457
  val target = (offset * density).roundToInt().coerceAtLeast(0)
1252
1458
  if (!animated) {
@@ -1254,11 +1460,24 @@ class NativeListView(
1254
1460
  }
1255
1461
  if (!animated) {
1256
1462
  recyclerView.stopScroll()
1463
+ if (acceptedScrollRequest === request) commandLayoutRequest = request
1257
1464
  manager.scrollToPositionWithOffset(0, -target)
1258
1465
  relayoutRecyclerView()
1259
1466
  return
1260
1467
  }
1261
1468
  if (target == 0) {
1469
+ val startView = manager.findViewByPosition(0)
1470
+ val paddingStart = if (manager.orientation == RecyclerView.VERTICAL) manager.paddingTop else manager.paddingLeft
1471
+ val relativeStart = startView?.let { decoratedStart(manager, it) - paddingStart }
1472
+ homeOffsetDiagnostic("command-top-dispatch-first=${manager.findFirstVisibleItemPosition()}-relativeStart=$relativeStart-smooth=${manager.isSmoothScrolling}")
1473
+ if (
1474
+ startView != null && manager.findFirstVisibleItemPosition() == 0 && relativeStart == 0 &&
1475
+ recyclerView.scrollState == RecyclerView.SCROLL_STATE_IDLE &&
1476
+ !recyclerView.isComputingLayout && !manager.isSmoothScrolling
1477
+ ) {
1478
+ homeOffsetDiagnostic("command-top-noop")
1479
+ return
1480
+ }
1262
1481
  recyclerView.smoothScrollToPosition(0)
1263
1482
  return
1264
1483
  }
@@ -1276,6 +1495,7 @@ class NativeListView(
1276
1495
  }
1277
1496
 
1278
1497
  private fun alignAfterLayout(
1498
+ request: ScrollRequest,
1279
1499
  index: Int,
1280
1500
  viewPosition: Double,
1281
1501
  viewOffset: Int,
@@ -1283,15 +1503,26 @@ class NativeListView(
1283
1503
  attemptsRemaining: Int = 2,
1284
1504
  ) {
1285
1505
  recyclerView.post {
1286
- val manager = recyclerView.layoutManager as? LinearLayoutManager ?: return@post
1287
- val view = manager.findViewByPosition(scrollPositionForRow(index))
1288
- if (view == null) {
1506
+ if (acceptedScrollRequest !== request || !executingScrollCommand || disposed || !isAttachedToWindow) return@post
1507
+ val manager = recyclerView.layoutManager as? LinearLayoutManager
1508
+ val view = manager?.findViewByPosition(scrollPositionForRow(index))
1509
+ if (view == null || recyclerView.isComputingLayout) {
1289
1510
  if (attemptsRemaining > 0) {
1290
- alignAfterLayout(index, viewPosition, viewOffset, nearest, attemptsRemaining - 1)
1511
+ alignAfterLayout(request, index, viewPosition, viewOffset, nearest, attemptsRemaining - 1)
1512
+ return@post
1291
1513
  }
1292
- return@post
1514
+ } else if (manager != null) {
1515
+ dispatchingScrollRequest = request
1516
+ try {
1517
+ alignVisibleView(manager, view, viewPosition, viewOffset, false, nearest)
1518
+ } finally {
1519
+ if (dispatchingScrollRequest === request) dispatchingScrollRequest = null
1520
+ }
1521
+ }
1522
+ if (acceptedScrollRequest === request && commandAlignmentRequest === request) {
1523
+ commandAlignmentRequest = null
1524
+ finishScrollCommandIfSettled()
1293
1525
  }
1294
- alignVisibleView(manager, view, viewPosition, viewOffset, false, nearest)
1295
1526
  }
1296
1527
  }
1297
1528
 
@@ -1397,6 +1628,8 @@ class NativeListView(
1397
1628
  fun dispose() {
1398
1629
  if (disposed) return
1399
1630
  disposed = true
1631
+ val cancelTag = R.id.onekey_native_scroll_coordinator_command_cancel_listener
1632
+ if (recyclerView.getTag(cancelTag) === coordinatorCommandCancelListener) recyclerView.setTag(cancelTag, null)
1400
1633
  containerSlotRelayoutPending = false
1401
1634
  onScrollPositionThresholdChange = null
1402
1635
  if (!isAttachedToWindow) disposeResources()
@@ -1414,6 +1647,14 @@ class NativeListView(
1414
1647
  visibleEventScheduled = false
1415
1648
  layoutManager.onChildLaidOut = null
1416
1649
  layoutManager.onLayoutPassCompleted = null
1650
+ layoutManager.onDiagnosticLayout = null
1651
+ layoutManager.onBeforeDataLayout = null
1652
+ pendingBodyStartEpoch = null
1653
+ executingScrollCommand = false
1654
+ acceptedScrollRequest = null
1655
+ dispatchingScrollRequest = null
1656
+ commandLayoutRequest = null
1657
+ commandAlignmentRequest = null
1417
1658
  measuredRowExtentsPx.clear()
1418
1659
  measuredRowExtentSumPx = 0L
1419
1660
  reorderTouchHandler?.removeCallbacksAndMessages(null)
@@ -2,4 +2,6 @@
2
2
  <item name="onekey_native_scroll_coordinator_consumed_offsets" type="id" />
3
3
  <item name="onekey_native_scroll_coordinator_offset_listener" type="id" />
4
4
  <item name="onekey_native_scroll_coordinator_refresh_inset" type="id" />
5
+ <item name="onekey_native_scroll_coordinator_command_listener" type="id" />
6
+ <item name="onekey_native_scroll_coordinator_command_cancel_listener" type="id" />
5
7
  </resources>
package/docs/SPEC.md CHANGED
@@ -70,6 +70,53 @@ Acceptance requires loading → idle → loading reuse, title color updates and
70
70
  detachment on each native platform. A title's localized confirming text remains
71
71
  caller-owned; the renderer does not infer transaction status.
72
72
 
73
+
74
+ ### First-fill position and imperative command ownership
75
+
76
+ Status: implemented in source and verified in the consuming app's local native
77
+ builds; acceptance of the newly published package remains pending.
78
+
79
+ On Android, empty-to-populated data MUST preserve the actual body-start anchor
80
+ through the first committed layout. This applies only when the old body was
81
+ empty and the view was actually at its start; it MUST NOT reset a non-empty
82
+ refresh, pagination, deep reading position, or a newer accepted command.
83
+ Continuous snapshots retain this intent until the first populated layout.
84
+ A real drag or valid command invalidates the intent. Container-slot positions
85
+ and scrollbar estimates MUST NOT be mistaken for data-row reading anchors.
86
+
87
+ An accepted imperative command owns its request, execution, pending-layout and
88
+ posted-alignment work. Invalid commands MUST NOT cancel a valid request.
89
+ On iOS, known-configuration validation MUST precede changing accepted, pending
90
+ or animated-command state; invalid input MUST preserve the existing terminal
91
+ notification, while configuration-unavailable requests retain deferred validation.
92
+ Completion on Android requires the current request to have finished dispatch,
93
+ its own pending layout/alignment barriers to have completed, and native scrolling
94
+ to be idle without an active layout-manager animation. A true animated top
95
+ no-op completes without starting redundant native motion. Non-animated key and
96
+ end alignment complete after their requested layout/alignment, not beforehand.
97
+ An unrelated layout request MUST NOT indefinitely postpone command completion.
98
+ Replacement, actual drag, detach and disposal invalidate old callbacks; a stale
99
+ callback MUST NOT move the view or clear a newer command's barriers.
100
+
101
+ A valid command can privately notify the owning Pager before execution, including
102
+ a same-position no-op; this does not add a public NativeList API. A Pager-owned
103
+ page-top or sticky-top command can privately cancel only its owned list's old
104
+ pending command and native animation before acquiring its own target. On iOS
105
+ the notification identifies the collection view; on Android the listener belongs
106
+ to the actual owned RecyclerView. No notification may take over another owner.
107
+
108
+ On Web, the owned viewport's private cancellation event clears pending list
109
+ commands and stops old browser smooth motion. A newly accepted valid list
110
+ command invalidates a queued Pager target; invalid commands do not. Destroy
111
+ removes the exact listeners and clears pending work. Other list viewports and
112
+ rendering work remain independent.
113
+
114
+ Conformance: NativeListView.kt, RNCNativeListView.swift, NativeListWebEngine.ts,
115
+ and the related Pager owner coordination. Runtime acceptance must exercise
116
+ first populated layout, command replacement, animated/non-animated completion,
117
+ true top no-op, and short pending content followed by usable geometry on the
118
+ actual installed package; source or helper-only checks are not runtime evidence.
119
+
73
120
  ## Data, cache and identity
74
121
 
75
122
  `row.key` identifies data; renderer reuse identity describes compatible view
@@ -167,6 +167,10 @@ final class NativeListView: UIView {
167
167
  private var sectionIndexHapticsEnabled = true
168
168
  private var lastLayoutDirection: UIUserInterfaceLayoutDirection?
169
169
  private var lastLayoutSize: CGSize?
170
+ private var commandAcceptedForPendingScroll = false
171
+ private var performingExplicitScrollCommand = false
172
+ private var animatedExplicitScrollCommand = false
173
+ private var pagerCommandCancelObserver: NSObjectProtocol?
170
174
  private var pendingScrollRequest: ScrollRequest?
171
175
  private let sectionIndexFeedback = UISelectionFeedbackGenerator()
172
176
  private lazy var reorderLongPress = UILongPressGestureRecognizer(
@@ -217,6 +221,17 @@ final class NativeListView: UIView {
217
221
 
218
222
  override init(frame: CGRect) {
219
223
  super.init(frame: frame)
224
+ pagerCommandCancelObserver = NotificationCenter.default.addObserver(
225
+ forName: Notification.Name("OneKeyNativeListCancelScrollCommand"),
226
+ object: collectionView, queue: .main
227
+ ) { [weak self] notification in
228
+ guard let self, !self.disposed, (notification.object as? UICollectionView) === self.collectionView else { return }
229
+ self.pendingScrollRequest = nil
230
+ self.commandAcceptedForPendingScroll = false
231
+ self.scrollViewDidEndScrollingAnimation(self.collectionView)
232
+ self.performingExplicitScrollCommand = false
233
+ self.collectionView.setContentOffset(self.collectionView.contentOffset, animated: false)
234
+ }
220
235
  NativeListRendererRegistry.register(in: collectionView)
221
236
  collectionView.backgroundColor = .clear
222
237
  collectionView.delegate = self
@@ -392,6 +407,10 @@ final class NativeListView: UIView {
392
407
  return item.isRowPressEnabled
393
408
  }
394
409
 
410
+ deinit {
411
+ if let pagerCommandCancelObserver { NotificationCenter.default.removeObserver(pagerCommandCancelObserver) }
412
+ }
413
+
395
414
  required init?(coder: NSCoder) {
396
415
  fatalError("init(coder:) has not been implemented")
397
416
  }
@@ -638,7 +657,38 @@ final class NativeListView: UIView {
638
657
  requestScroll(.end(animated))
639
658
  }
640
659
 
660
+ private func explicitTopForScrollCommand(_ request: ScrollRequest) -> Bool? {
661
+ guard let config else { return nil }
662
+ let explicitTop: Bool
663
+ switch request {
664
+ case let .key(key, _, alignment, viewPosition, viewOffset):
665
+ guard config.items.contains(where: { $0.key == key }), viewPosition.isFinite, viewOffset.isFinite else { return nil }
666
+ explicitTop = config.items.first?.key == key && alignment == "start" && viewPosition == 0 && viewOffset == 0
667
+ case let .index(index, _, alignment, viewPosition, viewOffset):
668
+ guard index >= 0, index < config.items.count, viewPosition.isFinite, viewOffset.isFinite else { return nil }
669
+ explicitTop = index == 0 && alignment == "start" && viewPosition == 0 && viewOffset == 0
670
+ case let .offset(offset, _):
671
+ guard offset.isFinite else { return nil }
672
+ explicitTop = offset <= 0
673
+ case .end:
674
+ explicitTop = false
675
+ }
676
+ return explicitTop
677
+ }
678
+
679
+ private func acceptScrollCommand(_ request: ScrollRequest) {
680
+ guard !commandAcceptedForPendingScroll,
681
+ let explicitTop = explicitTopForScrollCommand(request) else { return }
682
+ commandAcceptedForPendingScroll = true
683
+ NotificationCenter.default.post(name: Notification.Name("OneKeyNativeListScrollCommandAccepted"),
684
+ object: collectionView, userInfo: ["explicitTop": explicitTop, "phase": 0])
685
+ }
686
+
641
687
  private func requestScroll(_ request: ScrollRequest) {
688
+ if config != nil && explicitTopForScrollCommand(request) == nil { return }
689
+ animatedExplicitScrollCommand = false
690
+ commandAcceptedForPendingScroll = false
691
+ acceptScrollCommand(request)
642
692
  if performScroll(request) {
643
693
  pendingScrollRequest = nil
644
694
  } else {
@@ -647,7 +697,9 @@ final class NativeListView: UIView {
647
697
  }
648
698
 
649
699
  private func performPendingScrollIfNeeded() {
650
- guard let request = pendingScrollRequest, performScroll(request) else { return }
700
+ guard let request = pendingScrollRequest else { return }
701
+ acceptScrollCommand(request)
702
+ guard performScroll(request) else { return }
651
703
  pendingScrollRequest = nil
652
704
  }
653
705
 
@@ -660,6 +712,8 @@ final class NativeListView: UIView {
660
712
  : collectionView.bounds.height
661
713
  guard viewportLength > 0 else { return false }
662
714
  collectionView.layoutIfNeeded()
715
+ performingExplicitScrollCommand = commandAcceptedForPendingScroll
716
+ defer { performingExplicitScrollCommand = false }
663
717
 
664
718
  switch request {
665
719
  case let .key(key, animated, alignment, viewPosition, viewOffset):
@@ -764,7 +818,17 @@ final class NativeListView: UIView {
764
818
  let point = isHorizontal
765
819
  ? CGPoint(x: offset, y: collectionView.contentOffset.y)
766
820
  : CGPoint(x: collectionView.contentOffset.x, y: offset)
821
+ let moved = point != collectionView.contentOffset
822
+ if performingExplicitScrollCommand && moved {
823
+ NotificationCenter.default.post(name: Notification.Name("OneKeyNativeListScrollCommandAccepted"),
824
+ object: collectionView, userInfo: ["phase": 1])
825
+ }
826
+ if performingExplicitScrollCommand && moved { animatedExplicitScrollCommand = animated }
767
827
  collectionView.setContentOffset(point, animated: animated)
828
+ if performingExplicitScrollCommand && moved && !animated {
829
+ NotificationCenter.default.post(name: Notification.Name("OneKeyNativeListScrollCommandAccepted"),
830
+ object: collectionView, userInfo: ["phase": 2])
831
+ }
768
832
  }
769
833
 
770
834
  private func contentLayoutDidChange() {
@@ -1981,7 +2045,14 @@ final class NativeListView: UIView {
1981
2045
  config = nil
1982
2046
  itemsByKey.removeAll()
1983
2047
  sectionIndexEntries.removeAll()
2048
+ if let pagerCommandCancelObserver {
2049
+ NotificationCenter.default.removeObserver(pagerCommandCancelObserver)
2050
+ self.pagerCommandCancelObserver = nil
2051
+ }
1984
2052
  pendingScrollRequest = nil
2053
+ performingExplicitScrollCommand = false
2054
+ animatedExplicitScrollCommand = false
2055
+ commandAcceptedForPendingScroll = false
1985
2056
  needsContentShrinkCorrection = false
1986
2057
  lastLaidOutContentHeight = nil
1987
2058
  lastVisibleRange = nil
@@ -2281,7 +2352,15 @@ extension NativeListView: UICollectionViewDelegateFlowLayout {
2281
2352
  return item.isRowPressEnabled
2282
2353
  }
2283
2354
 
2355
+ func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
2356
+ guard animatedExplicitScrollCommand else { return }
2357
+ animatedExplicitScrollCommand = false
2358
+ NotificationCenter.default.post(name: Notification.Name("OneKeyNativeListScrollCommandAccepted"),
2359
+ object: collectionView, userInfo: ["phase": 2])
2360
+ }
2361
+
2284
2362
  func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
2363
+ scrollViewDidEndScrollingAnimation(scrollView)
2285
2364
  minimumDragOffset = 0
2286
2365
  refreshEmittedForDrag = false
2287
2366
  }
@@ -4,6 +4,63 @@ import XCTest
4
4
 
5
5
  @MainActor
6
6
  final class NativeListLifecycleTests: XCTestCase {
7
+ func testInvalidCommandsPreserveAcceptedAnimationCompletion() throws {
8
+ for invalid in 0..<3 {
9
+ let view = try makeScrollingList()
10
+ let window = UIWindow(frame: view.frame)
11
+ let controller = UIViewController()
12
+ window.rootViewController = controller
13
+ controller.view.addSubview(view)
14
+ window.isHidden = false
15
+ defer { window.isHidden = true }
16
+ window.layoutIfNeeded()
17
+ view.layoutIfNeeded()
18
+ drainMainQueue()
19
+ let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
20
+ XCTAssertNotNil(view.window)
21
+ XCTAssertGreaterThan(list.contentSize.height - list.bounds.height +
22
+ list.adjustedContentInset.top + list.adjustedContentInset.bottom, 800)
23
+ let phases = NSMutableArray()
24
+ let observer = NotificationCenter.default.addObserver(
25
+ forName: Notification.Name("OneKeyNativeListScrollCommandAccepted"),
26
+ object: list, queue: nil
27
+ ) { notification in
28
+ if let phase = notification.userInfo?["phase"] as? Int { phases.add(phase) }
29
+ }
30
+ defer { NotificationCenter.default.removeObserver(observer) }
31
+ view.scrollToOffset(800, animated: true)
32
+ XCTAssertEqual(phases as? [Int], [0, 1])
33
+ switch invalid {
34
+ case 0: view.scrollToKey("missing-fixture-key", animated: true, alignment: "start")
35
+ case 1: view.scrollToIndex(-1, animated: true, alignment: "start")
36
+ default: view.scrollToOffset(.nan, animated: true)
37
+ }
38
+ XCTAssertEqual(phases as? [Int], [0, 1], "Invalid input must not accept or end another command")
39
+ // Exercise the actual delegate terminal callback, not a copied state helper.
40
+ view.scrollViewDidEndScrollingAnimation(list)
41
+ XCTAssertEqual(phases as? [Int], [0, 1, 2])
42
+ view.scrollViewDidEndScrollingAnimation(list)
43
+ XCTAssertEqual(phases as? [Int], [0, 1, 2], "Completion is emitted once")
44
+ }
45
+ }
46
+
47
+ func testInvalidKeyPreservesValidPendingScrollUntilGeometryArrives() throws {
48
+ let view = try makeScrollingList()
49
+ let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
50
+ view.frame.size.height = 0
51
+ view.setNeedsLayout()
52
+ view.layoutIfNeeded()
53
+ XCTAssertEqual(list.bounds.height, 0)
54
+ view.scrollToOffset(500, animated: false)
55
+ view.scrollToKey("missing-fixture-key", animated: true, alignment: "start")
56
+ view.frame.size.height = 708
57
+ view.setNeedsLayout()
58
+ view.layoutIfNeeded()
59
+ list.layoutIfNeeded()
60
+ drainMainQueue()
61
+ XCTAssertEqual(list.contentOffset.y, 500 - list.adjustedContentInset.top, accuracy: 0.5)
62
+ }
63
+
7
64
  func testSectionLoadingStopsOnRebindReuseAndDetach() throws {
8
65
  let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 480))
9
66
  let controller = UIViewController()
@@ -1252,6 +1252,20 @@ export class NativeListWebEngine {
1252
1252
  sectionIndexAlignStart = false;
1253
1253
  // Renderers report intrinsic height after browser text wrapping.
1254
1254
  measuredRendererHeights = new Map();
1255
+ notifyScrollCommandAccepted() {
1256
+ if (this.destroyed) return;
1257
+ const EventConstructor = this.viewport.ownerDocument.defaultView?.Event;
1258
+ if (EventConstructor) this.viewport.dispatchEvent(new EventConstructor('ok-native-list-scroll-command-accepted'));
1259
+ }
1260
+ handlePagerScrollCommandCancel = event => {
1261
+ if (this.destroyed || event.target !== this.viewport) return;
1262
+ this.pendingScroll = undefined;
1263
+ this.viewport.scrollTo({
1264
+ left: this.viewport.scrollLeft,
1265
+ top: this.viewport.scrollTop,
1266
+ behavior: 'instant'
1267
+ });
1268
+ };
1255
1269
  constructor(host, snapshot, callbacks, virtualizationEnabled = true, sectionIndexContainer) {
1256
1270
  this.document = host.ownerDocument;
1257
1271
  this.snapshot = validateSnapshot(snapshot);
@@ -1285,6 +1299,7 @@ export class NativeListWebEngine {
1285
1299
  this.viewportFrame.append(this.viewport, this.sticky, this.indexRail, this.refreshIndicator);
1286
1300
  this.root.append(style, this.viewportFrame, this.footer);
1287
1301
  host.appendChild(this.root);
1302
+ this.viewport.addEventListener('ok-collapsible-pager-cancel-scroll-command', this.handlePagerScrollCommandCancel);
1288
1303
  this.viewport.addEventListener('scroll', this.handleScroll, {
1289
1304
  passive: true
1290
1305
  });
@@ -1392,6 +1407,7 @@ export class NativeListWebEngine {
1392
1407
  this.emitScrollFailure(index, 'index-out-of-range');
1393
1408
  return;
1394
1409
  }
1410
+ this.notifyScrollCommandAccepted();
1395
1411
  if (!this.canScroll()) {
1396
1412
  this.pendingScroll = {
1397
1413
  kind: 'index',
@@ -1430,6 +1446,7 @@ export class NativeListWebEngine {
1430
1446
  }
1431
1447
  scrollToOffset(offset, animated) {
1432
1448
  validateOffset(offset);
1449
+ this.notifyScrollCommandAccepted();
1433
1450
  if (!this.canScroll()) {
1434
1451
  this.pendingScroll = {
1435
1452
  kind: 'offset',
@@ -1441,6 +1458,7 @@ export class NativeListWebEngine {
1441
1458
  this.scrollToAbsoluteOffset(offset, animated);
1442
1459
  }
1443
1460
  scrollToEnd(animated) {
1461
+ this.notifyScrollCommandAccepted();
1444
1462
  if (!this.canScroll()) {
1445
1463
  this.pendingScroll = {
1446
1464
  kind: 'end',
@@ -1499,6 +1517,8 @@ export class NativeListWebEngine {
1499
1517
  this.resizeObserver?.disconnect();
1500
1518
  this.sectionIndexTransformObserver?.disconnect();
1501
1519
  this.document.defaultView?.removeEventListener('resize', this.handleWindowResize);
1520
+ this.pendingScroll = undefined;
1521
+ this.viewport.removeEventListener('ok-collapsible-pager-cancel-scroll-command', this.handlePagerScrollCommandCancel);
1502
1522
  this.viewport.removeEventListener('scroll', this.handleScroll);
1503
1523
  this.viewport.removeEventListener('ok-collapsible-pager-insets-changed', this.handleCollapsiblePagerInsetsChanged);
1504
1524
  this.root.removeEventListener('click', this.handleClick);
@@ -142,6 +142,8 @@ export declare class NativeListWebEngine {
142
142
  private destroyed;
143
143
  private sectionIndexAlignStart;
144
144
  private measuredRendererHeights;
145
+ private notifyScrollCommandAccepted;
146
+ private readonly handlePagerScrollCommandCancel;
145
147
  constructor(host: HTMLElement, snapshot: NativeListSnapshot, callbacks: NativeListWebCallbacks, virtualizationEnabled?: boolean, sectionIndexContainer?: HTMLElement | null);
146
148
  updateCallbacks(callbacks: NativeListWebCallbacks): void;
147
149
  setVirtualizationEnabled(enabled: boolean): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/react-native-native-list",
3
- "version": "3.0.173",
3
+ "version": "3.0.175",
4
4
  "description": "Template-driven native RecyclerView and UICollectionView for React Native",
5
5
  "source": "./src/index.ts",
6
6
  "main": "./lib/module/index.js",
@@ -87,8 +87,8 @@
87
87
  "typescript": "^5.9.2"
88
88
  },
89
89
  "peerDependencies": {
90
- "@onekeyfe/react-native-image": "3.0.173",
91
- "@onekeyfe/react-native-native-logger": "3.0.173",
90
+ "@onekeyfe/react-native-image": "3.0.175",
91
+ "@onekeyfe/react-native-native-logger": "3.0.175",
92
92
  "react": "*",
93
93
  "react-native": "*",
94
94
  "react-native-nitro-modules": "0.37.0"
@@ -2025,6 +2025,25 @@ export class NativeListWebEngine {
2025
2025
  { signature: string; height: number }
2026
2026
  >();
2027
2027
 
2028
+ private notifyScrollCommandAccepted() {
2029
+ if (this.destroyed) return;
2030
+ const EventConstructor = this.viewport.ownerDocument.defaultView?.Event;
2031
+ if (EventConstructor)
2032
+ this.viewport.dispatchEvent(
2033
+ new EventConstructor('ok-native-list-scroll-command-accepted')
2034
+ );
2035
+ }
2036
+
2037
+ private readonly handlePagerScrollCommandCancel = (event: Event) => {
2038
+ if (this.destroyed || event.target !== this.viewport) return;
2039
+ this.pendingScroll = undefined;
2040
+ this.viewport.scrollTo({
2041
+ left: this.viewport.scrollLeft,
2042
+ top: this.viewport.scrollTop,
2043
+ behavior: 'instant',
2044
+ });
2045
+ };
2046
+
2028
2047
  constructor(
2029
2048
  host: HTMLElement,
2030
2049
  snapshot: NativeListSnapshot,
@@ -2100,6 +2119,10 @@ export class NativeListWebEngine {
2100
2119
  this.root.append(style, this.viewportFrame, this.footer);
2101
2120
  host.appendChild(this.root);
2102
2121
 
2122
+ this.viewport.addEventListener(
2123
+ 'ok-collapsible-pager-cancel-scroll-command',
2124
+ this.handlePagerScrollCommandCancel
2125
+ );
2103
2126
  this.viewport.addEventListener('scroll', this.handleScroll, {
2104
2127
  passive: true,
2105
2128
  });
@@ -2242,6 +2265,7 @@ export class NativeListWebEngine {
2242
2265
  this.emitScrollFailure(index, 'index-out-of-range');
2243
2266
  return;
2244
2267
  }
2268
+ this.notifyScrollCommandAccepted();
2245
2269
  if (!this.canScroll()) {
2246
2270
  this.pendingScroll = { kind: 'index', index, scroll };
2247
2271
  return;
@@ -2274,6 +2298,7 @@ export class NativeListWebEngine {
2274
2298
 
2275
2299
  scrollToOffset(offset: number, animated: boolean) {
2276
2300
  validateOffset(offset);
2301
+ this.notifyScrollCommandAccepted();
2277
2302
  if (!this.canScroll()) {
2278
2303
  this.pendingScroll = { kind: 'offset', offset, animated };
2279
2304
  return;
@@ -2282,6 +2307,7 @@ export class NativeListWebEngine {
2282
2307
  }
2283
2308
 
2284
2309
  scrollToEnd(animated: boolean) {
2310
+ this.notifyScrollCommandAccepted();
2285
2311
  if (!this.canScroll()) {
2286
2312
  this.pendingScroll = { kind: 'end', animated };
2287
2313
  return;
@@ -2360,6 +2386,11 @@ export class NativeListWebEngine {
2360
2386
  'resize',
2361
2387
  this.handleWindowResize
2362
2388
  );
2389
+ this.pendingScroll = undefined;
2390
+ this.viewport.removeEventListener(
2391
+ 'ok-collapsible-pager-cancel-scroll-command',
2392
+ this.handlePagerScrollCommandCancel
2393
+ );
2363
2394
  this.viewport.removeEventListener('scroll', this.handleScroll);
2364
2395
  this.viewport.removeEventListener(
2365
2396
  'ok-collapsible-pager-insets-changed',