@onekeyfe/react-native-native-list 6.7.0-alpha.378 → 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
@@ -30,11 +30,20 @@ final class NativeListView: UIView {
30
30
  }
31
31
  private var refreshEmittedForDrag = false
32
32
  private var minimumDragOffset: CGFloat = 0
33
- private var containerSlotHeights: [CGFloat] = [0, 0, 0]
33
+ private var containerSlotHeights: [CGFloat] = [0, 0, 0, 0, 0, 0]
34
+ private var disclosureCVisibleHeight: CGFloat = 0
35
+ private var disclosureInset: CGFloat = 0
36
+ private var disclosureStartCHeight: CGFloat = 0
37
+ private var disclosureStartInset: CGFloat = 0
38
+ private var disclosureSegmentStartEased: CFTimeInterval = 0
39
+ private var deferredDisclosureGeometryInvalid = false
40
+ private var pendingDisclosureSettlement: (id: String, expanded: Bool)?
34
41
  // Fabric keeps assigning each slot root the frame React computed (inset, 0, width, height).
35
42
  // Each root therefore lives in a list-owned host whose origin the list alone controls, so a
36
43
  // React layout update can never move a slot out of its native position, even for a frame.
37
44
  private var reactContainerSlots: [(content: UIView, host: UIView)] = []
45
+ private var sidecarLayout: (width: CGFloat, gap: CGFloat, maxContentWidth: CGFloat)?
46
+ private let pagerStickyHeights = NSMapTable<AnyObject, NSNumber>(keyOptions: .weakMemory, valueOptions: .strongMemory)
38
47
  private let topOverlayHost = UIView()
39
48
  private weak var topOverlayContent: UIView?
40
49
  private var topEdgeInteraction: UIInteraction?
@@ -47,16 +56,239 @@ final class NativeListView: UIView {
47
56
  private var lastLaidOutContentHeight: CGFloat?
48
57
  private var needsContentShrinkCorrection = false
49
58
  private var contentShrinkCorrectionScheduled = false
59
+ private var consumedStructuralTransitionID: String?
60
+ private var structuralDisplayLink: CADisplayLink?
61
+ private var structuralScrollPhaseActive = false
62
+ private var structuralSpacerHeight: CGFloat = 0
63
+ private var structuralAnimationStart: CFTimeInterval = 0
64
+ private var structuralAnimationDuration: CFTimeInterval = 0
65
+ private var structuralStartSpacer: CGFloat = 0
66
+ private var structuralStartOffset: CGFloat = 0
67
+ private var structuralTargetOffset: CGFloat = 0
68
+ private var structuralScrollInterrupted = false
69
+ private var structuralFooterStartY: CGFloat?
70
+ private var structuralFooterProgress: CGFloat = 0
71
+ private var structuralRemovedItems: [String: NativeListItem] = [:]
72
+ private var structuralRemovedHeights: [String: CGFloat] = [:]
73
+ private var structuralRemovalProgress: CGFloat = 0
74
+ private final class StructuralTail {
75
+ var items: [NativeListItem]
76
+ let starts: [CGFloat]
77
+ let heights: [CGFloat]
78
+ let indexByKey: [String: Int]
79
+ let extent: CGFloat
80
+ let prefixCount: Int
81
+ let finalExtent: CGFloat
82
+ let retiringKeys: Set<String>
83
+ init(items: [NativeListItem], heights: [CGFloat], spacing: CGFloat,
84
+ prefixCount: Int, finalExtent: CGFloat) {
85
+ self.items = items
86
+ self.heights = heights
87
+ self.indexByKey = Dictionary(uniqueKeysWithValues: items.enumerated().map { ($1.key, $0) })
88
+ self.prefixCount = prefixCount
89
+ self.finalExtent = finalExtent
90
+ self.retiringKeys = finalExtent == 0 ? Set(items.map(\.key)) : []
91
+ var edge: CGFloat = prefixCount == 0 ? 0 : spacing
92
+ var starts: [CGFloat] = []
93
+ for (index, height) in heights.enumerated() {
94
+ starts.append(edge)
95
+ edge += height + (index + 1 < heights.count ? spacing : 0)
96
+ }
97
+ self.starts = starts
98
+ self.extent = edge
99
+ }
100
+ }
101
+ private var structuralTail: StructuralTail?
102
+ private var structuralTailStartExtent: CGFloat = 0
103
+ private var structuralTailSegmentStartEased: CFTimeInterval = 0
104
+ private var structuralTailStartY: CGFloat = 0
105
+ private func prefixTailCount(from old: NativeListConfig, to next: NativeListConfig) -> Int? {
106
+ guard old.orientation == "vertical", next.orientation == "vertical",
107
+ old.layout == next.layout, next.layout == "linear" || next.layout == "table",
108
+ old.itemSpacing == next.itemSpacing,
109
+ old.contentPaddingTop == next.contentPaddingTop,
110
+ old.contentPaddingBottom == next.contentPaddingBottom,
111
+ old.contentPaddingHorizontal == next.contentPaddingHorizontal,
112
+ dictionariesEqual(old.theme, next.theme),
113
+ dictionariesEqual(old.listStyle, next.listStyle),
114
+ old.items.count != next.items.count,
115
+ structuralDisplayLink == nil || structuralTail != nil,
116
+ !old.items.contains(where: { $0.type == "sectionHeader" }),
117
+ !next.items.contains(where: { $0.type == "sectionHeader" }) else { return nil }
118
+ let prefix = min(old.items.count, next.items.count)
119
+ guard prefix > 0 else { return nil }
120
+ for index in 0..<prefix {
121
+ let before = old.items[index], after = next.items[index]
122
+ guard before.key == after.key, before.type == after.type,
123
+ rowHeight(before) == rowHeight(after) else { return nil }
124
+ }
125
+ return prefix
126
+ }
127
+
128
+ private func tailExceedsHostLimit(_ tail: StructuralTail) -> Bool {
129
+ let window = max(0, collectionView.bounds.height) * 3
130
+ var end = 0
131
+ for first in tail.items.indices {
132
+ end = max(end, first)
133
+ while end < tail.items.count && tail.starts[end] <= tail.starts[first] + window {
134
+ end += 1
135
+ }
136
+ if end - first > 128 { return true }
137
+ }
138
+ return false
139
+ }
140
+
141
+ private func clearStructuralTail() {
142
+ structuralTail = nil
143
+ flowLayout.animatedBodyContentHeight = nil
144
+ flowLayout.tailClip = nil
145
+ for case let cell as NativeListRowHost in collectionView.visibleCells {
146
+ let indexPath = collectionView.indexPath(for: cell)
147
+ let key = indexPath.flatMap { dataSource?.itemIdentifier(for: $0) }
148
+ let accepted = indexPath.flatMap { config?.items[safe: $0.item]?.key } == key && key != nil
149
+ cell.layer.mask = nil
150
+ cell.isHidden = !accepted
151
+ cell.accessibilityElementsHidden = !accepted
152
+ cell.isUserInteractionEnabled = accepted && !(key.flatMap { itemsByKey[$0] }?.data.bool("disabled") ?? false)
153
+ }
154
+ }
155
+
156
+ private func updateStructuralTailWindow() {
157
+ guard let tail = structuralTail else { return }
158
+ let clipEnd = structuralTailStartY + structuralSpacerHeight
159
+ flowLayout.animatedBodyContentHeight = clipEnd
160
+ + (config?.contentPaddingBottom ?? 0) + containerSlotHeights[2]
161
+ + (config?.disclosure == nil ? 0 : disclosureCVisibleHeight
162
+ + containerSlotHeights[5] + disclosureInset)
163
+ flowLayout.tailClip = (tail.prefixCount, clipEnd)
164
+ for case let cell as NativeListRowHost in collectionView.visibleCells {
165
+ guard let indexPath = collectionView.indexPath(for: cell),
166
+ let key = dataSource.itemIdentifier(for: indexPath) else { continue }
167
+ presentTailCell(cell, key: key, index: indexPath.item, tail: tail)
168
+ }
169
+ }
170
+
171
+ private func presentTailCell(_ cell: NativeListRowHost, key: String, index: Int,
172
+ tail: StructuralTail) {
173
+ let clipEnd = structuralTailStartY + structuralSpacerHeight
174
+ let visibleHeight = index >= tail.prefixCount
175
+ ? max(0, min(cell.bounds.height, clipEnd - cell.frame.minY)) : cell.bounds.height
176
+ let clipped = visibleHeight < cell.bounds.height
177
+ CATransaction.begin()
178
+ CATransaction.setDisableActions(true)
179
+ cell.isHidden = visibleHeight <= 0
180
+ if clipped && visibleHeight > 0 {
181
+ let mask = cell.layer.mask ?? CALayer()
182
+ mask.backgroundColor = UIColor.black.cgColor
183
+ mask.frame = CGRect(x: 0, y: 0, width: cell.bounds.width, height: visibleHeight)
184
+ cell.layer.mask = mask
185
+ } else {
186
+ cell.layer.mask = nil
187
+ }
188
+ CATransaction.commit()
189
+ let inert = index >= tail.prefixCount || clipped
190
+ cell.isUserInteractionEnabled = !inert && !(itemsByKey[key]?.data.bool("disabled") ?? false)
191
+ cell.accessibilityElementsHidden = inert
192
+ }
193
+
194
+ private func finishStructuralTail(interrupted: Bool) {
195
+ guard let tail = structuralTail, let config else { return }
196
+ stopStructuralAnimation()
197
+ if !tail.retiringKeys.isEmpty {
198
+ var snapshot = dataSource.snapshot()
199
+ snapshot.deleteItems(Array(tail.retiringKeys))
200
+ dataSource.apply(snapshot, animatingDifferences: false)
201
+ itemsByKey = Dictionary(uniqueKeysWithValues: config.items.map { ($0.key, $0) })
202
+ }
203
+ clearStructuralTail()
204
+ structuralSpacerHeight = 0
205
+ disclosureCVisibleHeight = config.disclosure?.expanded == true ? containerSlotHeights[4] : 0
206
+ disclosureInset = config.disclosure?.expanded == true
207
+ ? config.disclosure?.expandedTrailingInset ?? 0 : 0
208
+ structuralFooterStartY = nil
209
+ structuralFooterProgress = 0
210
+ configureLayout(config)
211
+ collectionView.layoutIfNeeded()
212
+ if interrupted {
213
+ // A diffable deletion can leave UIKit's content-shrink scroll animation active.
214
+ // Cancel it without choosing a new offset for the user's gesture.
215
+ collectionView.setContentOffset(collectionView.contentOffset, animated: false)
216
+ } else if !collectionView.isTracking && !collectionView.isDragging
217
+ && !collectionView.isDecelerating {
218
+ let inset = collectionView.adjustedContentInset
219
+ let maximum = max(-inset.top,
220
+ collectionView.contentSize.height - collectionView.bounds.height + inset.bottom)
221
+ let target = min(max(-inset.top, structuralTargetOffset), maximum)
222
+ collectionView.setContentOffset(CGPoint(x: collectionView.contentOffset.x, y: target), animated: false)
223
+ }
224
+ layoutContainerSlots()
225
+ emitVisibleRangeIfNeeded()
226
+ checkEndReached()
227
+ settleDisclosure(interrupted ? "interrupted" : "completed")
228
+ }
229
+
230
+ private func updateStructuralTailTarget() {
231
+ guard let tail = structuralTail, let config else { return }
232
+ let inset = collectionView.adjustedContentInset
233
+ let leftHeight = structuralTailStartY + tail.finalExtent
234
+ + config.contentPaddingBottom + containerSlotHeights[2]
235
+ + (config.disclosure == nil ? 0 :
236
+ (config.disclosure?.expanded == true ? containerSlotHeights[4] : 0)
237
+ + containerSlotHeights[5]
238
+ + (config.disclosure?.expanded == true
239
+ ? config.disclosure?.expandedTrailingInset ?? 0 : 0))
240
+ let sidecarHeight = sidecarLayout == nil ? 0 :
241
+ config.contentPaddingTop + containerSlotHeights[3] + config.contentPaddingBottom
242
+ let finalHeight = max(leftHeight, sidecarHeight)
243
+ let maximum = max(-inset.top,
244
+ finalHeight - collectionView.bounds.height + inset.bottom)
245
+ var target = min(max(-inset.top, structuralStartOffset), maximum)
246
+ if tail.finalExtent == 0, config.disclosure != nil, containerSlotHeights[5] > 0 {
247
+ let toggleTop = structuralTailStartY
248
+ let toggleBottom = toggleTop + containerSlotHeights[5]
249
+ var sticky: CGFloat = 0
250
+ for case let value as NSNumber in pagerStickyHeights.objectEnumerator()?.allObjects ?? [] {
251
+ sticky += CGFloat(value.doubleValue)
252
+ }
253
+ let lower = toggleBottom - collectionView.bounds.height + inset.bottom
254
+ let upper = toggleTop - inset.top - sticky
255
+ target = lower <= upper ? min(max(target, lower), upper) : upper
256
+ } else if tail.finalExtent == 0, containerSlotHeights[2] > 0 {
257
+ let footerTop = structuralTailStartY
258
+ let footerBottom = footerTop + containerSlotHeights[2]
259
+ var sticky: CGFloat = 0
260
+ for case let value as NSNumber in pagerStickyHeights.objectEnumerator()?.allObjects ?? [] {
261
+ sticky += CGFloat(value.doubleValue)
262
+ }
263
+ let lower = footerBottom - collectionView.bounds.height + inset.bottom
264
+ let upper = footerTop - inset.top - sticky
265
+ target = lower <= upper ? min(max(target, lower), upper) : upper
266
+ }
267
+ structuralTargetOffset = min(max(-inset.top, target), maximum)
268
+ }
269
+
270
+ private func structuralTailSegmentProgress(_ eased: CFTimeInterval) -> CFTimeInterval {
271
+ if eased >= 1 { return 1 }
272
+ let remaining = max(0.001, 1 - structuralTailSegmentStartEased)
273
+ return min(1, max(0, (eased - structuralTailSegmentStartEased) / remaining))
274
+ }
275
+
276
+ private func disclosureSegmentProgress(_ eased: CFTimeInterval) -> CFTimeInterval {
277
+ if eased >= 1 { return 1 }
278
+ let remaining = max(0.001, 1 - disclosureSegmentStartEased)
279
+ return min(1, max(0, (eased - disclosureSegmentStartEased) / remaining))
280
+ }
50
281
 
51
282
  @objc func mountContainerSlot(_ view: UIView, atIndex index: Int) {
52
- if index == 3 {
283
+ if index == 6 {
53
284
  topOverlayContent?.removeFromSuperview()
54
285
  topOverlayContent = view
55
286
  topOverlayHost.addSubview(view)
56
287
  updateTopScrollEdge()
57
288
  return
58
289
  }
59
- let host = UIView()
290
+ let host: UIView = index == 2 || index == 4 || index == 5
291
+ ? NativeListClippedSlotHost() : UIView()
60
292
  // Clipping to the reported height keeps new content from overlapping rows until the list
61
293
  // has reserved space for it.
62
294
  host.clipsToBounds = true
@@ -142,16 +374,113 @@ final class NativeListView: UIView {
142
374
  }
143
375
  }
144
376
 
145
- func setContainerSlotHeightsJson(_ json: String) {
377
+ func setContainerSlotHeightsJson(_ json: String, followedBySnapshot: Bool = false) {
146
378
  guard let data = json.data(using: .utf8),
147
379
  let heights = try? JSONSerialization.jsonObject(with: data) as? [Double],
148
- heights.count == 3, heights.allSatisfy({ $0.isFinite && $0 >= 0 }) else { return }
149
- containerSlotHeights = heights.map { CGFloat($0) }
380
+ (heights.count == 4 || heights.count == 6),
381
+ heights.allSatisfy({ $0.isFinite && $0 >= 0 }) else { return }
382
+ let previousHeights = containerSlotHeights
383
+ let footerDelta = containerSlotHeights[2] - CGFloat(heights[2])
384
+ containerSlotHeights = heights.map { CGFloat($0) } + (heights.count == 4 ? [0, 0] : [])
385
+ let contentHeightChanged = previousHeights[4] != containerSlotHeights[4]
386
+ let toggleHeightChanged = previousHeights[5] != containerSlotHeights[5]
387
+ let disclosureGeometryInvalid = structuralTail != nil && config?.disclosure != nil
388
+ && (toggleHeightChanged || footerDelta != 0
389
+ || containerSlotHeights[4] < disclosureCVisibleHeight - 0.5)
390
+ let surroundingSlotGeometryInvalid = structuralTail != nil
391
+ && (previousHeights[0] != containerSlotHeights[0]
392
+ || previousHeights[1] != containerSlotHeights[1]
393
+ || previousHeights[3] != containerSlotHeights[3])
394
+ if surroundingSlotGeometryInvalid || disclosureGeometryInvalid {
395
+ if followedBySnapshot, config?.disclosure != nil {
396
+ deferredDisclosureGeometryInvalid = true
397
+ }
398
+ else { finishStructuralTail(interrupted: true) }
399
+ }
400
+ if structuralTail != nil, contentHeightChanged, !disclosureGeometryInvalid,
401
+ !surroundingSlotGeometryInvalid,
402
+ config?.disclosure != nil, structuralDisplayLink != nil {
403
+ let progress = min(1, max(0,
404
+ (CACurrentMediaTime() - structuralAnimationStart) / max(0.001, structuralAnimationDuration)))
405
+ let eased = progress * progress * (3 - 2 * progress)
406
+ disclosureStartCHeight = disclosureCVisibleHeight
407
+ disclosureSegmentStartEased = eased
408
+ }
409
+ if structuralTail != nil, structuralDisplayLink != nil, footerDelta != 0,
410
+ config?.disclosure == nil {
411
+ let progress = min(1, max(0,
412
+ (CACurrentMediaTime() - structuralAnimationStart) / max(0.001, structuralAnimationDuration)))
413
+ let eased = progress * progress * (3 - 2 * progress)
414
+ // Rebase from the last extent actually presented, not elapsed wall time.
415
+ structuralTailStartExtent = structuralSpacerHeight + footerDelta
416
+ structuralSpacerHeight = structuralTailStartExtent
417
+ structuralTailSegmentStartEased = eased
418
+ disclosureStartCHeight = disclosureCVisibleHeight
419
+ disclosureSegmentStartEased = eased
420
+ disclosureStartInset = disclosureInset
421
+ structuralStartOffset = collectionView.contentOffset.y
422
+ } else if structuralTail == nil, structuralDisplayLink != nil,
423
+ footerDelta != 0, config?.disclosure == nil {
424
+ let progress = min(1, max(0,
425
+ (CACurrentMediaTime() - structuralAnimationStart) / max(0.001, structuralAnimationDuration)))
426
+ let eased = progress * progress * (3 - 2 * progress)
427
+ structuralStartSpacer = max(0, structuralStartSpacer + footerDelta)
428
+ structuralSpacerHeight = structuralStartSpacer * (1 - eased)
429
+ if let startY = structuralFooterStartY { structuralFooterStartY = startY + footerDelta }
430
+ }
431
+ if structuralTail == nil, let disclosure = config?.disclosure {
432
+ disclosureCVisibleHeight = disclosure.expanded ? containerSlotHeights[4] : 0
433
+ disclosureInset = disclosure.expanded ? disclosure.expandedTrailingInset : 0
434
+ }
435
+ if let config { configureLayout(config) }
436
+ if structuralTail != nil {
437
+ updateStructuralTailTarget()
438
+ updateStructuralTailWindow()
439
+ flowLayout.invalidateLayout()
440
+ collectionView.layoutIfNeeded()
441
+ updateStructuralTailWindow()
442
+ } else if structuralDisplayLink != nil {
443
+ collectionView.layoutIfNeeded()
444
+ let inset = collectionView.adjustedContentInset
445
+ let finalMaximum = max(-inset.top,
446
+ collectionView.contentSize.height - structuralSpacerHeight
447
+ - collectionView.bounds.height + inset.bottom)
448
+ structuralTargetOffset = min(structuralTargetOffset, finalMaximum)
449
+ }
450
+ layoutContainerSlots()
451
+ setNeedsLayout()
452
+ }
453
+
454
+ func setSidecarLayoutJson(_ json: String) {
455
+ if json.isEmpty {
456
+ sidecarLayout = nil
457
+ } else if let data = json.data(using: .utf8),
458
+ let value = try? JSONSerialization.jsonObject(with: data) as? [String: Double],
459
+ let width = value["width"], let gap = value["gap"],
460
+ let maxWidth = value["maxContentWidth"],
461
+ width.isFinite, width > 0, gap.isFinite, gap >= 0,
462
+ maxWidth.isFinite, maxWidth > width + gap {
463
+ sidecarLayout = (CGFloat(width), CGFloat(gap), CGFloat(maxWidth))
464
+ } else { return }
465
+ if structuralTail != nil { finishStructuralTail(interrupted: true) }
150
466
  if let config { configureLayout(config) }
151
467
  layoutContainerSlots()
152
468
  setNeedsLayout()
153
469
  }
154
470
 
471
+ private func sidecarBaseInset(_ width: CGFloat) -> CGFloat {
472
+ guard let sidecarLayout else { return 0 }
473
+ return max(0, (width - min(width, sidecarLayout.maxContentWidth)) / 2)
474
+ }
475
+
476
+ @objc(setPagerStickyHeight:forOwner:)
477
+ func setPagerStickyHeight(_ height: CGFloat, forOwner owner: AnyObject) {
478
+ let next = height.isFinite ? max(0, height) : 0
479
+ if next == 0 { pagerStickyHeights.removeObject(forKey: owner) }
480
+ else { pagerStickyHeights.setObject(NSNumber(value: Double(next)), forKey: owner) }
481
+ layoutContainerSlots()
482
+ }
483
+
155
484
  private func layoutContainerSlots() {
156
485
  let empty = config?.items.isEmpty ?? true
157
486
  let header = containerSlotHeights[0]
@@ -159,15 +488,76 @@ final class NativeListView: UIView {
159
488
  let footer = containerSlotHeights[2]
160
489
  let paddingTop = config?.contentPaddingTop ?? 0
161
490
  let paddingBottom = config?.contentPaddingBottom ?? 0
162
- let footerY = max(paddingTop + header + emptyHeight,
163
- collectionView.contentSize.height - paddingBottom - footer)
164
- let positions = [paddingTop, paddingTop + header, footerY]
491
+ let naturalFooterY = flowLayout.bodyContentHeight - paddingBottom - footer
492
+ let footerY = structuralTail == nil
493
+ ? max(paddingTop + header + emptyHeight, naturalFooterY) : naturalFooterY
494
+ let footerPosition = structuralTail == nil && structuralRemovedItems.isEmpty ? structuralFooterStartY.map {
495
+ $0 + (footerY - $0) * structuralFooterProgress
496
+ } ?? footerY : footerY
497
+ let positions = [paddingTop, paddingTop + header, footerPosition]
165
498
  for (index, slot) in reactContainerSlots.prefix(3).enumerated() {
166
499
  slot.host.isHidden = index == 1 && !empty
167
500
  // React places the root at the horizontal inset inside this full-width host.
168
501
  slot.host.frame = CGRect(x: 0, y: positions[index],
169
502
  width: collectionView.bounds.width, height: containerSlotHeights[index])
170
503
  }
504
+ if reactContainerSlots.count > 3 {
505
+ let slot = reactContainerSlots[3]
506
+ let width = sidecarLayout?.width ?? 0
507
+ let base = sidecarBaseInset(collectionView.bounds.width)
508
+ slot.host.isHidden = sidecarLayout == nil || containerSlotHeights[3] <= 0
509
+ let x = effectiveUserInterfaceLayoutDirection == .rightToLeft
510
+ ? base : collectionView.bounds.width - base - width
511
+ slot.host.frame = CGRect(x: x,
512
+ y: paddingTop, width: width, height: containerSlotHeights[3])
513
+ }
514
+ if reactContainerSlots.count > 5 {
515
+ let disclosureEnabled = config?.disclosure != nil
516
+ let content = reactContainerSlots[4].host
517
+ let toggle = reactContainerSlots[5].host
518
+ let toggleHeight = disclosureEnabled ? containerSlotHeights[5] : 0
519
+ let contentY = footerPosition - disclosureInset - toggleHeight
520
+ - disclosureCVisibleHeight
521
+ content.frame = CGRect(x: 0, y: contentY, width: collectionView.bounds.width,
522
+ height: containerSlotHeights[4])
523
+ toggle.frame = CGRect(x: 0, y: contentY + disclosureCVisibleHeight,
524
+ width: collectionView.bounds.width, height: containerSlotHeights[5])
525
+ content.isHidden = !disclosureEnabled || disclosureCVisibleHeight <= 0
526
+ toggle.isHidden = !disclosureEnabled || toggleHeight <= 0
527
+ let contentSettled = structuralDisplayLink == nil && structuralTail == nil
528
+ && config?.disclosure?.expanded == true
529
+ && disclosureCVisibleHeight >= containerSlotHeights[4] - 0.5
530
+ content.isUserInteractionEnabled = contentSettled
531
+ content.accessibilityElementsHidden = !contentSettled
532
+ toggle.isUserInteractionEnabled = disclosureEnabled && toggleHeight > 0
533
+ toggle.accessibilityElementsHidden = !disclosureEnabled || toggleHeight <= 0
534
+ }
535
+ var stickyHeight: CGFloat = 0
536
+ for case let value as NSNumber in pagerStickyHeights.objectEnumerator()?.allObjects ?? [] {
537
+ stickyHeight += CGFloat(value.doubleValue)
538
+ }
539
+ CATransaction.begin()
540
+ CATransaction.setDisableActions(true)
541
+ for (index, slot) in reactContainerSlots.enumerated() {
542
+ let stickyTop = max(0, collectionView.contentOffset.y + stickyHeight - slot.host.frame.minY)
543
+ let tailTop = index == 2 && structuralTail != nil ? max(0, -structuralSpacerHeight) : 0
544
+ let top = max(stickyHeight > 0 ? stickyTop : 0, tailTop)
545
+ let bottom = index == 4 && config?.disclosure != nil
546
+ ? min(slot.host.bounds.height, max(0, disclosureCVisibleHeight))
547
+ : slot.host.bounds.height
548
+ (slot.host as? NativeListClippedSlotHost)?.visibleTop = top
549
+ (slot.host as? NativeListClippedSlotHost)?.visibleBottom = index == 4 ? bottom : nil
550
+ guard top > 0 || bottom < slot.host.bounds.height else {
551
+ slot.host.layer.mask = nil
552
+ continue
553
+ }
554
+ let mask = slot.host.layer.mask ?? CALayer()
555
+ mask.backgroundColor = UIColor.black.cgColor
556
+ mask.frame = CGRect(x: 0, y: top, width: slot.host.bounds.width,
557
+ height: max(0, bottom - top))
558
+ slot.host.layer.mask = mask
559
+ }
560
+ CATransaction.commit()
171
561
  }
172
562
 
173
563
  private final class ActionAnchorRecord {
@@ -238,6 +628,7 @@ final class NativeListView: UIView {
238
628
  var onSelectionDelta: ((String) -> Void)?
239
629
  var onReorder: ((String) -> Void)?
240
630
  var onEndReached: ((String) -> Void)?
631
+ var onStructuralTransitionSettled: ((String) -> Void)?
241
632
  var onVisibleRangeChanged: ((String) -> Void)? {
242
633
  didSet {
243
634
  lastVisibleRange = nil
@@ -263,7 +654,16 @@ final class NativeListView: UIView {
263
654
  private var sectionIndexHostHeightConstraint: NSLayoutConstraint?
264
655
  private var footerHeightConstraint: NSLayoutConstraint!
265
656
  private var dataSource: UICollectionViewDiffableDataSource<Int, String>!
657
+ private final class AcceptedRowIndex {
658
+ let byKey: [String: Int]
659
+
660
+ init(items: [NativeListItem]) {
661
+ byKey = Dictionary(uniqueKeysWithValues: items.enumerated().map { ($1.key, $0) })
662
+ }
663
+ }
664
+
266
665
  private var config: NativeListConfig?
666
+ private var acceptedRowIndex: AcceptedRowIndex?
267
667
  private var itemsByKey: [String: NativeListItem] = [:]
268
668
  private var endReachedGeneration: Int?
269
669
  private var lastVisibleRange: (first: Int, last: Int, firstKey: String?, lastKey: String?)?
@@ -273,9 +673,11 @@ final class NativeListView: UIView {
273
673
  private var sectionIndexHapticsEnabled = true
274
674
  private var lastLayoutDirection: UIUserInterfaceLayoutDirection?
275
675
  private var lastLayoutSize: CGSize?
676
+ private var lastConfiguredCollectionWidth: CGFloat?
276
677
  private var commandAcceptedForPendingScroll = false
277
678
  private var performingExplicitScrollCommand = false
278
679
  private var animatedExplicitScrollCommand = false
680
+ private var explicitScrollRevision = 0
279
681
  private var pagerCommandCancelObserver: NSObjectProtocol?
280
682
  private var pendingScrollRequest: ScrollRequest?
281
683
  private let sectionIndexFeedback = UISelectionFeedbackGenerator()
@@ -452,7 +854,8 @@ final class NativeListView: UIView {
452
854
  }
453
855
  dataSource.reorderingHandlers.canReorderItem = { [weak self] key in
454
856
  guard let self else { return false }
455
- return self.config?.reorderable == true && self.itemsByKey[key]?.isReorderable == true
857
+ return self.config?.reorderable == true && !self.isInertPresentationKey(key)
858
+ && self.itemsByKey[key]?.isReorderable == true
456
859
  }
457
860
  dataSource.reorderingHandlers.didReorder = { [weak self] transaction in
458
861
  guard self?.interactiveReorderUsesAtomicTargeting != true else { return }
@@ -510,7 +913,7 @@ final class NativeListView: UIView {
510
913
  let point = touch.location(in: collectionView)
511
914
  guard collectionView.bounds.contains(point),
512
915
  let indexPath = collectionView.indexPathForItem(at: point),
513
- let item = item(at: indexPath) else { return false }
916
+ let item = item(at: indexPath), !isInertPresentationKey(item.key) else { return false }
514
917
  return item.isRowPressEnabled
515
918
  }
516
919
 
@@ -523,7 +926,10 @@ final class NativeListView: UIView {
523
926
  }
524
927
 
525
928
  override func willMove(toWindow newWindow: UIWindow?) {
526
- if newWindow == nil { attachSectionIndexToList() }
929
+ if newWindow == nil {
930
+ attachSectionIndexToList()
931
+ if structuralTail != nil { finishStructuralTail(interrupted: true) }
932
+ }
527
933
  super.willMove(toWindow: newWindow)
528
934
  }
529
935
 
@@ -553,14 +959,17 @@ final class NativeListView: UIView {
553
959
  height: requestedTopOverlayHeight)
554
960
  updateSectionIndexAttachment()
555
961
  let direction = effectiveUserInterfaceLayoutDirection
556
- if let lastLayoutSize,
557
- lastLayoutSize != bounds.size || lastLayoutDirection != nil && lastLayoutDirection != direction {
962
+ let sizeChanged = lastLayoutSize != nil && lastLayoutSize != bounds.size
963
+ if sizeChanged && structuralTail != nil { finishStructuralTail(interrupted: true) }
964
+ if sizeChanged || lastLayoutDirection != nil && lastLayoutDirection != direction {
558
965
  invalidateActionAnchor(reason: "layout")
559
966
  }
560
967
  lastLayoutSize = bounds.size
561
- if lastLayoutDirection != direction, let config {
968
+ let collectionWidthChanged = collectionView.bounds.width > 0
969
+ && lastConfiguredCollectionWidth != collectionView.bounds.width
970
+ if (lastLayoutDirection != direction || collectionWidthChanged), let config {
562
971
  configureLayout(config)
563
- rebindRowsForLayoutDirection(config)
972
+ if lastLayoutDirection != direction { rebindRowsForLayoutDirection(config) }
564
973
  }
565
974
  performPendingScrollIfNeeded()
566
975
  layoutContainerSlots()
@@ -596,10 +1005,163 @@ final class NativeListView: UIView {
596
1005
  }
597
1006
  }
598
1007
 
1008
+ private func acceptRowIndex(for next: NativeListConfig, replacing previous: NativeListConfig?) {
1009
+ if let previous, acceptedRowIndex != nil,
1010
+ previous.generation == next.generation,
1011
+ previous.items.count == next.items.count,
1012
+ zip(previous.items, next.items).allSatisfy({ old, new in
1013
+ old.key == new.key && old.type == new.type
1014
+ }) {
1015
+ return
1016
+ }
1017
+ acceptedRowIndex = AcceptedRowIndex(items: next.items)
1018
+ }
1019
+
599
1020
  func applySnapshotJson(_ json: String) {
600
1021
  guard let next = try? NativeListConfig.parse(json: json) else { return }
1022
+ let transition = next.structuralTransition
1023
+ let isNewTransition = transition != nil && transition?.id != consumedStructuralTransitionID
1024
+ let disclosureGeometryInvalid = deferredDisclosureGeometryInvalid
1025
+ deferredDisclosureGeometryInvalid = false
1026
+ let disclosureTargetChanged = config?.disclosure?.expanded != next.disclosure?.expanded
1027
+ let disclosureInsetChanged = config?.disclosure?.expandedTrailingInset
1028
+ != next.disclosure?.expandedTrailingInset
1029
+ let disclosureStateChanged = disclosureTargetChanged || disclosureInsetChanged
1030
+ let activePendingID = structuralTail == nil ? nil : pendingDisclosureSettlement?.id
1031
+ let oldKeys = dataSource.snapshot().itemIdentifiers
1032
+ let logicalOldKeys = config?.items.map(\.key) ?? []
1033
+ let presentationItems = itemsByKey
1034
+ let newKeys = next.items.map(\.key)
1035
+ let newDisclosureCommand = config != nil && next.disclosure != nil && isNewTransition
1036
+ if let transition { consumedStructuralTransitionID = transition.id }
1037
+ if newDisclosureCommand, let transition, let disclosure = next.disclosure {
1038
+ pendingDisclosureSettlement = (transition.id, disclosure.expanded)
1039
+ } else if next.disclosure == nil || disclosureTargetChanged {
1040
+ // A target change without a new command cannot settle the previous target.
1041
+ pendingDisclosureSettlement = nil
1042
+ }
1043
+ let wantsStructure = config?.orientation == "vertical"
1044
+ && (config?.layout == "linear" || config?.layout == "table")
1045
+ && isNewTransition && logicalOldKeys != newKeys
1046
+ && next.orientation == "vertical" && (next.layout == "linear" || next.layout == "table")
1047
+ && !UIAccessibility.isReduceMotionEnabled
1048
+ && (transition?.duration ?? 0) > 0
1049
+ let tailPrefix = wantsStructure ? config.flatMap { prefixTailCount(from: $0, to: next) } : nil
1050
+ // A carried tail extent belongs to its original prefix boundary.
1051
+ // Commit a new boundary atomically instead of starting a legacy row animation.
1052
+ let prefixChangedDuringTail = structuralTail != nil && tailPrefix != nil
1053
+ && structuralTail?.prefixCount != tailPrefix
1054
+ let rawTailCandidate: StructuralTail? = tailPrefix.flatMap { prefix in
1055
+ guard let previous = config else { return nil }
1056
+ let items = previous.items.count > next.items.count
1057
+ ? Array(previous.items.dropFirst(prefix)) : Array(next.items.dropFirst(prefix))
1058
+ let heights = items.map { rowHeight($0) }
1059
+ let tail = StructuralTail(items: items, heights: heights,
1060
+ spacing: next.itemSpacing, prefixCount: prefix,
1061
+ finalExtent: previous.items.count > next.items.count ? 0 :
1062
+ heights.reduce(0, +) + CGFloat(max(0, items.count - 1)) * next.itemSpacing
1063
+ + (prefix == 0 ? 0 : next.itemSpacing))
1064
+ return tail
1065
+ }
1066
+ let tailOverCap = rawTailCandidate.map(tailExceedsHostLimit) ?? false
1067
+ let animateStructure = wantsStructure && !tailOverCap && !prefixChangedDuringTail
1068
+ && !disclosureGeometryInvalid
1069
+ && (next.disclosure == nil ||
1070
+ (rawTailCandidate != nil && reactContainerSlots.count > 5))
1071
+ let directDisclosureStatus: String? = newDisclosureCommand && !animateStructure
1072
+ ? (disclosureGeometryInvalid || logicalOldKeys != newKeys && (transition?.duration ?? 0) > 0
1073
+ && !UIAccessibility.isReduceMotionEnabled ? "interrupted" : "completed")
1074
+ : nil
1075
+ var interruptedPendingID: String?
1076
+ let tailCandidate = animateStructure ? rawTailCandidate : nil
1077
+ let presentationHeights: [String: CGFloat] = animateStructure ? Dictionary(uniqueKeysWithValues: oldKeys.compactMap { key -> (String, CGFloat)? in
1078
+ guard let item = presentationItems[key] else { return nil }
1079
+ let height = structuralRemovedHeights[key].map { $0 * (1 - structuralRemovalProgress) }
1080
+ ?? rowHeight(item)
1081
+ return (key, height)
1082
+ }) : [:]
1083
+ let previousOffset = collectionView.contentOffset.y
1084
+ let oldLeftContentHeight = animateStructure ? flowLayout.bodyContentHeight : 0
1085
+ let oldSpacerHeight = structuralSpacerHeight
1086
+ let oldRowsHeight = animateStructure
1087
+ ? presentationHeights.values.reduce(CGFloat(0), +)
1088
+ + CGFloat(max(0, oldKeys.count - 1)) * (config?.itemSpacing ?? 0)
1089
+ : 0
1090
+ let newRowsHeight = animateStructure
1091
+ ? next.items.reduce(CGFloat(0)) { $0 + rowHeight($1) }
1092
+ + CGFloat(max(0, newKeys.count - 1)) * next.itemSpacing
1093
+ : 0
1094
+ let survivingKeys = animateStructure ? Set(newKeys) : Set<String>()
1095
+ let visibleAnchor = animateStructure
1096
+ ? collectionView.indexPathsForVisibleItems.sorted().first {
1097
+ oldKeys.indices.contains($0.item) && survivingKeys.contains(oldKeys[$0.item])
1098
+ }
1099
+ : nil
1100
+ let anchorKey = visibleAnchor.map { oldKeys[$0.item] }
1101
+ let oldPrefix = (animateStructure ? anchorKey : nil).flatMap { key -> CGFloat? in
1102
+ guard let index = oldKeys.firstIndex(of: key) else { return nil }
1103
+ return oldKeys.prefix(index).reduce(CGFloat(0)) { $0 + (presentationHeights[$1] ?? 0) }
1104
+ + CGFloat(index) * (config?.itemSpacing ?? 0)
1105
+ } ?? 0
1106
+ let newPrefix = (animateStructure ? anchorKey : nil).flatMap { key -> CGFloat? in
1107
+ guard let index = newKeys.firstIndex(of: key) else { return nil }
1108
+ return next.items.prefix(index).reduce(CGFloat(0)) { $0 + rowHeight($1) }
1109
+ + CGFloat(index) * next.itemSpacing
1110
+ } ?? 0
1111
+ if animateStructure {
1112
+ let carriedExtent = structuralTail == nil ? nil : structuralSpacerHeight
1113
+ stopStructuralAnimation()
1114
+ clearStructuralTail()
1115
+ structuralScrollInterrupted = false
1116
+ structuralFooterStartY = reactContainerSlots.count > 2
1117
+ ? reactContainerSlots[2].host.frame.minY : nil
1118
+ structuralFooterProgress = 0
1119
+ let removedKeys = Set(oldKeys).subtracting(newKeys)
1120
+ let retainsOrder = oldKeys.filter { survivingKeys.contains($0) } == newKeys
1121
+ structuralRemovedItems = tailCandidate == nil && retainsOrder
1122
+ ? presentationItems.filter { removedKeys.contains($0.key) } : [:]
1123
+ structuralRemovedHeights = tailCandidate == nil && retainsOrder
1124
+ ? presentationHeights.filter { removedKeys.contains($0.key) } : [:]
1125
+ structuralRemovalProgress = 0
1126
+ if let tail = tailCandidate {
1127
+ structuralTail = tail
1128
+ structuralTailStartExtent = carriedExtent ?? (tail.finalExtent == 0 ? tail.extent : 0)
1129
+ structuralTailSegmentStartEased = 0
1130
+ structuralSpacerHeight = structuralTailStartExtent
1131
+ let prefixHeights = next.items.prefix(tail.prefixCount).reduce(CGFloat(0)) {
1132
+ $0 + rowHeight($1)
1133
+ }
1134
+ structuralTailStartY = next.contentPaddingTop + containerSlotHeights[0]
1135
+ + prefixHeights + CGFloat(max(0, tail.prefixCount - 1)) * next.itemSpacing
1136
+ disclosureStartCHeight = disclosureCVisibleHeight
1137
+ disclosureStartInset = disclosureInset
1138
+ disclosureSegmentStartEased = 0
1139
+ } else {
1140
+ structuralSpacerHeight = structuralRemovedItems.isEmpty
1141
+ ? max(0, structuralSpacerHeight + oldRowsHeight - newRowsHeight) : 0
1142
+ }
1143
+ NotificationCenter.default.post(name: Notification.Name("OneKeyNativeListScrollCommandAccepted"),
1144
+ object: collectionView, userInfo: ["phase": 0])
1145
+ NotificationCenter.default.post(name: Notification.Name("OneKeyNativeListScrollCommandAccepted"),
1146
+ object: collectionView, userInfo: ["phase": 1])
1147
+ structuralScrollPhaseActive = true
1148
+ needsContentShrinkCorrection = false
1149
+ } else if logicalOldKeys != newKeys
1150
+ || (newDisclosureCommand || disclosureGeometryInvalid || disclosureStateChanged)
1151
+ && structuralTail != nil {
1152
+ if !newDisclosureCommand && !disclosureTargetChanged { interruptedPendingID = activePendingID }
1153
+ stopStructuralAnimation()
1154
+ clearStructuralTail()
1155
+ structuralSpacerHeight = 0
1156
+ structuralFooterStartY = nil
1157
+ structuralRemovedItems.removeAll()
1158
+ structuralRemovedHeights.removeAll()
1159
+ }
601
1160
  invalidateActionAnchor(reason: "snapshot")
602
- if let current = config, isControlledSelectionSnapshotUpdate(from: current, to: next) {
1161
+ if let current = config, !newDisclosureCommand,
1162
+ structuralRemovedItems.isEmpty, structuralTail == nil,
1163
+ isControlledSelectionSnapshotUpdate(from: current, to: next) {
1164
+ acceptRowIndex(for: next, replacing: current)
603
1165
  config = next
604
1166
  itemsByKey = Dictionary(uniqueKeysWithValues: next.items.map { ($0.key, $0) })
605
1167
  // OneKey patch: A controlled selection echo is fully handled by the
@@ -608,7 +1170,7 @@ final class NativeListView: UIView {
608
1170
  return
609
1171
  }
610
1172
  if let current = config, interactiveReorderSource != nil {
611
- if canDeferSnapshotDuringInteractiveReorder(from: current, to: next) {
1173
+ if !newDisclosureCommand && canDeferSnapshotDuringInteractiveReorder(from: current, to: next) {
612
1174
  deferSnapshotDuringInteractiveReorder(from: current, to: next)
613
1175
  return
614
1176
  }
@@ -619,17 +1181,111 @@ final class NativeListView: UIView {
619
1181
  // rows whose own content is unchanged.
620
1182
  let themeChanged = !dictionariesEqual(config?.theme, next.theme)
621
1183
  || !dictionariesEqual(config?.listStyle, next.listStyle)
1184
+ || config?.layout != next.layout
1185
+ let tailGeometryChanged = config.map { current in
1186
+ current.disclosure?.expanded != next.disclosure?.expanded
1187
+ || current.disclosure?.expandedTrailingInset != next.disclosure?.expandedTrailingInset
1188
+ || current.orientation != next.orientation
1189
+ || current.stickyHeaders != next.stickyHeaders
1190
+ || current.sectionIndexEnabled != next.sectionIndexEnabled
1191
+ || current.contentPadding != next.contentPadding
1192
+ || current.itemSpacing != next.itemSpacing
1193
+ || current.contentPaddingTop != next.contentPaddingTop
1194
+ || current.contentPaddingBottom != next.contentPaddingBottom
1195
+ || current.contentPaddingHorizontal != next.contentPaddingHorizontal
1196
+ || current.items.count != next.items.count
1197
+ || zip(current.items, next.items).contains { old, new in
1198
+ old.key != new.key || old.rendererKey != new.rendererKey
1199
+ || rowHeight(old) != rowHeight(new)
1200
+ }
1201
+ } ?? false
622
1202
  if config?.generation != next.generation { endReachedGeneration = nil }
1203
+ acceptRowIndex(for: next, replacing: config)
623
1204
  config = next
1205
+ if structuralTail == nil {
1206
+ disclosureCVisibleHeight = next.disclosure?.expanded == true ? containerSlotHeights[4] : 0
1207
+ disclosureInset = next.disclosure?.expanded == true
1208
+ ? next.disclosure?.expandedTrailingInset ?? 0 : 0
1209
+ }
624
1210
  itemsByKey = Dictionary(uniqueKeysWithValues: next.items.map { ($0.key, $0) })
1211
+ itemsByKey.merge(structuralRemovedItems) { current, _ in current }
1212
+ if let tail = structuralTail, !tail.retiringKeys.isEmpty {
1213
+ itemsByKey.merge(presentationItems.filter { tail.retiringKeys.contains($0.key) }) {
1214
+ current, _ in current
1215
+ }
1216
+ }
1217
+ if !animateStructure, let tail = structuralTail {
1218
+ if themeChanged || tailGeometryChanged {
1219
+ if !newDisclosureCommand { interruptedPendingID = activePendingID }
1220
+ stopStructuralAnimation()
1221
+ clearStructuralTail()
1222
+ structuralSpacerHeight = 0
1223
+ structuralFooterStartY = nil
1224
+ disclosureCVisibleHeight = next.disclosure?.expanded == true ? containerSlotHeights[4] : 0
1225
+ disclosureInset = next.disclosure?.expanded == true
1226
+ ? next.disclosure?.expandedTrailingInset ?? 0 : 0
1227
+ } else {
1228
+ for (index, previous) in tail.items.enumerated() {
1229
+ guard let item = itemsByKey[previous.key], item.content != previous.content else { continue }
1230
+ tail.items[index] = item
1231
+ }
1232
+ }
1233
+ }
625
1234
  configureSectionIndex(next)
626
1235
  configureLayout(next)
627
1236
  configureRefresh(next)
628
1237
  configureFooter(next)
1238
+ if structuralTail != nil { updateStructuralTailWindow() }
1239
+
1240
+ if animateStructure, let transition {
1241
+ let inset = collectionView.adjustedContentInset
1242
+ let finalLeftHeight = structuralTail.map { tail in
1243
+ structuralTailStartY + tail.finalExtent + next.contentPaddingBottom + containerSlotHeights[2]
1244
+ + (next.disclosure == nil ? 0 :
1245
+ (next.disclosure?.expanded == true ? containerSlotHeights[4] : 0)
1246
+ + containerSlotHeights[5]
1247
+ + (next.disclosure?.expanded == true
1248
+ ? next.disclosure?.expandedTrailingInset ?? 0 : 0))
1249
+ } ?? max(0, oldLeftContentHeight + newRowsHeight - oldRowsHeight - oldSpacerHeight)
1250
+ let sidecarHeight = sidecarLayout == nil ? 0 :
1251
+ next.contentPaddingTop + containerSlotHeights[3] + next.contentPaddingBottom
1252
+ let finalHeight = max(finalLeftHeight, sidecarHeight)
1253
+ let finalMaximum = max(-inset.top, finalHeight - collectionView.bounds.height + inset.bottom)
1254
+ structuralStartSpacer = structuralSpacerHeight
1255
+ structuralStartOffset = previousOffset
1256
+ var targetOffset = structuralTail == nil
1257
+ ? min(max(-inset.top, previousOffset + newPrefix - oldPrefix), finalMaximum)
1258
+ : min(max(-inset.top, previousOffset), finalMaximum)
1259
+ if let tail = structuralTail, tail.finalExtent == 0,
1260
+ (next.disclosure != nil ? containerSlotHeights[5] : containerSlotHeights[2]) > 0 {
1261
+ let targetTop = structuralTailStartY
1262
+ let targetBottom = targetTop + (next.disclosure != nil
1263
+ ? containerSlotHeights[5] : containerSlotHeights[2])
1264
+ var sticky: CGFloat = 0
1265
+ for case let value as NSNumber in pagerStickyHeights.objectEnumerator()?.allObjects ?? [] {
1266
+ sticky += CGFloat(value.doubleValue)
1267
+ }
1268
+ let lower = targetBottom - collectionView.bounds.height + inset.bottom
1269
+ let upper = targetTop - inset.top - sticky
1270
+ targetOffset = lower <= upper
1271
+ ? min(max(targetOffset, lower), upper) : upper
1272
+ targetOffset = min(max(-inset.top, targetOffset), finalMaximum)
1273
+ } else if structuralTail == nil, visibleAnchor == nil, let fallbackKey = transition.anchorKey,
1274
+ let fallbackIndex = newKeys.firstIndex(of: fallbackKey) {
1275
+ let fallbackY = next.contentPaddingTop + containerSlotHeights[0]
1276
+ + next.items.prefix(fallbackIndex).reduce(CGFloat(0)) { $0 + rowHeight($1) }
1277
+ + CGFloat(fallbackIndex) * next.itemSpacing
1278
+ targetOffset = min(targetOffset, max(-inset.top, fallbackY - inset.top))
1279
+ }
1280
+ structuralTargetOffset = targetOffset
1281
+ if structuralTail != nil { updateStructuralTailTarget() }
1282
+ structuralAnimationDuration = transition.duration
1283
+ }
629
1284
 
630
1285
  var snapshot = NSDiffableDataSourceSnapshot<Int, String>()
631
1286
  snapshot.appendSections([0])
632
- let keys = next.items.map(\.key)
1287
+ let keys = structuralTail.map { $0.retiringKeys.isEmpty ? newKeys : oldKeys }
1288
+ ?? (structuralRemovedItems.isEmpty ? newKeys : oldKeys)
633
1289
  snapshot.appendItems(keys, toSection: 0)
634
1290
  var changedKeys = Set(keys.filter { key in
635
1291
  guard let old = oldItems[key], let new = itemsByKey[key] else { return false }
@@ -644,27 +1300,219 @@ final class NativeListView: UIView {
644
1300
  let replacedKeys = changedRetainedKeys.filter { oldItems[$0]?.rendererKey != itemsByKey[$0]?.rendererKey }
645
1301
  snapshot.reloadItems(Array(replacedKeys))
646
1302
  snapshot.reconfigureItems(Array(changedRetainedKeys.subtracting(replacedKeys)))
647
- dataSource.apply(snapshot, animatingDifferences: false) { [weak self] in
1303
+ let scrollRevisionAtApply = explicitScrollRevision
1304
+ let explicitScrollPendingAtApply = pendingScrollRequest != nil
1305
+ dataSource.apply(snapshot, animatingDifferences: animateStructure && structuralRemovedItems.isEmpty
1306
+ && structuralTail == nil) { [weak self] in
648
1307
  guard let self else { return }
649
1308
  self.collectionView.layoutIfNeeded()
1309
+ self.updateStructuralTailWindow()
1310
+ self.layoutContainerSlots()
650
1311
  self.notifyPagerScrollViewReadyIfNeeded()
651
1312
  self.performPendingScrollIfNeeded()
1313
+ if directDisclosureStatus == "completed", let transition {
1314
+ self.revealDirectCollapsedToggle(expectedID: transition.id,
1315
+ scrollRevision: scrollRevisionAtApply,
1316
+ explicitScrollPendingAtApply: explicitScrollPendingAtApply)
1317
+ }
652
1318
  self.emitVisibleRangeIfNeeded()
653
1319
  self.checkEndReached()
654
1320
  self.syncSectionIndexToVisibleRows()
1321
+ if let directDisclosureStatus, let transition {
1322
+ self.settleDisclosure(directDisclosureStatus, expectedID: transition.id)
1323
+ } else if let interruptedPendingID {
1324
+ self.settleDisclosure("interrupted", expectedID: interruptedPendingID)
1325
+ }
1326
+ }
1327
+ if animateStructure && (structuralTail != nil || tailCandidate == nil) {
1328
+ updateStructuralTailWindow()
1329
+ if tailCandidate != nil && structuralTail == nil { return }
1330
+ structuralAnimationStart = CACurrentMediaTime()
1331
+ let displayLink = CADisplayLink(target: self, selector: #selector(advanceStructuralAnimation(_:)))
1332
+ structuralDisplayLink = displayLink
1333
+ displayLink.add(to: .main, forMode: .common)
1334
+ }
1335
+ }
1336
+
1337
+ private func stopStructuralAnimation() {
1338
+ if structuralScrollPhaseActive {
1339
+ NotificationCenter.default.post(name: Notification.Name("OneKeyNativeListScrollCommandAccepted"),
1340
+ object: collectionView, userInfo: ["phase": 2])
1341
+ structuralScrollPhaseActive = false
1342
+ }
1343
+ structuralDisplayLink?.invalidate()
1344
+ structuralDisplayLink = nil
1345
+ }
1346
+
1347
+ private func revealDirectCollapsedToggle(expectedID: String, scrollRevision: Int,
1348
+ explicitScrollPendingAtApply: Bool) {
1349
+ guard !disposed, let pending = pendingDisclosureSettlement,
1350
+ pending.id == expectedID, !pending.expanded,
1351
+ consumedStructuralTransitionID == expectedID,
1352
+ config?.disclosure?.expanded == false,
1353
+ structuralTail == nil, structuralDisplayLink == nil,
1354
+ !structuralScrollPhaseActive, scrollRevision == explicitScrollRevision,
1355
+ !explicitScrollPendingAtApply,
1356
+ pendingScrollRequest == nil, !animatedExplicitScrollCommand,
1357
+ !collectionView.isTracking, !collectionView.isDragging,
1358
+ !collectionView.isDecelerating,
1359
+ collectionView.bounds.height > 0, reactContainerSlots.count > 5 else { return }
1360
+ let toggle = reactContainerSlots[5].host
1361
+ guard !toggle.isHidden, toggle.bounds.height > 0 else { return }
1362
+ let inset = collectionView.adjustedContentInset
1363
+ let maximum = max(-inset.top,
1364
+ collectionView.contentSize.height - collectionView.bounds.height + inset.bottom)
1365
+ var sticky: CGFloat = 0
1366
+ for case let value as NSNumber in pagerStickyHeights.objectEnumerator()?.allObjects ?? [] {
1367
+ sticky += CGFloat(value.doubleValue)
1368
+ }
1369
+ let lower = toggle.frame.maxY - collectionView.bounds.height + inset.bottom
1370
+ let upper = toggle.frame.minY - inset.top - sticky
1371
+ let current = collectionView.contentOffset.y
1372
+ let visibleTarget = lower <= upper ? min(max(current, lower), upper) : upper
1373
+ let target = min(max(-inset.top, visibleTarget), maximum)
1374
+ guard abs(target - current) > 0.5 else { return }
1375
+ NotificationCenter.default.post(name: Notification.Name("OneKeyNativeListScrollCommandAccepted"),
1376
+ object: collectionView, userInfo: ["phase": 0])
1377
+ defer {
1378
+ NotificationCenter.default.post(name: Notification.Name("OneKeyNativeListScrollCommandAccepted"),
1379
+ object: collectionView, userInfo: ["phase": 2])
1380
+ }
1381
+ NotificationCenter.default.post(name: Notification.Name("OneKeyNativeListScrollCommandAccepted"),
1382
+ object: collectionView, userInfo: ["phase": 1])
1383
+ collectionView.setContentOffset(CGPoint(x: collectionView.contentOffset.x, y: target), animated: false)
1384
+ layoutContainerSlots()
1385
+ }
1386
+
1387
+ private func settleDisclosure(_ status: String, expectedID: String? = nil) {
1388
+ guard let pending = pendingDisclosureSettlement,
1389
+ expectedID == nil || pending.id == expectedID,
1390
+ consumedStructuralTransitionID == pending.id,
1391
+ config?.disclosure?.expanded == pending.expanded else { return }
1392
+ pendingDisclosureSettlement = nil
1393
+ guard let callback = onStructuralTransitionSettled else { return }
1394
+ emit(callback, [
1395
+ "transitionId": pending.id,
1396
+ "status": status,
1397
+ "settledExpanded": pending.expanded,
1398
+ ])
1399
+ }
1400
+
1401
+ @objc private func advanceStructuralAnimation(_ link: CADisplayLink) {
1402
+ guard let config else { stopStructuralAnimation(); return }
1403
+ if structuralTail != nil && UIAccessibility.isReduceMotionEnabled {
1404
+ finishStructuralTail(interrupted: false)
1405
+ return
1406
+ }
1407
+ let elapsed = CACurrentMediaTime() - structuralAnimationStart
1408
+ let progress = min(1, max(0, elapsed / max(0.001, structuralAnimationDuration)))
1409
+ let eased = progress * progress * (3 - 2 * progress)
1410
+ if let tail = structuralTail {
1411
+ // Commit cell clipping, viewport movement, and slots together for this tick.
1412
+ CATransaction.begin()
1413
+ CATransaction.setDisableActions(true)
1414
+ defer { CATransaction.commit() }
1415
+ let segment = structuralTailSegmentProgress(eased)
1416
+ structuralSpacerHeight = structuralTailStartExtent
1417
+ + (tail.finalExtent - structuralTailStartExtent) * segment
1418
+ if let disclosure = config.disclosure {
1419
+ let targetC = disclosure.expanded ? containerSlotHeights[4] : 0
1420
+ let targetInset = disclosure.expanded ? disclosure.expandedTrailingInset : 0
1421
+ let contentSegment = disclosureSegmentProgress(eased)
1422
+ disclosureCVisibleHeight = disclosureStartCHeight
1423
+ + (targetC - disclosureStartCHeight) * contentSegment
1424
+ disclosureInset = disclosureStartInset
1425
+ + (targetInset - disclosureStartInset) * segment
1426
+ }
1427
+ structuralFooterProgress = CGFloat(eased)
1428
+ updateStructuralTailWindow()
1429
+ flowLayout.invalidateLayout()
1430
+ collectionView.layoutIfNeeded()
1431
+ if !structuralScrollInterrupted && !collectionView.isTracking
1432
+ && !collectionView.isDragging && !collectionView.isDecelerating {
1433
+ collectionView.contentOffset.y = structuralStartOffset
1434
+ + (structuralTargetOffset - structuralStartOffset) * segment
1435
+ collectionView.layoutIfNeeded()
1436
+ }
1437
+ updateStructuralTailWindow()
1438
+ layoutContainerSlots()
1439
+ if progress >= 1 { finishStructuralTail(interrupted: structuralScrollInterrupted) }
1440
+ return
1441
+ }
1442
+ structuralSpacerHeight = structuralStartSpacer * (1 - eased)
1443
+ structuralFooterProgress = CGFloat(eased)
1444
+ structuralRemovalProgress = CGFloat(eased)
1445
+ for case let cell as NativeListRowHost in collectionView.visibleCells {
1446
+ guard let indexPath = collectionView.indexPath(for: cell),
1447
+ let key = dataSource.itemIdentifier(for: indexPath) else { continue }
1448
+ cell.structuralContentHeight = structuralRemovedItems[key].map {
1449
+ rowHeight($0, allocatedWidth: cell.bounds.width)
1450
+ }
1451
+ }
1452
+ var inset = flowLayout.sectionInset
1453
+ inset.bottom = config.contentPaddingBottom + containerSlotHeights[2] + structuralSpacerHeight
1454
+ flowLayout.sectionInset = inset
1455
+ flowLayout.invalidateLayout()
1456
+ collectionView.layoutIfNeeded()
1457
+ if !structuralScrollInterrupted && !collectionView.isTracking
1458
+ && !collectionView.isDragging && !collectionView.isDecelerating {
1459
+ collectionView.contentOffset.y = structuralStartOffset
1460
+ + (structuralTargetOffset - structuralStartOffset) * eased
1461
+ // Materialize the new viewport before updating presentation cells.
1462
+ collectionView.layoutIfNeeded()
1463
+ }
1464
+ for cell in collectionView.visibleCells {
1465
+ guard let indexPath = collectionView.indexPath(for: cell),
1466
+ let key = dataSource.itemIdentifier(for: indexPath) else { continue }
1467
+ let retiring = structuralRemovedItems[key] != nil
1468
+ if retiring { cell.clipsToBounds = true }
1469
+ cell.alpha = retiring ? 1 - structuralRemovalProgress : 1
1470
+ cell.isUserInteractionEnabled = !retiring && !(itemsByKey[key]?.data.bool("disabled") ?? false)
1471
+ }
1472
+ layoutContainerSlots()
1473
+ if progress >= 1 {
1474
+ if !structuralRemovedItems.isEmpty {
1475
+ var snapshot = dataSource.snapshot()
1476
+ snapshot.deleteItems(Array(structuralRemovedItems.keys))
1477
+ structuralRemovedItems.removeAll()
1478
+ structuralRemovedHeights.removeAll()
1479
+ dataSource.apply(snapshot, animatingDifferences: false)
1480
+ itemsByKey = Dictionary(uniqueKeysWithValues: config.items.map { ($0.key, $0) })
1481
+ collectionView.layoutIfNeeded()
1482
+ if !structuralScrollInterrupted && !collectionView.isTracking
1483
+ && !collectionView.isDragging && !collectionView.isDecelerating {
1484
+ let inset = collectionView.adjustedContentInset
1485
+ let maximum = max(-inset.top,
1486
+ collectionView.contentSize.height - collectionView.bounds.height + inset.bottom)
1487
+ let target = min(max(-inset.top, structuralTargetOffset), maximum)
1488
+ // Cancel UIKit's residual content-shrink scroll animation after deleting retired rows.
1489
+ collectionView.setContentOffset(
1490
+ CGPoint(x: collectionView.contentOffset.x, y: target), animated: false)
1491
+ collectionView.layoutIfNeeded()
1492
+ }
1493
+ }
1494
+ stopStructuralAnimation()
1495
+ structuralSpacerHeight = 0
1496
+ structuralFooterStartY = nil
1497
+ layoutContainerSlots()
1498
+ emitVisibleRangeIfNeeded()
1499
+ checkEndReached()
1500
+ scheduleContentShrinkCorrection()
655
1501
  }
656
1502
  }
657
1503
 
658
1504
  func applyPatchesJson(_ json: String) {
659
1505
  guard var current = config,
1506
+ let indexByKey = acceptedRowIndex?.byKey,
660
1507
  let bytes = json.data(using: .utf8),
661
1508
  let patches = try? JSONSerialization.jsonObject(with: bytes) as? [[String: Any]] else { return }
662
- let indexByKey = Dictionary(uniqueKeysWithValues: current.items.enumerated().map { ($1.key, $0) })
663
1509
  var seen = Set<String>()
664
1510
  var pending: [(Int, [String: Any])] = []
665
1511
  for patch in patches {
666
1512
  let key = patch.string("key")
667
1513
  guard let index = indexByKey[key],
1514
+ current.items.indices.contains(index),
1515
+ current.items[index].key == key,
668
1516
  seen.insert(key).inserted,
669
1517
  patch.string("type") == current.items[index].type,
670
1518
  let changes = patch.dictionary("changes") else { return }
@@ -713,7 +1561,17 @@ final class NativeListView: UIView {
713
1561
  cancelInteractiveReorderForStructuralUpdate()
714
1562
  }
715
1563
  config = current
716
- itemsByKey = Dictionary(uniqueKeysWithValues: current.items.map { ($0.key, $0) })
1564
+ for (index, _) in pending {
1565
+ let item = current.items[index]
1566
+ itemsByKey[item.key] = item
1567
+ }
1568
+ if let tail = structuralTail {
1569
+ for key in changedKeys.union(marketQuoteKeys) {
1570
+ guard let index = tail.indexByKey[key], let item = itemsByKey[key] else { continue }
1571
+ tail.items[index] = item
1572
+ }
1573
+ if sizeChanged { finishStructuralTail(interrupted: true) }
1574
+ }
717
1575
  for indexPath in collectionView.indexPathsForVisibleItems {
718
1576
  guard let item = item(at: indexPath),
719
1577
  marketQuoteKeys.contains(item.key),
@@ -814,6 +1672,8 @@ final class NativeListView: UIView {
814
1672
 
815
1673
  private func requestScroll(_ request: ScrollRequest) {
816
1674
  if config != nil && explicitTopForScrollCommand(request) == nil { return }
1675
+ explicitScrollRevision &+= 1
1676
+ if structuralTail != nil { finishStructuralTail(interrupted: true) }
817
1677
  animatedExplicitScrollCommand = false
818
1678
  commandAcceptedForPendingScroll = false
819
1679
  acceptScrollCommand(request)
@@ -977,6 +1837,7 @@ final class NativeListView: UIView {
977
1837
  guard let self else { return }
978
1838
  self.contentShrinkCorrectionScheduled = false
979
1839
  guard !self.disposed, self.needsContentShrinkCorrection,
1840
+ self.structuralDisplayLink == nil,
980
1841
  !self.collectionView.isTracking, !self.collectionView.isDragging,
981
1842
  !self.collectionView.isDecelerating,
982
1843
  self.collectionView.refreshControl?.isRefreshing != true,
@@ -1012,15 +1873,25 @@ final class NativeListView: UIView {
1012
1873
  flowLayout.scrollDirection = isHorizontal ? .horizontal : .vertical
1013
1874
  flowLayout.minimumLineSpacing = config.itemSpacing
1014
1875
  flowLayout.minimumInteritemSpacing = config.itemSpacing
1876
+ let sidecarInset = sidecarBaseInset(collectionView.bounds.width)
1877
+ let sidecarSpace = sidecarLayout.map { $0.width + $0.gap } ?? 0
1878
+ let horizontalInset = config.contentPaddingHorizontal + sidecarInset
1879
+ let disclosureHeight = config.disclosure == nil ? 0 :
1880
+ disclosureCVisibleHeight + containerSlotHeights[5] + disclosureInset
1015
1881
  flowLayout.sectionInset = UIEdgeInsets(
1016
1882
  top: config.contentPaddingTop + containerSlotHeights[0],
1017
- left: config.contentPaddingHorizontal + (isRightToLeft ? indexGutter : 0),
1018
- bottom: config.contentPaddingBottom + containerSlotHeights[2],
1019
- right: config.contentPaddingHorizontal + (isRightToLeft ? 0 : indexGutter)
1883
+ left: horizontalInset + (isRightToLeft ? sidecarSpace + indexGutter : 0),
1884
+ bottom: config.contentPaddingBottom + containerSlotHeights[2] + disclosureHeight
1885
+ + (structuralTail == nil ? structuralSpacerHeight : 0),
1886
+ right: horizontalInset + (isRightToLeft ? 0 : sidecarSpace + indexGutter)
1020
1887
  )
1021
- flowLayout.minimumContainerContentHeight = config.items.isEmpty
1022
- ? config.contentPaddingTop + config.contentPaddingBottom + containerSlotHeights.reduce(0, +)
1888
+ let emptyMinimum = config.items.isEmpty
1889
+ ? config.contentPaddingTop + config.contentPaddingBottom
1890
+ + containerSlotHeights.prefix(3).reduce(0, +) + disclosureHeight
1023
1891
  : 0
1892
+ let sidecarMinimum = sidecarLayout == nil ? 0 :
1893
+ config.contentPaddingTop + containerSlotHeights[3] + config.contentPaddingBottom
1894
+ flowLayout.minimumContainerContentHeight = max(emptyMinimum, sidecarMinimum)
1024
1895
  flowLayout.stickyItemIndexes = config.stickyHeaders
1025
1896
  ? Set(config.items.enumerated().compactMap {
1026
1897
  $0.element.type == "sectionHeader" && $0.element.data.bool("sticky", default: true) && $0.element.data.string("variant") != "summary"
@@ -1034,6 +1905,7 @@ final class NativeListView: UIView {
1034
1905
  listBodyGestureGuard.isEnabled = !isHorizontal
1035
1906
  collectionView.showsVerticalScrollIndicator = sectionIndexEntries.isEmpty
1036
1907
  collectionView.dragInteractionEnabled = false
1908
+ lastConfiguredCollectionWidth = collectionView.bounds.width
1037
1909
  lastLayoutDirection = effectiveUserInterfaceLayoutDirection
1038
1910
  }
1039
1911
 
@@ -1301,6 +2173,7 @@ final class NativeListView: UIView {
1301
2173
  let toIndex = keys.firstIndex(of: source.key),
1302
2174
  items.count == keys.count else { return }
1303
2175
  current.items = items
2176
+ acceptedRowIndex = AcceptedRowIndex(items: items)
1304
2177
  config = current
1305
2178
  guard source.index != toIndex else { return }
1306
2179
  var payload: [String: Any] = [
@@ -1371,6 +2244,10 @@ final class NativeListView: UIView {
1371
2244
  return config?.items[safe: indexPath.item]
1372
2245
  }
1373
2246
 
2247
+ private func isInertPresentationKey(_ key: String) -> Bool {
2248
+ structuralTail?.indexByKey[key] != nil || structuralRemovedItems[key] != nil
2249
+ }
2250
+
1374
2251
  private func updateAtomicReorderTarget(to targetIndex: Int, key targetKey: String, animated: Bool) {
1375
2252
  guard let source = interactiveReorderSource else { return }
1376
2253
  interactiveReorderTargetIndex = targetIndex
@@ -1714,11 +2591,23 @@ final class NativeListView: UIView {
1714
2591
  if item.key == interactiveReorderCompactKey {
1715
2592
  cell.setWalletGroupReorderCompact(true)
1716
2593
  }
2594
+ let retiring = structuralRemovedItems[item.key] != nil
2595
+ cell.structuralContentHeight = retiring
2596
+ ? rowHeight(item, allocatedWidth: cell.bounds.width) : nil
2597
+ if retiring { cell.clipsToBounds = true }
2598
+ cell.alpha = retiring ? 1 - structuralRemovalProgress : 1
2599
+ cell.isUserInteractionEnabled = !retiring && !item.data.bool("disabled")
2600
+ cell.layer.mask = nil
2601
+ cell.isHidden = false
2602
+ cell.accessibilityElementsHidden = false
2603
+ if let structuralTail, let itemIndex {
2604
+ presentTailCell(cell, key: item.key, index: itemIndex, tail: structuralTail)
2605
+ }
1717
2606
  }
1718
2607
 
1719
2608
  private func handleRowPress(_ item: NativeListItem, origin: NativeListActionOrigin?) {
1720
2609
  // OneKey patch: missing-address rows keep accessory actions available.
1721
- guard let config, item.isRowPressEnabled else { return }
2610
+ guard let config, item.isRowPressEnabled, !isInertPresentationKey(item.key) else { return }
1722
2611
  if config.rowPressToggles && item.isSelectable && config.selectionMode != "none" {
1723
2612
  updateSelection(target: NativeSelectionTarget(scope: "row", key: item.key), sourceKey: item.key)
1724
2613
  return
@@ -1738,8 +2627,8 @@ final class NativeListView: UIView {
1738
2627
  guard gesture.state == .began else { return }
1739
2628
  let point = gesture.location(in: collectionView)
1740
2629
  guard let indexPath = collectionView.indexPathForItem(at: point),
1741
- let item = config?.items[safe: indexPath.item],
1742
- item.type == "market",
2630
+ let item = item(at: indexPath), !isInertPresentationKey(item.key),
2631
+ (item.type == "market" || item.type == "identity"),
1743
2632
  item.isRowPressEnabled else { return }
1744
2633
  let actionKey = item.data.string("longPressActionKey")
1745
2634
  guard !actionKey.isEmpty else { return }
@@ -1754,7 +2643,7 @@ final class NativeListView: UIView {
1754
2643
  target: NativeSelectionTarget?,
1755
2644
  origin: NativeListActionOrigin?
1756
2645
  ) {
1757
- guard !item.data.bool("disabled") else { return }
2646
+ guard !item.data.bool("disabled"), !isInertPresentationKey(item.key) else { return }
1758
2647
  if let target, config?.selectionMode != "none" {
1759
2648
  updateSelection(target: target, sourceKey: item.key)
1760
2649
  return
@@ -1763,7 +2652,8 @@ final class NativeListView: UIView {
1763
2652
  }
1764
2653
 
1765
2654
  private func updateSelection(target: NativeSelectionTarget, sourceKey: String) {
1766
- guard var current = config, current.selectionMode != "none" else { return }
2655
+ guard var current = config, current.selectionMode != "none",
2656
+ !isInertPresentationKey(sourceKey) else { return }
1767
2657
  let keys = selectionKeys(target: target, items: current.items)
1768
2658
  guard !keys.isEmpty else { return }
1769
2659
  let before = current.selectedKeys
@@ -1824,7 +2714,7 @@ final class NativeListView: UIView {
1824
2714
  self?.resolveCheckboxState(item: item, target: target, fallback: fallback) ?? fallback
1825
2715
  }
1826
2716
  for indexPath in collectionView.indexPathsForVisibleItems {
1827
- guard let item = item(at: indexPath),
2717
+ guard let item = item(at: indexPath), !isInertPresentationKey(item.key),
1828
2718
  let cell = collectionView.cellForItem(at: indexPath) as? NativeListRowHost else { continue }
1829
2719
  if changedSummaryKeys.contains(item.key) {
1830
2720
  bind(cell: cell, item: item, itemIndex: indexPath.item)
@@ -1975,6 +2865,7 @@ final class NativeListView: UIView {
1975
2865
  let selectionChanged = current.selectionMode != next.selectionMode ||
1976
2866
  current.selectedKeys != next.selectedKeys
1977
2867
  if current.generation != next.generation { endReachedGeneration = nil }
2868
+ acceptRowIndex(for: next, replacing: current)
1978
2869
  config = next
1979
2870
  itemsByKey = Dictionary(uniqueKeysWithValues: next.items.map { ($0.key, $0) })
1980
2871
  let changedKeys = next.items.compactMap { item -> String? in
@@ -2175,6 +3066,13 @@ final class NativeListView: UIView {
2175
3066
  dataSource = nil
2176
3067
 
2177
3068
  config = nil
3069
+ acceptedRowIndex = nil
3070
+ pagerStickyHeights.removeAllObjects()
3071
+ for slot in reactContainerSlots { slot.host.layer.mask = nil }
3072
+ stopStructuralAnimation()
3073
+ clearStructuralTail()
3074
+ structuralSpacerHeight = 0
3075
+ structuralFooterStartY = nil
2178
3076
  itemsByKey.removeAll()
2179
3077
  sectionIndexEntries.removeAll()
2180
3078
  if let pagerCommandCancelObserver {
@@ -2193,8 +3091,10 @@ final class NativeListView: UIView {
2193
3091
  onSelectionDelta = nil
2194
3092
  onReorder = nil
2195
3093
  onEndReached = nil
3094
+ onStructuralTransitionSettled = nil
2196
3095
  onVisibleRangeChanged = nil
2197
3096
  onScrollPositionThresholdChange = nil
3097
+ pendingDisclosureSettlement = nil
2198
3098
  }
2199
3099
 
2200
3100
  private func createActionAnchor(origin: NativeListActionOrigin) -> [String: Any]? {
@@ -2290,8 +3190,9 @@ final class NativeListView: UIView {
2290
3190
  DispatchQueue.main.async { [weak self] in
2291
3191
  guard let self else { return }
2292
3192
  self.visibleEventScheduled = false
2293
- guard let onVisibleRangeChanged = self.onVisibleRangeChanged else { return }
2294
- let indexes = self.collectionView.indexPathsForVisibleItems.map(\.item).sorted()
3193
+ guard self.structuralRemovedItems.isEmpty,
3194
+ let onVisibleRangeChanged = self.onVisibleRangeChanged else { return }
3195
+ let indexes = self.acceptedVisibleIndexes()
2295
3196
  let first = indexes.first ?? -1
2296
3197
  let last = indexes.last ?? -1
2297
3198
  let firstKey = self.config?.items[safe: first]?.key
@@ -2309,11 +3210,11 @@ final class NativeListView: UIView {
2309
3210
  }
2310
3211
 
2311
3212
  private func checkEndReached() {
2312
- guard let config,
3213
+ guard let config, structuralRemovedItems.isEmpty,
2313
3214
  config.loadMore,
2314
3215
  endReachedGeneration != config.generation,
2315
3216
  !config.items.isEmpty else { return }
2316
- let lastVisible = collectionView.indexPathsForVisibleItems.map(\.item).max() ?? -1
3217
+ let lastVisible = acceptedVisibleIndexes().last ?? -1
2317
3218
  let threshold = max(1, Int(ceil(Double(config.items.count) * config.endReachedThreshold)))
2318
3219
  guard lastVisible >= config.items.count - threshold else { return }
2319
3220
  endReachedGeneration = config.generation
@@ -2322,6 +3223,18 @@ final class NativeListView: UIView {
2322
3223
  emit(onEndReached, payload)
2323
3224
  }
2324
3225
 
3226
+ private func acceptedVisibleIndexes() -> [Int] {
3227
+ guard let config else { return [] }
3228
+ let presentedPrefix = structuralTail?.prefixCount
3229
+ return collectionView.indexPathsForVisibleItems.compactMap { indexPath in
3230
+ guard presentedPrefix.map({ indexPath.item < $0 }) ?? true,
3231
+ let item = config.items[safe: indexPath.item],
3232
+ dataSource.itemIdentifier(for: indexPath) == item.key,
3233
+ let cell = collectionView.cellForItem(at: indexPath), !cell.isHidden else { return nil }
3234
+ return indexPath.item
3235
+ }.sorted()
3236
+ }
3237
+
2325
3238
  override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
2326
3239
  if gestureRecognizer === keyboardDragRecognizer {
2327
3240
  return keyboardDismissMode == .onDrag && focusedTextInput(in: window) != nil
@@ -2329,12 +3242,13 @@ final class NativeListView: UIView {
2329
3242
  if gestureRecognizer === reorderLongPress || gestureRecognizer === marketLongPress {
2330
3243
  let point = gestureRecognizer.location(in: collectionView)
2331
3244
  guard let indexPath = collectionView.indexPathForItem(at: point),
2332
- let item = item(at: indexPath),
3245
+ let item = item(at: indexPath), !isInertPresentationKey(item.key),
2333
3246
  !item.data.bool("disabled") else { return false }
2334
3247
  if gestureRecognizer === reorderLongPress {
2335
3248
  return config?.reorderable == true && item.isReorderable
2336
3249
  }
2337
- return item.type == "market" && item.isRowPressEnabled && !item.data.string("longPressActionKey").isEmpty
3250
+ return (item.type == "market" || item.type == "identity")
3251
+ && item.isRowPressEnabled && !item.data.string("longPressActionKey").isEmpty
2338
3252
  }
2339
3253
  guard gestureRecognizer === listBodyGestureGuard,
2340
3254
  let pan = gestureRecognizer as? UIPanGestureRecognizer else { return true }
@@ -2445,14 +3359,16 @@ extension NativeListView: UICollectionViewDelegateFlowLayout {
2445
3359
  return CGSize(width: width, height: height)
2446
3360
  }
2447
3361
  let available = max(0, collectionView.bounds.width - insets.left - insets.right)
2448
- let structural = item.type == "sectionHeader" || item.type == "system" || item.type == "action"
3362
+ let structural = item.type == "sectionHeader" || item.type == "system" || item.type == "action" || item.type == "buttonGroup"
2449
3363
  if config.layout == "grid", !structural {
2450
3364
  let spacing = CGFloat(config.gridColumns - 1) * config.itemSpacing
2451
3365
  let width = floor((available - spacing) / CGFloat(config.gridColumns))
2452
3366
  let height = item.styledHeight ?? (item.type == "mediaTile" && item.data["height"] == nil ? width + 48 : rowHeight(item, allocatedWidth: width))
2453
3367
  return CGSize(width: width, height: height)
2454
3368
  }
2455
- return CGSize(width: available, height: rowHeight(item, allocatedWidth: available))
3369
+ let height = structuralRemovedHeights[item.key].map { $0 * (1 - structuralRemovalProgress) }
3370
+ ?? rowHeight(item, allocatedWidth: available)
3371
+ return CGSize(width: available, height: max(0.01, height))
2456
3372
  }
2457
3373
 
2458
3374
  private func railWidth(_ item: NativeListItem) -> CGFloat {
@@ -2460,19 +3376,20 @@ extension NativeListView: UICollectionViewDelegateFlowLayout {
2460
3376
  }
2461
3377
 
2462
3378
  func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
2463
- guard let item = config?.items[safe: indexPath.item] else { return }
3379
+ guard let item = item(at: indexPath), !isInertPresentationKey(item.key) else { return }
2464
3380
  if item.type == "walletGroup" { return }
2465
3381
  let origin = (collectionView.cellForItem(at: indexPath) as? NativeListRowHost)?.rowActionOrigin()
2466
3382
  handleRowPress(item, origin: origin)
2467
3383
  }
2468
3384
 
2469
3385
  func collectionView(_ collectionView: UICollectionView, shouldHighlightItemAt indexPath: IndexPath) -> Bool {
2470
- guard let item = config?.items[safe: indexPath.item] else { return false }
3386
+ guard let item = item(at: indexPath), !isInertPresentationKey(item.key) else { return false }
2471
3387
  return item.isRowPressEnabled
2472
3388
  }
2473
3389
 
2474
3390
  func collectionView(_ collectionView: UICollectionView, didHighlightItemAt indexPath: IndexPath) {
2475
- guard let item = config?.items[safe: indexPath.item], item.type == "market" else { return }
3391
+ guard let item = item(at: indexPath), !isInertPresentationKey(item.key),
3392
+ item.type == "market" else { return }
2476
3393
  let actionKey = item.data.string("pressInActionKey")
2477
3394
  guard !actionKey.isEmpty else { return }
2478
3395
  let origin = (collectionView.cellForItem(at: indexPath) as? NativeListRowHost)?.rowActionOrigin()
@@ -2480,7 +3397,7 @@ extension NativeListView: UICollectionViewDelegateFlowLayout {
2480
3397
  }
2481
3398
 
2482
3399
  func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
2483
- guard let item = config?.items[safe: indexPath.item] else { return false }
3400
+ guard let item = item(at: indexPath), !isInertPresentationKey(item.key) else { return false }
2484
3401
  return item.isRowPressEnabled
2485
3402
  }
2486
3403
 
@@ -2492,6 +3409,15 @@ extension NativeListView: UICollectionViewDelegateFlowLayout {
2492
3409
  }
2493
3410
 
2494
3411
  func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
3412
+ if structuralDisplayLink != nil {
3413
+ structuralScrollInterrupted = true
3414
+ if structuralTail != nil { finishStructuralTail(interrupted: true) }
3415
+ else {
3416
+ NotificationCenter.default.post(name: Notification.Name("OneKeyNativeListScrollCommandAccepted"),
3417
+ object: collectionView, userInfo: ["phase": 2])
3418
+ structuralScrollPhaseActive = false
3419
+ }
3420
+ }
2495
3421
  scrollViewDidEndScrollingAnimation(scrollView)
2496
3422
  minimumDragOffset = 0
2497
3423
  refreshEmittedForDrag = false
@@ -2530,7 +3456,7 @@ extension NativeListView: UICollectionViewDragDelegate, UICollectionViewDropDele
2530
3456
  itemsForBeginning session: UIDragSession,
2531
3457
  at indexPath: IndexPath
2532
3458
  ) -> [UIDragItem] {
2533
- guard config?.reorderable == true,
3459
+ guard config?.reorderable == true, structuralDisplayLink == nil,
2534
3460
  let item = config?.items[safe: indexPath.item],
2535
3461
  item.isReorderable else { return [] }
2536
3462
  let dragItem = UIDragItem(itemProvider: NSItemProvider(object: item.key as NSString))
@@ -2557,6 +3483,7 @@ extension NativeListView: UICollectionViewDragDelegate, UICollectionViewDropDele
2557
3483
  guard sourceItem.isReorderable, targetItem.isReorderable, sourceItem.sectionKey == targetItem.sectionKey else { return }
2558
3484
  current.items.remove(at: source.item)
2559
3485
  current.items.insert(sourceItem, at: destination.item)
3486
+ acceptedRowIndex = AcceptedRowIndex(items: current.items)
2560
3487
  config = current
2561
3488
  itemsByKey = Dictionary(uniqueKeysWithValues: current.items.map { ($0.key, $0) })
2562
3489
  var snapshot = NSDiffableDataSourceSnapshot<Int, String>()
@@ -2602,12 +3529,26 @@ private final class NativeListCollectionView: UICollectionView {
2602
3529
  }
2603
3530
  }
2604
3531
 
3532
+ private final class NativeListClippedSlotHost: UIView {
3533
+ var visibleTop: CGFloat = 0
3534
+ var visibleBottom: CGFloat?
3535
+
3536
+ override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
3537
+ super.point(inside: point, with: event) && point.y >= visibleTop
3538
+ && point.y < (visibleBottom ?? bounds.height)
3539
+ }
3540
+ }
3541
+
2605
3542
  final class NativeListFlowLayout: UICollectionViewFlowLayout {
2606
3543
  var stickyItemIndexes: Set<Int> = []
2607
3544
  var minimumContainerContentHeight: CGFloat = 0
3545
+ var animatedBodyContentHeight: CGFloat?
3546
+ var tailClip: (firstIndex: Int, endY: CGFloat)?
3547
+ var bodyContentHeight: CGFloat {
3548
+ animatedBodyContentHeight ?? super.collectionViewContentSize.height
3549
+ }
2608
3550
  private var horizontalAttributes: [IndexPath: UICollectionViewLayoutAttributes] = [:]
2609
3551
  private var horizontalContentSize: CGSize = .zero
2610
-
2611
3552
  override func prepare() {
2612
3553
  super.prepare()
2613
3554
  horizontalAttributes.removeAll(keepingCapacity: true)
@@ -2635,7 +3576,7 @@ final class NativeListFlowLayout: UICollectionViewFlowLayout {
2635
3576
  override var collectionViewContentSize: CGSize {
2636
3577
  if scrollDirection == .horizontal { return horizontalContentSize }
2637
3578
  let size = super.collectionViewContentSize
2638
- return CGSize(width: size.width, height: max(size.height, minimumContainerContentHeight))
3579
+ return CGSize(width: size.width, height: max(bodyContentHeight, minimumContainerContentHeight))
2639
3580
  }
2640
3581
 
2641
3582
  override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
@@ -2672,10 +3613,17 @@ final class NativeListFlowLayout: UICollectionViewFlowLayout {
2672
3613
  .filter { $0.frame.intersects(rect) }
2673
3614
  .sorted { $0.indexPath.item < $1.indexPath.item }
2674
3615
  }
2675
- guard scrollDirection == .vertical,
2676
- !stickyItemIndexes.isEmpty,
2677
- let collectionView else { return super.layoutAttributesForElements(in: rect) }
2678
3616
  let base = super.layoutAttributesForElements(in: rect)?.compactMap { $0.copy() as? UICollectionViewLayoutAttributes } ?? []
3617
+ guard scrollDirection == .vertical else { return base }
3618
+ let clipped = base.filter { attributes in
3619
+ guard let tailClip, attributes.representedElementCategory == .cell,
3620
+ attributes.indexPath.item >= tailClip.firstIndex else { return true }
3621
+ return attributes.frame.minY < tailClip.endY
3622
+ }
3623
+ guard !stickyItemIndexes.isEmpty,
3624
+ let collectionView else {
3625
+ return clipped
3626
+ }
2679
3627
  let pinY = collectionView.contentOffset.y + collectionView.adjustedContentInset.top
2680
3628
  // Visible cells can still describe the old viewport during an imperative
2681
3629
  // scroll. Resolve the pinned header from unmodified layout positions.
@@ -2684,20 +3632,30 @@ final class NativeListFlowLayout: UICollectionViewFlowLayout {
2684
3632
  return attributes.frame.minY <= pinY
2685
3633
  }).max(),
2686
3634
  let original = super.layoutAttributesForItem(at: IndexPath(item: stickyIndex, section: 0)),
2687
- let sticky = original.copy() as? UICollectionViewLayoutAttributes else { return base }
3635
+ let sticky = original.copy() as? UICollectionViewLayoutAttributes else {
3636
+ return clipped
3637
+ }
2688
3638
  let nextHeaderY = stickyItemIndexes
2689
3639
  .filter { $0 > stickyIndex }
2690
3640
  .min()
2691
3641
  .flatMap { super.layoutAttributesForItem(at: IndexPath(item: $0, section: 0))?.frame.minY }
2692
3642
  sticky.frame.origin.y = min(max(pinY, original.frame.minY), (nextHeaderY ?? .greatestFiniteMagnitude) - sticky.frame.height)
2693
3643
  sticky.zIndex = 10_000
2694
- let filtered = base.filter { $0.indexPath != sticky.indexPath }
2695
- return filtered + [sticky]
3644
+ let filtered = clipped.filter { $0.indexPath != sticky.indexPath }
3645
+ let returned = filtered + [sticky]
3646
+ return returned
2696
3647
  }
2697
3648
 
2698
3649
  override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
2699
3650
  if scrollDirection == .horizontal { return horizontalAttributes[indexPath] }
2700
- return super.layoutAttributesForItem(at: indexPath)
3651
+ guard let attributes = super.layoutAttributesForItem(at: indexPath) else {
3652
+ return nil
3653
+ }
3654
+ if let tailClip, indexPath.item >= tailClip.firstIndex,
3655
+ attributes.frame.minY >= tailClip.endY {
3656
+ return nil
3657
+ }
3658
+ return attributes
2701
3659
  }
2702
3660
 
2703
3661
  }