@onekeyfe/react-native-native-list 3.0.157-alpha.254 → 3.0.157-alpha.258

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 (87) hide show
  1. package/android/build.gradle +7 -0
  2. package/android/src/main/java/com/onekey/nativelist/HybridNativeList.kt +20 -0
  3. package/android/src/main/java/com/onekey/nativelist/NativeListActivityDetailsView.kt +179 -0
  4. package/android/src/main/java/com/onekey/nativelist/NativeListActivityRenderer.kt +23 -2
  5. package/android/src/main/java/com/onekey/nativelist/NativeListContainerSlotAdapter.kt +53 -0
  6. package/android/src/main/java/com/onekey/nativelist/NativeListEndAlignment.kt +5 -0
  7. package/android/src/main/java/com/onekey/nativelist/NativeListMediaPreviewSlot.kt +215 -0
  8. package/android/src/main/java/com/onekey/nativelist/NativeListMediaTileRenderer.kt +15 -6
  9. package/android/src/main/java/com/onekey/nativelist/NativeListModels.kt +3 -1
  10. package/android/src/main/java/com/onekey/nativelist/NativeListScrollPositionTracker.kt +29 -0
  11. package/android/src/main/java/com/onekey/nativelist/NativeListSectionHeaderRenderer.kt +16 -1
  12. package/android/src/main/java/com/onekey/nativelist/NativeListView.kt +165 -26
  13. package/android/src/main/res/values/native_scroll_coordinator_ids.xml +5 -0
  14. package/android/src/test/java/com/margelo/nitro/nativelist/NativeListEndAlignmentTest.kt +34 -0
  15. package/android/src/test/java/com/margelo/nitro/nativelist/NativeListScrollPositionTrackerTest.kt +28 -0
  16. package/docs/DESIGN.md +4 -2
  17. package/docs/ROW_TEMPLATES.md +45 -0
  18. package/docs/SPEC.md +241 -1
  19. package/docs/STYLE_SPEC.md +8 -0
  20. package/ios/HybridNativeList.swift +30 -0
  21. package/ios/NativeListActivityDetailsView.swift +229 -0
  22. package/ios/NativeListActivityRenderer.swift +24 -2
  23. package/ios/NativeListMediaPreviewSlot.swift +199 -0
  24. package/ios/NativeListMediaTileRenderer.swift +10 -4
  25. package/ios/NativeListModels.swift +4 -1
  26. package/ios/NativeListScrollPositionTracker.swift +30 -0
  27. package/ios/NativeListSectionHeaderRenderer.swift +27 -1
  28. package/ios/RNCNativeListView.swift +159 -4
  29. package/ios/tests/NativeListActivityPresentationTests.swift +43 -0
  30. package/ios/tests/NativeListLifecycleTests.swift +123 -0
  31. package/ios/tests/NativeListMediaVisibilityTests.swift +42 -0
  32. package/ios/tests/NativeListScrollPositionTrackerTests.swift +25 -0
  33. package/lib/module/NativeList.js +56 -3
  34. package/lib/module/NativeList.web.js +5 -0
  35. package/lib/module/validation.js +64 -4
  36. package/lib/nitrogen/generated/android/c++/JFunc_void_bool.hpp +75 -0
  37. package/lib/nitrogen/generated/android/c++/JHybridNativeListSpec.cpp +39 -3
  38. package/lib/nitrogen/generated/android/c++/JHybridNativeListSpec.hpp +6 -0
  39. package/lib/nitrogen/generated/android/c++/views/JHybridNativeListStateUpdater.cpp +15 -0
  40. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/Func_void_bool.kt +78 -0
  41. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/HybridNativeListSpec.kt +26 -0
  42. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/views/HybridNativeListManager.kt +24 -9
  43. package/lib/nitrogen/generated/android/nativelistOnLoad.cpp +2 -0
  44. package/lib/nitrogen/generated/ios/NativeListModule-Swift-Cxx-Bridge.cpp +8 -0
  45. package/lib/nitrogen/generated/ios/NativeListModule-Swift-Cxx-Bridge.hpp +37 -0
  46. package/lib/nitrogen/generated/ios/c++/HybridNativeListSpecSwift.hpp +23 -2
  47. package/lib/nitrogen/generated/ios/c++/views/HybridNativeListComponent.mm +32 -0
  48. package/lib/nitrogen/generated/ios/swift/Func_void_bool.swift +46 -0
  49. package/lib/nitrogen/generated/ios/swift/HybridNativeListSpec.swift +3 -0
  50. package/lib/nitrogen/generated/ios/swift/HybridNativeListSpec_cxx.swift +54 -0
  51. package/lib/nitrogen/generated/shared/c++/HybridNativeListSpec.cpp +6 -0
  52. package/lib/nitrogen/generated/shared/c++/HybridNativeListSpec.hpp +8 -2
  53. package/lib/nitrogen/generated/shared/c++/views/HybridNativeListComponent.cpp +6 -0
  54. package/lib/nitrogen/generated/shared/c++/views/HybridNativeListComponent.hpp +11 -2
  55. package/lib/nitrogen/generated/shared/json/NativeListConfig.json +3 -0
  56. package/lib/typescript/src/NativeList.nitro.d.ts +3 -0
  57. package/lib/typescript/src/NativeList.types.d.ts +14 -1
  58. package/lib/typescript/src/models.d.ts +33 -5
  59. package/nitrogen/generated/android/c++/JFunc_void_bool.hpp +75 -0
  60. package/nitrogen/generated/android/c++/JHybridNativeListSpec.cpp +39 -3
  61. package/nitrogen/generated/android/c++/JHybridNativeListSpec.hpp +6 -0
  62. package/nitrogen/generated/android/c++/views/JHybridNativeListStateUpdater.cpp +15 -0
  63. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/Func_void_bool.kt +78 -0
  64. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/HybridNativeListSpec.kt +26 -0
  65. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/views/HybridNativeListManager.kt +24 -9
  66. package/nitrogen/generated/android/nativelistOnLoad.cpp +2 -0
  67. package/nitrogen/generated/ios/NativeListModule-Swift-Cxx-Bridge.cpp +8 -0
  68. package/nitrogen/generated/ios/NativeListModule-Swift-Cxx-Bridge.hpp +37 -0
  69. package/nitrogen/generated/ios/c++/HybridNativeListSpecSwift.hpp +23 -2
  70. package/nitrogen/generated/ios/c++/views/HybridNativeListComponent.mm +32 -0
  71. package/nitrogen/generated/ios/swift/Func_void_bool.swift +46 -0
  72. package/nitrogen/generated/ios/swift/HybridNativeListSpec.swift +3 -0
  73. package/nitrogen/generated/ios/swift/HybridNativeListSpec_cxx.swift +54 -0
  74. package/nitrogen/generated/shared/c++/HybridNativeListSpec.cpp +6 -0
  75. package/nitrogen/generated/shared/c++/HybridNativeListSpec.hpp +8 -2
  76. package/nitrogen/generated/shared/c++/views/HybridNativeListComponent.cpp +6 -0
  77. package/nitrogen/generated/shared/c++/views/HybridNativeListComponent.hpp +11 -2
  78. package/nitrogen/generated/shared/json/NativeListConfig.json +3 -0
  79. package/package.json +6 -5
  80. package/scripts/enable-container-slots.mjs +58 -0
  81. package/scripts/enable-container-slots.test.mjs +58 -0
  82. package/src/NativeList.nitro.ts +3 -0
  83. package/src/NativeList.tsx +84 -2
  84. package/src/NativeList.types.ts +14 -1
  85. package/src/NativeList.web.tsx +5 -0
  86. package/src/models.ts +38 -1
  87. package/src/validation.ts +146 -4
@@ -4,6 +4,54 @@ import UIKit
4
4
  import UniformTypeIdentifiers
5
5
 
6
6
  final class NativeListView: UIView {
7
+ private var refreshEmittedForDrag = false
8
+ private var minimumDragOffset: CGFloat = 0
9
+ private var reactContainerSlots: [UIView] = []
10
+ private var containerSlotHeights: [CGFloat] = [0, 0, 0]
11
+ private var lastLaidOutContentHeight: CGFloat?
12
+ private var needsContentShrinkCorrection = false
13
+ private var contentShrinkCorrectionScheduled = false
14
+
15
+ @objc func mountContainerSlot(_ view: UIView, atIndex index: Int) {
16
+ reactContainerSlots.insert(view, at: min(index, reactContainerSlots.count))
17
+ collectionView.addSubview(view)
18
+ setNeedsLayout()
19
+ }
20
+
21
+ @objc func unmountContainerSlot(_ view: UIView) {
22
+ reactContainerSlots.removeAll { $0 === view }
23
+ view.removeFromSuperview()
24
+ }
25
+
26
+ func setContainerSlotHeightsJson(_ json: String) {
27
+ guard let data = json.data(using: .utf8),
28
+ let heights = try? JSONSerialization.jsonObject(with: data) as? [Double],
29
+ heights.count == 3, heights.allSatisfy({ $0.isFinite && $0 >= 0 }) else { return }
30
+ containerSlotHeights = heights.map { CGFloat($0) }
31
+ if let config { configureLayout(config) }
32
+ setNeedsLayout()
33
+ }
34
+
35
+ private func layoutContainerSlots() {
36
+ guard reactContainerSlots.count == 3 else { return }
37
+ let empty = config?.items.isEmpty ?? true
38
+ let header = containerSlotHeights[0]
39
+ let emptyHeight = empty ? containerSlotHeights[1] : 0
40
+ let footer = containerSlotHeights[2]
41
+ let paddingTop = config?.contentPaddingTop ?? 0
42
+ let paddingBottom = config?.contentPaddingBottom ?? 0
43
+ let footerY = max(paddingTop + header + emptyHeight,
44
+ collectionView.contentSize.height - paddingBottom - footer)
45
+ let positions = [paddingTop, paddingTop + header, footerY]
46
+ for (index, view) in reactContainerSlots.enumerated() {
47
+ view.isHidden = index == 1 && !empty
48
+ let horizontalPadding = config?.contentPaddingHorizontal ?? 0
49
+ view.frame = CGRect(x: horizontalPadding, y: positions[index],
50
+ width: max(0, collectionView.bounds.width - horizontalPadding * 2),
51
+ height: containerSlotHeights[index])
52
+ }
53
+ }
54
+
7
55
  private final class ActionAnchorRecord {
8
56
  let token: String
9
57
  weak var sourceView: UIView?
@@ -47,6 +95,26 @@ final class NativeListView: UIView {
47
95
  case end(Bool)
48
96
  }
49
97
 
98
+ private var scrollPositionTracker = NativeListScrollPositionTracker()
99
+ var onScrollPositionThresholdChange: ((Bool) -> Void)? {
100
+ didSet {
101
+ scrollPositionTracker.resetDelivery()
102
+ emitScrollPositionThresholdIfNeeded()
103
+ }
104
+ }
105
+
106
+ func setScrollPositionThresholdsJson(_ json: String) {
107
+ scrollPositionTracker.configure(json: json)
108
+ emitScrollPositionThresholdIfNeeded()
109
+ }
110
+
111
+ private func emitScrollPositionThresholdIfNeeded() {
112
+ guard let callback = onScrollPositionThresholdChange,
113
+ let next = scrollPositionTracker.update(offset: Double(max(0,
114
+ collectionView.contentOffset.y + collectionView.contentInset.top))) else { return }
115
+ callback(next)
116
+ }
117
+
50
118
  var onRowAction: ((String) -> Void)?
51
119
  var onActionAnchorInvalidated: ((String) -> Void)?
52
120
  var onSelectionDelta: ((String) -> Void)?
@@ -60,7 +128,14 @@ final class NativeListView: UIView {
60
128
  }
61
129
 
62
130
  private let flowLayout = NativeListFlowLayout()
63
- private lazy var collectionView = NativeListCollectionView(frame: .zero, collectionViewLayout: flowLayout)
131
+ private lazy var collectionView: NativeListCollectionView = {
132
+ let view = NativeListCollectionView(frame: .zero, collectionViewLayout: flowLayout)
133
+ view.onContentLayout = { [weak self] in
134
+ self?.layoutContainerSlots()
135
+ self?.contentLayoutDidChange()
136
+ }
137
+ return view
138
+ }()
64
139
  private let footerContainer = UIView()
65
140
  private var footerCell: NativeListRowHost = NativeListRendererRegistry.create(.action)
66
141
  private var footerRendererKey = NativeListRendererKey.action
@@ -333,6 +408,7 @@ final class NativeListView: UIView {
333
408
  rebindRowsForLayoutDirection(config)
334
409
  }
335
410
  performPendingScrollIfNeeded()
411
+ layoutContainerSlots()
336
412
  }
337
413
 
338
414
  // A layout-direction change may arrive without a layout pass (semantic attribute or trait).
@@ -673,6 +749,39 @@ final class NativeListView: UIView {
673
749
  collectionView.setContentOffset(point, animated: animated)
674
750
  }
675
751
 
752
+ private func contentLayoutDidChange() {
753
+ let height = collectionView.contentSize.height
754
+ if config?.orientation != "horizontal",
755
+ let previous = lastLaidOutContentHeight, height < previous {
756
+ needsContentShrinkCorrection = true
757
+ }
758
+ lastLaidOutContentHeight = height
759
+ scheduleContentShrinkCorrection()
760
+ }
761
+
762
+ private func scheduleContentShrinkCorrection() {
763
+ guard needsContentShrinkCorrection, !contentShrinkCorrectionScheduled, !disposed else { return }
764
+ contentShrinkCorrectionScheduled = true
765
+ // Wait for the collection layout and ancestor pager inset observers to settle.
766
+ DispatchQueue.main.async { [weak self] in
767
+ guard let self else { return }
768
+ self.contentShrinkCorrectionScheduled = false
769
+ guard !self.disposed, self.needsContentShrinkCorrection,
770
+ !self.collectionView.isTracking, !self.collectionView.isDragging,
771
+ !self.collectionView.isDecelerating,
772
+ self.collectionView.refreshControl?.isRefreshing != true,
773
+ self.config?.refreshing != true else { return }
774
+ self.needsContentShrinkCorrection = false
775
+ guard self.config?.orientation != "horizontal", self.collectionView.bounds.height > 0 else { return }
776
+ let insets = self.collectionView.adjustedContentInset
777
+ let maximum = max(-insets.top,
778
+ self.collectionView.contentSize.height - self.collectionView.bounds.height + insets.bottom)
779
+ if self.collectionView.contentOffset.y > maximum {
780
+ self.setScrollOffset(self.collectionView.contentOffset.y, animated: false)
781
+ }
782
+ }
783
+ }
784
+
676
785
  func setRefreshing(_ refreshing: Bool) {
677
786
  guard var current = config else { return }
678
787
  current.refreshing = refreshing
@@ -681,6 +790,7 @@ final class NativeListView: UIView {
681
790
  collectionView.refreshControl?.beginRefreshing()
682
791
  } else {
683
792
  collectionView.refreshControl?.endRefreshing()
793
+ scheduleContentShrinkCorrection()
684
794
  }
685
795
  }
686
796
 
@@ -693,11 +803,14 @@ final class NativeListView: UIView {
693
803
  flowLayout.minimumLineSpacing = config.itemSpacing
694
804
  flowLayout.minimumInteritemSpacing = config.itemSpacing
695
805
  flowLayout.sectionInset = UIEdgeInsets(
696
- top: config.contentPaddingTop,
806
+ top: config.contentPaddingTop + containerSlotHeights[0],
697
807
  left: config.contentPaddingHorizontal + (isRightToLeft ? indexGutter : 0),
698
- bottom: config.contentPaddingBottom,
808
+ bottom: config.contentPaddingBottom + containerSlotHeights[2],
699
809
  right: config.contentPaddingHorizontal + (isRightToLeft ? 0 : indexGutter)
700
810
  )
811
+ flowLayout.minimumContainerContentHeight = config.items.isEmpty
812
+ ? config.contentPaddingTop + config.contentPaddingBottom + containerSlotHeights.reduce(0, +)
813
+ : 0
701
814
  flowLayout.stickyItemIndexes = config.stickyHeaders
702
815
  ? Set(config.items.enumerated().compactMap {
703
816
  $0.element.type == "sectionHeader" && $0.element.data.bool("sticky", default: true) && $0.element.data.string("variant") != "summary"
@@ -1347,6 +1460,7 @@ final class NativeListView: UIView {
1347
1460
  } else {
1348
1461
  collectionView.refreshControl = nil
1349
1462
  }
1463
+ if !config.refreshing { scheduleContentShrinkCorrection() }
1350
1464
  }
1351
1465
 
1352
1466
  private func configureFooter(_ config: NativeListConfig) {
@@ -1539,6 +1653,7 @@ final class NativeListView: UIView {
1539
1653
  current.rowPressToggles == next.rowPressToggles,
1540
1654
  current.reorderable == next.reorderable,
1541
1655
  current.pullToRefresh == next.pullToRefresh,
1656
+ current.refreshTriggerDistance == next.refreshTriggerDistance,
1542
1657
  current.refreshing == next.refreshing,
1543
1658
  current.loadMore == next.loadMore,
1544
1659
  current.endReachedThreshold == next.endReachedThreshold,
@@ -1849,6 +1964,8 @@ final class NativeListView: UIView {
1849
1964
  itemsByKey.removeAll()
1850
1965
  sectionIndexEntries.removeAll()
1851
1966
  pendingScrollRequest = nil
1967
+ needsContentShrinkCorrection = false
1968
+ lastLaidOutContentHeight = nil
1852
1969
  lastVisibleRange = nil
1853
1970
  onRowAction = nil
1854
1971
  onActionAnchorInvalidated = nil
@@ -1856,6 +1973,7 @@ final class NativeListView: UIView {
1856
1973
  onReorder = nil
1857
1974
  onEndReached = nil
1858
1975
  onVisibleRangeChanged = nil
1976
+ onScrollPositionThresholdChange = nil
1859
1977
  }
1860
1978
 
1861
1979
  private func createActionAnchor(origin: NativeListActionOrigin) -> [String: Any]? {
@@ -1921,6 +2039,9 @@ final class NativeListView: UIView {
1921
2039
  }
1922
2040
 
1923
2041
  @objc private func refreshTriggered() {
2042
+ guard !disposed, config?.pullToRefresh == true,
2043
+ config?.refreshing != true, !refreshEmittedForDrag else { return }
2044
+ refreshEmittedForDrag = true
1924
2045
  emit(onRowAction, ["actionKey": "nativeList.refresh"])
1925
2046
  }
1926
2047
 
@@ -2142,7 +2263,31 @@ extension NativeListView: UICollectionViewDelegateFlowLayout {
2142
2263
  return item.isRowPressEnabled
2143
2264
  }
2144
2265
 
2266
+ func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
2267
+ minimumDragOffset = 0
2268
+ refreshEmittedForDrag = false
2269
+ }
2270
+
2271
+ func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
2272
+ defer { if !decelerate { scheduleContentShrinkCorrection() } }
2273
+ minimumDragOffset = min(minimumDragOffset, scrollView.contentOffset.y + scrollView.contentInset.top)
2274
+ guard let threshold = config?.refreshTriggerDistance,
2275
+ config?.pullToRefresh == true, config?.refreshing != true,
2276
+ minimumDragOffset <= -threshold, !refreshEmittedForDrag else { return }
2277
+ collectionView.refreshControl?.beginRefreshing()
2278
+ refreshTriggered()
2279
+ }
2280
+
2281
+ func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
2282
+ scheduleContentShrinkCorrection()
2283
+ }
2284
+
2145
2285
  func scrollViewDidScroll(_ scrollView: UIScrollView) {
2286
+ if scrollView.isDragging {
2287
+ minimumDragOffset = min(minimumDragOffset, scrollView.contentOffset.y + scrollView.contentInset.top)
2288
+ }
2289
+ layoutContainerSlots()
2290
+ emitScrollPositionThresholdIfNeeded()
2146
2291
  invalidateActionAnchor(reason: "scroll")
2147
2292
  syncSectionIndexToVisibleRows()
2148
2293
  emitVisibleRangeIfNeeded()
@@ -2205,6 +2350,13 @@ extension NativeListView: UICollectionViewDragDelegate, UICollectionViewDropDele
2205
2350
  // quick tap highlighted and unhighlighted a row within one frame and the pressed background never
2206
2351
  // rendered. Deliver touches immediately, as Android rows do.
2207
2352
  private final class NativeListCollectionView: UICollectionView {
2353
+ var onContentLayout: (() -> Void)?
2354
+
2355
+ override func layoutSubviews() {
2356
+ super.layoutSubviews()
2357
+ onContentLayout?()
2358
+ }
2359
+
2208
2360
  override init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout) {
2209
2361
  super.init(frame: frame, collectionViewLayout: layout)
2210
2362
  delaysContentTouches = false
@@ -2223,6 +2375,7 @@ private final class NativeListCollectionView: UICollectionView {
2223
2375
 
2224
2376
  final class NativeListFlowLayout: UICollectionViewFlowLayout {
2225
2377
  var stickyItemIndexes: Set<Int> = []
2378
+ var minimumContainerContentHeight: CGFloat = 0
2226
2379
  private var horizontalAttributes: [IndexPath: UICollectionViewLayoutAttributes] = [:]
2227
2380
  private var horizontalContentSize: CGSize = .zero
2228
2381
 
@@ -2251,7 +2404,9 @@ final class NativeListFlowLayout: UICollectionViewFlowLayout {
2251
2404
  }
2252
2405
 
2253
2406
  override var collectionViewContentSize: CGSize {
2254
- scrollDirection == .horizontal ? horizontalContentSize : super.collectionViewContentSize
2407
+ if scrollDirection == .horizontal { return horizontalContentSize }
2408
+ let size = super.collectionViewContentSize
2409
+ return CGSize(width: size.width, height: max(size.height, minimumContainerContentHeight))
2255
2410
  }
2256
2411
 
2257
2412
  override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
@@ -0,0 +1,43 @@
1
+ import UIKit
2
+ import XCTest
3
+ @testable import NativeListModule
4
+
5
+ @MainActor
6
+ final class NativeListActivityPresentationTests: XCTestCase {
7
+ func testSmallNumberRunsPreserveBaselineAcrossAmountsFiatAndFee() throws {
8
+ func segments(_ prefix: String, _ suffix: String) -> [[String: Any]] {
9
+ [["text": prefix], ["text": "5", "style": "subscript"], ["text": suffix]]
10
+ }
11
+ let item = try NativeListItem(data: [
12
+ "type": "activity", "key": "transfer", "title": "Transfer",
13
+ "leading": ["kind": "icon", "name": "ArrowTopOutline"],
14
+ "amounts": [[
15
+ "key": "receive", "text": "0.051 ETH", "textSegments": segments("0.0", "1 ETH"),
16
+ "secondaryText": "$0.051", "secondaryTextSegments": segments("$0.0", "1"),
17
+ ]],
18
+ "fee": [
19
+ "primary": "0.051 GAS", "primaryTextSegments": segments("0.0", "1 GAS"),
20
+ "secondary": "€0.051", "secondaryTextSegments": segments("€0.0", "1"),
21
+ ],
22
+ ])
23
+ let view = NativeListActivityDetailsView()
24
+ view.bind(item, theme: nil)
25
+ func labels(in view: UIView) -> [UILabel] {
26
+ (view as? UILabel).map { [$0] } ?? view.subviews.flatMap { labels(in: $0) }
27
+ }
28
+ let cases: [(String, Int, CGFloat)] = [
29
+ ("0.051 ETH", 3, 16), ("$0.051", 4, 14),
30
+ ("0.051 GAS", 3, 14), ("€0.051", 4, 12),
31
+ ]
32
+ let rendered = labels(in: view)
33
+ for (text, countIndex, parentSize) in cases {
34
+ let label = try XCTUnwrap(rendered.first { $0.attributedText?.string == text })
35
+ let value = try XCTUnwrap(label.attributedText)
36
+ let font = try XCTUnwrap(value.attribute(.font, at: countIndex, effectiveRange: nil) as? UIFont)
37
+ XCTAssertEqual(font.pointSize, ceil(parentSize * 0.6))
38
+ let baseline = value.attribute(.baselineOffset, at: 0, effectiveRange: nil) as? NSNumber
39
+ let countBaseline = value.attribute(.baselineOffset, at: countIndex, effectiveRange: nil) as? NSNumber
40
+ XCTAssertEqual(countBaseline?.doubleValue ?? 0, baseline?.doubleValue ?? 0)
41
+ }
42
+ }
43
+ }
@@ -4,6 +4,42 @@ import XCTest
4
4
 
5
5
  @MainActor
6
6
  final class NativeListLifecycleTests: XCTestCase {
7
+ func testSectionLoadingStopsOnRebindReuseAndDetach() throws {
8
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 480))
9
+ let controller = UIViewController()
10
+ window.rootViewController = controller
11
+ window.isHidden = false
12
+ defer { window.isHidden = true }
13
+ let cell = NativeListSectionHeaderCell(frame: CGRect(x: 0, y: 0, width: 320, height: 40))
14
+ controller.view.addSubview(cell)
15
+ func bind(loading: Bool) throws {
16
+ let item = try NativeListItem(data: [
17
+ "key": "section", "type": "sectionHeader", "sectionKey": "pending",
18
+ "title": "Confirming", "variant": "history", "titleLoading": loading,
19
+ "style": ["title": ["color": "#FFAA00"]],
20
+ ])
21
+ cell.bind(item: item, theme: nil, layout: "sectioned", selected: false,
22
+ checkboxState: { _, _, fallback in fallback })
23
+ }
24
+ try bind(loading: true)
25
+ let spinner = try XCTUnwrap(cell.root.arrangedSubviews.first as? UIActivityIndicatorView)
26
+ XCTAssertTrue(spinner.isAnimating)
27
+ XCTAssertFalse(spinner.isAccessibilityElement)
28
+ XCTAssertEqual(spinner.color, UIColor(nativeListHex: "#FFAA00", fallback: .clear))
29
+
30
+ try bind(loading: false)
31
+ XCTAssertFalse(spinner.isAnimating)
32
+ XCTAssertNil(spinner.superview)
33
+ try bind(loading: true)
34
+ XCTAssertTrue(spinner.isAnimating)
35
+ cell.removeFromSuperview()
36
+ XCTAssertFalse(spinner.isAnimating)
37
+ controller.view.addSubview(cell)
38
+ XCTAssertTrue(spinner.isAnimating)
39
+ cell.prepareForReuse()
40
+ XCTAssertFalse(spinner.isAnimating)
41
+ }
42
+
7
43
  func testRebindingAndReuseInvalidateThePreviousActionEpoch() throws {
8
44
  let cell = NativeListActionCell(frame: CGRect(x: 0, y: 0, width: 320, height: 60))
9
45
  let first = try NativeListItem(data: ["key": "row", "type": "action", "title": "First"])
@@ -82,6 +118,93 @@ final class NativeListLifecycleTests: XCTestCase {
82
118
  return String(decoding: data, as: UTF8.self)
83
119
  }
84
120
 
121
+ func testContentShrinkCorrectsRestoredOffsetAfterPagerInsetsAndUpdatesObserver() throws {
122
+ let view = try makeScrollingList()
123
+ let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
124
+ list.contentInset = UIEdgeInsets(top: 108, left: 0, bottom: 0, right: 0)
125
+ list.contentOffset.y = 600
126
+ var savedLogicalOffset = list.contentOffset.y + list.contentInset.top
127
+ let offsetObserver = list.observe(\.contentOffset, options: [.new]) { list, _ in
128
+ savedLogicalOffset = list.contentOffset.y + list.contentInset.top
129
+ }
130
+ // Model the pager's synchronous content-size observer and cached-offset restoration.
131
+ let sizeObserver = list.observe(\.contentSize, options: [.old, .new]) { list, change in
132
+ if change.oldValue != change.newValue, list.contentSize.height < 200 {
133
+ list.contentInset.bottom = max(0, list.bounds.height - 44 - list.contentSize.height)
134
+ list.contentOffset.y = 600
135
+ }
136
+ }
137
+ view.applySnapshotJson(try scrollingSnapshot(count: 0))
138
+ view.layoutIfNeeded()
139
+ list.layoutIfNeeded()
140
+ drainMainQueue()
141
+ XCTAssertEqual(list.contentSize.height, 132, accuracy: 0.5)
142
+ XCTAssertEqual(list.contentOffset.y, -44, accuracy: 0.5)
143
+ XCTAssertEqual(savedLogicalOffset, 64, accuracy: 0.5)
144
+ withExtendedLifetime((offsetObserver, sizeObserver)) {}
145
+ }
146
+
147
+ func testContentShrinkPreservesInRangeAndNegativePullOffsets() throws {
148
+ for offset in [CGFloat(120), CGFloat(-160)] {
149
+ let view = try makeScrollingList()
150
+ let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
151
+ list.contentInset.top = 108
152
+ view.applySnapshotJson(try scrollingSnapshot(count: 20))
153
+ view.layoutIfNeeded()
154
+ list.layoutIfNeeded()
155
+ // Preserve a valid offset or a refresh pull while shrink correction is queued.
156
+ list.contentOffset.y = offset
157
+ drainMainQueue()
158
+ XCTAssertEqual(list.contentOffset.y, offset, accuracy: 0.5)
159
+ }
160
+ }
161
+
162
+ func testContentShrinkWaitsForRefreshCompletion() throws {
163
+ let view = try makeScrollingList()
164
+ let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
165
+ view.applySnapshotJson(try scrollingSnapshot(count: 0, refreshing: true))
166
+ view.layoutIfNeeded()
167
+ list.layoutIfNeeded()
168
+ list.contentOffset.y = 600
169
+ drainMainQueue()
170
+ XCTAssertEqual(list.contentOffset.y, 600, accuracy: 0.5)
171
+ view.setRefreshing(false)
172
+ drainMainQueue()
173
+ let inset = list.adjustedContentInset
174
+ let maximum = max(-inset.top, list.contentSize.height - list.bounds.height + inset.bottom)
175
+ XCTAssertEqual(list.contentOffset.y, maximum, accuracy: 0.5)
176
+ }
177
+
178
+ private func makeScrollingList() throws -> NativeListView {
179
+ let view = NativeListView(frame: CGRect(x: 0, y: 0, width: 320, height: 708))
180
+ for index in 0..<3 { view.mountContainerSlot(UIView(), atIndex: index) }
181
+ view.setContainerSlotHeightsJson("[44,44,44]")
182
+ view.applySnapshotJson(try scrollingSnapshot(count: 40))
183
+ view.layoutIfNeeded()
184
+ let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
185
+ list.layoutIfNeeded()
186
+ drainMainQueue()
187
+ return view
188
+ }
189
+
190
+ private func scrollingSnapshot(count: Int, refreshing: Bool = false) throws -> String {
191
+ let rows: [[String: Any]] = (0..<count).map {
192
+ ["key": "row-\($0)", "type": "action", "title": "Row", "height": 80]
193
+ }
194
+ let value: [String: Any] = [
195
+ "schemaVersion": 1, "generation": count + 1,
196
+ "layout": ["kind": "linear"], "rows": rows,
197
+ "capabilities": ["pullToRefresh": true, "refreshing": refreshing],
198
+ ]
199
+ return String(decoding: try JSONSerialization.data(withJSONObject: value), as: UTF8.self)
200
+ }
201
+
202
+ private func drainMainQueue() {
203
+ let settled = expectation(description: "Deferred content shrink correction")
204
+ DispatchQueue.main.async { settled.fulfill() }
205
+ wait(for: [settled], timeout: 1)
206
+ }
207
+
85
208
  private func footerCell(in view: NativeListView) -> NativeListRowHost? {
86
209
  view.subviews.lazy.compactMap { container in
87
210
  container.subviews.compactMap { $0 as? NativeListRowHost }.first
@@ -0,0 +1,42 @@
1
+ import UIKit
2
+ import XCTest
3
+ @testable import NativeListModule
4
+
5
+ @MainActor
6
+ final class NativeListMediaVisibilityTests: XCTestCase {
7
+ func testRetainedPagerAndListCellsRequireViewportIntersection() {
8
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 480))
9
+ let controller = UIViewController()
10
+ window.rootViewController = controller
11
+ window.isHidden = false
12
+ defer { window.isHidden = true }
13
+ let pager = UIScrollView(frame: CGRect(x: 0, y: 0, width: 320, height: 480))
14
+ pager.contentSize = CGSize(width: 640, height: 480)
15
+ controller.view.addSubview(pager)
16
+ let list = UIScrollView(frame: CGRect(x: 320, y: 0, width: 320, height: 480))
17
+ list.contentSize = CGSize(width: 320, height: 960)
18
+ pager.addSubview(list)
19
+ let tile = UIView(frame: CGRect(x: 10, y: 10, width: 100, height: 100))
20
+ list.addSubview(tile)
21
+
22
+ XCTAssertNotNil(tile.window, "Pager retains the offscreen cell in the window")
23
+ XCTAssertFalse(NativeListMediaPreviewSlot.intersectsViewport(tile))
24
+ pager.contentOffset.x = 320
25
+ XCTAssertTrue(NativeListMediaPreviewSlot.intersectsViewport(tile))
26
+ list.contentOffset.y = 110
27
+ XCTAssertFalse(NativeListMediaPreviewSlot.intersectsViewport(tile))
28
+ list.contentOffset.y = 100
29
+ XCTAssertTrue(NativeListMediaPreviewSlot.intersectsViewport(tile), "Partially visible previews remain eligible")
30
+ list.transform = CGAffineTransform(translationX: 320, y: 0)
31
+ XCTAssertFalse(NativeListMediaPreviewSlot.intersectsViewport(tile))
32
+ list.transform = .identity
33
+ XCTAssertTrue(NativeListMediaPreviewSlot.intersectsViewport(tile))
34
+ list.isHidden = true
35
+ XCTAssertFalse(NativeListMediaPreviewSlot.intersectsViewport(tile))
36
+ list.isHidden = false
37
+ list.alpha = 0
38
+ XCTAssertFalse(NativeListMediaPreviewSlot.intersectsViewport(tile))
39
+ tile.removeFromSuperview()
40
+ XCTAssertFalse(NativeListMediaPreviewSlot.intersectsViewport(tile))
41
+ }
42
+ }
@@ -0,0 +1,25 @@
1
+ import XCTest
2
+ @testable import NativeListModule
3
+
4
+ final class NativeListScrollPositionTrackerTests: XCTestCase {
5
+ func testHysteresisAndDisabledLifecycle() {
6
+ var tracker = NativeListScrollPositionTracker()
7
+ XCTAssertNil(tracker.update(offset: 200))
8
+ tracker.configure(json: "{\"start\":48,\"end\":160}")
9
+ XCTAssertEqual(tracker.update(offset: 0), false)
10
+ XCTAssertNil(tracker.update(offset: 159))
11
+ XCTAssertEqual(tracker.update(offset: 160), true)
12
+ XCTAssertNil(tracker.update(offset: 100))
13
+ XCTAssertEqual(tracker.update(offset: 48), false)
14
+ XCTAssertNil(tracker.update(offset: 49))
15
+ tracker.configure(json: "")
16
+ XCTAssertNil(tracker.update(offset: 200))
17
+ tracker.configure(json: "{\"start\":48,\"end\":160}")
18
+ XCTAssertEqual(tracker.update(offset: 200), true)
19
+ tracker.resetDelivery()
20
+ XCTAssertEqual(tracker.update(offset: 200), true)
21
+ XCTAssertNil(tracker.update(offset: .nan))
22
+ tracker.configure(json: "{\"start\":48,\"end\":48}")
23
+ XCTAssertNil(tracker.update(offset: 48))
24
+ }
25
+ }
@@ -1,8 +1,9 @@
1
1
  "use strict";
2
2
 
3
- import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from 'react';
3
+ import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
4
4
  import { OneKeyImageCache, OneKeyImageCachePolicy } from '@onekeyfe/react-native-image';
5
5
  import { NativeAvatarPrefetchModel, NativeAvatarPrefetchQueue } from "./avatarPrefetch.js";
6
+ import { View } from 'react-native';
6
7
  import { callback, getHostComponent } from 'react-native-nitro-modules';
7
8
  import { isSelectableRow } from "./selection.js";
8
9
  import { normalizeIndexScroll, normalizeKeyScroll, normalizePositionScroll, resolveLocationIndex, scrollFailure, validateOffset } from "./scrolling.js";
@@ -26,6 +27,10 @@ function parsePayload(payloadJson) {
26
27
  }
27
28
  export const NativeList = /*#__PURE__*/forwardRef(function NativeList({
28
29
  snapshot,
30
+ listHeader,
31
+ listFooter,
32
+ listEmpty,
33
+ scrollPositionThresholds,
29
34
  webVirtualizationEnabled: _webVirtualizationEnabled,
30
35
  webSectionIndexContainerRef: _webSectionIndexContainerRef,
31
36
  keyboardDismissMode = 'none',
@@ -36,6 +41,7 @@ export const NativeList = /*#__PURE__*/forwardRef(function NativeList({
36
41
  onReorder,
37
42
  onEndReached,
38
43
  onVisibleRangeChanged,
44
+ onScrollPositionThresholdChange,
39
45
  onRefresh,
40
46
  onScrollToIndexFailed,
41
47
  initialScrollIndex,
@@ -44,6 +50,13 @@ export const NativeList = /*#__PURE__*/forwardRef(function NativeList({
44
50
  initialScrollViewOffset,
45
51
  ...viewProps
46
52
  }, forwardedRef) {
53
+ const [slotHeights, setSlotHeights] = useState([0, 0, 0]);
54
+ const containerSlotHeightsJson = JSON.stringify(slotHeights);
55
+ const slotInset = snapshot.layout.contentPaddingHorizontal ?? snapshot.layout.contentPadding ?? 0;
56
+ if (snapshot.layout.orientation === 'horizontal' && (listHeader || listEmpty || listFooter)) {
57
+ throw new Error('NativeList container slots require vertical orientation');
58
+ }
59
+ const slotContents = [listHeader, listEmpty, listFooter];
47
60
  const normalizedKeyboardShouldPersistTaps = keyboardShouldPersistTaps === true ? 'always' : keyboardShouldPersistTaps === false ? 'never' : keyboardShouldPersistTaps;
48
61
  const nativeRef = useRef(null);
49
62
  const callbacksRef = useRef({
@@ -53,6 +66,7 @@ export const NativeList = /*#__PURE__*/forwardRef(function NativeList({
53
66
  onReorder,
54
67
  onEndReached,
55
68
  onVisibleRangeChanged,
69
+ onScrollPositionThresholdChange,
56
70
  onRefresh,
57
71
  onScrollToIndexFailed
58
72
  });
@@ -63,9 +77,24 @@ export const NativeList = /*#__PURE__*/forwardRef(function NativeList({
63
77
  onReorder,
64
78
  onEndReached,
65
79
  onVisibleRangeChanged,
80
+ onScrollPositionThresholdChange,
66
81
  onRefresh,
67
82
  onScrollToIndexFailed
68
83
  };
84
+ const scrollPositionThresholdsJson = useMemo(() => {
85
+ if (!scrollPositionThresholds || scrollPositionThresholds.enabled === false) return '';
86
+ const {
87
+ start,
88
+ end
89
+ } = scrollPositionThresholds;
90
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end <= start) {
91
+ throw new Error('scrollPositionThresholds requires 0 <= start < end and finite values');
92
+ }
93
+ return JSON.stringify({
94
+ start,
95
+ end
96
+ });
97
+ }, [scrollPositionThresholds]);
69
98
  const snapshotJson = useMemo(() => serializeSnapshot(snapshot), [snapshot]);
70
99
  const snapshotRef = useRef(snapshot);
71
100
  const previousSnapshotJsonRef = useRef(snapshotJson);
@@ -235,7 +264,10 @@ export const NativeList = /*#__PURE__*/forwardRef(function NativeList({
235
264
  }),
236
265
  onRowAction: callback(payloadJson => {
237
266
  const payload = parsePayload(payloadJson);
238
- if (payload.actionKey === 'nativeList.refresh') callbacksRef.current.onRefresh?.();
267
+ if (payload.actionKey === 'nativeList.refresh' && payload.rowKey === undefined) {
268
+ callbacksRef.current.onRefresh?.();
269
+ return;
270
+ }
239
271
  callbacksRef.current.onRowAction?.(payload);
240
272
  }),
241
273
  onActionAnchorInvalidated: callback(payloadJson => {
@@ -264,6 +296,11 @@ export const NativeList = /*#__PURE__*/forwardRef(function NativeList({
264
296
  onEndReached: callback(payloadJson => {
265
297
  callbacksRef.current.onEndReached?.(parsePayload(payloadJson));
266
298
  }),
299
+ onScrollPositionThresholdChange: callback(isBeyondThreshold => {
300
+ callbacksRef.current.onScrollPositionThresholdChange?.({
301
+ isBeyondThreshold
302
+ });
303
+ }),
267
304
  onVisibleRangeChanged: callback(payloadJson => {
268
305
  const payload = parsePayload(payloadJson);
269
306
  const previous = avatarRangeRef.current;
@@ -280,8 +317,24 @@ export const NativeList = /*#__PURE__*/forwardRef(function NativeList({
280
317
  ...viewProps,
281
318
  ...nativeCallbacks,
282
319
  snapshotJson: snapshotJson,
320
+ containerSlotHeightsJson: containerSlotHeightsJson,
321
+ scrollPositionThresholdsJson: scrollPositionThresholdsJson,
283
322
  keyboardDismissMode: keyboardDismissMode,
284
- keyboardShouldPersistTaps: normalizedKeyboardShouldPersistTaps
323
+ keyboardShouldPersistTaps: normalizedKeyboardShouldPersistTaps,
324
+ children: slotContents.map((content, index) => /*#__PURE__*/_jsx(View, {
325
+ collapsable: false,
326
+ style: {
327
+ position: 'absolute',
328
+ left: slotInset,
329
+ right: slotInset,
330
+ top: 0
331
+ },
332
+ onLayout: event => {
333
+ const height = Math.ceil(event.nativeEvent.layout.height);
334
+ setSlotHeights(previous => previous[index] === height ? previous : previous.map((value, slot) => slot === index ? height : value));
335
+ },
336
+ children: content
337
+ }, index))
285
338
  });
286
339
  });
287
340
  //# sourceMappingURL=NativeList.js.map
@@ -22,6 +22,11 @@ export function preloadNativeListAvatarImages(sources) {
22
22
  }
23
23
  export const NativeList = /*#__PURE__*/forwardRef(function NativeList({
24
24
  snapshot,
25
+ listHeader: _listHeader,
26
+ listEmpty: _listEmpty,
27
+ listFooter: _listFooter,
28
+ scrollPositionThresholds: _scrollPositionThresholds,
29
+ onScrollPositionThresholdChange: _onScrollPositionThresholdChange,
25
30
  webVirtualizationEnabled = true,
26
31
  webSectionIndexContainerRef,
27
32
  keyboardDismissMode: _keyboardDismissMode,