@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.
- package/android/src/main/java/com/onekey/nativelist/NativeListView.kt +310 -69
- package/android/src/main/res/values/native_scroll_coordinator_ids.xml +2 -0
- package/docs/SPEC.md +44 -0
- package/ios/NativeListAccessoryStack.swift +23 -1
- package/ios/RNCNativeListView.swift +73 -1
- package/lib/module/web/NativeListWebEngine.js +20 -0
- package/lib/typescript/src/web/NativeListWebEngine.d.ts +2 -0
- package/package.json +3 -3
- package/src/web/NativeListWebEngine.ts +31 -0
|
@@ -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 ->
|
|
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
|
-
|
|
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
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
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
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
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
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
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
|
-
|
|
1147
|
-
val
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
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
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1287
|
-
val
|
|
1288
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
91
|
-
"@onekeyfe/react-native-native-logger": "3.0.
|
|
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',
|