@onekeyfe/react-native-native-list 6.7.0-alpha.375 → 6.7.0-alpha.382

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/android/src/main/java/com/onekey/nativelist/HybridNativeList.kt +14 -1
  2. package/android/src/main/java/com/onekey/nativelist/NativeListAccessoryStack.kt +240 -11
  3. package/android/src/main/java/com/onekey/nativelist/NativeListActivityDetailsView.kt +7 -4
  4. package/android/src/main/java/com/onekey/nativelist/NativeListActivityRenderer.kt +6 -3
  5. package/android/src/main/java/com/onekey/nativelist/NativeListButtonGroupRenderer.kt +102 -0
  6. package/android/src/main/java/com/onekey/nativelist/NativeListContainerSlotAdapter.kt +12 -0
  7. package/android/src/main/java/com/onekey/nativelist/NativeListDataRowRenderer.kt +87 -7
  8. package/android/src/main/java/com/onekey/nativelist/NativeListIdentityRenderer.kt +177 -1
  9. package/android/src/main/java/com/onekey/nativelist/NativeListLeadingVisual.kt +43 -14
  10. package/android/src/main/java/com/onekey/nativelist/NativeListMessageRenderer.kt +25 -0
  11. package/android/src/main/java/com/onekey/nativelist/NativeListModels.kt +33 -2
  12. package/android/src/main/java/com/onekey/nativelist/NativeListPrimitives.kt +22 -0
  13. package/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt +2 -0
  14. package/android/src/main/java/com/onekey/nativelist/NativeListRendererRowView.kt +2 -0
  15. package/android/src/main/java/com/onekey/nativelist/NativeListResolvedText.kt +3 -2
  16. package/android/src/main/java/com/onekey/nativelist/NativeListSectionHeaderRenderer.kt +69 -0
  17. package/android/src/main/java/com/onekey/nativelist/NativeListTextView.kt +55 -1
  18. package/android/src/main/java/com/onekey/nativelist/NativeListView.kt +1912 -107
  19. package/android/src/test/java/com/margelo/nitro/nativelist/NativeListPressPolicyTest.kt +1 -0
  20. package/docs/ROW_TEMPLATES.md +60 -9
  21. package/docs/SPEC.md +450 -5
  22. package/docs/STYLE_SPEC.md +6 -0
  23. package/ios/HybridNativeList.swift +21 -2
  24. package/ios/NativeListAccessoryStack.swift +227 -39
  25. package/ios/NativeListActivityDetailsView.swift +18 -8
  26. package/ios/NativeListActivityRenderer.swift +8 -2
  27. package/ios/NativeListButtonGroupRenderer.swift +89 -0
  28. package/ios/NativeListDataRowRenderer.swift +155 -27
  29. package/ios/NativeListDesignAssets.swift +3 -0
  30. package/ios/NativeListIdentityRenderer.swift +166 -30
  31. package/ios/NativeListLeadingVisual.swift +49 -14
  32. package/ios/NativeListMessageRenderer.swift +34 -1
  33. package/ios/NativeListModels.swift +42 -2
  34. package/ios/NativeListRendererRegistry.swift +1 -0
  35. package/ios/NativeListResolvedText.swift +2 -1
  36. package/ios/NativeListRowHost.swift +14 -0
  37. package/ios/NativeListSectionHeaderRenderer.swift +88 -0
  38. package/ios/RNCNativeListView.swift +1013 -55
  39. package/ios/Resources/NativeListIcons.xcassets/onekey_crypto_coin_outline.imageset/Contents.json +15 -0
  40. package/ios/Resources/NativeListIcons.xcassets/onekey_crypto_coin_outline.imageset/icon.svg +4 -0
  41. package/ios/Resources/NativeListIcons.xcassets/onekey_gas_solid.imageset/Contents.json +15 -0
  42. package/ios/Resources/NativeListIcons.xcassets/onekey_gas_solid.imageset/icon.svg +3 -0
  43. package/ios/Resources/NativeListIcons.xcassets/onekey_refresh_ccw_outline.imageset/Contents.json +15 -0
  44. package/ios/Resources/NativeListIcons.xcassets/onekey_refresh_ccw_outline.imageset/icon.svg +3 -0
  45. package/ios/tests/NativeListActivityPresentationTests.swift +56 -0
  46. package/ios/tests/NativeListLifecycleTests.swift +2720 -3
  47. package/lib/module/NativeList.js +175 -28
  48. package/lib/module/NativeList.web.js +129 -30
  49. package/lib/module/index.js +1 -1
  50. package/lib/module/models.js +1 -0
  51. package/lib/module/selection.js +1 -1
  52. package/lib/module/validation.js +421 -8
  53. package/lib/module/web/NativeListWebEngine.js +507 -19
  54. package/lib/module/web/templates/ActivityRowRenderer.js +8 -7
  55. package/lib/module/web/templates/ButtonGroupRowRenderer.js +47 -0
  56. package/lib/module/web/templates/DataRowRenderer.js +40 -5
  57. package/lib/module/web/templates/IdentityRowRenderer.js +90 -4
  58. package/lib/module/web/templates/MessageRowRenderer.js +18 -2
  59. package/lib/module/web/templates/RowContainerStyle.js +1 -0
  60. package/lib/module/web/templates/RowRendererRegistry.js +3 -0
  61. package/lib/module/web/templates/SectionHeaderRowRenderer.js +36 -2
  62. package/lib/nitrogen/generated/android/c++/JHybridNativeListSpec.cpp +26 -0
  63. package/lib/nitrogen/generated/android/c++/JHybridNativeListSpec.hpp +4 -0
  64. package/lib/nitrogen/generated/android/c++/views/JHybridNativeListStateUpdater.cpp +10 -0
  65. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/HybridNativeListSpec.kt +20 -0
  66. package/lib/nitrogen/generated/ios/c++/HybridNativeListSpecSwift.hpp +14 -0
  67. package/lib/nitrogen/generated/ios/c++/views/HybridNativeListComponent.mm +12 -0
  68. package/lib/nitrogen/generated/ios/swift/HybridNativeListSpec.swift +2 -0
  69. package/lib/nitrogen/generated/ios/swift/HybridNativeListSpec_cxx.swift +43 -0
  70. package/lib/nitrogen/generated/shared/c++/HybridNativeListSpec.cpp +4 -0
  71. package/lib/nitrogen/generated/shared/c++/HybridNativeListSpec.hpp +4 -0
  72. package/lib/nitrogen/generated/shared/c++/views/HybridNativeListComponent.cpp +4 -0
  73. package/lib/nitrogen/generated/shared/c++/views/HybridNativeListComponent.hpp +6 -0
  74. package/lib/nitrogen/generated/shared/json/NativeListConfig.json +2 -0
  75. package/lib/typescript/src/NativeList.d.ts +1 -1
  76. package/lib/typescript/src/NativeList.nitro.d.ts +2 -0
  77. package/lib/typescript/src/NativeList.types.d.ts +18 -0
  78. package/lib/typescript/src/index.d.ts +2 -2
  79. package/lib/typescript/src/models.d.ts +105 -9
  80. package/lib/typescript/src/validation.d.ts +12 -0
  81. package/lib/typescript/src/web/NativeListWebEngine.d.ts +32 -0
  82. package/lib/typescript/src/web/templates/ButtonGroupRowRenderer.d.ts +14 -0
  83. package/lib/typescript/src/web/templates/IdentityRowRenderer.d.ts +1 -1
  84. package/lib/typescript/src/web/templates/MessageRowRenderer.d.ts +9 -3
  85. package/nitrogen/generated/android/c++/JHybridNativeListSpec.cpp +26 -0
  86. package/nitrogen/generated/android/c++/JHybridNativeListSpec.hpp +4 -0
  87. package/nitrogen/generated/android/c++/views/JHybridNativeListStateUpdater.cpp +10 -0
  88. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/HybridNativeListSpec.kt +20 -0
  89. package/nitrogen/generated/ios/c++/HybridNativeListSpecSwift.hpp +14 -0
  90. package/nitrogen/generated/ios/c++/views/HybridNativeListComponent.mm +12 -0
  91. package/nitrogen/generated/ios/swift/HybridNativeListSpec.swift +2 -0
  92. package/nitrogen/generated/ios/swift/HybridNativeListSpec_cxx.swift +43 -0
  93. package/nitrogen/generated/shared/c++/HybridNativeListSpec.cpp +4 -0
  94. package/nitrogen/generated/shared/c++/HybridNativeListSpec.hpp +4 -0
  95. package/nitrogen/generated/shared/c++/views/HybridNativeListComponent.cpp +4 -0
  96. package/nitrogen/generated/shared/c++/views/HybridNativeListComponent.hpp +6 -0
  97. package/nitrogen/generated/shared/json/NativeListConfig.json +2 -0
  98. package/package.json +3 -3
  99. package/react-native-native-list.podspec +1 -0
  100. package/src/NativeList.nitro.ts +2 -0
  101. package/src/NativeList.tsx +313 -44
  102. package/src/NativeList.types.ts +22 -0
  103. package/src/NativeList.web.tsx +204 -48
  104. package/src/index.ts +2 -0
  105. package/src/models.ts +117 -3
  106. package/src/selection.ts +1 -0
  107. package/src/validation.ts +852 -6
  108. package/src/web/NativeListWebEngine.ts +803 -20
  109. package/src/web/templates/ActivityRowRenderer.ts +14 -6
  110. package/src/web/templates/ButtonGroupRowRenderer.ts +58 -0
  111. package/src/web/templates/DataRowRenderer.ts +93 -20
  112. package/src/web/templates/IdentityRowRenderer.ts +150 -5
  113. package/src/web/templates/MessageRowRenderer.ts +23 -7
  114. package/src/web/templates/RowContainerStyle.ts +2 -0
  115. package/src/web/templates/RowRendererRegistry.ts +11 -1
  116. package/src/web/templates/SectionHeaderRowRenderer.ts +47 -4
@@ -1,6 +1,9 @@
1
1
  package com.margelo.nitro.nativelist
2
2
 
3
3
  import android.animation.TimeInterpolator
4
+ import android.animation.ValueAnimator
5
+ import android.animation.Animator
6
+ import android.animation.AnimatorListenerAdapter
4
7
  import android.content.Context
5
8
  import android.graphics.Canvas
6
9
  import android.graphics.Color
@@ -11,6 +14,7 @@ import android.graphics.RectF
11
14
  import android.graphics.drawable.ShapeDrawable
12
15
  import android.graphics.drawable.shapes.PathShape
13
16
  import android.os.Bundle
17
+ import android.os.Build
14
18
  import android.os.Handler
15
19
  import android.os.Looper
16
20
  import android.os.SystemClock
@@ -32,6 +36,7 @@ import android.widget.TextView
32
36
  import androidx.core.view.ViewCompat
33
37
  import androidx.core.view.WindowInsetsCompat
34
38
  import androidx.recyclerview.widget.ConcatAdapter
39
+ import androidx.recyclerview.widget.DefaultItemAnimator
35
40
  import androidx.recyclerview.widget.GridLayoutManager
36
41
  import androidx.recyclerview.widget.ItemTouchHelper
37
42
  import androidx.recyclerview.widget.LinearLayoutManager
@@ -39,6 +44,8 @@ import androidx.recyclerview.widget.LinearSmoothScroller
39
44
  import androidx.recyclerview.widget.RecyclerView
40
45
  import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
41
46
  import com.facebook.react.uimanager.ThemedReactContext
47
+ import com.facebook.react.uimanager.PointerEvents
48
+ import com.facebook.react.uimanager.ReactPointerEventsView
42
49
  import com.margelo.nitro.nativelogger.OneKeyLog
43
50
  import org.json.JSONArray
44
51
  import org.json.JSONObject
@@ -62,13 +69,10 @@ private class NativeListGridLayoutManager(
62
69
  var onChildLaidOut: ((View, Int) -> Unit)? = null
63
70
  var onLayoutPassCompleted: (() -> Unit)? = null
64
71
  var onBeforeDataLayout: (() -> Unit)? = null
65
- var onDiagnosticLayout: ((String, RecyclerView.State) -> Unit)? = null
66
72
 
67
73
  override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) {
68
- onDiagnosticLayout?.invoke("layout-children-before", state)
69
74
  if (!state.isPreLayout) onBeforeDataLayout?.invoke()
70
75
  super.onLayoutChildren(recycler, state)
71
- onDiagnosticLayout?.invoke("layout-children-after", state)
72
76
  }
73
77
 
74
78
  // Scroll fills and layout passes both place children here, including rows that a
@@ -80,7 +84,6 @@ private class NativeListGridLayoutManager(
80
84
 
81
85
  override fun onLayoutCompleted(state: RecyclerView.State) {
82
86
  super.onLayoutCompleted(state)
83
- onDiagnosticLayout?.invoke("layout-completed", state)
84
87
  onLayoutPassCompleted?.invoke()
85
88
  }
86
89
 
@@ -105,6 +108,7 @@ class NativeListView(
105
108
 
106
109
  fun setFontFamiliesJson(json: String) {
107
110
  if (!fonts.configure(json)) return
111
+ if (structuralTail != null) finishStructuralTail(interrupted = true)
108
112
  invalidateActionAnchor("layout")
109
113
  measuredRowExtentsPx.clear()
110
114
  measuredRowExtentSumPx = 0L
@@ -200,67 +204,243 @@ class NativeListView(
200
204
  var onReorder: ((String) -> Unit)? = null
201
205
  var onEndReached: ((String) -> Unit)? = null
202
206
  var onVisibleRangeChanged: ((String) -> Unit)? = null
207
+ var onStructuralTransitionSettled: ((String) -> Unit)? = null
203
208
  var keyboardDismissMode = NativeListKeyboardDismissMode.NONE
204
209
  var keyboardShouldPersistTaps = NativeListKeyboardShouldPersistTaps.NEVER
205
210
  private val density = resources.displayMetrics.density
206
- private val recyclerView = RecyclerView(context)
211
+ private val recyclerView = object : RecyclerView(context) {
212
+ override fun onMeasure(widthSpec: Int, heightSpec: Int) {
213
+ if (isLayoutSuppressed) {
214
+ setMeasuredDimension(measuredWidth, measuredHeight)
215
+ return
216
+ }
217
+ super.onMeasure(widthSpec, heightSpec)
218
+ }
219
+
220
+ override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
221
+ if (isLayoutSuppressed) return
222
+ super.onLayout(changed, left, top, right, bottom)
223
+ }
224
+ }
207
225
  private var configuredTopPaddingPx = 0
208
226
  private var slotInsetPx = 0
227
+ private var slotRightInsetPx = 0
209
228
  private var configuredBottomPaddingPx = 0
229
+ private data class SidecarLayout(val width: Int, val gap: Int, val maxContentWidth: Int)
230
+ private var sidecarLayout: SidecarLayout? = null
231
+ private var sidecarSpacerHeightPx = 0
232
+ private var sidecarSpacerScheduled = false
233
+ private var consumedStructuralTransitionId: String? = null
234
+ private var structuralAnimator: ValueAnimator? = null
235
+ private var structuralSpacerHeightPx = 0
236
+ private var structuralTailSignedExtentPx = 0
237
+ private var structuralStartSpacerPx = 0
238
+ private var structuralFooterVisualStartTop: Int? = null
239
+ private var structuralFooterVisualEndTop: Int? = null
240
+ private var structuralFooterVisualProgress = 0f
241
+ private var structuralFooterCompensatedHeightPx: Int? = null
242
+ private var structuralFooterObservedNativeHeightPx: Int? = null
243
+ private var acceptedFooterNaturalHeightPx: Int? = null
244
+ private data class PendingPreparingFooterFrame(
245
+ val heights: IntArray,
246
+ val naturalHeight: Int,
247
+ val observedNativeHeight: Int?,
248
+ val ownerOffset: Int,
249
+ val epoch: Int,
250
+ val footer: View?,
251
+ val handoffOnly: Boolean,
252
+ )
253
+ private var pendingPreparingFooterFrame: PendingPreparingFooterFrame? = null
254
+ private var preparingFooterFrameScheduled = false
255
+ private var structuralScrollInterrupted = false
256
+ private var structuralFooterHoldTop: Int? = null
257
+ private var structuralTransitionEpoch = 0
258
+ private class StructuralTail(
259
+ var items: MutableList<NativeListItem>,
260
+ val heights: IntArray,
261
+ spacings: IntArray,
262
+ val prefixCount: Int,
263
+ val finalExtent: Int,
264
+ ) {
265
+ val starts = IntArray(items.size)
266
+ val indexByKey = items.withIndex().associate { it.value.key to it.index }
267
+ val hosts = mutableMapOf<Int, NativeListRowHost>()
268
+ var peakHosts = 0
269
+ val extent: Int
270
+ init {
271
+ var edge = 0
272
+ for (index in items.indices) {
273
+ starts[index] = edge
274
+ edge += heights[index] + spacings[index]
275
+ }
276
+ extent = edge
277
+ }
278
+ }
279
+ private val structuralTailLayer = object : FrameLayout(reactContext), ReactPointerEventsView {
280
+ override val pointerEvents: PointerEvents = PointerEvents.NONE
281
+ override fun dispatchTouchEvent(event: MotionEvent) = false
282
+ }.apply {
283
+ clipChildren = true
284
+ clipToPadding = true
285
+ isClickable = false
286
+ importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
287
+ visibility = GONE
288
+ }
289
+ private var structuralTail: StructuralTail? = null
290
+ private var structuralTailRowsInAdapterPx = 0
291
+ private var structuralTailStartExtentPx = 0
292
+ private var structuralTailStartProgress = 0f
293
+ private var structuralTailStartContentY = 0
294
+ private var structuralTailStartOffset = 0
295
+ private var structuralTailTargetOffset = 0
296
+ private var disclosureFraction = 0f
297
+ private var disclosureStartFraction = 0f
298
+ private var disclosurePresentedContentPx = 0
299
+ private var disclosurePresentedInsetPx = 0
300
+ private var disclosureContentStartPx = 0
301
+ private var disclosureContentStartProgress = 0f
302
+ private var disclosureTransitioning = false
303
+ private var activeDisclosureTransitionId: String? = null
304
+ private var settledDisclosureTransitionId: String? = null
305
+ private var directDisclosureStatus = "interrupted"
306
+ private var directDisclosureTogglePositionPending = false
307
+ private var batchedDisclosureGeometryInvalid = false
308
+ private var structuralTailCommandActive = false
309
+ private var structuralTailFinalizing = false
310
+ private var structuralTailPreparing = false
311
+ private data class PendingStructuralTransition(
312
+ val transition: NativeListConfig.StructuralTransition,
313
+ val epoch: Int,
314
+ val anchorKey: String?,
315
+ val anchorTop: Int?,
316
+ val footerStartTop: Int?,
317
+ val heldHeightPx: Int,
318
+ val collapsing: Boolean,
319
+ val spacerInserted: Boolean = false,
320
+ val footerAnchorAppliedTop: Int? = null,
321
+ val rowAnchorAppliedTop: Int? = null,
322
+ )
323
+ private var pendingStructuralTransition: PendingStructuralTransition? = null
324
+ private var pendingStructuralStartScheduled = false
210
325
  private val refreshLayout = SwipeRefreshLayout(context)
211
326
  private val refreshIndicatorTravelPx = refreshLayout.progressViewEndOffset
212
327
  private val defaultRefreshTriggerDistancePx = (64 * resources.displayMetrics.density).roundToInt()
213
328
  private var refreshIndicatorOffsetPx = 0
214
329
  private val contentContainer = FrameLayout(context)
330
+ private val sidecarHost = object : FrameLayout(context) {
331
+ private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
332
+ private var down: MotionEvent? = null
333
+ private var routing = false
334
+
335
+ private fun route(event: MotionEvent) {
336
+ val copy = MotionEvent.obtain(event)
337
+ copy.offsetLocation(x - refreshLayout.x, y - refreshLayout.y)
338
+ refreshLayout.dispatchTouchEvent(copy)
339
+ copy.recycle()
340
+ }
341
+
342
+ override fun dispatchTouchEvent(event: MotionEvent): Boolean {
343
+ when (event.actionMasked) {
344
+ MotionEvent.ACTION_DOWN -> {
345
+ down?.recycle()
346
+ down = MotionEvent.obtain(event)
347
+ routing = false
348
+ }
349
+ MotionEvent.ACTION_MOVE -> {
350
+ if (routing) {
351
+ route(event)
352
+ return true
353
+ }
354
+ val start = down
355
+ if (start != null && abs(event.y - start.y) > touchSlop &&
356
+ abs(event.y - start.y) > abs(event.x - start.x)) {
357
+ val cancel = MotionEvent.obtain(event)
358
+ cancel.action = MotionEvent.ACTION_CANCEL
359
+ super.dispatchTouchEvent(cancel)
360
+ cancel.recycle()
361
+ route(start)
362
+ route(event)
363
+ routing = true
364
+ return true
365
+ }
366
+ }
367
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
368
+ if (routing) route(event)
369
+ down?.recycle()
370
+ down = null
371
+ if (routing) {
372
+ routing = false
373
+ return true
374
+ }
375
+ }
376
+ else -> if (routing) {
377
+ route(event)
378
+ return true
379
+ }
380
+ }
381
+ return super.dispatchTouchEvent(event)
382
+ }
383
+ }
215
384
  private val adapter = NativeListAdapter(reactContext, fonts)
216
385
  private val containerSlots = mutableListOf<View>()
217
- private val slotHeights = IntArray(3)
386
+ private val slotHeights = IntArray(6)
387
+ private val expandedContentHost = object : FrameLayout(context), ReactPointerEventsView {
388
+ var naturalHeightPx = 0
389
+ override val pointerEvents: PointerEvents
390
+ get() = if (config?.disclosure?.expanded == true &&
391
+ !disclosureTransitioning && disclosureFraction >= 1f) PointerEvents.AUTO
392
+ else PointerEvents.NONE
393
+
394
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
395
+ setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec),
396
+ MeasureSpec.getSize(heightMeasureSpec))
397
+ }
398
+
399
+ override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
400
+ getChildAt(0)?.layout(0, 0, right - left, naturalHeightPx)
401
+ }
402
+ }.apply {
403
+ clipChildren = true
404
+ clipToPadding = true
405
+ isClickable = false
406
+ importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
407
+ }
408
+ private val footerPresentationHost = object : FrameLayout(context) {
409
+ var naturalHeightPx = 0
410
+ var hiddenTopPx = 0
411
+
412
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
413
+ setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec),
414
+ MeasureSpec.getSize(heightMeasureSpec))
415
+ }
416
+
417
+ override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
418
+ val child = getChildAt(0) ?: return
419
+ child.layout(0, -hiddenTopPx, right - left, naturalHeightPx - hiddenTopPx)
420
+ }
421
+ }.apply {
422
+ clipChildren = true
423
+ clipToPadding = true
424
+ }
425
+ private var initialHeaderInsertionHandled = false
218
426
  private var containerSlotRelayoutPending = false
219
427
  private var containerSlotRelayoutScheduled = false
220
428
  private val headerSlotAdapter = NativeListContainerSlotAdapter()
221
429
  private val emptySlotAdapter = NativeListContainerSlotAdapter()
430
+ private val structuralSpacerView = View(context)
431
+ private val structuralSpacerAdapter = NativeListContainerSlotAdapter()
432
+ private val expandedContentAdapter = NativeListContainerSlotAdapter()
433
+ private val structureToggleAdapter = NativeListContainerSlotAdapter()
434
+ private val disclosureInsetView = View(context)
435
+ private val disclosureInsetAdapter = NativeListContainerSlotAdapter()
222
436
  private val footerSlotAdapter = NativeListContainerSlotAdapter()
223
- private val scrollContentAdapter = ConcatAdapter(headerSlotAdapter, adapter, emptySlotAdapter, footerSlotAdapter)
224
-
225
- private val homeOffsetDiagnosticEnabled =
226
- context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE != 0
227
- private var homeOffsetDiagnosticSequence = 0L
228
-
229
- private fun homeOffsetDiagnostic(phase: String, state: RecyclerView.State? = null) {
230
- if (!homeOffsetDiagnosticEnabled) return
231
- try {
232
- val first = layoutManager.findFirstVisibleItemPosition()
233
- val firstView = layoutManager.findViewByPosition(first)
234
- val holder = firstView?.let { recyclerView.getChildViewHolder(it) }
235
- val lastView = layoutManager.findViewByPosition(layoutManager.findLastVisibleItemPosition())
236
- val lastHolder = lastView?.let { recyclerView.getChildViewHolder(it) }
237
- fun adapterOrdinal(value: RecyclerView.Adapter<*>?): Int = when (value) {
238
- headerSlotAdapter -> 0
239
- adapter -> 1
240
- emptySlotAdapter -> 2
241
- footerSlotAdapter -> 3
242
- else -> -1
243
- }
244
- val focused = recyclerView.findFocus()
245
- val focusChild = focused?.let { recyclerView.findContainingItemView(it) }
246
- val focusHolder = focusChild?.let { recyclerView.getChildViewHolder(it) }
247
- OneKeyLog.debug("NativeList", "[HOME-OFFSET-DIAG] listPhase=$phase " +
248
- "seq=${++homeOffsetDiagnosticSequence} wallMs=${System.currentTimeMillis()} monoMs=${SystemClock.uptimeMillis()} " +
249
- "list=${System.identityHashCode(this)} view=${System.identityHashCode(recyclerView)} tag=$id " +
250
- "generation=${config?.generation} configuredCount=${config?.items?.size} " +
251
- "counts=${headerSlotAdapter.itemCount},${adapter.itemCount},${emptySlotAdapter.itemCount},${footerSlotAdapter.itemCount} " +
252
- "total=${scrollContentAdapter.itemCount} pendingCommand=${pendingScrollRequest?.javaClass?.simpleName ?: "none"} " +
253
- "stateCount=${state?.itemCount} preLayout=${state?.isPreLayout} structureChanged=${state?.didStructureChange()} predictive=${state?.willRunPredictiveAnimations()} " +
254
- "first=$first last=${layoutManager.findLastVisibleItemPosition()} " +
255
- "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} " +
256
- "anchorTop=${firstView?.let(layoutManager::getDecoratedTop)} paddingTop=${recyclerView.paddingTop} " +
257
- "childHeight=${firstView?.height} height=${recyclerView.height} range=${recyclerView.computeVerticalScrollRange()} extent=${recyclerView.computeVerticalScrollExtent()} " +
258
- "canUp=${recyclerView.canScrollVertically(-1)} canDown=${recyclerView.canScrollVertically(1)} " +
259
- "focusClass=${focused?.javaClass?.simpleName ?: "none"} focusId=${focused?.id} focusAdapterOrdinal=${adapterOrdinal(focusHolder?.bindingAdapter)} focusLayout=${focusHolder?.layoutPosition} focusPosition=${focusHolder?.absoluteAdapterPosition} focusBinding=${focusHolder?.bindingAdapterPosition}")
260
- } catch (_: Exception) {
261
- // Diagnostic reads must not affect layout or command delivery.
262
- }
263
- }
437
+ private val sidecarSpacerView = View(context)
438
+ private val sidecarSpacerAdapter = NativeListContainerSlotAdapter()
439
+ private val scrollContentAdapter = ConcatAdapter(
440
+ headerSlotAdapter, adapter, emptySlotAdapter, structuralSpacerAdapter,
441
+ expandedContentAdapter, structureToggleAdapter, disclosureInsetAdapter,
442
+ footerSlotAdapter, sidecarSpacerAdapter,
443
+ )
264
444
 
265
445
  fun mountContainerSlot(view: View, index: Int) {
266
446
  containerSlots.add(index.coerceIn(0, containerSlots.size), view)
@@ -277,23 +457,283 @@ class NativeListView(
277
457
  scheduleContainerSlotRelayout()
278
458
  }
279
459
 
280
- fun setContainerSlotHeightsJson(json: String) {
460
+ fun setContainerSlotHeightsJson(json: String, upcomingSnapshotJson: String? = null) {
281
461
  val values = runCatching { JSONArray(json) }.getOrNull() ?: return
282
- if (values.length() != 3) return
283
- for (index in 0..2) {
462
+ if (values.length() != 4 && values.length() != 6) return
463
+ val nextHeights = IntArray(6)
464
+ for (index in 0 until values.length()) {
284
465
  val value = values.optDouble(index)
285
466
  if (!value.isFinite() || value < 0) return
467
+ nextHeights[index] = (value * density).roundToInt()
468
+ }
469
+ val disclosureTail = structuralTail != null && config?.disclosure != null &&
470
+ !structuralTailFinalizing
471
+ if (disclosureTail && upcomingSnapshotJson != null &&
472
+ (nextHeights[2] != slotHeights[2] || nextHeights[5] != slotHeights[5] ||
473
+ nextHeights[4] < disclosurePresentedContentPx)) {
474
+ // Nitro delivers both setters in this UI turn. Let the complete new snapshot
475
+ // replace the old command before deciding whether its geometry is supported.
476
+ batchedDisclosureGeometryInvalid = true
477
+ for (index in 0..5) slotHeights[index] = nextHeights[index]
478
+ acceptedFooterNaturalHeightPx = nextHeights[2]
479
+ return
286
480
  }
481
+ val unsupportedDisclosureGeometry = disclosureTail &&
482
+ (nextHeights[2] != slotHeights[2] || nextHeights[5] != slotHeights[5] ||
483
+ nextHeights[4] < disclosurePresentedContentPx)
484
+ if (unsupportedDisclosureGeometry) {
485
+ // A changing fixed footer/toggle or a shrinking mounted C cannot be represented
486
+ // by the current visual segment. Commit the accepted endpoint before ACK.
487
+ structuralFooterCompensatedHeightPx = null
488
+ acceptedFooterNaturalHeightPx = nextHeights[2]
489
+ for (index in 0..5) slotHeights[index] = nextHeights[index]
490
+ updateContainerSlots()
491
+ finishStructuralTail(interrupted = true)
492
+ return
493
+ }
494
+ if (disclosureTail && nextHeights[4] != slotHeights[4]) {
495
+ disclosureContentStartPx = disclosurePresentedContentPx
496
+ disclosureContentStartProgress =
497
+ (structuralAnimator?.animatedValue as? Float) ?: 0f
498
+ }
499
+ if (structuralTail != null && structuralTailRowsInAdapterPx > 0 &&
500
+ listOf(0, 1, 3).all { slotHeights[it] == nextHeights[it] } &&
501
+ (pendingPreparingFooterFrame != null ||
502
+ (!structuralTailFinalizing && slotHeights[2] != nextHeights[2]))) {
503
+ enqueuePreparingFooterFrame(nextHeights, nextHeights[2])
504
+ return
505
+ }
506
+ pendingPreparingFooterFrame?.takeIf {
507
+ structuralTailRowsInAdapterPx > 0 && it.footer === containerSlots.getOrNull(2)
508
+ }?.let { pending ->
509
+ val preservesNewerHeight = nextHeights[2] == slotHeights[2] &&
510
+ pending.naturalHeight != nextHeights[2]
511
+ pendingPreparingFooterFrame = pending.copy(
512
+ heights = nextHeights.copyOf(),
513
+ naturalHeight = if (preservesNewerHeight) pending.naturalHeight else nextHeights[2],
514
+ observedNativeHeight = pending.observedNativeHeight.takeIf {
515
+ preservesNewerHeight || pending.naturalHeight == nextHeights[2]
516
+ },
517
+ )
518
+ }
519
+ if (structuralTailPreparing && (0..3).any { index ->
520
+ slotHeights[index] != nextHeights[index]
521
+ }) invalidateTailForContainerGeometry()
522
+ if (structuralTail != null && listOf(0, 1, 3).any { index ->
523
+ slotHeights[index] != nextHeights[index]
524
+ }) finishStructuralTail(interrupted = true)
525
+ val footerHeightPx = nextHeights[2]
526
+ if (footerHeightPx != slotHeights[2]) {
527
+ containerSlots.getOrNull(2)?.measuredHeight?.takeIf { it > 0 && it != footerHeightPx }
528
+ ?.let { structuralFooterObservedNativeHeightPx = it }
529
+ reconcileStructuralFooterHeight(footerHeightPx)
530
+ acceptedFooterNaturalHeightPx = footerHeightPx
531
+ } else if (acceptedFooterNaturalHeightPx == null) {
532
+ acceptedFooterNaturalHeightPx = footerHeightPx
533
+ }
534
+ val hiddenDisclosureContentOnlyChange = config?.disclosure?.expanded == false &&
535
+ !disclosureTransitioning && structuralTail == null &&
536
+ !structuralTailPreparing && !structuralTailFinalizing &&
537
+ disclosurePresentedContentPx == 0 && disclosurePresentedInsetPx == 0 &&
538
+ expandedContentAdapter.itemCount == 0 &&
539
+ slotHeights[4] != nextHeights[4] &&
540
+ (0..5).all { index -> index == 4 || slotHeights[index] == nextHeights[index] }
287
541
  var changed = false
288
- for (index in 0..2) {
289
- val height = (values.getDouble(index) * density).roundToInt()
542
+ for (index in 0..5) {
543
+ val height = nextHeights[index]
290
544
  if (slotHeights[index] != height) changed = true
291
545
  slotHeights[index] = height
292
546
  }
547
+ if (hiddenDisclosureContentOnlyChange) {
548
+ expandedContentHost.naturalHeightPx = slotHeights[4]
549
+ layoutExpandedContentHostNow()
550
+ return
551
+ }
293
552
  updateContainerSlots()
553
+ if (structuralTail != null) updateStructuralTailTarget()
554
+ updateSidecarGeometry()
555
+ scheduleSidecarSpacer()
294
556
  if (changed) scheduleContainerSlotRelayout()
295
557
  }
296
558
 
559
+ private fun enqueuePreparingFooterFrame(
560
+ heights: IntArray,
561
+ naturalHeight: Int,
562
+ observedNativeHeight: Int? = null,
563
+ ) {
564
+ val footer = containerSlots.getOrNull(2)
565
+ val existing = pendingPreparingFooterFrame?.takeIf { it.footer === footer }
566
+ val preservesNewerHeight = existing != null && observedNativeHeight == null &&
567
+ naturalHeight == slotHeights[2] && existing.naturalHeight != naturalHeight
568
+ val acceptedHeight = if (preservesNewerHeight) existing.naturalHeight else naturalHeight
569
+ val observedHeight = observedNativeHeight ?:
570
+ existing?.observedNativeHeight?.takeIf { preservesNewerHeight || it == acceptedHeight }
571
+ pendingPreparingFooterFrame = PendingPreparingFooterFrame(
572
+ heights.copyOf(), acceptedHeight, observedHeight,
573
+ existing?.ownerOffset ?: structuralTailStartOffset,
574
+ structuralTransitionEpoch, footer, existing?.handoffOnly == true,
575
+ )
576
+ if (!commitPreparingFooterFrame() && pendingPreparingFooterFrame?.handoffOnly != true) {
577
+ schedulePreparingFooterFrame()
578
+ }
579
+ }
580
+
581
+ private fun schedulePreparingFooterFrame() {
582
+ if (preparingFooterFrameScheduled) return
583
+ preparingFooterFrameScheduled = true
584
+ recyclerView.post {
585
+ preparingFooterFrameScheduled = false
586
+ if (!commitPreparingFooterFrame()) recyclerView.postInvalidateOnAnimation()
587
+ }
588
+ recyclerView.postInvalidateOnAnimation()
589
+ }
590
+
591
+ private fun reconcileStructuralFooterHeight(nextHeightPx: Int) {
592
+ if (structuralTail != null) {
593
+ if (!structuralTailFinalizing) {
594
+ val delta = (structuralFooterCompensatedHeightPx ?:
595
+ acceptedFooterNaturalHeightPx ?: slotHeights[2]) - nextHeightPx
596
+ if (delta != 0) {
597
+ if (structuralTailPreparing) {
598
+ invalidateTailForContainerGeometry()
599
+ return
600
+ }
601
+ structuralFooterCompensatedHeightPx = nextHeightPx
602
+ val compensated = structuralTailSignedExtentPx + delta
603
+ structuralTailStartProgress = (structuralAnimator?.animatedValue as? Float) ?: 0f
604
+ structuralTailStartExtentPx = compensated
605
+ if (structuralTailRowsInAdapterPx == 0) structuralTailStartOffset = tailScrollExtentPx()
606
+ setStructuralTailExtent(compensated)
607
+ if (structuralTailRowsInAdapterPx == 0) updateStructuralTailWindow()
608
+ }
609
+ }
610
+ return
611
+ }
612
+ val previousHeightPx = structuralFooterCompensatedHeightPx ?:
613
+ acceptedFooterNaturalHeightPx ?: slotHeights[2]
614
+ val footerDelta = previousHeightPx - nextHeightPx
615
+ if (footerDelta == 0) return
616
+ if (pendingStructuralTransition != null || structuralAnimator != null || structuralFooterHoldTop != null) {
617
+ structuralFooterCompensatedHeightPx = nextHeightPx
618
+ }
619
+ if (pendingStructuralTransition != null) {
620
+ pendingStructuralTransition = pendingStructuralTransition?.let {
621
+ val updated = it.copy(
622
+ footerStartTop = it.footerStartTop?.plus(footerDelta),
623
+ heldHeightPx = (it.heldHeightPx + footerDelta).coerceAtLeast(0),
624
+ )
625
+ if (updated.spacerInserted) setStructuralSpacerHeight(updated.heldHeightPx)
626
+ updated
627
+ }
628
+ structuralFooterHoldTop = structuralFooterHoldTop?.plus(footerDelta)
629
+ updateStructuralFooterHoldPosition()
630
+ } else if (structuralFooterHoldTop != null && structuralAnimator == null) {
631
+ setStructuralSpacerHeight(structuralSpacerHeightPx + footerDelta)
632
+ structuralFooterHoldTop = structuralFooterHoldTop?.plus(footerDelta)
633
+ updateStructuralFooterHoldPosition()
634
+ } else if (structuralAnimator != null) {
635
+ structuralStartSpacerPx = (structuralStartSpacerPx + footerDelta).coerceAtLeast(0)
636
+ structuralFooterVisualStartTop = structuralFooterVisualStartTop?.plus(footerDelta)
637
+ val fraction = (structuralAnimator?.animatedValue as? Float) ?: 0f
638
+ setStructuralSpacerHeight((structuralStartSpacerPx * (1f - fraction)).roundToInt())
639
+ updateStructuralFooterVisualPosition()
640
+ }
641
+ }
642
+
643
+ fun setSidecarLayoutJson(json: String) {
644
+ val next = if (json.isEmpty()) null else {
645
+ val value = runCatching { JSONObject(json) }.getOrNull() ?: return
646
+ val width = value.optDouble("width")
647
+ val gap = value.optDouble("gap")
648
+ val maxWidth = value.optDouble("maxContentWidth")
649
+ if (!width.isFinite() || width <= 0 || !gap.isFinite() || gap < 0 ||
650
+ !maxWidth.isFinite() || maxWidth <= width + gap) return
651
+ SidecarLayout((width * density).roundToInt(), (gap * density).roundToInt(),
652
+ (maxWidth * density).roundToInt())
653
+ }
654
+ if (next == sidecarLayout) return
655
+ if (structuralTail != null) finishStructuralTail(interrupted = true)
656
+ sidecarLayout = next
657
+ config?.let(::updateLayout)
658
+ updateSidecarGeometry()
659
+ scheduleSidecarSpacer()
660
+ }
661
+
662
+ private fun sidecarBaseInsetPx(): Int {
663
+ val layout = sidecarLayout ?: return 0
664
+ val width = recyclerView.width
665
+ return max(0, (width - minOf(width, layout.maxContentWidth)) / 2)
666
+ }
667
+
668
+ private fun updateSidecarGeometry() {
669
+ val layout = sidecarLayout
670
+ val root = containerSlots.getOrNull(3)
671
+ if (root !== sidecarHost.getChildAt(0)) {
672
+ sidecarHost.removeAllViews()
673
+ (root?.parent as? ViewGroup)?.removeView(root)
674
+ if (root != null) sidecarHost.addView(root)
675
+ }
676
+ val params = sidecarHost.layoutParams as? FrameLayout.LayoutParams
677
+ ?: FrameLayout.LayoutParams(0, 0, Gravity.TOP or Gravity.RIGHT)
678
+ val nextWidth = layout?.width ?: 0
679
+ val nextHeight = if (layout != null) slotHeights[3] else 0
680
+ val isRtl = layoutDirection == LAYOUT_DIRECTION_RTL
681
+ val nextGravity = Gravity.TOP or (if (isRtl) Gravity.LEFT else Gravity.RIGHT)
682
+ val nextSideMargin = sidecarBaseInsetPx()
683
+ if (params.width != nextWidth || params.height != nextHeight ||
684
+ params.gravity != nextGravity ||
685
+ params.leftMargin != (if (isRtl) nextSideMargin else 0) ||
686
+ params.rightMargin != (if (isRtl) 0 else nextSideMargin)) {
687
+ params.width = nextWidth
688
+ params.height = nextHeight
689
+ params.gravity = nextGravity
690
+ params.leftMargin = if (isRtl) nextSideMargin else 0
691
+ params.rightMargin = if (isRtl) 0 else nextSideMargin
692
+ sidecarHost.layoutParams = params
693
+ }
694
+ sidecarHost.visibility = if (layout != null && root != null && slotHeights[3] > 0) VISIBLE else GONE
695
+ updateSidecarPosition()
696
+ }
697
+
698
+ private fun updateSidecarPosition(resync: Boolean = false) {
699
+ if (sidecarLayout == null) return
700
+ if (resync) (if (structuralTail == null) measuredScrollDistancePx()
701
+ else structuralTailMeasuredScrollDistancePx())?.let { logicalScrollDistancePx = it }
702
+ sidecarHost.translationY = (recyclerView.paddingTop - logicalScrollDistancePx).toFloat()
703
+ }
704
+
705
+ private fun sidecarLeftExtentPx(): Int =
706
+ (nativeListScrollPrefixPx(
707
+ scrollContentAdapter.itemCount - sidecarSpacerAdapter.itemCount,
708
+ layoutManager.spanCount,
709
+ layoutManager.spanSizeLookup::getSpanSize,
710
+ if (structuralTail == null) ::estimatedScrollExtentPx else ::structuralTailRowOrSlotExtentPx,
711
+ ) + recyclerView.paddingTop + recyclerView.paddingBottom)
712
+ .coerceIn(0L, Int.MAX_VALUE.toLong()).toInt()
713
+
714
+ private fun scheduleSidecarSpacer() {
715
+ if (sidecarLayout == null && sidecarSpacerHeightPx == 0) return
716
+ if (disposed || sidecarSpacerScheduled) return
717
+ sidecarSpacerScheduled = true
718
+ recyclerView.post {
719
+ sidecarSpacerScheduled = false
720
+ updateSidecarSpacerNow()
721
+ }
722
+ }
723
+
724
+ private fun updateSidecarSpacerNow() {
725
+ if (disposed || recyclerView.width <= 0 || recyclerView.height <= 0) return
726
+ val desired = if (sidecarLayout != null) {
727
+ recyclerView.paddingTop + slotHeights[3] + recyclerView.paddingBottom
728
+ } else 0
729
+ val leftExtent = sidecarLeftExtentPx()
730
+ val next = (desired - leftExtent).coerceAtLeast(0)
731
+ if (abs(next - sidecarSpacerHeightPx) <= 1) return
732
+ sidecarSpacerHeightPx = next
733
+ sidecarSpacerAdapter.update(sidecarSpacerView, next, next > 0)
734
+ recyclerView.requestLayout()
735
+ }
736
+
297
737
  private fun scheduleContainerSlotRelayout() {
298
738
  if (disposed) return
299
739
  containerSlotRelayoutPending = true
@@ -313,15 +753,185 @@ class NativeListView(
313
753
  }
314
754
 
315
755
  private fun updateContainerSlots() {
316
- homeOffsetDiagnostic("slots-before")
756
+ val headerWasAbsent = headerSlotAdapter.itemCount == 0
757
+ val firstVisible = layoutManager.findFirstVisibleItemPosition()
758
+ val firstView = layoutManager.findViewByPosition(firstVisible)
759
+ val restoreInitialHeaderTop = !initialHeaderInsertionHandled && headerWasAbsent &&
760
+ layoutManager.orientation == RecyclerView.VERTICAL &&
761
+ adapter.itemCount > 0 && readingIntentEpoch == 0L && pendingScrollRequest == null &&
762
+ acceptedScrollRequest == null && !executingScrollCommand &&
763
+ recyclerView.scrollState == RecyclerView.SCROLL_STATE_IDLE && firstVisible == 0 &&
764
+ firstView != null && abs(layoutManager.getDecoratedTop(firstView) - recyclerView.paddingTop) <= 1 &&
765
+ !recyclerView.canScrollVertically(-1)
317
766
  headerSlotAdapter.update(containerSlots.getOrNull(0), slotHeights[0], true)
318
767
  emptySlotAdapter.update(containerSlots.getOrNull(1), slotHeights[1], config?.items?.isEmpty() != false)
319
- footerSlotAdapter.update(containerSlots.getOrNull(2), slotHeights[2], true)
320
- homeOffsetDiagnostic("slots-after")
768
+ syncDisclosurePresentation()
769
+ syncFooterPresentation()
770
+ updateSidecarGeometry()
771
+ if (headerWasAbsent && headerSlotAdapter.itemCount > 0) {
772
+ initialHeaderInsertionHandled = true
773
+ if (restoreInitialHeaderTop) {
774
+ // RecyclerView otherwise keeps the first body row at the top when this slot arrives late.
775
+ layoutManager.scrollToPositionWithOffset(0, 0)
776
+ }
777
+ }
321
778
  layoutManager.spanSizeLookup.invalidateSpanIndexCache()
322
779
  recyclerView.requestLayout()
323
780
  }
324
781
 
782
+ private fun disclosureInsetExtentPx(): Int =
783
+ ((config?.disclosure?.expandedTrailingInset ?: 0.0) * density)
784
+ .coerceIn(0.0, Int.MAX_VALUE.toDouble()).roundToInt()
785
+
786
+ private fun settleDisclosureCommand(status: String) {
787
+ val id = activeDisclosureTransitionId ?: return
788
+ if (disposed || id == settledDisclosureTransitionId ||
789
+ consumedStructuralTransitionId != id || structuralTail != null) return
790
+ settledDisclosureTransitionId = id
791
+ activeDisclosureTransitionId = null
792
+ directDisclosureTogglePositionPending = false
793
+ onStructuralTransitionSettled?.invoke(JSONObject().apply {
794
+ put("transitionId", id)
795
+ put("status", status)
796
+ put("settledExpanded", config?.disclosure?.expanded == true)
797
+ }.toString())
798
+ }
799
+
800
+ private fun settleCommittedDirectDisclosure(
801
+ next: NativeListConfig,
802
+ id: String?,
803
+ positionToggle: Boolean,
804
+ ) {
805
+ if (id == null || disposed || config !== next ||
806
+ activeDisclosureTransitionId != id || consumedStructuralTransitionId != id ||
807
+ structuralTail != null || next.disclosure == null) return
808
+ relayoutRecyclerViewImmediately()
809
+ if (positionToggle && directDisclosureTogglePositionPending)
810
+ positionSettledDisclosureToggle()
811
+ settleDisclosureCommand(directDisclosureStatus)
812
+ }
813
+
814
+ private fun presentAcceptedDisclosureEndpoint(next: NativeListConfig) {
815
+ val disclosure = next.disclosure ?: return
816
+ disclosureFraction = if (disclosure.expanded) 1f else 0f
817
+ disclosurePresentedContentPx = if (disclosure.expanded) slotHeights[4] else 0
818
+ syncDisclosurePresentation()
819
+ }
820
+
821
+ private fun syncDisclosurePresentation() {
822
+ val disclosure = config?.disclosure
823
+ if (disclosure == null) {
824
+ disclosurePresentedContentPx = 0
825
+ disclosurePresentedInsetPx = 0
826
+ expandedContentAdapter.update(null, 0, false)
827
+ structureToggleAdapter.update(null, 0, false)
828
+ disclosureInsetAdapter.update(null, 0, false)
829
+ return
830
+ }
831
+ val content = containerSlots.getOrNull(4)
832
+ if (expandedContentHost.getChildAt(0) !== content) {
833
+ expandedContentHost.removeAllViews()
834
+ if (content != null) {
835
+ (content.parent as? ViewGroup)?.removeView(content)
836
+ expandedContentHost.addView(content, FrameLayout.LayoutParams(
837
+ ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
838
+ }
839
+ }
840
+ expandedContentHost.naturalHeightPx = slotHeights[4]
841
+ val fraction = disclosureFraction.coerceIn(0f, 1f)
842
+ val contentHeight = if (disclosureTransitioning) disclosurePresentedContentPx else
843
+ if (disclosure.expanded) slotHeights[4] else 0
844
+ disclosurePresentedContentPx = contentHeight
845
+ disclosurePresentedInsetPx = (disclosureInsetExtentPx() * fraction).roundToInt()
846
+ val interactive = disclosure.expanded && !disclosureTransitioning && fraction >= 1f
847
+ expandedContentHost.importantForAccessibility = if (interactive)
848
+ View.IMPORTANT_FOR_ACCESSIBILITY_AUTO
849
+ else View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
850
+ expandedContentAdapter.update(if (content == null) null else expandedContentHost,
851
+ contentHeight, true)
852
+ structureToggleAdapter.update(containerSlots.getOrNull(5), slotHeights[5], true)
853
+ disclosureInsetAdapter.update(disclosureInsetView,
854
+ disclosurePresentedInsetPx, true)
855
+ if (content != null) layoutExpandedContentHostNow()
856
+ }
857
+
858
+ private fun layoutExpandedContentHostNow() {
859
+ val wrapper = expandedContentHost
860
+ val child = wrapper.getChildAt(0) ?: return
861
+ val width = wrapper.width.takeIf { it > 0 } ?: wrapper.measuredWidth.takeIf { it > 0 } ?: return
862
+ val height = disclosurePresentedContentPx
863
+ if (!wrapper.isLayoutRequested && wrapper.measuredWidth == width &&
864
+ wrapper.measuredHeight == height && wrapper.height == height &&
865
+ child.left == 0 && child.top == 0 && child.right == width &&
866
+ child.bottom == wrapper.naturalHeightPx) return
867
+ wrapper.forceLayout()
868
+ wrapper.measure(View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
869
+ View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY))
870
+ wrapper.layout(wrapper.left, wrapper.top, wrapper.left + width, wrapper.top + height)
871
+ }
872
+
873
+ private fun syncFooterPresentation() {
874
+ val footer = containerSlots.getOrNull(2)
875
+ if (footerPresentationHost.getChildAt(0) !== footer) {
876
+ invalidateTailForContainerGeometry()
877
+ structuralFooterCompensatedHeightPx = null
878
+ structuralFooterObservedNativeHeightPx = null
879
+ acceptedFooterNaturalHeightPx = null
880
+ footerPresentationHost.removeAllViews()
881
+ if (footer != null) {
882
+ (footer.parent as? ViewGroup)?.removeView(footer)
883
+ footerPresentationHost.addView(footer, FrameLayout.LayoutParams(
884
+ ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
885
+ }
886
+ }
887
+ val naturalHeight = footerNaturalHeightPx()
888
+ val hiddenTop = footerHiddenTopPx()
889
+ if (footerPresentationHost.naturalHeightPx != naturalHeight ||
890
+ footerPresentationHost.hiddenTopPx != hiddenTop) {
891
+ footerPresentationHost.naturalHeightPx = naturalHeight
892
+ footerPresentationHost.hiddenTopPx = hiddenTop
893
+ footerPresentationHost.requestLayout()
894
+ }
895
+ footerSlotAdapter.update(if (footer == null) null else footerPresentationHost,
896
+ naturalHeight - hiddenTop, true)
897
+ if (footer != null) layoutFooterPresentationHostNow()
898
+ }
899
+
900
+ private fun layoutFooterPresentationHostNow() {
901
+ val wrapper = footerPresentationHost
902
+ val child = wrapper.getChildAt(0) ?: return
903
+ val width = wrapper.width.takeIf { it > 0 } ?: wrapper.measuredWidth.takeIf { it > 0 } ?: return
904
+ val height = footerPresentedHeightPx()
905
+ val childTop = -wrapper.hiddenTopPx
906
+ val childBottom = wrapper.naturalHeightPx - wrapper.hiddenTopPx
907
+ if (!wrapper.isLayoutRequested && wrapper.measuredWidth == width &&
908
+ wrapper.measuredHeight == height && wrapper.height == height &&
909
+ child.left == 0 && child.top == childTop && child.right == width &&
910
+ child.bottom == childBottom) return
911
+ wrapper.forceLayout()
912
+ wrapper.measure(View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
913
+ View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY))
914
+ wrapper.layout(wrapper.left, wrapper.top, wrapper.left + width, wrapper.top + height)
915
+ }
916
+
917
+ private fun invalidateTailForContainerGeometry() {
918
+ pendingPreparingFooterFrame = null
919
+ if (structuralTail == null) return
920
+ if (!structuralTailFinalizing) finishStructuralTail(interrupted = true)
921
+ structuralFooterCompensatedHeightPx = null
922
+ structuralTailSignedExtentPx = structuralTailRowsInAdapterPx + structuralSpacerHeightPx
923
+ }
924
+
925
+ private fun footerNaturalHeightPx(): Int = if (structuralTail == null)
926
+ acceptedFooterNaturalHeightPx ?: slotHeights[2] else
927
+ structuralFooterCompensatedHeightPx ?: acceptedFooterNaturalHeightPx ?: slotHeights[2]
928
+
929
+ private fun footerHiddenTopPx(): Int = if (structuralTail == null) 0 else
930
+ (structuralTailRowsInAdapterPx - structuralTailSignedExtentPx)
931
+ .coerceIn(0, footerNaturalHeightPx())
932
+
933
+ private fun footerPresentedHeightPx(): Int = footerNaturalHeightPx() - footerHiddenTopPx()
934
+
325
935
  private fun scrollPositionForRow(index: Int): Int = index + headerSlotAdapter.itemCount
326
936
 
327
937
  private fun recordLaidOutRowExtent(child: View, extentPx: Int) {
@@ -345,7 +955,23 @@ class NativeListView(
345
955
  if (position < headerCount) return slotHeights[0]
346
956
  val row = position - headerCount
347
957
  if (row >= adapter.itemCount) {
348
- return if (row - adapter.itemCount < emptySlotAdapter.itemCount) slotHeights[1] else slotHeights[2]
958
+ val slot = row - adapter.itemCount
959
+ return when {
960
+ slot < emptySlotAdapter.itemCount -> slotHeights[1]
961
+ slot < emptySlotAdapter.itemCount + structuralSpacerAdapter.itemCount -> structuralSpacerHeightPx
962
+ slot < emptySlotAdapter.itemCount + structuralSpacerAdapter.itemCount +
963
+ expandedContentAdapter.itemCount -> disclosurePresentedContentPx
964
+ slot < emptySlotAdapter.itemCount + structuralSpacerAdapter.itemCount +
965
+ expandedContentAdapter.itemCount + structureToggleAdapter.itemCount -> slotHeights[5]
966
+ slot < emptySlotAdapter.itemCount + structuralSpacerAdapter.itemCount +
967
+ expandedContentAdapter.itemCount + structureToggleAdapter.itemCount +
968
+ disclosureInsetAdapter.itemCount ->
969
+ disclosurePresentedInsetPx
970
+ slot < emptySlotAdapter.itemCount + structuralSpacerAdapter.itemCount +
971
+ expandedContentAdapter.itemCount + structureToggleAdapter.itemCount +
972
+ disclosureInsetAdapter.itemCount + footerSlotAdapter.itemCount -> footerPresentedHeightPx()
973
+ else -> sidecarSpacerHeightPx
974
+ }
349
975
  }
350
976
  val item = adapter.itemAt(row)
351
977
  item?.key?.let(measuredRowExtentsPx::get)?.let { return it }
@@ -354,6 +980,847 @@ class NativeListView(
354
980
  return if (measuredRowExtentsPx.isEmpty()) 0 else (measuredRowExtentSumPx / measuredRowExtentsPx.size).toInt()
355
981
  }
356
982
 
983
+ private fun estimatedItemExtentPx(item: NativeListItem): Int {
984
+ measuredRowExtentsPx[item.key]?.let { return it }
985
+ val explicit = item.styledHeight ?: item.json.optDouble("height").takeIf { it.isFinite() }
986
+ if (explicit != null) return (explicit * density).roundToInt()
987
+ return if (measuredRowExtentsPx.isEmpty()) dp(56)
988
+ else (measuredRowExtentSumPx / measuredRowExtentsPx.size).toInt()
989
+ }
990
+
991
+ private fun setStructuralSpacerHeight(heightPx: Int) {
992
+ val next = heightPx.coerceAtLeast(0)
993
+ if (next == structuralSpacerHeightPx) return
994
+ structuralSpacerHeightPx = next
995
+ structuralSpacerAdapter.update(structuralSpacerView, next, true)
996
+ layoutManager.spanSizeLookup.invalidateSpanIndexCache()
997
+ }
998
+
999
+ private fun setStructuralTailExtent(extentPx: Int) {
1000
+ structuralTailSignedExtentPx = extentPx
1001
+ setStructuralSpacerHeight((extentPx - structuralTailRowsInAdapterPx).coerceAtLeast(0))
1002
+ syncFooterPresentation()
1003
+ }
1004
+
1005
+ private fun prefixTailCount(old: NativeListConfig, next: NativeListConfig): Int? {
1006
+ if (old.orientation != "vertical" || next.orientation != "vertical" ||
1007
+ old.layout != next.layout || next.layout !in setOf("linear", "table") ||
1008
+ old.itemSpacing != next.itemSpacing ||
1009
+ old.contentPadding != next.contentPadding ||
1010
+ old.contentPaddingTop != next.contentPaddingTop ||
1011
+ old.contentPaddingBottom != next.contentPaddingBottom ||
1012
+ old.contentPaddingHorizontal != next.contentPaddingHorizontal ||
1013
+ old.theme?.toString() != next.theme?.toString() ||
1014
+ old.listStyle?.toString() != next.listStyle?.toString() ||
1015
+ old.items.size == next.items.size ||
1016
+ old.reorderable || next.reorderable ||
1017
+ structuralTailFinalizing || structuralTailRowsInAdapterPx > 0 ||
1018
+ old.items.any { it.usesSelectorSourceScale } !=
1019
+ next.items.any { it.usesSelectorSourceScale } ||
1020
+ (structuralAnimator != null && structuralTail == null) ||
1021
+ old.items.any { !it.hasExplicitHeight } ||
1022
+ next.items.any { !it.hasExplicitHeight } ||
1023
+ old.items.any { it.type !in setOf("identity", "dataRow") } ||
1024
+ next.items.any { it.type !in setOf("identity", "dataRow") } ||
1025
+ old.items.any { it.type == "sectionHeader" } ||
1026
+ next.items.any { it.type == "sectionHeader" }) return null
1027
+ val prefix = minOf(old.items.size, next.items.size)
1028
+ if (prefix == 0) return null
1029
+ if (structuralTail != null && prefix != structuralTail?.prefixCount) return null
1030
+ for (index in 0 until prefix) {
1031
+ val before = old.items[index]
1032
+ val after = next.items[index]
1033
+ if (before.key != after.key || before.rendererKey != after.rendererKey ||
1034
+ explicitTailRowHeight(before) != explicitTailRowHeight(after) ||
1035
+ explicitTailRowSpacingPx(before, old.itemSpacing) !=
1036
+ explicitTailRowSpacingPx(after, next.itemSpacing)) return null
1037
+ }
1038
+ return prefix
1039
+ }
1040
+
1041
+ private fun explicitTailRowHeight(item: NativeListItem): Int =
1042
+ item.styledHeight?.let { (it * density).roundToInt() } ?: run {
1043
+ val value = item.json.optDouble("height")
1044
+ when (item.json.optString("heightRounding")) {
1045
+ "floor" -> (value * density).toInt()
1046
+ "nearest" -> (value * density).roundToInt()
1047
+ else -> if (usesSelectorSourceScale || item.usesSelectorSourceScale)
1048
+ (item.json.optInt("height") * density).roundToInt()
1049
+ else NativeListScale.dp(resources, item.json.optInt("height"))
1050
+ }
1051
+ }
1052
+
1053
+ private fun explicitTailRowSpacingPx(item: NativeListItem, spacing: Int): Int {
1054
+ if (item.type == "identity" &&
1055
+ item.json.optString("presentation") == "walletSidebar") {
1056
+ val height = item.styledHeight ?: item.json.optDouble("height")
1057
+ return ((height + spacing) * density).roundToInt() -
1058
+ (height * density).roundToInt()
1059
+ }
1060
+ return dp(spacing)
1061
+ }
1062
+
1063
+ private fun exceedsStructuralTailHostLimit(
1064
+ old: NativeListConfig,
1065
+ next: NativeListConfig,
1066
+ prefixCount: Int,
1067
+ ): Boolean {
1068
+ val items = (if (old.items.size > next.items.size) old.items else next.items)
1069
+ .drop(prefixCount)
1070
+ if (items.size <= 128) return false
1071
+ val starts = LongArray(items.size)
1072
+ val heights = IntArray(items.size)
1073
+ var cursor = 0L
1074
+ items.forEachIndexed { index, item ->
1075
+ starts[index] = cursor
1076
+ heights[index] = explicitTailRowHeight(item)
1077
+ cursor += heights[index] + explicitTailRowSpacingPx(item, next.itemSpacing)
1078
+ }
1079
+ val window = 3L * recyclerView.height
1080
+ return (0..(items.size - 129)).any { first ->
1081
+ starts[first + 128] - (starts[first] + heights[first]) <= window
1082
+ }
1083
+ }
1084
+
1085
+ private fun clearStructuralTail() {
1086
+ pendingPreparingFooterFrame = null
1087
+ structuralTail?.hosts?.values?.forEach { host ->
1088
+ structuralTailLayer.removeView(host)
1089
+ host.recycle()
1090
+ host.dispose()
1091
+ }
1092
+ structuralTail = null
1093
+ structuralTailFinalizing = false
1094
+ structuralTailPreparing = false
1095
+ structuralFooterCompensatedHeightPx = null
1096
+ structuralTailRowsInAdapterPx = 0
1097
+ setStructuralTailExtent(0)
1098
+ structuralTailLayer.removeAllViews()
1099
+ structuralTailLayer.visibility = GONE
1100
+ structuralTailLayer.layoutParams?.let { params ->
1101
+ params.width = 0
1102
+ params.height = 0
1103
+ structuralTailLayer.layoutParams = params
1104
+ }
1105
+ structuralTailLayer.forceLayout()
1106
+ structuralTailLayer.measure(View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.EXACTLY),
1107
+ View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.EXACTLY))
1108
+ structuralTailLayer.layout(0, 0, 0, 0)
1109
+ }
1110
+
1111
+ private fun structuralTailRowOrSlotExtentPx(position: Int): Int {
1112
+ val row = position - headerSlotAdapter.itemCount
1113
+ val item = adapter.itemAt(row)
1114
+ if (row < 0 || item == null || !item.hasExplicitHeight) return estimatedScrollExtentPx(position)
1115
+ return explicitTailRowHeight(item) +
1116
+ explicitTailRowSpacingPx(item, config?.itemSpacing ?: 0)
1117
+ }
1118
+
1119
+ private fun structuralTailMeasuredScrollDistancePx(): Int? {
1120
+ if (layoutManager.orientation != RecyclerView.VERTICAL) return null
1121
+ val first = layoutManager.findFirstVisibleItemPosition()
1122
+ if (first == RecyclerView.NO_POSITION) return null
1123
+ val view = layoutManager.findViewByPosition(first) ?: return null
1124
+ val prefix = nativeListScrollPrefixPx(first, layoutManager.spanCount,
1125
+ layoutManager.spanSizeLookup::getSpanSize, ::structuralTailRowOrSlotExtentPx)
1126
+ val topMargin = (view.layoutParams as? ViewGroup.MarginLayoutParams)?.topMargin ?: 0
1127
+ val top = layoutManager.getDecoratedTop(view) - topMargin
1128
+ return (prefix + recyclerView.paddingTop - top).coerceIn(0L, Int.MAX_VALUE.toLong()).toInt()
1129
+ }
1130
+
1131
+ private fun tailScrollExtentPx(): Int =
1132
+ (if (structuralTail != null) structuralTailMeasuredScrollDistancePx()
1133
+ else measuredScrollDistancePx()) ?: logicalScrollDistancePx
1134
+
1135
+ private fun updateStructuralTailWindow() {
1136
+ val tail = structuralTail ?: return
1137
+ if (structuralTailFinalizing || structuralTailRowsInAdapterPx > 0) return
1138
+ val scroll = tailScrollExtentPx()
1139
+ val viewport = recyclerView.height
1140
+ if (viewport <= 0) return
1141
+ val y = recyclerView.top + recyclerView.paddingTop + structuralTailStartContentY - scroll
1142
+ val width = (recyclerView.width - recyclerView.paddingLeft - recyclerView.paddingRight).coerceAtLeast(0)
1143
+ val params = structuralTailLayer.layoutParams as FrameLayout.LayoutParams
1144
+ val left = recyclerView.left + recyclerView.paddingLeft
1145
+ if (params.width != width || params.height != structuralSpacerHeightPx ||
1146
+ params.leftMargin != left || params.topMargin != y) {
1147
+ params.width = width
1148
+ params.height = structuralSpacerHeightPx
1149
+ params.leftMargin = left
1150
+ params.topMargin = y
1151
+ structuralTailLayer.layoutParams = params
1152
+ }
1153
+ structuralTailLayer.visibility = VISIBLE
1154
+ val low = (-y - viewport).coerceAtLeast(0)
1155
+ val high = minOf(structuralSpacerHeightPx, -y + 2 * viewport)
1156
+ var first = 0
1157
+ var end = tail.items.size
1158
+ while (first < end) {
1159
+ val middle = (first + end) / 2
1160
+ if (tail.starts[middle] + tail.heights[middle] < low) first = middle + 1
1161
+ else end = middle
1162
+ }
1163
+ var last = first
1164
+ end = tail.items.size
1165
+ while (last < end) {
1166
+ val middle = (last + end) / 2
1167
+ if (tail.starts[middle] <= high) last = middle + 1
1168
+ else end = middle
1169
+ }
1170
+ if (last - first > 128) {
1171
+ finishStructuralTail(interrupted = false)
1172
+ return
1173
+ }
1174
+ tail.hosts.keys.filter { it < first || it >= last }.forEach { index ->
1175
+ val host = tail.hosts.remove(index) ?: return@forEach
1176
+ structuralTailLayer.removeView(host)
1177
+ host.recycle()
1178
+ host.dispose()
1179
+ }
1180
+ for (index in first until last) {
1181
+ if (tail.hosts.containsKey(index)) continue
1182
+ val item = tail.items[index]
1183
+ val host = NativeListRendererRegistry.create(reactContext, item.rendererKey.ordinal, fonts)
1184
+ host.listStyle = config?.listStyle
1185
+ host.bind(item, config?.theme, config?.layout ?: "linear", "vertical",
1186
+ tail.prefixCount + index, item.json.optBoolean("selected") ||
1187
+ (config?.selectedKeys?.contains(item.key) == true), adapter.checkboxState,
1188
+ useSourceScale = usesSelectorSourceScale)
1189
+ host.isClickable = false
1190
+ host.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
1191
+ structuralTailLayer.addView(host, FrameLayout.LayoutParams(width, tail.heights[index],
1192
+ Gravity.TOP or Gravity.LEFT).apply { topMargin = tail.starts[index] })
1193
+ tail.hosts[index] = host
1194
+ tail.peakHosts = maxOf(tail.peakHosts, tail.hosts.size)
1195
+ }
1196
+ val height = structuralSpacerHeightPx
1197
+ val needsMeasure = structuralTailLayer.isLayoutRequested ||
1198
+ structuralTailLayer.measuredWidth != width ||
1199
+ structuralTailLayer.measuredHeight != height ||
1200
+ tail.hosts.values.any { it.isLayoutRequested }
1201
+ if (needsMeasure) {
1202
+ structuralTailLayer.measure(
1203
+ MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
1204
+ MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY),
1205
+ )
1206
+ }
1207
+ if (needsMeasure || structuralTailLayer.left != left || structuralTailLayer.top != y ||
1208
+ structuralTailLayer.right != left + width || structuralTailLayer.bottom != y + height) {
1209
+ structuralTailLayer.layout(left, y, left + width, y + height)
1210
+ }
1211
+ }
1212
+
1213
+ private fun updateStructuralTailTarget() {
1214
+ val tail = structuralTail ?: return
1215
+ val disclosure = config?.disclosure
1216
+ val finalChrome = if (disclosure == null) 0 else
1217
+ slotHeights[5] + if (disclosure.expanded) slotHeights[4] + disclosureInsetExtentPx() else 0
1218
+ val leftHeight = recyclerView.paddingTop + structuralTailStartContentY + tail.finalExtent +
1219
+ finalChrome + footerNaturalHeightPx() + recyclerView.paddingBottom
1220
+ val sidecarHeight = if (sidecarLayout == null) 0 else
1221
+ recyclerView.paddingTop + slotHeights[3] + recyclerView.paddingBottom
1222
+ val maximum = (maxOf(leftHeight, sidecarHeight) - recyclerView.height).coerceAtLeast(0)
1223
+ var target = structuralTailStartOffset.coerceIn(0, maximum)
1224
+ if (tail.finalExtent == 0 && disclosure != null && slotHeights[5] > 0) {
1225
+ val toggleTop = recyclerView.paddingTop + structuralTailStartContentY
1226
+ val toggleBottom = toggleTop + slotHeights[5]
1227
+ val sticky = stickyHeaderHost.height
1228
+ val lower = toggleBottom - recyclerView.height + recyclerView.paddingBottom
1229
+ val upper = toggleTop - recyclerView.paddingTop - sticky
1230
+ target = if (lower <= upper) target.coerceIn(lower, upper) else upper
1231
+ } else if (tail.finalExtent == 0 && footerNaturalHeightPx() > 0) {
1232
+ val footerTop = recyclerView.paddingTop + structuralTailStartContentY
1233
+ val footerBottom = footerTop + footerNaturalHeightPx()
1234
+ val sticky = stickyHeaderHost.height
1235
+ val lower = footerBottom - recyclerView.height + recyclerView.paddingBottom
1236
+ val upper = footerTop - recyclerView.paddingTop - sticky
1237
+ target = if (lower <= upper) target.coerceIn(lower, upper) else upper
1238
+ }
1239
+ structuralTailTargetOffset = target.coerceIn(0, maximum)
1240
+ }
1241
+
1242
+ private fun positionSettledDisclosureToggle() {
1243
+ if (recyclerView.width <= 0 || recyclerView.height <= 0 ||
1244
+ structureToggleAdapter.itemCount == 0 || slotHeights[5] <= 0 ||
1245
+ recyclerView.scrollState != RecyclerView.SCROLL_STATE_IDLE ||
1246
+ pendingScrollRequest != null || acceptedScrollRequest != null ||
1247
+ executingScrollCommand) return
1248
+ val togglePosition = headerSlotAdapter.itemCount + adapter.itemCount +
1249
+ emptySlotAdapter.itemCount + structuralSpacerAdapter.itemCount +
1250
+ expandedContentAdapter.itemCount
1251
+ val toggleTop = (recyclerView.paddingTop.toLong() +
1252
+ nativeListScrollPrefixPx(togglePosition, layoutManager.spanCount,
1253
+ layoutManager.spanSizeLookup::getSpanSize, ::structuralTailRowOrSlotExtentPx))
1254
+ .coerceIn(0L, Int.MAX_VALUE.toLong()).toInt()
1255
+ val stickyTop = recyclerView.paddingTop + stickyHeaderHost.height
1256
+ val lower = toggleTop + slotHeights[5] - recyclerView.height + recyclerView.paddingBottom
1257
+ val upper = toggleTop - stickyTop
1258
+ val sidecarBottom = if (sidecarLayout == null) 0 else
1259
+ recyclerView.paddingTop + slotHeights[3] + recyclerView.paddingBottom
1260
+ val leftExtent = (nativeListScrollPrefixPx(
1261
+ scrollContentAdapter.itemCount - sidecarSpacerAdapter.itemCount,
1262
+ layoutManager.spanCount, layoutManager.spanSizeLookup::getSpanSize,
1263
+ ::structuralTailRowOrSlotExtentPx) +
1264
+ recyclerView.paddingTop + recyclerView.paddingBottom)
1265
+ .coerceIn(0L, Int.MAX_VALUE.toLong()).toInt()
1266
+ val maximum = (maxOf(leftExtent, sidecarBottom) - recyclerView.height).coerceAtLeast(0)
1267
+ val current = structuralTailMeasuredScrollDistancePx() ?: tailScrollExtentPx()
1268
+ val target = (if (lower <= upper) current.coerceIn(lower, upper) else upper)
1269
+ .coerceIn(0, maximum)
1270
+ val ownsCommand = !structuralTailCommandActive
1271
+ if (ownsCommand) {
1272
+ notifyScrollCommand(false, 0)
1273
+ notifyScrollCommand(false, 1)
1274
+ }
1275
+ try {
1276
+ recyclerView.scrollBy(0, target - current)
1277
+ relayoutRecyclerViewImmediately()
1278
+ } finally {
1279
+ if (ownsCommand) notifyScrollCommand(false, 2)
1280
+ }
1281
+ }
1282
+
1283
+ private fun finishStructuralTail(interrupted: Boolean) {
1284
+ val next = config ?: return
1285
+ if (structuralTail == null || structuralTailFinalizing) return
1286
+ structuralTailFinalizing = true
1287
+ val epoch = ++structuralTransitionEpoch
1288
+ pendingPreparingFooterFrame = pendingPreparingFooterFrame?.takeIf {
1289
+ it.footer === containerSlots.getOrNull(2)
1290
+ }?.copy(epoch = epoch, handoffOnly = true)
1291
+ structuralAnimator?.cancel()
1292
+ structuralAnimator = null
1293
+ recyclerView.itemAnimator = null
1294
+ structuralFooterHoldTop = null
1295
+ structuralFooterVisualStartTop = null
1296
+ structuralFooterVisualEndTop = null
1297
+ footerSlotAdapter.setVisualTranslationY(0f)
1298
+ if (interrupted && structuralTailCommandActive) {
1299
+ structuralTailCommandActive = false
1300
+ notifyScrollCommand(false, 2)
1301
+ }
1302
+ adapter.submitList(next.items) {
1303
+ if (epoch != structuralTransitionEpoch || disposed) return@submitList
1304
+ fun handoff() {
1305
+ if (epoch != structuralTransitionEpoch || disposed) return
1306
+ if (recyclerView.isComputingLayout) {
1307
+ recyclerView.post { handoff() }
1308
+ return
1309
+ }
1310
+ val acceptedPendingFooter = acceptPendingFooterValues(adjustTailStart = false)
1311
+ presentAcceptedDisclosureEndpoint(next)
1312
+ if (recyclerView.width <= 0 || recyclerView.height <= 0) {
1313
+ clearStructuralTail()
1314
+ if (acceptedPendingFooter) updateContainerSlots()
1315
+ if (structuralTailCommandActive) {
1316
+ structuralTailCommandActive = false
1317
+ notifyScrollCommand(false, 2)
1318
+ }
1319
+ if (next.disclosure != null) {
1320
+ disclosureTransitioning = false
1321
+ syncDisclosurePresentation()
1322
+ settleDisclosureCommand(if (interrupted) "interrupted" else "completed")
1323
+ }
1324
+ scheduleVisibleEvent()
1325
+ checkEndReached()
1326
+ return
1327
+ }
1328
+ structuralTailRowsInAdapterPx = 0
1329
+ setStructuralTailExtent(0)
1330
+ updateSidecarSpacerNow()
1331
+ relayoutRecyclerViewImmediately()
1332
+ if (epoch != structuralTransitionEpoch || disposed) return
1333
+ if (!interrupted && recyclerView.scrollState == RecyclerView.SCROLL_STATE_IDLE) {
1334
+ val sidecarBottom = if (sidecarLayout == null) 0 else
1335
+ recyclerView.paddingTop + slotHeights[3] + recyclerView.paddingBottom
1336
+ val maximum = (maxOf(sidecarLeftExtentPx(), sidecarBottom) -
1337
+ recyclerView.height).coerceAtLeast(0)
1338
+ val target = structuralTailTargetOffset.coerceIn(0, maximum)
1339
+ recyclerView.scrollBy(0, target - tailScrollExtentPx())
1340
+ }
1341
+ if (epoch != structuralTransitionEpoch || disposed) return
1342
+ clearStructuralTail()
1343
+ if (acceptedPendingFooter) updateContainerSlots()
1344
+ performPendingScrollIfNeeded()
1345
+ if (structuralTailCommandActive) {
1346
+ structuralTailCommandActive = false
1347
+ notifyScrollCommand(false, 2)
1348
+ }
1349
+ if (next.disclosure != null) {
1350
+ disclosureTransitioning = false
1351
+ syncDisclosurePresentation()
1352
+ settleDisclosureCommand(if (interrupted) "interrupted" else "completed")
1353
+ }
1354
+ scheduleVisibleEvent()
1355
+ checkEndReached()
1356
+ }
1357
+ handoff()
1358
+ }
1359
+ }
1360
+
1361
+ private fun discardStructuralTail() {
1362
+ if (structuralTail == null) return
1363
+ structuralTransitionEpoch += 1
1364
+ structuralAnimator?.cancel()
1365
+ structuralAnimator = null
1366
+ if (structuralTailCommandActive) {
1367
+ structuralTailCommandActive = false
1368
+ notifyScrollCommand(false, 2)
1369
+ }
1370
+ clearStructuralTail()
1371
+ setStructuralSpacerHeight(0)
1372
+ }
1373
+
1374
+ private fun holdStructuralTailUntilListCommit(): Int {
1375
+ val epoch = ++structuralTransitionEpoch
1376
+ pendingPreparingFooterFrame = pendingPreparingFooterFrame?.takeIf {
1377
+ it.footer === containerSlots.getOrNull(2)
1378
+ }?.copy(epoch = epoch, handoffOnly = true)
1379
+ structuralAnimator?.cancel()
1380
+ structuralAnimator = null
1381
+ structuralTailFinalizing = true
1382
+ if (structuralTailCommandActive) {
1383
+ structuralTailCommandActive = false
1384
+ notifyScrollCommand(false, 2)
1385
+ }
1386
+ return epoch
1387
+ }
1388
+
1389
+ private fun completeHeldTailCommit(
1390
+ next: NativeListConfig,
1391
+ epoch: Int,
1392
+ completion: () -> Unit,
1393
+ positionToggle: Boolean,
1394
+ ) {
1395
+ if (epoch != structuralTransitionEpoch || disposed) return
1396
+ if (recyclerView.isComputingLayout) {
1397
+ recyclerView.post { completeHeldTailCommit(next, epoch, completion, positionToggle) }
1398
+ return
1399
+ }
1400
+ val acceptedPendingFooter = acceptPendingFooterValues(adjustTailStart = false)
1401
+ presentAcceptedDisclosureEndpoint(next)
1402
+ structuralTailRowsInAdapterPx = 0
1403
+ setStructuralTailExtent(0)
1404
+ if (acceptedPendingFooter) updateContainerSlots()
1405
+ updateSidecarSpacerNow()
1406
+ relayoutRecyclerViewImmediately()
1407
+ if (positionToggle) positionSettledDisclosureToggle()
1408
+ if (epoch != structuralTransitionEpoch || disposed) return
1409
+ clearStructuralTail()
1410
+ disclosureTransitioning = false
1411
+ syncDisclosurePresentation()
1412
+ completion()
1413
+ }
1414
+
1415
+ private fun applyStructuralTailFrame(tail: StructuralTail, epoch: Int, progress: Float): Boolean {
1416
+ if (epoch != structuralTransitionEpoch || disposed || structuralTail !== tail ||
1417
+ structuralTailFinalizing) return true
1418
+ if (recyclerView.isComputingLayout) return false
1419
+ val segmentProgress = if (structuralTailStartProgress >= 1f) 1f else
1420
+ ((progress - structuralTailStartProgress) /
1421
+ (1f - structuralTailStartProgress)).coerceIn(0f, 1f)
1422
+ config?.disclosure?.let { disclosure ->
1423
+ val target = if (disclosure.expanded) 1f else 0f
1424
+ disclosureFraction = disclosureStartFraction +
1425
+ (target - disclosureStartFraction) * segmentProgress
1426
+ val contentProgress = if (disclosureContentStartProgress >= 1f) 1f else
1427
+ ((progress - disclosureContentStartProgress) /
1428
+ (1f - disclosureContentStartProgress)).coerceIn(0f, 1f)
1429
+ val contentTarget = if (disclosure.expanded) slotHeights[4] else 0
1430
+ disclosurePresentedContentPx = (disclosureContentStartPx +
1431
+ (contentTarget - disclosureContentStartPx) * contentProgress).roundToInt()
1432
+ syncDisclosurePresentation()
1433
+ }
1434
+ setStructuralTailExtent((structuralTailStartExtentPx +
1435
+ (tail.finalExtent - structuralTailStartExtentPx) * segmentProgress).roundToInt())
1436
+ updateSidecarSpacerNow()
1437
+ relayoutRecyclerViewImmediately()
1438
+ if (epoch != structuralTransitionEpoch || disposed || structuralTail !== tail) return true
1439
+ if (!structuralScrollInterrupted && recyclerView.scrollState == RecyclerView.SCROLL_STATE_IDLE) {
1440
+ val sidecarBottom = if (sidecarLayout == null) 0 else
1441
+ recyclerView.paddingTop + slotHeights[3] + recyclerView.paddingBottom
1442
+ val maximum = (maxOf(sidecarLeftExtentPx(), sidecarBottom) -
1443
+ recyclerView.height).coerceAtLeast(0)
1444
+ val desired = (structuralTailStartOffset +
1445
+ (structuralTailTargetOffset - structuralTailStartOffset) * segmentProgress)
1446
+ .roundToInt().coerceIn(0, maximum)
1447
+ val requestedScroll = desired - tailScrollExtentPx()
1448
+ recyclerView.scrollBy(0, requestedScroll)
1449
+ }
1450
+ updateStructuralTailWindow()
1451
+ return true
1452
+ }
1453
+
1454
+ private fun commitPreparingFooterFrame(): Boolean {
1455
+ val pending = pendingPreparingFooterFrame ?: return true
1456
+ if (structuralTail == null || structuralTailRowsInAdapterPx <= 0 ||
1457
+ pending.epoch != structuralTransitionEpoch || disposed ||
1458
+ pending.footer !== containerSlots.getOrNull(2)) {
1459
+ if (pendingPreparingFooterFrame === pending) pendingPreparingFooterFrame = null
1460
+ return true
1461
+ }
1462
+ if (pending.handoffOnly || recyclerView.isComputingLayout) return false
1463
+ pendingPreparingFooterFrame = null
1464
+ if (pending.observedNativeHeight != null) {
1465
+ structuralFooterObservedNativeHeightPx = pending.observedNativeHeight
1466
+ } else {
1467
+ containerSlots.getOrNull(2)?.measuredHeight
1468
+ ?.takeIf { it > 0 && it != pending.naturalHeight }
1469
+ ?.let { structuralFooterObservedNativeHeightPx = it }
1470
+ }
1471
+ reconcileStructuralFooterHeight(pending.naturalHeight)
1472
+ acceptedFooterNaturalHeightPx = pending.naturalHeight
1473
+ for (index in 0..5) slotHeights[index] = pending.heights[index]
1474
+ updateContainerSlots()
1475
+ updateStructuralTailTarget()
1476
+ updateSidecarGeometry()
1477
+ updateSidecarSpacerNow()
1478
+ relayoutRecyclerViewImmediately()
1479
+ if (pending.epoch != structuralTransitionEpoch || disposed ||
1480
+ pending.footer !== containerSlots.getOrNull(2)) {
1481
+ return pendingPreparingFooterFrame == null
1482
+ }
1483
+ val sidecarBottom = if (sidecarLayout == null) 0 else
1484
+ recyclerView.paddingTop + slotHeights[3] + recyclerView.paddingBottom
1485
+ val maximum = (maxOf(sidecarLeftExtentPx(), sidecarBottom) -
1486
+ recyclerView.height).coerceAtLeast(0)
1487
+ val desired = pending.ownerOffset.coerceIn(0, maximum)
1488
+ recyclerView.scrollBy(0, desired - tailScrollExtentPx())
1489
+ relayoutRecyclerViewImmediately()
1490
+ updateStructuralTailWindow()
1491
+ return pendingPreparingFooterFrame == null
1492
+ }
1493
+
1494
+ private fun acceptPendingFooterValues(adjustTailStart: Boolean): Boolean {
1495
+ val pending = pendingPreparingFooterFrame ?: return false
1496
+ if (pending.footer !== containerSlots.getOrNull(2) || disposed ||
1497
+ pending.epoch != structuralTransitionEpoch) {
1498
+ pendingPreparingFooterFrame = null
1499
+ return false
1500
+ }
1501
+ val latestNativeHeight = if (pending.observedNativeHeight != null)
1502
+ pending.footer?.measuredHeight?.takeIf { it > 0 } else null
1503
+ val naturalHeight = if (latestNativeHeight != null &&
1504
+ latestNativeHeight != pending.observedNativeHeight) {
1505
+ (ceil(latestNativeHeight / density.toDouble()) * density).roundToInt()
1506
+ } else pending.naturalHeight
1507
+ if (adjustTailStart) {
1508
+ val previousHeight = structuralFooterCompensatedHeightPx ?:
1509
+ acceptedFooterNaturalHeightPx ?: slotHeights[2]
1510
+ val delta = previousHeight - naturalHeight
1511
+ structuralTailSignedExtentPx += delta
1512
+ structuralTailStartExtentPx += delta
1513
+ }
1514
+ structuralFooterObservedNativeHeightPx = latestNativeHeight ?:
1515
+ containerSlots.getOrNull(2)?.measuredHeight?.takeIf {
1516
+ it > 0 && it != naturalHeight
1517
+ }
1518
+ acceptedFooterNaturalHeightPx = naturalHeight
1519
+ structuralFooterCompensatedHeightPx = naturalHeight
1520
+ for (index in 0..5) slotHeights[index] = pending.heights[index]
1521
+ pendingPreparingFooterFrame = null
1522
+ return true
1523
+ }
1524
+
1525
+ private fun startPrefixTailTransition(
1526
+ previous: NativeListConfig,
1527
+ next: NativeListConfig,
1528
+ transition: NativeListConfig.StructuralTransition,
1529
+ prefixCount: Int,
1530
+ ) {
1531
+ val previousTail = structuralTail
1532
+ val carriedExtent = previousTail?.let { structuralTailSignedExtentPx }
1533
+ val startOffset = structuralTailMeasuredScrollDistancePx() ?: tailScrollExtentPx()
1534
+ if (previousTail == null) {
1535
+ cancelStructuralTransition()
1536
+ } else {
1537
+ structuralTransitionEpoch += 1
1538
+ structuralAnimator?.cancel()
1539
+ structuralAnimator = null
1540
+ if (structuralTailCommandActive) {
1541
+ structuralTailCommandActive = false
1542
+ notifyScrollCommand(false, 2)
1543
+ }
1544
+ // The old presentation remains visible until the new prefix and spacer commit together.
1545
+ structuralTailPreparing = true
1546
+ }
1547
+ val epoch = structuralTransitionEpoch
1548
+ val collapsing = previous.items.size > next.items.size
1549
+ val items = (if (collapsing) previous.items else next.items)
1550
+ .drop(prefixCount).toMutableList()
1551
+ val heights = IntArray(items.size) { explicitTailRowHeight(items[it]) }
1552
+ val spacings = IntArray(items.size) {
1553
+ explicitTailRowSpacingPx(items[it], next.itemSpacing)
1554
+ }
1555
+ val tail = StructuralTail(items, heights, spacings, prefixCount,
1556
+ if (collapsing) 0 else heights.sum() + spacings.sum())
1557
+ val startExtent = carriedExtent ?: if (collapsing) tail.extent else 0
1558
+ disclosureStartFraction = disclosureFraction
1559
+ if (next.disclosure != null) {
1560
+ disclosureContentStartPx = disclosurePresentedContentPx
1561
+ disclosureContentStartProgress = 0f
1562
+ disclosureTransitioning = true
1563
+ }
1564
+ val startContentY = (if (headerSlotAdapter.itemCount > 0) slotHeights[0] else 0) +
1565
+ next.items.take(prefixCount).sumOf {
1566
+ explicitTailRowHeight(it) + explicitTailRowSpacingPx(it, next.itemSpacing)
1567
+ }
1568
+ if (previousTail == null) {
1569
+ structuralTail = tail
1570
+ structuralTailRowsInAdapterPx = if (collapsing) tail.extent else 0
1571
+ structuralTailStartExtentPx = startExtent
1572
+ structuralTailStartProgress = 0f
1573
+ structuralTailStartContentY = startContentY
1574
+ structuralTailStartOffset = startOffset
1575
+ structuralTailTargetOffset = startOffset
1576
+ setStructuralTailExtent(startExtent)
1577
+ }
1578
+ structuralScrollInterrupted = false
1579
+ structuralFooterCompensatedHeightPx = null
1580
+ recyclerView.itemAnimator = null
1581
+ acceptRowIndex(next, previous)
1582
+ config = next
1583
+ pruneMeasuredRowExtents(next.items)
1584
+ updateContainerSlots()
1585
+ usesSelectorSourceScale = next.items.any { it.usesSelectorSourceScale }
1586
+ adapter.usesSelectorSourceScale = usesSelectorSourceScale
1587
+ adapter.theme = next.theme
1588
+ adapter.listStyle = next.listStyle
1589
+ adapter.layout = next.layout
1590
+ adapter.orientation = next.orientation
1591
+ adapter.selectedKeys = next.selectedKeys
1592
+ if (previous.generation != next.generation) endReachedGeneration = null
1593
+ configureSectionIndex(next)
1594
+ updateLayout(next)
1595
+ bindFooter(next)
1596
+ updateReordering(next)
1597
+ refreshLayout.isEnabled = next.pullToRefresh
1598
+ refreshLayout.setDistanceToTriggerSync(
1599
+ next.refreshTriggerDistance?.let { (it * density).roundToInt() }
1600
+ ?: defaultRefreshTriggerDistancePx,
1601
+ )
1602
+ refreshLayout.isRefreshing = next.refreshing
1603
+ structuralTailCommandActive = true
1604
+ notifyScrollCommand(false, 0)
1605
+ notifyScrollCommand(false, 1)
1606
+ adapter.submitList(next.items.take(prefixCount)) {
1607
+ fun commitPrefix() {
1608
+ if (epoch != structuralTransitionEpoch || disposed ||
1609
+ structuralTail !== (previousTail ?: tail) || structuralTailFinalizing) return
1610
+ if (recyclerView.isComputingLayout) {
1611
+ recyclerView.post { commitPrefix() }
1612
+ return
1613
+ }
1614
+ if (previousTail != null) {
1615
+ clearStructuralTail()
1616
+ structuralTail = tail
1617
+ structuralTailStartExtentPx = startExtent
1618
+ structuralTailStartProgress = 0f
1619
+ structuralTailStartContentY = startContentY
1620
+ structuralTailStartOffset = startOffset
1621
+ structuralTailTargetOffset = startOffset
1622
+ }
1623
+ acceptPendingFooterValues(adjustTailStart = true)
1624
+ structuralTailPreparing = false
1625
+ structuralTailRowsInAdapterPx = 0
1626
+ setStructuralTailExtent(structuralTailStartExtentPx)
1627
+ updateStructuralTailTarget()
1628
+ if (recyclerView.width <= 0 || recyclerView.height <= 0) {
1629
+ finishStructuralTail(interrupted = false)
1630
+ return
1631
+ }
1632
+ applyStructuralTailFrame(tail, epoch, 0f)
1633
+ if (epoch != structuralTransitionEpoch || disposed || structuralTail !== tail ||
1634
+ structuralTailFinalizing) return
1635
+ var lastAppliedProgress = 0f
1636
+ var frameScheduled = false
1637
+ fun applyProgress(progress: Float) {
1638
+ if (epoch != structuralTransitionEpoch || disposed || structuralTail !== tail ||
1639
+ structuralTailFinalizing) return
1640
+ val nextProgress = maxOf(progress, lastAppliedProgress)
1641
+ if (!applyStructuralTailFrame(tail, epoch, nextProgress)) {
1642
+ if (!frameScheduled) {
1643
+ frameScheduled = true
1644
+ recyclerView.post {
1645
+ frameScheduled = false
1646
+ val current = structuralAnimator?.animatedValue as? Float ?: nextProgress
1647
+ applyProgress(current)
1648
+ }
1649
+ }
1650
+ return
1651
+ }
1652
+ lastAppliedProgress = nextProgress
1653
+ }
1654
+ structuralAnimator = ValueAnimator.ofFloat(0f, 1f).apply {
1655
+ duration = transition.durationMs
1656
+ interpolator = android.view.animation.AccelerateDecelerateInterpolator()
1657
+ addUpdateListener { animation ->
1658
+ if (epoch != structuralTransitionEpoch || disposed || structuralTail !== tail) return@addUpdateListener
1659
+ if (Build.VERSION.SDK_INT >= 26 && !ValueAnimator.areAnimatorsEnabled()) {
1660
+ finishStructuralTail(interrupted = false)
1661
+ return@addUpdateListener
1662
+ }
1663
+ applyProgress(animation.animatedValue as Float)
1664
+ }
1665
+ addListener(object : AnimatorListenerAdapter() {
1666
+ override fun onAnimationEnd(animation: Animator) {
1667
+ if (epoch == structuralTransitionEpoch && structuralTail === tail)
1668
+ finishStructuralTail(interrupted = structuralScrollInterrupted)
1669
+ }
1670
+ })
1671
+ start()
1672
+ }
1673
+ }
1674
+ commitPrefix()
1675
+ }
1676
+ }
1677
+
1678
+ private fun updatePrefixTailSnapshot(
1679
+ previous: NativeListConfig,
1680
+ next: NativeListConfig,
1681
+ acceptIndex: Boolean = false,
1682
+ ): Boolean {
1683
+ val tail = structuralTail ?: return false
1684
+ if (structuralTailPreparing || structuralTailFinalizing ||
1685
+ structuralTailRowsInAdapterPx > 0 ||
1686
+ previous.items.size != next.items.size ||
1687
+ previous.disclosure != next.disclosure ||
1688
+ previous.layout != next.layout || previous.orientation != next.orientation ||
1689
+ previous.theme?.toString() != next.theme?.toString() ||
1690
+ previous.listStyle?.toString() != next.listStyle?.toString() ||
1691
+ previous.itemSpacing != next.itemSpacing ||
1692
+ previous.contentPadding != next.contentPadding ||
1693
+ previous.contentPaddingTop != next.contentPaddingTop ||
1694
+ previous.contentPaddingBottom != next.contentPaddingBottom ||
1695
+ previous.contentPaddingHorizontal != next.contentPaddingHorizontal) return false
1696
+ for (index in next.items.indices) {
1697
+ val before = previous.items[index]
1698
+ val after = next.items[index]
1699
+ if (before.key != after.key || before.rendererKey != after.rendererKey ||
1700
+ !after.hasExplicitHeight || explicitTailRowHeight(before) != explicitTailRowHeight(after) ||
1701
+ explicitTailRowSpacingPx(before, previous.itemSpacing) !=
1702
+ explicitTailRowSpacingPx(after, next.itemSpacing))
1703
+ return false
1704
+ }
1705
+ if (acceptIndex) acceptRowIndex(next, previous)
1706
+ config = next
1707
+ adapter.selectedKeys = next.selectedKeys
1708
+ adapter.submitList(next.items.take(tail.prefixCount)) {
1709
+ if (structuralTail !== tail || disposed) return@submitList
1710
+ scheduleVisibleEvent()
1711
+ }
1712
+ if (tail.finalExtent > 0) {
1713
+ for (index in tail.items.indices) {
1714
+ val item = next.items[tail.prefixCount + index]
1715
+ if (tail.items[index].content == item.content) continue
1716
+ tail.items[index] = item
1717
+ tail.hosts[index]?.let { host ->
1718
+ host.bind(item, next.theme, next.layout, "vertical", tail.prefixCount + index,
1719
+ item.json.optBoolean("selected") || next.selectedKeys.contains(item.key),
1720
+ adapter.checkboxState, useSourceScale = usesSelectorSourceScale)
1721
+ host.isClickable = false
1722
+ }
1723
+ }
1724
+ }
1725
+ bindFooter(next)
1726
+ updateStructuralTailTarget()
1727
+ return true
1728
+ }
1729
+
1730
+ private fun updateStructuralFooterHoldPosition() {
1731
+ val targetTop = structuralFooterHoldTop ?: return
1732
+ if (footerSlotAdapter.itemCount == 0) return
1733
+ val footerPosition = scrollContentAdapter.itemCount - 1 - sidecarSpacerAdapter.itemCount
1734
+ layoutManager.findViewByPosition(footerPosition)?.let { footer ->
1735
+ footerSlotAdapter.setVisualTranslationY((targetTop - layoutManager.getDecoratedTop(footer)).toFloat())
1736
+ }
1737
+ }
1738
+
1739
+ private fun updateStructuralFooterVisualPosition() {
1740
+ val startTop = structuralFooterVisualStartTop ?: return
1741
+ val endTop = structuralFooterVisualEndTop ?: return
1742
+ if (footerSlotAdapter.itemCount == 0) return
1743
+ val footerPosition = scrollContentAdapter.itemCount - 1 - sidecarSpacerAdapter.itemCount
1744
+ layoutManager.findViewByPosition(footerPosition)?.let { footer ->
1745
+ val desiredTop = startTop + (endTop - startTop) * structuralFooterVisualProgress
1746
+ footerSlotAdapter.setVisualTranslationY(desiredTop - layoutManager.getDecoratedTop(footer))
1747
+ }
1748
+ }
1749
+
1750
+ private fun cancelStructuralTransition() {
1751
+ if (structuralTail != null) discardStructuralTail()
1752
+ structuralTransitionEpoch += 1
1753
+ pendingStructuralTransition = null
1754
+ pendingStructuralStartScheduled = false
1755
+ structuralAnimator?.cancel()
1756
+ structuralAnimator = null
1757
+ recyclerView.itemAnimator = null
1758
+ setStructuralSpacerHeight(0)
1759
+ footerSlotAdapter.setVisualTranslationY(0f)
1760
+ structuralFooterHoldTop = null
1761
+ structuralFooterVisualStartTop = null
1762
+ structuralFooterVisualEndTop = null
1763
+ structuralFooterVisualProgress = 0f
1764
+ structuralFooterCompensatedHeightPx = null
1765
+ structuralStartSpacerPx = 0
1766
+ }
1767
+
1768
+ private fun startPendingStructuralTransition() {
1769
+ val pending = pendingStructuralTransition ?: return
1770
+ if (pending.epoch != structuralTransitionEpoch || disposed || pendingStructuralStartScheduled) return
1771
+ val expected = config?.items?.map { it.key } ?: return
1772
+ if (adapter.currentList.map { it.key } != expected) return
1773
+ pendingStructuralStartScheduled = true
1774
+ recyclerView.postOnAnimation {
1775
+ if (pending.epoch != structuralTransitionEpoch) return@postOnAnimation
1776
+ pendingStructuralStartScheduled = false
1777
+ val latest = pendingStructuralTransition
1778
+ if (latest?.epoch == pending.epoch && !disposed) {
1779
+ if (recyclerView.isComputingLayout) {
1780
+ startPendingStructuralTransition()
1781
+ return@postOnAnimation
1782
+ }
1783
+ alignPendingStructuralAnchor(latest)
1784
+ if (latest.anchorTop != null || (latest.collapsing && latest.footerStartTop != null)) {
1785
+ relayoutRecyclerViewImmediately()
1786
+ }
1787
+ pendingStructuralTransition = null
1788
+ startStructuralTransition(
1789
+ latest.transition, latest.epoch, latest.anchorKey, latest.anchorTop,
1790
+ latest.footerStartTop,
1791
+ )
1792
+ }
1793
+ }
1794
+ }
1795
+
1796
+ private fun alignPendingStructuralFooter(pending: PendingStructuralTransition): Boolean {
1797
+ val top = pending.footerStartTop ?: return false
1798
+ if (!pending.collapsing || !pending.spacerInserted || footerSlotAdapter.itemCount == 0 || structuralScrollInterrupted) return false
1799
+ if (pending.footerAnchorAppliedTop == top) return false
1800
+ val footerPosition = scrollContentAdapter.itemCount - 1 - sidecarSpacerAdapter.itemCount
1801
+ layoutManager.scrollToPositionWithOffset(footerPosition, top - recyclerView.paddingTop)
1802
+ pendingStructuralTransition = pending.copy(footerAnchorAppliedTop = top)
1803
+ return true
1804
+ }
1805
+
1806
+ private fun alignPendingStructuralRow(pending: PendingStructuralTransition): Boolean {
1807
+ val key = pending.anchorKey ?: return false
1808
+ val top = pending.anchorTop ?: return false
1809
+ if (structuralScrollInterrupted || pending.rowAnchorAppliedTop == top) return false
1810
+ val desiredItems = config?.items ?: return false
1811
+ val rowIndex = desiredItems.indexOfFirst { it.key == key }
1812
+ if (rowIndex < 0 || adapter.itemCount != desiredItems.size ||
1813
+ adapter.currentList.getOrNull(rowIndex)?.key != key) return false
1814
+ layoutManager.scrollToPositionWithOffset(scrollPositionForRow(rowIndex), top - recyclerView.paddingTop)
1815
+ pendingStructuralTransition = pending.copy(rowAnchorAppliedTop = top)
1816
+ return true
1817
+ }
1818
+
1819
+ private fun alignPendingStructuralAnchor(pending: PendingStructuralTransition) {
1820
+ if (pending.anchorTop != null) alignPendingStructuralRow(pending)
1821
+ else alignPendingStructuralFooter(pending)
1822
+ }
1823
+
357
1824
  private fun measuredScrollDistancePx(): Int? {
358
1825
  if (layoutManager.orientation != RecyclerView.VERTICAL) return null
359
1826
  val first = layoutManager.findFirstVisibleItemPosition()
@@ -527,6 +1994,39 @@ class NativeListView(
527
1994
  override fun onDetachedFromWindow() { down?.recycle(); down = null; super.onDetachedFromWindow() }
528
1995
  }
529
1996
  private val stickyPreDraw = android.view.ViewTreeObserver.OnPreDrawListener {
1997
+ if (!commitPreparingFooterFrame()) {
1998
+ if (pendingPreparingFooterFrame?.handoffOnly != true) schedulePreparingFooterFrame()
1999
+ return@OnPreDrawListener false
2000
+ }
2001
+ if (pendingStructuralTransition != null || structuralAnimator != null || structuralTail != null) {
2002
+ val measuredHeightPx = containerSlots.getOrNull(2)?.measuredHeight ?: 0
2003
+ if (measuredHeightPx > 0 && measuredHeightPx != structuralFooterObservedNativeHeightPx) {
2004
+ // React layout can change the footer's contents before its onLayout height reaches JS.
2005
+ val roundedDip = ceil(measuredHeightPx / density.toDouble())
2006
+ val naturalHeight = (roundedDip * density).roundToInt()
2007
+ if (structuralTail != null && config?.disclosure != null &&
2008
+ naturalHeight != footerNaturalHeightPx()) {
2009
+ structuralFooterObservedNativeHeightPx = measuredHeightPx
2010
+ acceptedFooterNaturalHeightPx = naturalHeight
2011
+ structuralFooterCompensatedHeightPx = null
2012
+ finishStructuralTail(interrupted = true)
2013
+ return@OnPreDrawListener false
2014
+ }
2015
+ if (structuralTail != null && structuralTailRowsInAdapterPx > 0 &&
2016
+ !structuralTailFinalizing) {
2017
+ enqueuePreparingFooterFrame(slotHeights.copyOf(), naturalHeight,
2018
+ observedNativeHeight = measuredHeightPx)
2019
+ if (pendingPreparingFooterFrame != null) return@OnPreDrawListener false
2020
+ } else {
2021
+ structuralFooterObservedNativeHeightPx = measuredHeightPx
2022
+ reconcileStructuralFooterHeight(naturalHeight)
2023
+ acceptedFooterNaturalHeightPx = naturalHeight
2024
+ if (structuralTail == null || structuralTailFinalizing) syncFooterPresentation()
2025
+ }
2026
+ }
2027
+ }
2028
+ if (structuralTail != null && !structuralTailFinalizing) layoutFooterPresentationHostNow()
2029
+ if (config?.disclosure != null) layoutExpandedContentHostNow()
530
2030
  updateStickyHeader()
531
2031
  finishScrollCommandIfSettled()
532
2032
  true
@@ -572,7 +2072,6 @@ class NativeListView(
572
2072
  private var dispatchingScrollRequest: ScrollRequest? = null
573
2073
  private var commandLayoutRequest: ScrollRequest? = null
574
2074
  private var commandAlignmentRequest: ScrollRequest? = null
575
- private var commandFinishDiagnosticReason: String? = null
576
2075
  private var readingIntentEpoch = 0L
577
2076
  private var pendingBodyStartEpoch: Long? = null
578
2077
  private val actionAnchorInstanceId = UUID.randomUUID().toString()
@@ -584,6 +2083,17 @@ class NativeListView(
584
2083
  private var lastMarketPaginationAnchorLogAtMs = 0L
585
2084
  private var disposed = false
586
2085
  private var resourcesDisposed = false
2086
+ private var acceptedKeyIndex: Map<String, Int>? = null
2087
+
2088
+ private fun acceptRowIndex(next: NativeListConfig, previous: NativeListConfig?) {
2089
+ if (previous != null && acceptedKeyIndex != null &&
2090
+ previous.generation == next.generation && previous.items.size == next.items.size &&
2091
+ previous.items.indices.all { index ->
2092
+ previous.items[index].key == next.items[index].key &&
2093
+ previous.items[index].type == next.items[index].type
2094
+ }) return
2095
+ acceptedKeyIndex = next.items.withIndex().associate { it.value.key to it.index }
2096
+ }
587
2097
 
588
2098
  init {
589
2099
  orientation = VERTICAL
@@ -595,18 +2105,20 @@ class NativeListView(
595
2105
  recyclerView.addItemDecoration(reorderPlaceholderDecoration)
596
2106
  // OneKey patch: full-width selector header backgrounds do not change row content insets.
597
2107
  recyclerView.addItemDecoration(SelectorBackgroundDecoration(adapter))
598
- recyclerView.addItemDecoration(ContainerSlotInsetDecoration { slotInsetPx })
599
- if (homeOffsetDiagnosticEnabled) layoutManager.onDiagnosticLayout = ::homeOffsetDiagnostic
2108
+ recyclerView.addItemDecoration(ContainerSlotInsetDecoration { slotInsetPx to slotRightInsetPx })
600
2109
  layoutManager.onBeforeDataLayout = {
601
2110
  if (pendingBodyStartEpoch == readingIntentEpoch && adapter.itemCount > 0 && pendingScrollRequest == null) {
602
2111
  layoutManager.scrollToPositionWithOffset(0, 0)
603
2112
  pendingBodyStartEpoch = null
604
- homeOffsetDiagnostic("first-fill-body-start")
605
2113
  }
2114
+ pendingStructuralTransition?.let(::alignPendingStructuralAnchor)
606
2115
  }
607
2116
  layoutManager.onChildLaidOut = ::recordLaidOutRowExtent
608
2117
  layoutManager.onLayoutPassCompleted = {
2118
+ if (structuralFooterHoldTop != null) updateStructuralFooterHoldPosition()
2119
+ else if (structuralAnimator != null) updateStructuralFooterVisualPosition()
609
2120
  emitScrollPositionThresholdIfNeeded(resync = true)
2121
+ updateSidecarPosition(resync = true)
610
2122
  if (containerSlotRelayoutPending) scheduleContainerSlotRelayout()
611
2123
  if (commandLayoutRequest === acceptedScrollRequest) commandLayoutRequest = null
612
2124
  finishScrollCommandIfSettled()
@@ -618,7 +2130,7 @@ class NativeListView(
618
2130
  val rowPosition = rowPositionForScroll(position)
619
2131
  if (rowPosition !in 0 until adapter.itemCount) return layoutManager.spanCount
620
2132
  val type = adapter.itemAt(rowPosition)?.type
621
- return if (type == "sectionHeader" || type == "system" || type == "action") {
2133
+ return if (type == "sectionHeader" || type == "system" || type == "action" || type == "buttonGroup") {
622
2134
  layoutManager.spanCount
623
2135
  } else {
624
2136
  1
@@ -636,6 +2148,9 @@ class NativeListView(
636
2148
  FrameLayout.LayoutParams.MATCH_PARENT,
637
2149
  ),
638
2150
  )
2151
+ contentContainer.addView(structuralTailLayer, FrameLayout.LayoutParams(0, 0, Gravity.TOP or Gravity.LEFT))
2152
+ contentContainer.addView(sidecarHost,
2153
+ FrameLayout.LayoutParams(0, 0, Gravity.TOP or Gravity.RIGHT))
639
2154
  stickyHeaderHost.visibility = GONE
640
2155
  stickyHeaderHost.addView(stickyHeaderView, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT))
641
2156
  contentContainer.addView(stickyHeaderHost, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT, Gravity.TOP))
@@ -700,6 +2215,9 @@ class NativeListView(
700
2215
  recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
701
2216
  override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
702
2217
  if (newState == RecyclerView.SCROLL_STATE_DRAGGING) {
2218
+ if (structuralTail != null) finishStructuralTail(interrupted = true)
2219
+ if (structuralAnimator != null || pendingStructuralTransition != null)
2220
+ structuralScrollInterrupted = true
703
2221
  cancelExecutingScrollCommand()
704
2222
  readingIntentEpoch++
705
2223
  pendingBodyStartEpoch = null
@@ -724,6 +2242,21 @@ class NativeListView(
724
2242
 
725
2243
  override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
726
2244
  logicalScrollDistancePx = (logicalScrollDistancePx + dy).coerceAtLeast(0)
2245
+ if (structuralScrollInterrupted && dy != 0) {
2246
+ if (pendingStructuralTransition != null) {
2247
+ pendingStructuralTransition = pendingStructuralTransition?.let {
2248
+ it.copy(footerStartTop = it.footerStartTop?.minus(dy))
2249
+ }
2250
+ structuralFooterHoldTop = structuralFooterHoldTop?.minus(dy)
2251
+ updateStructuralFooterHoldPosition()
2252
+ } else if (structuralAnimator != null) {
2253
+ structuralFooterVisualStartTop = structuralFooterVisualStartTop?.minus(dy)
2254
+ structuralFooterVisualEndTop = structuralFooterVisualEndTop?.minus(dy)
2255
+ updateStructuralFooterVisualPosition()
2256
+ }
2257
+ }
2258
+ updateSidecarPosition()
2259
+ if (structuralTail != null) updateStructuralTailWindow()
727
2260
  emitScrollPositionThresholdIfNeeded(resync = dx == 0 && dy == 0)
728
2261
  if (dx != 0 || dy != 0) invalidateActionAnchor("scroll")
729
2262
  syncSectionIndexToVisibleRows()
@@ -781,6 +2314,11 @@ class NativeListView(
781
2314
  }
782
2315
  val nextWidth = right - left
783
2316
  val nextHeight = bottom - top
2317
+ val widthChanged = lastLayoutWidth != nextWidth
2318
+ val directionChanged = layoutDirection != lastLayoutDirection
2319
+ if (structuralTail != null && lastLayoutWidth >= 0 &&
2320
+ (widthChanged || lastLayoutHeight != nextHeight || directionChanged))
2321
+ finishStructuralTail(interrupted = true)
784
2322
  if (
785
2323
  lastLayoutWidth >= 0 &&
786
2324
  (nextWidth != lastLayoutWidth || nextHeight != lastLayoutHeight || layoutDirection != lastLayoutDirection)
@@ -790,6 +2328,9 @@ class NativeListView(
790
2328
  lastLayoutWidth = nextWidth
791
2329
  lastLayoutHeight = nextHeight
792
2330
  lastLayoutDirection = layoutDirection
2331
+ if ((widthChanged || directionChanged) && sidecarLayout != null) config?.let(::updateLayout)
2332
+ updateSidecarGeometry()
2333
+ scheduleSidecarSpacer()
793
2334
  updateSectionIndexAttachment()
794
2335
  performPendingScrollIfNeeded()
795
2336
  }
@@ -821,6 +2362,27 @@ class NativeListView(
821
2362
  }
822
2363
 
823
2364
  override fun onDetachedFromWindow() {
2365
+ val disclosureId = activeDisclosureTransitionId
2366
+ if (structuralTail != null) {
2367
+ discardStructuralTail()
2368
+ val epoch = structuralTransitionEpoch
2369
+ config?.let { accepted ->
2370
+ adapter.submitList(accepted.items) {
2371
+ if (!disposed && epoch == structuralTransitionEpoch &&
2372
+ activeDisclosureTransitionId == disclosureId && disclosureId != null) {
2373
+ disclosureFraction = if (accepted.disclosure?.expanded == true) 1f else 0f
2374
+ disclosureTransitioning = false
2375
+ syncDisclosurePresentation()
2376
+ settleDisclosureCommand("interrupted")
2377
+ }
2378
+ }
2379
+ }
2380
+ } else if (disclosureId != null && !disposed) {
2381
+ disclosureFraction = if (config?.disclosure?.expanded == true) 1f else 0f
2382
+ disclosureTransitioning = false
2383
+ syncDisclosurePresentation()
2384
+ settleDisclosureCommand("interrupted")
2385
+ }
824
2386
  val cancelTag = R.id.onekey_native_scroll_coordinator_command_cancel_listener
825
2387
  if (recyclerView.getTag(cancelTag) === coordinatorCommandCancelListener) recyclerView.setTag(cancelTag, null)
826
2388
  cancelExecutingScrollCommand()
@@ -841,9 +2403,94 @@ class NativeListView(
841
2403
  } catch (_: Exception) {
842
2404
  return
843
2405
  }
844
- homeOffsetDiagnostic("snapshot-before-nextCount=${next.items.size}")
845
2406
  invalidateActionAnchor("snapshot")
846
2407
  val previous = config
2408
+ val transition = next.structuralTransition
2409
+ val isNewTransition = transition != null && transition.id != consumedStructuralTransitionId
2410
+ if (transition != null) consumedStructuralTransitionId = transition.id
2411
+ val disclosureGeometryInvalid = batchedDisclosureGeometryInvalid ||
2412
+ (!isNewTransition && structuralTail != null &&
2413
+ previous?.disclosure != next.disclosure)
2414
+ batchedDisclosureGeometryInvalid = false
2415
+ if (!isNewTransition &&
2416
+ (next.disclosure == null || previous?.disclosure?.expanded != next.disclosure.expanded)) {
2417
+ activeDisclosureTransitionId = null
2418
+ directDisclosureTogglePositionPending = false
2419
+ }
2420
+ val oldKeys = previous?.items?.map { it.key } ?: emptyList()
2421
+ val nextKeys = next.items.map { it.key }
2422
+ if (next.disclosure != null && isNewTransition && previous != null) {
2423
+ activeDisclosureTransitionId = transition?.id
2424
+ directDisclosureStatus = if (oldKeys == nextKeys || nextKeys.isEmpty() ||
2425
+ transition?.durationMs == 0L ||
2426
+ (Build.VERSION.SDK_INT >= 26 && !ValueAnimator.areAnimatorsEnabled()))
2427
+ "completed" else "interrupted"
2428
+ directDisclosureTogglePositionPending = !next.disclosure.expanded &&
2429
+ !disclosureGeometryInvalid && directDisclosureStatus == "completed"
2430
+ } else if (next.disclosure == null) {
2431
+ activeDisclosureTransitionId = null
2432
+ directDisclosureTogglePositionPending = false
2433
+ }
2434
+ if (disclosureGeometryInvalid && activeDisclosureTransitionId != null) {
2435
+ directDisclosureStatus = "interrupted"
2436
+ directDisclosureTogglePositionPending = false
2437
+ }
2438
+ val positionDirectCollapse = directDisclosureTogglePositionPending
2439
+ var animateStructure = !disclosureGeometryInvalid && previous != null &&
2440
+ isNewTransition && oldKeys != nextKeys &&
2441
+ previous.orientation == "vertical" &&
2442
+ (previous.layout == "linear" || previous.layout == "table") &&
2443
+ next.orientation == "vertical" && (next.layout == "linear" || next.layout == "table") &&
2444
+ (transition?.durationMs ?: 0) > 0 &&
2445
+ (if (Build.VERSION.SDK_INT >= 26) ValueAnimator.areAnimatorsEnabled() else
2446
+ android.provider.Settings.Global.getFloat(context.contentResolver, "animator_duration_scale", 1f) > 0f)
2447
+ val prefixCount = if (animateStructure && previous != null && recyclerView.width > 0 &&
2448
+ recyclerView.height > 0) prefixTailCount(previous, next) else null
2449
+ val exceedsTailHostLimit = prefixCount != null && previous != null &&
2450
+ exceedsStructuralTailHostLimit(previous, next, prefixCount)
2451
+ if (exceedsTailHostLimit) animateStructure = false
2452
+ if (next.disclosure != null && prefixCount == null) animateStructure = false
2453
+ if (prefixCount != null && !exceedsTailHostLimit && previous != null && transition != null) {
2454
+ startPrefixTailTransition(previous, next, transition, prefixCount)
2455
+ return
2456
+ }
2457
+ var heldTailEpoch: Int? = null
2458
+ if (structuralTail != null) {
2459
+ if (!disclosureGeometryInvalid && !animateStructure && oldKeys == nextKeys &&
2460
+ (Build.VERSION.SDK_INT < 26 || ValueAnimator.areAnimatorsEnabled()) &&
2461
+ previous != null && updatePrefixTailSnapshot(previous, next, acceptIndex = true)) {
2462
+ return
2463
+ }
2464
+ heldTailEpoch = holdStructuralTailUntilListCommit()
2465
+ // Keep the accepted presentation until the full adapter list has committed.
2466
+ animateStructure = false
2467
+ }
2468
+ val nextKeySet = nextKeys.toHashSet()
2469
+ val visibleAnchor = if (animateStructure) {
2470
+ val first = layoutManager.findFirstVisibleItemPosition()
2471
+ val last = layoutManager.findLastVisibleItemPosition()
2472
+ (first..last).firstNotNullOfOrNull { position ->
2473
+ val rowIndex = rowPositionForScroll(position)
2474
+ oldKeys.getOrNull(rowIndex)?.takeIf(nextKeySet::contains)?.let { key ->
2475
+ layoutManager.findViewByPosition(position)?.let { key to layoutManager.getDecoratedTop(it) }
2476
+ }
2477
+ }
2478
+ } else null
2479
+ val anchorKey = visibleAnchor?.first ?: transition?.anchorKey?.takeIf(nextKeySet::contains)
2480
+ val anchorTop = visibleAnchor?.second
2481
+ val footerStartTop = if (animateStructure && footerSlotAdapter.itemCount > 0) {
2482
+ layoutManager.findViewByPosition(scrollContentAdapter.itemCount - 1 - sidecarSpacerAdapter.itemCount)?.let {
2483
+ layoutManager.getDecoratedTop(it) + it.translationY.roundToInt()
2484
+ }
2485
+ } else null
2486
+ val oldKeySet = oldKeys.toHashSet()
2487
+ val removedExtent = if (animateStructure) previous?.items?.sumOf { item ->
2488
+ if (nextKeySet.contains(item.key)) 0 else estimatedItemExtentPx(item)
2489
+ } ?: 0 else 0
2490
+ val addedExtent = if (animateStructure) next.items.sumOf { item ->
2491
+ if (oldKeySet.contains(item.key)) 0 else estimatedItemExtentPx(item)
2492
+ } else 0
2493
+ if (!animateStructure && oldKeys != nextKeys && heldTailEpoch == null) cancelStructuralTransition()
847
2494
  if (previous?.items?.isEmpty() == true && next.items.isNotEmpty() && adapter.itemCount == 0 &&
848
2495
  pendingScrollRequest == null && recyclerView.scrollState != RecyclerView.SCROLL_STATE_DRAGGING &&
849
2496
  !recyclerView.canScrollVertically(-1) && layoutManager.findFirstVisibleItemPosition() == 0) {
@@ -866,7 +2513,12 @@ class NativeListView(
866
2513
  previous.items[index].content != next.items[index].content
867
2514
  }
868
2515
  }.toSet()
2516
+ acceptRowIndex(next, previous)
869
2517
  config = next
2518
+ if (heldTailEpoch == null) {
2519
+ disclosureFraction = if (next.disclosure?.expanded == true) 1f else 0f
2520
+ disclosureTransitioning = false
2521
+ }
870
2522
  updateContainerSlots()
871
2523
  usesSelectorSourceScale = next.items.any { it.usesSelectorSourceScale }
872
2524
  adapter.usesSelectorSourceScale = usesSelectorSourceScale
@@ -878,15 +2530,23 @@ class NativeListView(
878
2530
  // Rows rebind through the adapter's listStyle invalidation; the fixed footer is bound
879
2531
  // outside the adapter, so a listStyle-only snapshot must rebind it here.
880
2532
  if (previous.listStyle?.toString() != next.listStyle?.toString()) bindFooter(next)
2533
+ val directDisclosureId = activeDisclosureTransitionId
881
2534
  adapter.submitList(next.items) {
882
- homeOffsetDiagnostic("snapshot-stable-submit-commit")
883
- // OneKey patch: DiffUtil has dispatched its payloads; release the old serialized rows.
884
- next.items.forEach { it.selectionUpdateFromContent = null }
885
- recyclerView.post {
886
- bindVisibleSelection(changedSummaryKeys)
887
- bindFooterSelection()
888
- performPendingScrollIfNeeded()
2535
+ val committed: () -> Unit = {
2536
+ // OneKey patch: DiffUtil has dispatched its payloads; release the old serialized rows.
2537
+ next.items.forEach { it.selectionUpdateFromContent = null }
2538
+ recyclerView.post {
2539
+ scheduleSidecarSpacer()
2540
+ startPendingStructuralTransition()
2541
+ bindVisibleSelection(changedSummaryKeys)
2542
+ bindFooterSelection()
2543
+ performPendingScrollIfNeeded()
2544
+ settleCommittedDirectDisclosure(next, directDisclosureId, heldTailEpoch == null)
2545
+ }
889
2546
  }
2547
+ if (heldTailEpoch != null) completeHeldTailCommit(next, heldTailEpoch, committed,
2548
+ positionDirectCollapse)
2549
+ else committed()
890
2550
  }
891
2551
  return
892
2552
  }
@@ -905,7 +2565,12 @@ class NativeListView(
905
2565
  layoutManager.getDecoratedTop(it) - recyclerView.paddingTop
906
2566
  }
907
2567
  } else null
2568
+ acceptRowIndex(next, previous)
908
2569
  config = next
2570
+ if (heldTailEpoch == null) {
2571
+ disclosureFraction = if (next.disclosure?.expanded == true) 1f else 0f
2572
+ disclosureTransitioning = false
2573
+ }
909
2574
  pruneMeasuredRowExtents(next.items)
910
2575
  updateContainerSlots()
911
2576
  usesSelectorSourceScale = next.items.any { it.usesSelectorSourceScale }
@@ -919,16 +2584,52 @@ class NativeListView(
919
2584
  adapter.selectedKeys = next.selectedKeys
920
2585
  configureSectionIndex(next)
921
2586
  updateLayout(next)
2587
+ if (animateStructure && transition != null) {
2588
+ structuralTransitionEpoch += 1
2589
+ pendingStructuralStartScheduled = false
2590
+ structuralAnimator?.cancel()
2591
+ structuralAnimator = null
2592
+ structuralScrollInterrupted = false
2593
+ structuralFooterHoldTop = footerStartTop
2594
+ structuralFooterCompensatedHeightPx = slotHeights[2]
2595
+ val held = (structuralSpacerHeightPx + removedExtent - addedExtent).coerceAtLeast(0)
2596
+ pendingStructuralTransition = PendingStructuralTransition(
2597
+ transition, structuralTransitionEpoch, anchorKey, anchorTop, footerStartTop, held,
2598
+ removedExtent > addedExtent,
2599
+ )
2600
+ recyclerView.itemAnimator = DefaultItemAnimator().apply {
2601
+ addDuration = transition.durationMs
2602
+ removeDuration = transition.durationMs
2603
+ moveDuration = 0
2604
+ changeDuration = 0
2605
+ supportsChangeAnimations = false
2606
+ }
2607
+ }
2608
+ val directDisclosureId = activeDisclosureTransitionId
922
2609
  adapter.submitList(next.items) {
923
- homeOffsetDiagnostic("snapshot-submit-commit-marketAnchor=${marketStartOffset != null}")
924
- if (marketStartOffset != null) {
925
- layoutManager.scrollToPositionWithOffset(scrollPositionForRow(0), marketStartOffset)
2610
+ val committed: () -> Unit = {
2611
+ pendingStructuralTransition?.takeIf { it.epoch == structuralTransitionEpoch }?.let {
2612
+ setStructuralSpacerHeight(it.heldHeightPx)
2613
+ pendingStructuralTransition = it.copy(spacerInserted = true)
2614
+ pendingStructuralTransition?.let(::alignPendingStructuralAnchor)
2615
+ }
2616
+ if (marketStartOffset != null) {
2617
+ layoutManager.scrollToPositionWithOffset(scrollPositionForRow(0), marketStartOffset)
2618
+ }
2619
+ relayoutContents(preserveMarketPaginationAnchor)
2620
+ scheduleSidecarSpacer()
2621
+ startPendingStructuralTransition()
2622
+ performPendingScrollIfNeeded()
2623
+ syncSectionIndexToVisibleRows()
2624
+ scheduleVisibleEvent()
2625
+ if (next.items.isNotEmpty()) checkEndReached()
2626
+ if (directDisclosureId != null) recyclerView.post {
2627
+ settleCommittedDirectDisclosure(next, directDisclosureId, heldTailEpoch == null)
2628
+ }
926
2629
  }
927
- relayoutContents(preserveMarketPaginationAnchor)
928
- performPendingScrollIfNeeded()
929
- syncSectionIndexToVisibleRows()
930
- scheduleVisibleEvent()
931
- if (next.items.isNotEmpty()) checkEndReached()
2630
+ if (heldTailEpoch != null) completeHeldTailCommit(next, heldTailEpoch, committed,
2631
+ positionDirectCollapse)
2632
+ else committed()
932
2633
  }
933
2634
  refreshLayout.isEnabled = next.pullToRefresh
934
2635
  refreshLayout.setDistanceToTriggerSync(next.refreshTriggerDistance?.let { (it * density).roundToInt() } ?: defaultRefreshTriggerDistancePx)
@@ -937,6 +2638,85 @@ class NativeListView(
937
2638
  updateReordering(next)
938
2639
  }
939
2640
 
2641
+ private fun startStructuralTransition(
2642
+ transition: NativeListConfig.StructuralTransition,
2643
+ epoch: Int,
2644
+ anchorKey: String?,
2645
+ anchorTop: Int?,
2646
+ footerStartTop: Int?,
2647
+ ) {
2648
+ structuralFooterHoldTop = null
2649
+ val startSpacer = structuralSpacerHeightPx
2650
+ structuralStartSpacerPx = startSpacer
2651
+ structuralFooterVisualStartTop = footerStartTop
2652
+ structuralFooterVisualProgress = 0f
2653
+ val startOffset = measuredScrollDistancePx()?.also { logicalScrollDistancePx = it }
2654
+ ?: logicalScrollDistancePx
2655
+ val anchorIndex = anchorKey?.let { key -> adapter.currentList.indexOfFirst { it.key == key } }
2656
+ val currentAnchorTop = anchorIndex?.takeIf { it >= 0 }?.let { index ->
2657
+ layoutManager.findViewByPosition(scrollPositionForRow(index))?.let(layoutManager::getDecoratedTop)
2658
+ }
2659
+ val anchorDelta = if (anchorTop != null && currentAnchorTop != null) currentAnchorTop - anchorTop else 0
2660
+ val leftFinal = (sidecarLeftExtentPx() - startSpacer).coerceAtLeast(0)
2661
+ val sidecarBottom = if (sidecarLayout != null)
2662
+ recyclerView.paddingTop + slotHeights[3] + recyclerView.paddingBottom else 0
2663
+ val finalMax = (max(leftFinal, sidecarBottom) - recyclerView.height).coerceAtLeast(0)
2664
+ var targetOffset = (startOffset + anchorDelta).coerceIn(0, finalMax)
2665
+ if (anchorTop == null && anchorIndex != null && anchorIndex >= 0) {
2666
+ val fallbackTop = nativeListScrollPrefixPx(
2667
+ scrollPositionForRow(anchorIndex),
2668
+ layoutManager.spanCount,
2669
+ layoutManager.spanSizeLookup::getSpanSize,
2670
+ ::estimatedScrollExtentPx,
2671
+ ).coerceIn(0L, Int.MAX_VALUE.toLong()).toInt()
2672
+ targetOffset = minOf(targetOffset, fallbackTop)
2673
+ }
2674
+ structuralFooterVisualEndTop = if (footerStartTop != null && footerSlotAdapter.itemCount > 0) {
2675
+ val footerPosition = scrollContentAdapter.itemCount - 1 - sidecarSpacerAdapter.itemCount
2676
+ val finalPrefix = nativeListScrollPrefixPx(
2677
+ footerPosition,
2678
+ layoutManager.spanCount,
2679
+ layoutManager.spanSizeLookup::getSpanSize,
2680
+ ::estimatedScrollExtentPx,
2681
+ ) - startSpacer
2682
+ (finalPrefix + recyclerView.paddingTop - targetOffset)
2683
+ .coerceIn(Int.MIN_VALUE.toLong(), Int.MAX_VALUE.toLong()).toInt()
2684
+ } else null
2685
+ structuralAnimator = ValueAnimator.ofFloat(0f, 1f).apply {
2686
+ duration = transition.durationMs
2687
+ interpolator = android.view.animation.AccelerateDecelerateInterpolator()
2688
+ addUpdateListener { animation ->
2689
+ if (epoch != structuralTransitionEpoch || disposed) return@addUpdateListener
2690
+ val fraction = animation.animatedValue as Float
2691
+ structuralFooterVisualProgress = fraction
2692
+ setStructuralSpacerHeight((structuralStartSpacerPx * (1f - fraction)).roundToInt())
2693
+ measuredScrollDistancePx()?.let { logicalScrollDistancePx = it }
2694
+ val finalLeftExtent = (sidecarLeftExtentPx() - structuralSpacerHeightPx).coerceAtLeast(0)
2695
+ val finalMaximum = (max(finalLeftExtent, sidecarBottom) - recyclerView.height).coerceAtLeast(0)
2696
+ val desired = (startOffset + (minOf(targetOffset, finalMaximum) - startOffset) * fraction).roundToInt()
2697
+ if (!structuralScrollInterrupted && recyclerView.scrollState == RecyclerView.SCROLL_STATE_IDLE)
2698
+ recyclerView.scrollBy(0, desired - logicalScrollDistancePx)
2699
+ updateStructuralFooterVisualPosition()
2700
+ }
2701
+ addListener(object : AnimatorListenerAdapter() {
2702
+ override fun onAnimationEnd(animation: Animator) {
2703
+ if (epoch != structuralTransitionEpoch) return
2704
+ structuralAnimator = null
2705
+ recyclerView.itemAnimator = null
2706
+ setStructuralSpacerHeight(0)
2707
+ scheduleSidecarSpacer()
2708
+ footerSlotAdapter.setVisualTranslationY(0f)
2709
+ structuralFooterVisualStartTop = null
2710
+ structuralFooterVisualEndTop = null
2711
+ structuralFooterVisualProgress = 0f
2712
+ structuralFooterCompensatedHeightPx = null
2713
+ structuralStartSpacerPx = 0
2714
+ }
2715
+ })
2716
+ start()
2717
+ }
2718
+ }
2719
+
940
2720
  private fun captureVisibleMarketAnchor(): VisibleMarketAnchor? {
941
2721
  val position = rowPositionForScroll(layoutManager.findFirstVisibleItemPosition()).coerceAtLeast(0)
942
2722
  if (position == RecyclerView.NO_POSITION) return null
@@ -1111,20 +2891,22 @@ class NativeListView(
1111
2891
 
1112
2892
  fun applyPatches(patchesJson: String) {
1113
2893
  val current = config ?: return
2894
+ val indexByKey = acceptedKeyIndex ?: return
1114
2895
  val patches = try {
1115
2896
  JSONArray(patchesJson)
1116
2897
  } catch (_: Exception) {
1117
2898
  return
1118
2899
  }
1119
- val indexByKey = current.items.withIndex().associate { it.value.key to it.index }
1120
2900
  val pending = ArrayList<Pair<Int, JSONObject>>(patches.length())
1121
2901
  val seen = HashSet<String>()
1122
2902
  for (index in 0 until patches.length()) {
1123
2903
  val patch = patches.optJSONObject(index) ?: return
1124
2904
  val key = patch.optString("key")
1125
2905
  val itemIndex = indexByKey[key] ?: return
2906
+ val item = current.items.getOrNull(itemIndex) ?: return
2907
+ if (item.key != key) return
1126
2908
  if (!seen.add(key)) return
1127
- if (patch.optString("type") != current.items[itemIndex].type) return
2909
+ if (patch.optString("type") != item.type) return
1128
2910
  val changes = patch.optJSONObject("changes") ?: return
1129
2911
  pending.add(itemIndex to changes)
1130
2912
  }
@@ -1148,12 +2930,27 @@ class NativeListView(
1148
2930
  }
1149
2931
  if (!marketQuoteOnly) invalidateActionAnchor("snapshot")
1150
2932
  val next = current.copy(items = nextItems, selectedKeys = selected)
2933
+ if (structuralTail != null && updatePrefixTailSnapshot(current, next)) return
2934
+ val heldTailEpoch = if (structuralTail != null) holdStructuralTailUntilListCommit() else null
1151
2935
  config = next
1152
2936
  updateContainerSlots()
1153
2937
  usesSelectorSourceScale = next.items.any { it.usesSelectorSourceScale }
1154
2938
  adapter.usesSelectorSourceScale = usesSelectorSourceScale
1155
2939
  adapter.selectedKeys = selected
1156
- adapter.submitList(nextItems) { relayoutContents() }
2940
+ val directDisclosureId = activeDisclosureTransitionId
2941
+ adapter.submitList(nextItems) {
2942
+ if (heldTailEpoch != null) {
2943
+ if (heldTailEpoch != structuralTransitionEpoch || disposed) return@submitList
2944
+ val acceptedPendingFooter = acceptPendingFooterValues(adjustTailStart = false)
2945
+ clearStructuralTail()
2946
+ if (acceptedPendingFooter) updateContainerSlots()
2947
+ setStructuralSpacerHeight(0)
2948
+ }
2949
+ relayoutContents()
2950
+ if (directDisclosureId != null) recyclerView.post {
2951
+ settleCommittedDirectDisclosure(next, directDisclosureId, heldTailEpoch == null)
2952
+ }
2953
+ }
1157
2954
  bindFooter(next)
1158
2955
  }
1159
2956
 
@@ -1215,12 +3012,12 @@ class NativeListView(
1215
3012
  is ScrollRequest.End -> true
1216
3013
  }
1217
3014
  if (!valid) return
3015
+ if (structuralTail != null) finishStructuralTail(interrupted = true)
1218
3016
  executingScrollCommand = false
1219
3017
  dispatchingScrollRequest = null
1220
3018
  commandLayoutRequest = null
1221
3019
  commandAlignmentRequest = null
1222
3020
  acceptedScrollRequest = request
1223
- commandFinishDiagnosticReason = null
1224
3021
  readingIntentEpoch++
1225
3022
  pendingBodyStartEpoch = null
1226
3023
  val explicitTop = when (request) {
@@ -1240,6 +3037,7 @@ class NativeListView(
1240
3037
 
1241
3038
  private val coordinatorCommandCancelListener = Runnable {
1242
3039
  if (disposed) return@Runnable
3040
+ if (structuralTail != null) finishStructuralTail(interrupted = true)
1243
3041
  pendingScrollRequest = null
1244
3042
  readingIntentEpoch++
1245
3043
  pendingBodyStartEpoch = null
@@ -1261,15 +3059,6 @@ class NativeListView(
1261
3059
 
1262
3060
  private fun finishScrollCommandIfSettled() {
1263
3061
  val request = acceptedScrollRequest ?: return
1264
- if (homeOffsetDiagnosticEnabled && executingScrollCommand) {
1265
- val reason = "pendingOwn=${pendingScrollRequest === request}-dispatchOwn=${dispatchingScrollRequest === request}" +
1266
- "-layoutOwn=${commandLayoutRequest === request}-alignOwn=${commandAlignmentRequest === request}" +
1267
- "-computing=${recyclerView.isComputingLayout}-scrollState=${recyclerView.scrollState}-smooth=${layoutManager.isSmoothScrolling}"
1268
- if (commandFinishDiagnosticReason != reason) {
1269
- commandFinishDiagnosticReason = reason
1270
- homeOffsetDiagnostic("command-finish-gates-$reason")
1271
- }
1272
- }
1273
3062
  if (
1274
3063
  !executingScrollCommand || pendingScrollRequest === request ||
1275
3064
  dispatchingScrollRequest === request || commandLayoutRequest === request ||
@@ -1285,7 +3074,6 @@ class NativeListView(
1285
3074
  private fun requestScroll(request: ScrollRequest) {
1286
3075
  acceptScrollRequest(request)
1287
3076
  if (config != null && acceptedScrollRequest !== request) return
1288
- homeOffsetDiagnostic("command-request-kind=${request.javaClass.simpleName}")
1289
3077
  if (performScroll(request)) {
1290
3078
  pendingScrollRequest = null
1291
3079
  finishScrollCommandIfSettled()
@@ -1301,7 +3089,6 @@ class NativeListView(
1301
3089
  if (pendingScrollRequest === request) pendingScrollRequest = null
1302
3090
  return
1303
3091
  }
1304
- homeOffsetDiagnostic("command-pending-kind=${request.javaClass.simpleName}")
1305
3092
  if (performScroll(request)) {
1306
3093
  pendingScrollRequest = null
1307
3094
  finishScrollCommandIfSettled()
@@ -1309,7 +3096,6 @@ class NativeListView(
1309
3096
  }
1310
3097
 
1311
3098
  private fun performScroll(request: ScrollRequest): Boolean {
1312
- homeOffsetDiagnostic("command-perform-kind=${request.javaClass.simpleName}")
1313
3099
  val current = config ?: return false
1314
3100
  if (adapter.itemCount != current.items.size) return false
1315
3101
  if (recyclerView.width <= 0 || recyclerView.height <= 0) return false
@@ -1353,7 +3139,8 @@ class NativeListView(
1353
3139
  }
1354
3140
  is ScrollRequest.End -> {
1355
3141
  val finalSlotHeight = when {
1356
- footerSlotAdapter.itemCount > 0 -> slotHeights[2]
3142
+ sidecarSpacerAdapter.itemCount > 0 -> sidecarSpacerHeightPx
3143
+ footerSlotAdapter.itemCount > 0 -> footerPresentedHeightPx()
1357
3144
  emptySlotAdapter.itemCount > 0 -> slotHeights[1]
1358
3145
  adapter.itemCount == 0 && headerSlotAdapter.itemCount > 0 -> slotHeights[0]
1359
3146
  else -> null
@@ -1489,13 +3276,11 @@ class NativeListView(
1489
3276
  val startView = manager.findViewByPosition(0)
1490
3277
  val paddingStart = if (manager.orientation == RecyclerView.VERTICAL) manager.paddingTop else manager.paddingLeft
1491
3278
  val relativeStart = startView?.let { decoratedStart(manager, it) - paddingStart }
1492
- homeOffsetDiagnostic("command-top-dispatch-first=${manager.findFirstVisibleItemPosition()}-relativeStart=$relativeStart-smooth=${manager.isSmoothScrolling}")
1493
3279
  if (
1494
3280
  startView != null && manager.findFirstVisibleItemPosition() == 0 && relativeStart == 0 &&
1495
3281
  recyclerView.scrollState == RecyclerView.SCROLL_STATE_IDLE &&
1496
3282
  !recyclerView.isComputingLayout && !manager.isSmoothScrolling
1497
3283
  ) {
1498
- homeOffsetDiagnostic("command-top-noop")
1499
3284
  return
1500
3285
  }
1501
3286
  recyclerView.smoothScrollToPosition(0)
@@ -1648,6 +3433,8 @@ class NativeListView(
1648
3433
  fun dispose() {
1649
3434
  if (disposed) return
1650
3435
  disposed = true
3436
+ acceptedKeyIndex = null
3437
+ if (structuralTail != null) discardStructuralTail()
1651
3438
  val cancelTag = R.id.onekey_native_scroll_coordinator_command_cancel_listener
1652
3439
  if (recyclerView.getTag(cancelTag) === coordinatorCommandCancelListener) recyclerView.setTag(cancelTag, null)
1653
3440
  containerSlotRelayoutPending = false
@@ -1658,6 +3445,8 @@ class NativeListView(
1658
3445
  private fun disposeResources() {
1659
3446
  if (resourcesDisposed) return
1660
3447
  resourcesDisposed = true
3448
+ cancelStructuralTransition()
3449
+ recyclerView.itemAnimator = null
1661
3450
  stopSectionIndexAttachmentTracking()
1662
3451
  scheduleSectionIndexAttachmentToList()
1663
3452
  stopReorderRelayoutLoop()
@@ -1667,7 +3456,6 @@ class NativeListView(
1667
3456
  visibleEventScheduled = false
1668
3457
  layoutManager.onChildLaidOut = null
1669
3458
  layoutManager.onLayoutPassCompleted = null
1670
- layoutManager.onDiagnosticLayout = null
1671
3459
  layoutManager.onBeforeDataLayout = null
1672
3460
  pendingBodyStartEpoch = null
1673
3461
  executingScrollCommand = false
@@ -1722,12 +3510,26 @@ class NativeListView(
1722
3510
  val coordinatorBottomPadding = (recyclerView.paddingBottom - configuredBottomPaddingPx).coerceAtLeast(0)
1723
3511
  // Slot roots are laid out by React at the unscaled JS inset, without the index gutter.
1724
3512
  slotInsetPx = (horizontalPadding * density).roundToInt()
3513
+ val sidecar = sidecarLayout
3514
+ val contentWidth = recyclerView.width
3515
+ if (sidecar != null && contentWidth > 0) {
3516
+ slotInsetPx = (contentWidth - minOf(contentWidth, sidecar.maxContentWidth)) / 2
3517
+ }
3518
+ val sidecarSpace = (sidecar?.width ?: 0) + (sidecar?.gap ?: 0)
3519
+ val baseSlotInset = slotInsetPx
3520
+ if (sidecar != null && layoutDirection == LAYOUT_DIRECTION_RTL) {
3521
+ slotInsetPx += sidecarSpace
3522
+ slotRightInsetPx = baseSlotInset
3523
+ } else {
3524
+ slotRightInsetPx = baseSlotInset + sidecarSpace
3525
+ }
1725
3526
  configuredTopPaddingPx = dp(topPadding)
1726
3527
  configuredBottomPaddingPx = dp(bottomPadding)
1727
3528
  recyclerView.setPaddingRelative(
1728
- dp(horizontalPadding),
3529
+ if (sidecar == null) dp(horizontalPadding) else baseSlotInset + dp(horizontalPadding),
1729
3530
  configuredTopPaddingPx + coordinatorTopPadding,
1730
- dp(horizontalPadding + indexGutter),
3531
+ if (sidecar == null) dp(horizontalPadding + indexGutter)
3532
+ else baseSlotInset + sidecarSpace + dp(horizontalPadding + indexGutter),
1731
3533
  configuredBottomPaddingPx + coordinatorBottomPadding,
1732
3534
  )
1733
3535
  recyclerView.clipToPadding = false
@@ -2117,15 +3919,16 @@ class NativeListView(
2117
3919
  actionAnchor = record
2118
3920
  // OneKey patch: selector menus anchor to the glyph slot, not its expanded press target.
2119
3921
  val anchorInset = origin.anchorInsetPixels
3922
+ val helpBounds = (origin.sourceView as? NativeListTextView)?.helpAnchorBounds
2120
3923
  return JSONObject()
2121
3924
  .put("token", token)
2122
3925
  .put(
2123
3926
  "windowRect",
2124
3927
  JSONObject()
2125
- .put("x", (location[0] + anchorInset) / density)
2126
- .put("y", (location[1] + anchorInset) / density)
2127
- .put("width", (origin.sourceView.width - anchorInset * 2) / density)
2128
- .put("height", (origin.sourceView.height - anchorInset * 2) / density),
3928
+ .put("x", (location[0] + (helpBounds?.left ?: anchorInset.toFloat())) / density)
3929
+ .put("y", (location[1] + (helpBounds?.top ?: anchorInset.toFloat())) / density)
3930
+ .put("width", (helpBounds?.width() ?: (origin.sourceView.width - anchorInset * 2).toFloat()) / density)
3931
+ .put("height", (helpBounds?.height() ?: (origin.sourceView.height - anchorInset * 2).toFloat()) / density),
2129
3932
  )
2130
3933
  .put("source", origin.source)
2131
3934
  .put("generation", generation)
@@ -2666,6 +4469,7 @@ class NativeListView(
2666
4469
  if (from != RecyclerView.NO_POSITION && to != RecyclerView.NO_POSITION && from != to && reordered != null) {
2667
4470
  val moved = reordered.getOrNull(to)
2668
4471
  if (moved != null) {
4472
+ acceptedKeyIndex = reordered.withIndex().associate { it.value.key to it.index }
2669
4473
  config = config?.copy(items = reordered)
2670
4474
  val payload = JSONObject()
2671
4475
  .put("key", moved.key)
@@ -2849,6 +4653,7 @@ class NativeListView(
2849
4653
  }
2850
4654
 
2851
4655
  private fun checkEndReached() {
4656
+ if (structuralTail != null) return
2852
4657
  val current = config ?: return
2853
4658
  if (!current.loadMore || current.generation == endReachedGeneration || adapter.itemCount == 0) return
2854
4659
  val manager = recyclerView.layoutManager as? LinearLayoutManager ?: return
@@ -3354,7 +5159,7 @@ private class ItemSpacingDecoration(
3354
5159
  * from each edge. Row padding may be scaled or widened by the section-index gutter, so slot
3355
5160
  * holders offset themselves (possibly negatively) back to the JavaScript inset.
3356
5161
  */
3357
- private class ContainerSlotInsetDecoration(private val insetPx: () -> Int) : RecyclerView.ItemDecoration() {
5162
+ private class ContainerSlotInsetDecoration(private val insetsPx: () -> Pair<Int, Int>) : RecyclerView.ItemDecoration() {
3358
5163
  override fun getItemOffsets(
3359
5164
  outRect: android.graphics.Rect,
3360
5165
  view: View,
@@ -3362,9 +5167,9 @@ private class ContainerSlotInsetDecoration(private val insetPx: () -> Int) : Rec
3362
5167
  state: RecyclerView.State,
3363
5168
  ) {
3364
5169
  if (parent.getChildViewHolder(view) !is NativeListContainerSlotAdapter.Holder) return
3365
- val inset = insetPx()
3366
- outRect.left = inset - parent.paddingLeft
3367
- outRect.right = inset - parent.paddingRight
5170
+ val (leftInset, rightInset) = insetsPx()
5171
+ outRect.left = leftInset - parent.paddingLeft
5172
+ outRect.right = rightInset - parent.paddingRight
3368
5173
  }
3369
5174
  }
3370
5175