@onekeyfe/react-native-native-list 3.0.157-alpha.253 → 3.0.157-alpha.255

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 (88) 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 +163 -26
  13. package/android/src/main/res/values/native_scroll_coordinator_ids.xml +4 -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 +227 -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 +213 -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/react-native-native-list.podspec +6 -0
  81. package/scripts/enable-container-slots.mjs +58 -0
  82. package/scripts/enable-container-slots.test.mjs +58 -0
  83. package/src/NativeList.nitro.ts +3 -0
  84. package/src/NativeList.tsx +84 -2
  85. package/src/NativeList.types.ts +14 -1
  86. package/src/NativeList.web.tsx +5 -0
  87. package/src/models.ts +38 -1
  88. package/src/validation.ts +146 -4
@@ -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
+ }
@@ -0,0 +1,213 @@
1
+ import UIKit
2
+ import XCTest
3
+ @testable import NativeListModule
4
+
5
+ @MainActor
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
+
43
+ func testRebindingAndReuseInvalidateThePreviousActionEpoch() throws {
44
+ let cell = NativeListActionCell(frame: CGRect(x: 0, y: 0, width: 320, height: 60))
45
+ let first = try NativeListItem(data: ["key": "row", "type": "action", "title": "First"])
46
+ let changed = try NativeListItem(data: ["key": "row", "type": "action", "title": "Changed"])
47
+ var invalidatedEpochs: [Int] = []
48
+ cell.onBindingInvalidated = { _, epoch in invalidatedEpochs.append(epoch) }
49
+
50
+ func bind(_ item: NativeListItem) {
51
+ cell.bind(item: item, theme: nil, layout: "linear", selected: false,
52
+ checkboxState: { _, _, fallback in fallback })
53
+ }
54
+
55
+ bind(first)
56
+ let originalEpoch = cell.rowActionOrigin().bindingEpoch
57
+ bind(first)
58
+ XCTAssertEqual(cell.bindingEpoch, originalEpoch, "Identical content should retain its action origin")
59
+ XCTAssertTrue(invalidatedEpochs.isEmpty)
60
+
61
+ bind(changed)
62
+ XCTAssertEqual(invalidatedEpochs, [originalEpoch])
63
+ XCTAssertNotEqual(cell.rowActionOrigin().bindingEpoch, originalEpoch)
64
+
65
+ let changedEpoch = cell.bindingEpoch
66
+ cell.prepareForReuse()
67
+ XCTAssertEqual(invalidatedEpochs, [originalEpoch, changedEpoch])
68
+ XCTAssertNotEqual(cell.bindingEpoch, changedEpoch)
69
+ }
70
+
71
+ func testFixedFooterReplacesRendererAndKeepsActionRouting() throws {
72
+ let view = NativeListView(frame: CGRect(x: 0, y: 0, width: 320, height: 480))
73
+ var actions: [[String: Any]] = []
74
+ view.onRowAction = { payload in
75
+ let data = Data(payload.utf8)
76
+ if let action = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] {
77
+ actions.append(action)
78
+ }
79
+ }
80
+
81
+ view.applySnapshotJson(try snapshot(footer: [
82
+ "key": "footer", "type": "action", "title": "Continue", "actionKey": "continue",
83
+ ]))
84
+ let first = try XCTUnwrap(footerCell(in: view))
85
+ XCTAssertTrue(first is NativeListActionCell)
86
+ XCTAssertFalse(first.isHidden)
87
+ XCTAssertEqual(first.accessibilityLabel, "Continue")
88
+ let gestureCount = first.gestureRecognizers?.count
89
+
90
+ view.applySnapshotJson(try snapshot(footer: [
91
+ "key": "footer", "type": "system", "variant": "retry", "title": "Retry",
92
+ "actionKey": "retry",
93
+ ]))
94
+ let second = try XCTUnwrap(footerCell(in: view))
95
+ XCTAssertTrue(second is NativeListSystemCell)
96
+ XCTAssertFalse(second === first)
97
+ XCTAssertNil(first.superview)
98
+ XCTAssertFalse(second.isHidden)
99
+ XCTAssertEqual(second.accessibilityLabel, "Retry")
100
+ XCTAssertEqual(second.gestureRecognizers?.count, gestureCount)
101
+
102
+ second.onAction?(try NativeListItem(data: [
103
+ "key": "footer", "type": "system", "variant": "retry", "title": "Retry",
104
+ "actionKey": "retry",
105
+ ]), "retry", nil, nil)
106
+ XCTAssertEqual(actions.last?["actionKey"] as? String, "retry")
107
+
108
+ view.applySnapshotJson(try snapshot(footer: nil))
109
+ XCTAssertTrue(second.isHidden)
110
+ }
111
+
112
+ private func snapshot(footer: [String: Any]?) throws -> String {
113
+ var value: [String: Any] = [
114
+ "schemaVersion": 1, "generation": 1, "layout": ["kind": "linear"], "rows": [],
115
+ ]
116
+ if let footer { value["fixedFooter"] = footer }
117
+ let data = try JSONSerialization.data(withJSONObject: value, options: [.sortedKeys])
118
+ return String(decoding: data, as: UTF8.self)
119
+ }
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
+
208
+ private func footerCell(in view: NativeListView) -> NativeListRowHost? {
209
+ view.subviews.lazy.compactMap { container in
210
+ container.subviews.compactMap { $0 as? NativeListRowHost }.first
211
+ }.first
212
+ }
213
+ }
@@ -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,
@@ -128,13 +128,13 @@ const TEXT_STYLE_KEYS_BY_ROW_TYPE = {
128
128
  identity: ['title', 'subtitle', 'tertiary', 'badge', 'value', 'valueSecondary'],
129
129
  walletGroup: [],
130
130
  rail: ['title', 'badge', 'status'],
131
- activity: ['title', 'description', 'status', 'primaryAmount', 'secondaryAmount'],
131
+ activity: ['title', 'description', 'status', 'amounts', 'badges', 'presentation', 'fee', 'descriptionActionKey', 'primaryAmount', 'secondaryAmount'],
132
132
  message: ['title', 'body', 'time'],
133
133
  dataRow: ['columns', 'columnSecondary', 'index'],
134
134
  market: ['title', 'subtitle', 'price', 'change'],
135
135
  mediaTile: ['title', 'subtitle', 'badge'],
136
136
  metricCard: ['title', 'value', 'subtitle', 'trend'],
137
- sectionHeader: ['title', 'subtitle', 'value'],
137
+ sectionHeader: ['titleLoading', 'title', 'subtitle', 'value'],
138
138
  action: ['title', 'value'],
139
139
  system: ['title', 'message', 'actionText']
140
140
  };
@@ -517,6 +517,46 @@ function assertVisual(row, path) {
517
517
  });
518
518
  }
519
519
  function assertActivity(row, path) {
520
+ assertText(row.title, `${path}.title`);
521
+ assertText(row.description, `${path}.description`);
522
+ if (row.presentation !== undefined && !['stacked', 'table'].includes(row.presentation)) fail(`${path}.presentation`, 'must be stacked or table');
523
+ if (row.descriptionActionKey !== undefined) assertKey(row.descriptionActionKey, `${path}.descriptionActionKey`);
524
+ if ((row.amounts?.length ?? 0) > 32) fail(`${path}.amounts`, 'supports at most 32 amount lines');
525
+ const amountKeys = new Set();
526
+ row.amounts?.forEach((amount, index) => {
527
+ const amountPath = `${path}.amounts[${index}]`;
528
+ assertKey(amount.key, `${amountPath}.key`);
529
+ if (amountKeys.has(amount.key)) fail(amountPath, 'duplicate amount key');
530
+ amountKeys.add(amount.key);
531
+ assertText(amount.text, `${amountPath}.text`);
532
+ assertLeadingVisual(amount.leading, `${amountPath}.leading`);
533
+ assertText(amount.secondaryText, `${amountPath}.secondaryText`);
534
+ if (amount.tone !== undefined && !['primary', 'secondary', 'positive', 'negative'].includes(amount.tone)) fail(`${amountPath}.tone`, 'invalid text tone');
535
+ amount.textSegments?.forEach((segment, segmentIndex) => {
536
+ assertText(segment.text, `${amountPath}.textSegments[${segmentIndex}].text`);
537
+ if (segment.style !== undefined && segment.style !== 'subscript') fail(amountPath, 'invalid text segment style');
538
+ });
539
+ });
540
+ const assertActivitySegments = (segments, segmentPath) => segments?.forEach((segment, index) => {
541
+ assertText(segment.text, `${segmentPath}[${index}].text`);
542
+ if (segment.style !== undefined && segment.style !== 'subscript') fail(segmentPath, 'invalid text segment style');
543
+ });
544
+ row.amounts?.forEach((amount, index) => assertActivitySegments(amount.secondaryTextSegments, `${path}.amounts[${index}].secondaryTextSegments`));
545
+ assertActivitySegments(row.fee?.primaryTextSegments, `${path}.fee.primaryTextSegments`);
546
+ assertActivitySegments(row.fee?.secondaryTextSegments, `${path}.fee.secondaryTextSegments`);
547
+ assertText(row.fee?.label, `${path}.fee.label`);
548
+ assertText(row.fee?.primary, `${path}.fee.primary`);
549
+ assertText(row.fee?.secondary, `${path}.fee.secondary`);
550
+ if ((row.badges?.length ?? 0) > 4) fail(`${path}.badges`, 'supports at most 4 activity badges');
551
+ const badgeKeys = new Set();
552
+ row.badges?.forEach((badge, index) => {
553
+ assertKey(badge.key, `${path}.badges[${index}].key`);
554
+ if (badgeKeys.has(badge.key)) fail(`${path}.badges`, 'duplicate badge key');
555
+ badgeKeys.add(badge.key);
556
+ assertText(badge.text, `${path}.badges[${index}].text`);
557
+ if (badge.tone !== undefined && !['neutral', 'info', 'success', 'warning', 'danger'].includes(badge.tone)) fail(`${path}.badges`, 'invalid badge tone');
558
+ });
559
+ if (row.fee?.hidden !== undefined && typeof row.fee.hidden !== 'boolean') fail(`${path}.fee.hidden`, 'must be boolean');
520
560
  if ((row.footerActions?.length ?? 0) > MAX_FOOTER_ACTIONS) {
521
561
  fail(`${path}.footerActions`, `supports at most ${MAX_FOOTER_ACTIONS} actions`);
522
562
  }
@@ -634,12 +674,13 @@ function assertRow(row, index, path = `rows[${index}]`) {
634
674
  assertMarketRow(row, path);
635
675
  break;
636
676
  case 'mediaTile':
677
+ assertMediaPreview(row.media, `${path}.media`);
637
678
  assertImage(row.image, `${path}.image`);
638
679
  if (row.imageState !== undefined && !['empty', 'error'].includes(row.imageState)) {
639
680
  fail(`${path}.imageState`, 'must be empty or error when provided');
640
681
  }
641
- if (row.imageState === undefined && row.image === undefined) {
642
- fail(`${path}.image`, 'is required unless imageState is empty or error');
682
+ if (row.imageState === undefined && row.image === undefined && row.media === undefined) {
683
+ fail(`${path}.image`, 'is required unless media is provided or imageState is empty or error');
643
684
  }
644
685
  assertImage(row.networkImage, `${path}.networkImage`);
645
686
  assertText(row.title, `${path}.title`);
@@ -668,6 +709,7 @@ function assertRow(row, index, path = `rows[${index}]`) {
668
709
  }
669
710
  break;
670
711
  case 'sectionHeader':
712
+ if (row.titleLoading !== undefined && typeof row.titleLoading !== 'boolean') fail(`${path}.titleLoading`, 'must be boolean');
671
713
  assertKey(row.sectionKey, `${path}.sectionKey`);
672
714
  if (row.presentation !== undefined && row.presentation !== 'networkSelector') {
673
715
  fail(`${path}.presentation`, 'must be networkSelector when provided');
@@ -746,12 +788,27 @@ function assertGroups(rows) {
746
788
  }
747
789
  }
748
790
  }
791
+ function assertMediaPreview(value, path) {
792
+ if (value === undefined) return;
793
+ if (!value || typeof value !== 'object' || Array.isArray(value)) fail(path, 'must be a media descriptor');
794
+ const media = value;
795
+ assertImage(media.source, `${path}.source`);
796
+ if (!media.source) fail(`${path}.source`, 'is required');
797
+ const order = media.probeOrder;
798
+ if (!Array.isArray(order) || order.length < 1 || order.length > 2 || order.some(kind => kind !== 'image' && kind !== 'video') || new Set(order).size !== order.length) {
799
+ fail(`${path}.probeOrder`, 'must contain one or two distinct image/video candidates');
800
+ }
801
+ }
749
802
  export function validateSnapshot(snapshot) {
803
+ if (snapshot.capabilities?.refreshTriggerDistance !== undefined && (!Number.isFinite(snapshot.capabilities.refreshTriggerDistance) || snapshot.capabilities.refreshTriggerDistance <= 0)) fail('capabilities.refreshTriggerDistance', 'must be a positive finite number');
750
804
  assertPlainSerializable(snapshot, 'snapshot', new Set());
751
805
  if (snapshot.schemaVersion !== 1) fail('snapshot.schemaVersion', 'must be 1');
752
806
  if (!Number.isSafeInteger(snapshot.generation) || snapshot.generation < 0) {
753
807
  fail('snapshot.generation', 'must be a non-negative safe integer');
754
808
  }
809
+ if (snapshot.layout.gridColumns !== undefined && (!Number.isInteger(snapshot.layout.gridColumns) || snapshot.layout.gridColumns < 2 || snapshot.layout.gridColumns > 7)) {
810
+ fail('snapshot.layout.gridColumns', 'must be an integer from 2 to 7');
811
+ }
755
812
  if (snapshot.layout.kind === 'grid' && !snapshot.layout.gridColumns) {
756
813
  fail('snapshot.layout.gridColumns', 'is required for grid layout');
757
814
  }
@@ -880,6 +937,7 @@ function assertPatchChanges(patch, index) {
880
937
  assertLeadingVisual(patch.changes.visual, `${path}.visual`);
881
938
  break;
882
939
  case 'activity':
940
+ assertActivity(patch.changes, path);
883
941
  assertText(patch.changes.title, `${path}.title`);
884
942
  assertText(patch.changes.description, `${path}.description`);
885
943
  assertText(patch.changes.status, `${path}.status`);
@@ -933,6 +991,7 @@ function assertPatchChanges(patch, index) {
933
991
  }, path);
934
992
  break;
935
993
  case 'mediaTile':
994
+ assertMediaPreview(patch.changes.media, `${path}.media`);
936
995
  assertImage(patch.changes.image, `${path}.image`);
937
996
  if (patch.changes.imageState !== undefined && !['empty', 'error'].includes(patch.changes.imageState)) {
938
997
  fail(`${path}.imageState`, 'must be empty or error when provided');
@@ -965,6 +1024,7 @@ function assertPatchChanges(patch, index) {
965
1024
  }
966
1025
  break;
967
1026
  case 'sectionHeader':
1027
+ if (patch.changes.titleLoading !== undefined && typeof patch.changes.titleLoading !== 'boolean') fail(`${path}.titleLoading`, 'must be boolean');
968
1028
  assertSectionHeaderVariant(patch.changes.variant, `${path}.variant`);
969
1029
  assertText(patch.changes.title, `${path}.title`);
970
1030
  assertText(patch.changes.subtitle, `${path}.subtitle`);