@onekeyfe/react-native-native-list 6.7.0-alpha.404 → 6.7.0-alpha.406

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.
@@ -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.string(
170
- "accessibilityLabel",
171
- default: [
172
- item.data.string("title"), item.data.string("subtitle"), item.data.string("price"),
173
- item.data.dictionary("change")?.string("text") ?? "",
174
- ].filter { !$0.isEmpty }.joined(separator: ", "))
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
- bindQuote(item, theme: theme)
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
- root.addArrangedSubview(trailingStack)
627
- trailingStack.axis = .horizontal
628
- trailingStack.alignment = .center
629
- trailingStack.spacing = CGFloat(style?.double("trailingGap", default: 8) ?? 8)
630
- bindQuote(item, theme: theme)
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
- if previous.type == "market", Set(changes.keys).isSubset(of: marketQuoteFields) {
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
+ }
@@ -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 nextSnapshotJson = JSON.stringify(accepted);
319
- nativeRef.current?.applySnapshot(nextSnapshotJson);
319
+ const native = nativeRef.current;
320
+ if (native) native.applySnapshot(JSON.stringify(accepted));
320
321
  snapshotRef.current = accepted;
321
- if (nativeRef.current && avatarLifecycle.current !== 'unmounted') {
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
- const dispatch = native ? () => {
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
- } : undefined;
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 snapshotRef.current = next;
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 => {