@onekeyfe/react-native-native-list 6.7.0-alpha.405 → 6.7.0-alpha.407
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/src/main/java/com/onekey/nativelist/NativeListActivityDetailsView.kt +29 -4
- package/android/src/main/java/com/onekey/nativelist/NativeListActivityRenderer.kt +6 -10
- package/android/src/main/java/com/onekey/nativelist/NativeListAdapter.kt +17 -1
- package/android/src/main/java/com/onekey/nativelist/NativeListDataRowRenderer.kt +15 -0
- package/android/src/main/java/com/onekey/nativelist/NativeListMarketRenderer.kt +92 -28
- package/android/src/main/java/com/onekey/nativelist/NativeListModels.kt +10 -9
- package/android/src/main/java/com/onekey/nativelist/NativeListView.kt +2 -0
- package/android/src/test/java/com/margelo/nitro/nativelist/NativeListActivityHostClipPolicyTest.kt +11 -5
- package/docs/ROW_TEMPLATES.md +6 -2
- package/docs/SPEC.md +101 -7
- package/ios/NativeListActivityDetailsView.swift +13 -4
- package/ios/NativeListMarketRenderer.swift +113 -18
- package/ios/RNCNativeListView.swift +15 -2
- package/ios/tests/NativeListActivityPresentationTests.swift +97 -0
- package/ios/tests/NativeListMarketMetricsTests.swift +117 -0
- package/lib/module/NativeList.js +25 -6
- package/lib/module/validation.js +73 -5
- package/lib/typescript/src/models.d.ts +3 -1
- package/package.json +3 -3
- package/src/NativeList.tsx +30 -10
- package/src/models.ts +14 -0
- package/src/validation.ts +140 -2
|
@@ -114,8 +114,9 @@ final class NativeListActivityDetailsView: UIStackView {
|
|
|
114
114
|
let feeColumn = data.dictionary("fee") == nil ? 0 : gap + feeWidth
|
|
115
115
|
let identityWidth: CGFloat
|
|
116
116
|
if table {
|
|
117
|
-
let
|
|
118
|
-
|
|
117
|
+
let hasFee = data.dictionary("fee") != nil
|
|
118
|
+
let columnWidth = max(0, mainWidth - gap * (hasFee ? 2 : 1)) / (hasFee ? 2.6 : 2)
|
|
119
|
+
identityWidth = columnWidth - leadingWidth
|
|
119
120
|
} else {
|
|
120
121
|
identityWidth = mainWidth - leadingWidth - gap - feeColumn - min(amountsWidth, mainWidth * 0.5)
|
|
121
122
|
}
|
|
@@ -163,9 +164,17 @@ final class NativeListActivityDetailsView: UIStackView {
|
|
|
163
164
|
dimensions = [
|
|
164
165
|
visualWidth,
|
|
165
166
|
visual.heightAnchor.constraint(equalToConstant: CGFloat(image.double("height", default: 40))),
|
|
166
|
-
amounts.widthAnchor.constraint(lessThanOrEqualTo: main.widthAnchor, multiplier: table ? 0.42 : 0.5),
|
|
167
167
|
]
|
|
168
|
-
if table {
|
|
168
|
+
if table {
|
|
169
|
+
// The leading visual belongs to identity, so its visibility cannot shift amounts.
|
|
170
|
+
let leadingWidth = hideLeading ? 0 : CGFloat(image.double("width", default: 40)) + gap
|
|
171
|
+
dimensions.append(amounts.widthAnchor.constraint(equalTo: identity.widthAnchor, constant: leadingWidth))
|
|
172
|
+
if data.dictionary("fee") != nil {
|
|
173
|
+
dimensions.append(fee.widthAnchor.constraint(equalTo: amounts.widthAnchor, multiplier: 0.6))
|
|
174
|
+
}
|
|
175
|
+
} else {
|
|
176
|
+
dimensions.append(amounts.widthAnchor.constraint(lessThanOrEqualTo: main.widthAnchor, multiplier: 0.5))
|
|
177
|
+
}
|
|
169
178
|
NSLayoutConstraint.activate(dimensions)
|
|
170
179
|
visual.isHidden = hideLeading
|
|
171
180
|
if hideLeading {
|
|
@@ -22,6 +22,15 @@ final class NativeListMarketCell: NativeListRendererCell {
|
|
|
22
22
|
private let marketSubtitleStack = UIStackView()
|
|
23
23
|
private let marketSubtitleSpacer = UIView()
|
|
24
24
|
private let trailingStack = UIStackView()
|
|
25
|
+
private let metricStacks = (0..<3).map { _ -> UIStackView in
|
|
26
|
+
let stack = UIStackView()
|
|
27
|
+
stack.axis = .vertical
|
|
28
|
+
stack.alignment = .fill
|
|
29
|
+
stack.spacing = 2
|
|
30
|
+
return stack
|
|
31
|
+
}
|
|
32
|
+
private let metricPrimaryLabels = (0..<3).map { _ in NativeListTextLabel() }
|
|
33
|
+
private let metricSecondaryLabels = (0..<3).map { _ in NativeListTextLabel() }
|
|
25
34
|
private let accessoryButtons = (0..<2).map { _ in NativeListAccessoryButton(type: .system) }
|
|
26
35
|
private let marketBadgeButtons = (0..<3).map { _ in NativeListAccessoryButton(type: .system) }
|
|
27
36
|
private let badgeSlots = (0..<3).map { _ in NativeListImageSlot() }
|
|
@@ -69,6 +78,10 @@ final class NativeListMarketCell: NativeListRendererCell {
|
|
|
69
78
|
trailingStack.setContentHuggingPriority(.required, for: .horizontal)
|
|
70
79
|
trailingStack.setContentCompressionResistancePriority(.required, for: .horizontal)
|
|
71
80
|
accessoryButtons.forEach(trailingStack.addArrangedSubview)
|
|
81
|
+
for index in metricStacks.indices {
|
|
82
|
+
metricStacks[index].addArrangedSubview(metricPrimaryLabels[index])
|
|
83
|
+
metricStacks[index].addArrangedSubview(metricSecondaryLabels[index])
|
|
84
|
+
}
|
|
72
85
|
visual.translatesAutoresizingMaskIntoConstraints = false
|
|
73
86
|
leadingWidth = visual.widthAnchor.constraint(equalToConstant: 32)
|
|
74
87
|
leadingHeight = visual.heightAnchor.constraint(equalToConstant: 32)
|
|
@@ -130,6 +143,14 @@ final class NativeListMarketCell: NativeListRendererCell {
|
|
|
130
143
|
}
|
|
131
144
|
}
|
|
132
145
|
accessoryButtons.forEach(resetButton)
|
|
146
|
+
for label in metricPrimaryLabels + metricSecondaryLabels {
|
|
147
|
+
label.attributedText = nil
|
|
148
|
+
label.text = nil
|
|
149
|
+
label.isHidden = true
|
|
150
|
+
label.numberOfLines = 1
|
|
151
|
+
label.lineBreakMode = .byTruncatingTail
|
|
152
|
+
label.textAlignment = .natural
|
|
153
|
+
}
|
|
133
154
|
bindMarket(item, theme: theme)
|
|
134
155
|
let style = item.data.dictionary("style")
|
|
135
156
|
for (label, descriptor) in [
|
|
@@ -166,20 +187,38 @@ final class NativeListMarketCell: NativeListRendererCell {
|
|
|
166
187
|
style.filter { ["lines", "truncate", "verticalAlignment", "offsetY"].contains($0.key) }
|
|
167
188
|
}
|
|
168
189
|
override func accessibilityText(_ item: NativeListItem) -> String {
|
|
169
|
-
item.data
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
190
|
+
if let label = item.data["accessibilityLabel"] as? String { return label }
|
|
191
|
+
var parts = [item.data.string("title"), item.data.string("subtitle")]
|
|
192
|
+
let metrics = item.data.dictionaries("metrics")
|
|
193
|
+
if metrics.count == 2 || metrics.count == 3 {
|
|
194
|
+
for index in metrics.indices {
|
|
195
|
+
for label in [metricPrimaryLabels[index], metricSecondaryLabels[index]] where !label.isHidden {
|
|
196
|
+
if let text = label.text { parts.append(text) }
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
} else {
|
|
200
|
+
parts.append(item.data.string("price"))
|
|
201
|
+
parts.append(item.data.dictionary("change")?.string("text") ?? "")
|
|
202
|
+
}
|
|
203
|
+
return parts.filter { !$0.isEmpty }.joined(separator: ", ")
|
|
175
204
|
}
|
|
176
205
|
override func updateMarketQuote(_ item: NativeListItem, theme: [String: Any]?) {
|
|
177
206
|
guard retainBoundItem(item) else { return }
|
|
178
|
-
|
|
207
|
+
let metrics = item.data.dictionaries("metrics")
|
|
208
|
+
if metrics.count == 2 || metrics.count == 3 {
|
|
209
|
+
updateMetricLabels(metrics, theme: theme)
|
|
210
|
+
} else {
|
|
211
|
+
bindQuote(item, theme: theme)
|
|
212
|
+
}
|
|
213
|
+
accessibilityLabel = accessibilityText(item)
|
|
179
214
|
}
|
|
180
215
|
override func recycleContent() {
|
|
181
216
|
visual.recycle()
|
|
182
217
|
badgeSlots.forEach { $0.recycle() }
|
|
218
|
+
for label in metricPrimaryLabels + metricSecondaryLabels {
|
|
219
|
+
label.attributedText = nil
|
|
220
|
+
label.text = nil
|
|
221
|
+
}
|
|
183
222
|
imageSignature = ""
|
|
184
223
|
}
|
|
185
224
|
@objc private func leadingPressed() {
|
|
@@ -256,6 +295,61 @@ final class NativeListMarketCell: NativeListRendererCell {
|
|
|
256
295
|
default: return .natural
|
|
257
296
|
}
|
|
258
297
|
}
|
|
298
|
+
private func metricColor(_ tone: String, theme: [String: Any]?) -> UIColor {
|
|
299
|
+
switch tone {
|
|
300
|
+
case "positive": return nativeListColor(theme, "positive", "#218358")
|
|
301
|
+
case "negative": return nativeListColor(theme, "negative", "#CE2C31")
|
|
302
|
+
case "secondary": return nativeListColor(theme, "secondaryText", "#646464")
|
|
303
|
+
default: return nativeListColor(theme, "primaryText", "#202020")
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
private func bindMetrics(
|
|
307
|
+
_ columns: [[String: Any]], theme: [String: Any]?, gap: CGFloat
|
|
308
|
+
) {
|
|
309
|
+
for (index, column) in columns.enumerated() {
|
|
310
|
+
let stack = metricStacks[index]
|
|
311
|
+
root.addArrangedSubview(stack)
|
|
312
|
+
root.setCustomSpacing(gap, after: stack)
|
|
313
|
+
let weight = max(1, min(3, column.int("weight", default: 1)))
|
|
314
|
+
let width = stack.widthAnchor.constraint(
|
|
315
|
+
equalTo: mainStack.widthAnchor, multiplier: CGFloat(weight) / 2)
|
|
316
|
+
width.isActive = true
|
|
317
|
+
selectorConstraints.append(width)
|
|
318
|
+
}
|
|
319
|
+
updateMetricLabels(columns, theme: theme)
|
|
320
|
+
}
|
|
321
|
+
private func updateMetricLabels(_ columns: [[String: Any]], theme: [String: Any]?) {
|
|
322
|
+
for (index, column) in columns.enumerated() {
|
|
323
|
+
let alignment = marketTextAlignment(column.string("alignment", default: "end"))
|
|
324
|
+
let primary = metricPrimaryLabels[index]
|
|
325
|
+
let primaryText = column.string("text")
|
|
326
|
+
show(primary, primaryText, lines: 1)
|
|
327
|
+
let primaryColor = metricColor(column.string("tone"), theme: theme)
|
|
328
|
+
applyMarketTextStyle(
|
|
329
|
+
primary, data: nil, theme: theme, defaultSize: 14, defaultLineHeight: 20,
|
|
330
|
+
defaultWeight: .medium, defaultColor: primaryColor, defaultAlignment: alignment)
|
|
331
|
+
if !column.dictionaries("textSegments").isEmpty {
|
|
332
|
+
primary.attributedText = marketAttributedText(
|
|
333
|
+
primaryText, segments: column.dictionaries("textSegments"), style: nil,
|
|
334
|
+
defaultSize: 14, defaultLineHeight: 20, defaultWeight: .medium,
|
|
335
|
+
color: primaryColor, defaultAlignment: alignment)
|
|
336
|
+
}
|
|
337
|
+
let secondary = metricSecondaryLabels[index]
|
|
338
|
+
let secondaryText = column.string("secondaryText")
|
|
339
|
+
show(secondary, secondaryText, lines: 1)
|
|
340
|
+
let secondaryColor = metricColor(
|
|
341
|
+
column.string("secondaryTone", default: "secondary"), theme: theme)
|
|
342
|
+
applyMarketTextStyle(
|
|
343
|
+
secondary, data: nil, theme: theme, defaultSize: 12, defaultLineHeight: 16,
|
|
344
|
+
defaultWeight: .regular, defaultColor: secondaryColor, defaultAlignment: alignment)
|
|
345
|
+
if !column.dictionaries("secondaryTextSegments").isEmpty {
|
|
346
|
+
secondary.attributedText = marketAttributedText(
|
|
347
|
+
secondaryText, segments: column.dictionaries("secondaryTextSegments"), style: nil,
|
|
348
|
+
defaultSize: 12, defaultLineHeight: 16, defaultWeight: .regular,
|
|
349
|
+
color: secondaryColor, defaultAlignment: alignment)
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
259
353
|
private func applyMarketTextStyle(
|
|
260
354
|
_ label: UILabel,
|
|
261
355
|
data: [String: Any]?,
|
|
@@ -623,11 +717,18 @@ final class NativeListMarketCell: NativeListRendererCell {
|
|
|
623
717
|
}
|
|
624
718
|
marketSubtitleStack.isHidden = tertiaryLabel.isHidden && subtitleLabel.isHidden
|
|
625
719
|
}
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
720
|
+
let metrics = item.data.dictionaries("metrics")
|
|
721
|
+
if metrics.count == 2 || metrics.count == 3 {
|
|
722
|
+
let metricGap = CGFloat(style?.double("trailingGap", default: 8) ?? 8)
|
|
723
|
+
root.setCustomSpacing(metricGap, after: mainStack)
|
|
724
|
+
bindMetrics(metrics, theme: theme, gap: metricGap)
|
|
725
|
+
} else {
|
|
726
|
+
root.addArrangedSubview(trailingStack)
|
|
727
|
+
trailingStack.axis = .horizontal
|
|
728
|
+
trailingStack.alignment = .center
|
|
729
|
+
trailingStack.spacing = CGFloat(style?.double("trailingGap", default: 8) ?? 8)
|
|
730
|
+
bindQuote(item, theme: theme)
|
|
731
|
+
}
|
|
631
732
|
}
|
|
632
733
|
private func bindQuote(_ item: NativeListItem, theme: [String: Any]?) {
|
|
633
734
|
NSLayoutConstraint.deactivate(accessorySizeConstraints)
|
|
@@ -696,11 +797,5 @@ final class NativeListMarketCell: NativeListRendererCell {
|
|
|
696
797
|
if let changeStyle = style?.dictionary("change"), !textLayoutStyle(changeStyle).isEmpty {
|
|
697
798
|
NativeListTextStyles.applyStyledButton(change, textLayoutStyle(changeStyle), fonts: fonts)
|
|
698
799
|
}
|
|
699
|
-
accessibilityLabel = item.data.string(
|
|
700
|
-
"accessibilityLabel",
|
|
701
|
-
default: [
|
|
702
|
-
item.data.string("title"), item.data.string("subtitle"), item.data.string("price"),
|
|
703
|
-
changeData.string("text"),
|
|
704
|
-
].filter { !$0.isEmpty }.joined(separator: ", "))
|
|
705
800
|
}
|
|
706
801
|
}
|
|
@@ -1524,7 +1524,7 @@ final class NativeListView: UIView {
|
|
|
1524
1524
|
var sizeChanged = false
|
|
1525
1525
|
var reorderStructureChanged = false
|
|
1526
1526
|
let marketQuoteFields: Set<String> = [
|
|
1527
|
-
"revision", "price", "priceSegments", "change", "accessibilityLabel",
|
|
1527
|
+
"revision", "price", "priceSegments", "change", "accessibilityLabel", "metrics",
|
|
1528
1528
|
]
|
|
1529
1529
|
for (index, changes) in pending {
|
|
1530
1530
|
let previous = current.items[index]
|
|
@@ -1550,7 +1550,20 @@ final class NativeListView: UIView {
|
|
|
1550
1550
|
reorderStructureChanged = true
|
|
1551
1551
|
}
|
|
1552
1552
|
current.items[index] = item
|
|
1553
|
-
|
|
1553
|
+
let previousMetrics = previous.data.dictionaries("metrics")
|
|
1554
|
+
let nextMetrics = item.data.dictionaries("metrics")
|
|
1555
|
+
let sameMetricStructure = previousMetrics.count == nextMetrics.count &&
|
|
1556
|
+
previousMetrics.indices.allSatisfy { metricIndex in
|
|
1557
|
+
let before = previousMetrics[metricIndex]
|
|
1558
|
+
let after = nextMetrics[metricIndex]
|
|
1559
|
+
return before.string("key") == after.string("key") &&
|
|
1560
|
+
before.int("weight", default: 1) == after.int("weight", default: 1) &&
|
|
1561
|
+
before.string("alignment", default: "end") ==
|
|
1562
|
+
after.string("alignment", default: "end")
|
|
1563
|
+
}
|
|
1564
|
+
if previous.type == "market", Set(changes.keys).isSubset(of: marketQuoteFields),
|
|
1565
|
+
sameMetricStructure
|
|
1566
|
+
{
|
|
1554
1567
|
marketQuoteKeys.insert(item.key)
|
|
1555
1568
|
} else {
|
|
1556
1569
|
changedKeys.insert(item.key)
|
|
@@ -97,6 +97,103 @@ final class NativeListActivityPresentationTests: XCTestCase {
|
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
func testTableColumnsStayStableAcrossFeeLeadingAndAmountReuseWithForcedRendererDirection() throws {
|
|
101
|
+
let width: CGFloat = 900
|
|
102
|
+
let horizontal: CGFloat = 16
|
|
103
|
+
let contentWidth = width - horizontal * 2
|
|
104
|
+
let gap: CGFloat = 12
|
|
105
|
+
let container = UIView(frame: CGRect(x: 0, y: 0, width: contentWidth, height: 4000))
|
|
106
|
+
let details = NativeListActivityDetailsView()
|
|
107
|
+
details.translatesAutoresizingMaskIntoConstraints = false
|
|
108
|
+
container.addSubview(details)
|
|
109
|
+
NSLayoutConstraint.activate([
|
|
110
|
+
details.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
|
111
|
+
details.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
|
112
|
+
details.topAnchor.constraint(equalTo: container.topAnchor),
|
|
113
|
+
])
|
|
114
|
+
let main = try XCTUnwrap(details.arrangedSubviews.first as? UIStackView)
|
|
115
|
+
let amounts = try XCTUnwrap(main.arrangedSubviews[2] as? UIStackView)
|
|
116
|
+
let fee = try XCTUnwrap(main.arrangedSubviews[3] as? UIStackView)
|
|
117
|
+
for rtl in [false, true] {
|
|
118
|
+
let direction: UIUserInterfaceLayoutDirection = rtl ? .rightToLeft : .leftToRight
|
|
119
|
+
// This fixture covers renderer geometry; App-level RTL propagation is separate.
|
|
120
|
+
func applyDirection(_ view: UIView) {
|
|
121
|
+
view.semanticContentAttribute = rtl ? .forceRightToLeft : .forceLeftToRight
|
|
122
|
+
view.subviews.forEach(applyDirection)
|
|
123
|
+
}
|
|
124
|
+
for hasFee in [true, false, true] {
|
|
125
|
+
var previousHeight: CGFloat = 0
|
|
126
|
+
for count in [1, 2, 6, 32] {
|
|
127
|
+
for hideLeading in [false, true, false] {
|
|
128
|
+
for feeKind in 0...2 {
|
|
129
|
+
var data: [String: Any] = [
|
|
130
|
+
"type": "activity", "key": "table", "title": "Send",
|
|
131
|
+
"description": "Contract interaction with a long description that wraps",
|
|
132
|
+
"leading": ["kind": "icon", "name": "ArrowTopOutline"],
|
|
133
|
+
"hideLeading": hideLeading,
|
|
134
|
+
"amounts": (0..<count).map { index in [
|
|
135
|
+
"key": "amount-\(index)", "text": index % 2 == 0 ? "-123456789012345678901234567890.123456789 ETH" : "+1 USDC",
|
|
136
|
+
"secondaryText": "$1,000.00",
|
|
137
|
+
"leading": ["kind": "icon", "name": "ArrowTopOutline"],
|
|
138
|
+
] as [String: Any] },
|
|
139
|
+
]
|
|
140
|
+
if hasFee {
|
|
141
|
+
data["fee"] = ["label": "Network fee",
|
|
142
|
+
"primary": feeKind == 1 ? "0.000000012345678 ETH" : "1 ETH",
|
|
143
|
+
"secondary": "$0.20", "hidden": feeKind == 2]
|
|
144
|
+
}
|
|
145
|
+
// Create amount views before applying the fixture direction; each transition binds once.
|
|
146
|
+
details.bind(try NativeListItem(data: data), theme: nil, fonts: NativeListFontFamilies())
|
|
147
|
+
applyDirection(details)
|
|
148
|
+
for presentation in ["table", "stacked", "table"] {
|
|
149
|
+
data["presentation"] = presentation
|
|
150
|
+
details.bind(try NativeListItem(data: data), theme: nil, fonts: NativeListFontFamilies())
|
|
151
|
+
XCTAssertEqual(main.effectiveUserInterfaceLayoutDirection, direction)
|
|
152
|
+
XCTAssertEqual(amounts.effectiveUserInterfaceLayoutDirection, direction)
|
|
153
|
+
XCTAssertEqual(fee.effectiveUserInterfaceLayoutDirection, direction)
|
|
154
|
+
container.setNeedsLayout()
|
|
155
|
+
container.layoutIfNeeded()
|
|
156
|
+
guard presentation == "table" else { continue }
|
|
157
|
+
let expected = (contentWidth - gap * (hasFee ? 2 : 1)) / (hasFee ? 2.6 : 2)
|
|
158
|
+
XCTAssertEqual(amounts.frame.width, expected, accuracy: 1)
|
|
159
|
+
let start = rtl ? contentWidth - expected * 2 - gap : expected + gap
|
|
160
|
+
XCTAssertEqual(amounts.frame.minX, start, accuracy: 1)
|
|
161
|
+
XCTAssertEqual(amounts.frame.midX, start + expected / 2, accuracy: 1)
|
|
162
|
+
XCTAssertEqual(amounts.arrangedSubviews.count, count)
|
|
163
|
+
for amount in amounts.arrangedSubviews {
|
|
164
|
+
XCTAssertEqual(amount.frame.minX, 0, accuracy: 1)
|
|
165
|
+
XCTAssertEqual(amount.frame.width, expected, accuracy: 1)
|
|
166
|
+
let row = try XCTUnwrap(amount as? UIStackView)
|
|
167
|
+
let leading = row.arrangedSubviews[0]
|
|
168
|
+
let labels = row.arrangedSubviews[1]
|
|
169
|
+
if rtl { XCTAssertLessThanOrEqual(labels.frame.maxX, leading.frame.minX) }
|
|
170
|
+
else { XCTAssertLessThanOrEqual(leading.frame.maxX, labels.frame.minX) }
|
|
171
|
+
XCTAssertGreaterThanOrEqual(labels.frame.minX, 0)
|
|
172
|
+
XCTAssertLessThanOrEqual(labels.frame.maxX, amount.frame.width + 1)
|
|
173
|
+
}
|
|
174
|
+
if hasFee {
|
|
175
|
+
XCTAssertEqual(fee.frame.width, expected * 0.6, accuracy: 1)
|
|
176
|
+
XCTAssertEqual(fee.alpha, feeKind == 2 ? 0 : 1)
|
|
177
|
+
for child in fee.arrangedSubviews {
|
|
178
|
+
XCTAssertGreaterThanOrEqual(child.frame.minX, -1)
|
|
179
|
+
XCTAssertLessThanOrEqual(child.frame.maxX, fee.frame.width + 1)
|
|
180
|
+
if rtl { XCTAssertEqual(child.frame.minX, 0, accuracy: 1) }
|
|
181
|
+
else { XCTAssertEqual(child.frame.maxX, fee.frame.width, accuracy: 1) }
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
XCTAssertEqual(
|
|
185
|
+
NativeListActivityDetailsView.measure(data, width: width, fonts: NativeListFontFamilies()),
|
|
186
|
+
ceil(details.frame.height) + 16, accuracy: 1)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
XCTAssertGreaterThan(details.frame.height, previousHeight)
|
|
191
|
+
previousHeight = details.frame.height
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
100
197
|
func testAutomaticHeightMatchesRenderedStacks() throws {
|
|
101
198
|
let amount: [String: Any] = ["key": "send", "text": "-0.5 ETH", "secondaryText": "$1,000.00"]
|
|
102
199
|
let cases: [(String, [String: Any])] = [
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
import XCTest
|
|
3
|
+
@testable import NativeListModule
|
|
4
|
+
|
|
5
|
+
@MainActor
|
|
6
|
+
final class NativeListMarketMetricsTests: XCTestCase {
|
|
7
|
+
private func item(
|
|
8
|
+
_ metrics: [[String: Any]]? = nil, price: String = "$42", accessibilityLabel: String? = nil
|
|
9
|
+
) throws -> NativeListItem {
|
|
10
|
+
var data: [String: Any] = [
|
|
11
|
+
"key": "market-eth", "type": "market", "variant": "token", "height": 72,
|
|
12
|
+
"leading": ["kind": "token", "fallbackIcon": ["name": "CryptoCoinOutline"]],
|
|
13
|
+
"leadingAction": ["kind": "icon", "name": "StarOutline", "actionKey": "favorite"],
|
|
14
|
+
"title": "ETH", "subtitle": "Volume", "price": price,
|
|
15
|
+
"change": ["text": "+2%", "tone": "positive"],
|
|
16
|
+
"badges": [["key": "community", "text": "Verified", "actionKey": "info"]],
|
|
17
|
+
"pressActionKey": "detail", "pressInActionKey": "prewarm",
|
|
18
|
+
"longPressActionKey": "menu",
|
|
19
|
+
"style": ["horizontalPadding": 20, "leadingGap": 14, "trailingGap": 8],
|
|
20
|
+
]
|
|
21
|
+
if let metrics { data["metrics"] = metrics }
|
|
22
|
+
data["accessibilityLabel"] = accessibilityLabel
|
|
23
|
+
return try NativeListItem(data: data)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
private var two: [[String: Any]] { [
|
|
27
|
+
["key": "price", "text": "$42", "alignment": "end"],
|
|
28
|
+
["key": "change", "text": "+2%", "tone": "positive", "alignment": "end"],
|
|
29
|
+
] }
|
|
30
|
+
|
|
31
|
+
func testMetricCountAndCompactReuseClearInactiveViews() throws {
|
|
32
|
+
let cell = NativeListMarketCell(frame: CGRect(x: 0, y: 0, width: 800, height: 72))
|
|
33
|
+
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 800, height: 200))
|
|
34
|
+
window.addSubview(cell)
|
|
35
|
+
let bind: (NativeListItem) -> Void = { item in
|
|
36
|
+
cell.bind(item: item, theme: nil, layout: "linear", selected: false,
|
|
37
|
+
checkboxState: { _, _, fallback in fallback }, fonts: NativeListFontFamilies())
|
|
38
|
+
cell.layoutIfNeeded()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
bind(try item(two))
|
|
42
|
+
XCTAssertEqual(cell.root.arrangedSubviews.count, 5)
|
|
43
|
+
let identity = try XCTUnwrap(cell.root.arrangedSubviews[2] as? UIStackView)
|
|
44
|
+
let firstMetric = try XCTUnwrap(cell.root.arrangedSubviews[3] as? UIStackView)
|
|
45
|
+
XCTAssertEqual(identity.bounds.width, firstMetric.bounds.width * 2, accuracy: 1)
|
|
46
|
+
XCTAssertEqual(firstMetric.frame.minX - identity.frame.maxX, 8, accuracy: 1)
|
|
47
|
+
let metricStack = try XCTUnwrap(cell.root.arrangedSubviews.last as? UIStackView)
|
|
48
|
+
let metricLabel = try XCTUnwrap(metricStack.arrangedSubviews.first as? UILabel)
|
|
49
|
+
XCTAssertEqual(metricLabel.text, "+2%")
|
|
50
|
+
|
|
51
|
+
bind(try item(two + [["key": "volume", "text": "$1B", "secondaryText": "24h"]]))
|
|
52
|
+
XCTAssertEqual(cell.root.arrangedSubviews.count, 6)
|
|
53
|
+
bind(try item())
|
|
54
|
+
XCTAssertEqual(cell.root.arrangedSubviews.count, 4)
|
|
55
|
+
XCTAssertFalse(cell.root.arrangedSubviews.contains(metricStack))
|
|
56
|
+
bind(try item(two))
|
|
57
|
+
XCTAssertEqual(cell.root.arrangedSubviews.count, 5)
|
|
58
|
+
XCTAssertEqual(metricLabel.text, "+2%")
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
func testAccessibilityLabelFollowsVisibleMetricsAndExplicitOverride() throws {
|
|
62
|
+
let cell = NativeListMarketCell(frame: .zero)
|
|
63
|
+
let bind: (NativeListItem) -> Void = { item in
|
|
64
|
+
cell.bind(item: item, theme: nil, layout: "linear", selected: false,
|
|
65
|
+
checkboxState: { _, _, fallback in fallback }, fonts: NativeListFontFamilies())
|
|
66
|
+
}
|
|
67
|
+
var metrics: [[String: Any]] = [
|
|
68
|
+
["key": "volume", "text": "$5M", "secondaryText": "24h"],
|
|
69
|
+
["key": "cap", "text": "$8M"],
|
|
70
|
+
]
|
|
71
|
+
bind(try item(metrics))
|
|
72
|
+
XCTAssertEqual(cell.accessibilityLabel, "ETH, Volume, $5M, 24h, $8M")
|
|
73
|
+
|
|
74
|
+
metrics[0]["textSegments"] = [["text": "$"], ["text": "6M"]]
|
|
75
|
+
cell.updateMarketQuote(try item(metrics), theme: nil)
|
|
76
|
+
XCTAssertEqual(cell.accessibilityLabel, "ETH, Volume, $6M, 24h, $8M")
|
|
77
|
+
cell.updateMarketQuote(try item(metrics, accessibilityLabel: "Market summary"), theme: nil)
|
|
78
|
+
XCTAssertEqual(cell.accessibilityLabel, "Market summary")
|
|
79
|
+
|
|
80
|
+
bind(try item())
|
|
81
|
+
XCTAssertEqual(cell.accessibilityLabel, "ETH, Volume, $42, +2%")
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
func testQuoteUpdatesKeepMetricViewsAndActionTargetsStable() throws {
|
|
85
|
+
let cell = NativeListMarketCell(frame: CGRect(x: 0, y: 0, width: 800, height: 72))
|
|
86
|
+
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 800, height: 200))
|
|
87
|
+
window.addSubview(cell)
|
|
88
|
+
cell.bind(item: try item(two), theme: nil, layout: "linear", selected: false,
|
|
89
|
+
checkboxState: { _, _, fallback in fallback }, fonts: NativeListFontFamilies())
|
|
90
|
+
cell.layoutIfNeeded()
|
|
91
|
+
let metricStack = try XCTUnwrap(cell.root.arrangedSubviews[3] as? UIStackView)
|
|
92
|
+
let metricLabel = try XCTUnwrap(metricStack.arrangedSubviews.first as? UILabel)
|
|
93
|
+
let leadingAction = try XCTUnwrap(cell.root.arrangedSubviews.first as? UIButton)
|
|
94
|
+
var actions: [String] = []
|
|
95
|
+
cell.onAction = { _, key, _, _ in actions.append(key) }
|
|
96
|
+
|
|
97
|
+
for tick in 0..<50 {
|
|
98
|
+
var updated = two
|
|
99
|
+
updated[0]["text"] = "$\(tick)"
|
|
100
|
+
cell.updateMarketQuote(try item(updated, price: "$\(tick)"), theme: nil)
|
|
101
|
+
XCTAssertEqual(cell.root.arrangedSubviews.count, 5)
|
|
102
|
+
XCTAssertTrue(cell.root.arrangedSubviews[3] === metricStack)
|
|
103
|
+
XCTAssertTrue(cell.root.arrangedSubviews.first === leadingAction)
|
|
104
|
+
}
|
|
105
|
+
XCTAssertEqual((metricStack.arrangedSubviews.first as? UILabel)?.text, "$49")
|
|
106
|
+
let changeStack = try XCTUnwrap(cell.root.arrangedSubviews.last as? UIStackView)
|
|
107
|
+
XCTAssertEqual((changeStack.arrangedSubviews.first as? UILabel)?.text, "+2%")
|
|
108
|
+
XCTAssertTrue(leadingAction.allTargets.contains(cell))
|
|
109
|
+
let action = try XCTUnwrap(
|
|
110
|
+
leadingAction.actions(forTarget: cell, forControlEvent: .touchUpInside)?.first)
|
|
111
|
+
let selector = NSSelectorFromString(action)
|
|
112
|
+
XCTAssertTrue(cell.responds(to: selector))
|
|
113
|
+
_ = cell.perform(selector)
|
|
114
|
+
XCTAssertEqual(actions, ["favorite"])
|
|
115
|
+
XCTAssertEqual(metricLabel.text, "$49")
|
|
116
|
+
}
|
|
117
|
+
}
|
package/lib/module/NativeList.js
CHANGED
|
@@ -67,6 +67,7 @@ export const NativeList = /*#__PURE__*/forwardRef(function NativeList({
|
|
|
67
67
|
...viewProps
|
|
68
68
|
}, forwardedRef) {
|
|
69
69
|
const snapshotRef = useRef(emptyNativeListSnapshot());
|
|
70
|
+
const needsNativeSnapshotRef = useRef(false);
|
|
70
71
|
const renderedSnapshotRef = useRef(undefined);
|
|
71
72
|
const initializedSnapshot = useRef(false);
|
|
72
73
|
const pendingDisclosureSnapshotRef = useRef(undefined);
|
|
@@ -315,10 +316,11 @@ export const NativeList = /*#__PURE__*/forwardRef(function NativeList({
|
|
|
315
316
|
return;
|
|
316
317
|
}
|
|
317
318
|
pendingDisclosureSnapshotRef.current = undefined;
|
|
318
|
-
const
|
|
319
|
-
|
|
319
|
+
const native = nativeRef.current;
|
|
320
|
+
if (native) native.applySnapshot(JSON.stringify(accepted));
|
|
320
321
|
snapshotRef.current = accepted;
|
|
321
|
-
|
|
322
|
+
needsNativeSnapshotRef.current = !native;
|
|
323
|
+
if (native && avatarLifecycle.current !== 'unmounted') {
|
|
322
324
|
avatarModelRef.current?.replaceSnapshot(accepted.rows);
|
|
323
325
|
updateAvatarPrefetchRef.current();
|
|
324
326
|
}
|
|
@@ -336,10 +338,15 @@ export const NativeList = /*#__PURE__*/forwardRef(function NativeList({
|
|
|
336
338
|
return;
|
|
337
339
|
}
|
|
338
340
|
const native = nativeRef.current;
|
|
339
|
-
|
|
341
|
+
if (!native) {
|
|
342
|
+
snapshotRef.current = nextSnapshot;
|
|
343
|
+
needsNativeSnapshotRef.current = true;
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
const dispatch = () => {
|
|
340
347
|
native.applyPatches(accepted.json);
|
|
341
348
|
snapshotRef.current = nextSnapshot;
|
|
342
|
-
}
|
|
349
|
+
};
|
|
343
350
|
if (avatarLifecycle.current === 'unmounted') {
|
|
344
351
|
dispatch?.();
|
|
345
352
|
return;
|
|
@@ -362,7 +369,10 @@ export const NativeList = /*#__PURE__*/forwardRef(function NativeList({
|
|
|
362
369
|
if (pending) pendingDisclosureSnapshotRef.current = {
|
|
363
370
|
...pending,
|
|
364
371
|
owned: next
|
|
365
|
-
};else
|
|
372
|
+
};else {
|
|
373
|
+
snapshotRef.current = next;
|
|
374
|
+
if (!nativeRef.current) needsNativeSnapshotRef.current = true;
|
|
375
|
+
}
|
|
366
376
|
}
|
|
367
377
|
},
|
|
368
378
|
scrollToKey(paramsOrKey, animated, alignment) {
|
|
@@ -417,7 +427,16 @@ export const NativeList = /*#__PURE__*/forwardRef(function NativeList({
|
|
|
417
427
|
}));
|
|
418
428
|
const nativeCallbacks = useMemo(() => ({
|
|
419
429
|
hybridRef: callback(ref => {
|
|
430
|
+
const previous = nativeRef.current;
|
|
420
431
|
nativeRef.current = ref;
|
|
432
|
+
if (needsNativeSnapshotRef.current || previous && previous !== ref) {
|
|
433
|
+
// The parent can publish values before Fabric delivers the host.
|
|
434
|
+
const latest = snapshotRef.current;
|
|
435
|
+
ref.applySnapshot(JSON.stringify(latest));
|
|
436
|
+
needsNativeSnapshotRef.current = false;
|
|
437
|
+
avatarModelRef.current?.replaceSnapshot(latest.rows);
|
|
438
|
+
updateAvatarPrefetchRef.current();
|
|
439
|
+
}
|
|
421
440
|
applyInitialScrollCallbackRef.current();
|
|
422
441
|
}),
|
|
423
442
|
onRowAction: callback(payloadJson => {
|