@onekeyfe/react-native-native-list 3.0.172 → 3.0.174

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,50 @@ 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
+ Completion on Android requires the current request to have finished dispatch,
90
+ its own pending layout/alignment barriers to have completed, and native scrolling
91
+ to be idle without an active layout-manager animation. A true animated top
92
+ no-op completes without starting redundant native motion. Non-animated key and
93
+ end alignment complete after their requested layout/alignment, not beforehand.
94
+ An unrelated layout request MUST NOT indefinitely postpone command completion.
95
+ Replacement, actual drag, detach and disposal invalidate old callbacks; a stale
96
+ callback MUST NOT move the view or clear a newer command's barriers.
97
+
98
+ A valid command can privately notify the owning Pager before execution, including
99
+ a same-position no-op; this does not add a public NativeList API. A Pager-owned
100
+ page-top or sticky-top command can privately cancel only its owned list's old
101
+ pending command and native animation before acquiring its own target. On iOS
102
+ the notification identifies the collection view; on Android the listener belongs
103
+ to the actual owned RecyclerView. No notification may take over another owner.
104
+
105
+ On Web, the owned viewport's private cancellation event clears pending list
106
+ commands and stops old browser smooth motion. A newly accepted valid list
107
+ command invalidates a queued Pager target; invalid commands do not. Destroy
108
+ removes the exact listeners and clears pending work. Other list viewports and
109
+ rendering work remain independent.
110
+
111
+ Conformance: NativeListView.kt, RNCNativeListView.swift, NativeListWebEngine.ts,
112
+ and the related Pager owner coordination. Runtime acceptance must exercise
113
+ first populated layout, command replacement, animated/non-animated completion,
114
+ true top no-op, and short pending content followed by usable geometry on the
115
+ actual installed package; source or helper-only checks are not runtime evidence.
116
+
73
117
  ## Data, cache and identity
74
118
 
75
119
  `row.key` identifies data; renderer reuse identity describes compatible view
@@ -12,6 +12,26 @@ final class NativeListAccessoryButton: UIButton {
12
12
  }
13
13
  }
14
14
 
15
+ // A `.system` button cross-fades its title on every change. Rebinding a row
16
+ // or section header sets the title again, so a selector header value such as
17
+ // "$15.60" faded out and back in on each checkbox toggle (OK-64148). Data
18
+ // binding is not an animation: commit title changes immediately. UIKit
19
+ // applies a changed title in a later layout pass of the button, so
20
+ // layoutSubviews below runs without animation as well.
21
+ override func setTitle(_ title: String?, for state: UIControl.State) {
22
+ UIView.performWithoutAnimation {
23
+ super.setTitle(title, for: state)
24
+ layoutIfNeeded()
25
+ }
26
+ }
27
+
28
+ override func setAttributedTitle(_ title: NSAttributedString?, for state: UIControl.State) {
29
+ UIView.performWithoutAnimation {
30
+ super.setAttributedTitle(title, for: state)
31
+ layoutIfNeeded()
32
+ }
33
+ }
34
+
15
35
  var selectorSummaryLineHeight: CGFloat? {
16
36
  didSet {
17
37
  invalidateIntrinsicContentSize()
@@ -45,7 +65,9 @@ final class NativeListAccessoryButton: UIButton {
45
65
  }
46
66
 
47
67
  override func layoutSubviews() {
48
- super.layoutSubviews()
68
+ UIView.performWithoutAnimation {
69
+ super.layoutSubviews()
70
+ }
49
71
  defer {
50
72
  if let titleLabel {
51
73
  var frame = titleLabel.frame
@@ -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,31 @@ final class NativeListView: UIView {
638
657
  requestScroll(.end(animated))
639
658
  }
640
659
 
660
+ private func acceptScrollCommand(_ request: ScrollRequest) {
661
+ guard !commandAcceptedForPendingScroll, let config else { return }
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 }
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 }
669
+ explicitTop = index == 0 && alignment == "start" && viewPosition == 0 && viewOffset == 0
670
+ case let .offset(offset, _):
671
+ guard offset.isFinite else { return }
672
+ explicitTop = offset <= 0
673
+ case .end:
674
+ explicitTop = false
675
+ }
676
+ commandAcceptedForPendingScroll = true
677
+ NotificationCenter.default.post(name: Notification.Name("OneKeyNativeListScrollCommandAccepted"),
678
+ object: collectionView, userInfo: ["explicitTop": explicitTop, "phase": 0])
679
+ }
680
+
641
681
  private func requestScroll(_ request: ScrollRequest) {
682
+ animatedExplicitScrollCommand = false
683
+ commandAcceptedForPendingScroll = false
684
+ acceptScrollCommand(request)
642
685
  if performScroll(request) {
643
686
  pendingScrollRequest = nil
644
687
  } else {
@@ -647,7 +690,9 @@ final class NativeListView: UIView {
647
690
  }
648
691
 
649
692
  private func performPendingScrollIfNeeded() {
650
- guard let request = pendingScrollRequest, performScroll(request) else { return }
693
+ guard let request = pendingScrollRequest else { return }
694
+ acceptScrollCommand(request)
695
+ guard performScroll(request) else { return }
651
696
  pendingScrollRequest = nil
652
697
  }
653
698
 
@@ -660,6 +705,8 @@ final class NativeListView: UIView {
660
705
  : collectionView.bounds.height
661
706
  guard viewportLength > 0 else { return false }
662
707
  collectionView.layoutIfNeeded()
708
+ performingExplicitScrollCommand = commandAcceptedForPendingScroll
709
+ defer { performingExplicitScrollCommand = false }
663
710
 
664
711
  switch request {
665
712
  case let .key(key, animated, alignment, viewPosition, viewOffset):
@@ -764,7 +811,17 @@ final class NativeListView: UIView {
764
811
  let point = isHorizontal
765
812
  ? CGPoint(x: offset, y: collectionView.contentOffset.y)
766
813
  : CGPoint(x: collectionView.contentOffset.x, y: offset)
814
+ let moved = point != collectionView.contentOffset
815
+ if performingExplicitScrollCommand && moved {
816
+ NotificationCenter.default.post(name: Notification.Name("OneKeyNativeListScrollCommandAccepted"),
817
+ object: collectionView, userInfo: ["phase": 1])
818
+ }
819
+ if performingExplicitScrollCommand && moved { animatedExplicitScrollCommand = animated }
767
820
  collectionView.setContentOffset(point, animated: animated)
821
+ if performingExplicitScrollCommand && moved && !animated {
822
+ NotificationCenter.default.post(name: Notification.Name("OneKeyNativeListScrollCommandAccepted"),
823
+ object: collectionView, userInfo: ["phase": 2])
824
+ }
768
825
  }
769
826
 
770
827
  private func contentLayoutDidChange() {
@@ -1981,7 +2038,14 @@ final class NativeListView: UIView {
1981
2038
  config = nil
1982
2039
  itemsByKey.removeAll()
1983
2040
  sectionIndexEntries.removeAll()
2041
+ if let pagerCommandCancelObserver {
2042
+ NotificationCenter.default.removeObserver(pagerCommandCancelObserver)
2043
+ self.pagerCommandCancelObserver = nil
2044
+ }
1984
2045
  pendingScrollRequest = nil
2046
+ performingExplicitScrollCommand = false
2047
+ animatedExplicitScrollCommand = false
2048
+ commandAcceptedForPendingScroll = false
1985
2049
  needsContentShrinkCorrection = false
1986
2050
  lastLaidOutContentHeight = nil
1987
2051
  lastVisibleRange = nil
@@ -2281,7 +2345,15 @@ extension NativeListView: UICollectionViewDelegateFlowLayout {
2281
2345
  return item.isRowPressEnabled
2282
2346
  }
2283
2347
 
2348
+ func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
2349
+ guard animatedExplicitScrollCommand else { return }
2350
+ animatedExplicitScrollCommand = false
2351
+ NotificationCenter.default.post(name: Notification.Name("OneKeyNativeListScrollCommandAccepted"),
2352
+ object: collectionView, userInfo: ["phase": 2])
2353
+ }
2354
+
2284
2355
  func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
2356
+ scrollViewDidEndScrollingAnimation(scrollView)
2285
2357
  minimumDragOffset = 0
2286
2358
  refreshEmittedForDrag = false
2287
2359
  }
@@ -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.172",
3
+ "version": "3.0.174",
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.172",
91
- "@onekeyfe/react-native-native-logger": "3.0.172",
90
+ "@onekeyfe/react-native-image": "3.0.174",
91
+ "@onekeyfe/react-native-native-logger": "3.0.174",
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',