@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.
@@ -16,6 +16,7 @@ internal class NativeListActivityDetailsView(private val reactContext: ThemedRea
16
16
  var onAction: ((String, View, String, Int) -> Unit)? = null
17
17
  private var sourceScale = false
18
18
  private val main = LinearLayout(context).apply { orientation = HORIZONTAL; gravity = Gravity.CENTER_VERTICAL }
19
+ private val identityColumn = LinearLayout(context).apply { orientation = HORIZONTAL; gravity = Gravity.CENTER_VERTICAL }
19
20
  private val visual = NativeListLeadingVisual(reactContext, fonts)
20
21
  private val identity = LinearLayout(context).apply { orientation = VERTICAL }
21
22
  private val titleLine = LinearLayout(context).apply { orientation = HORIZONTAL; gravity = Gravity.CENTER_VERTICAL }
@@ -40,8 +41,9 @@ internal class NativeListActivityDetailsView(private val reactContext: ThemedRea
40
41
  identity.addView(status)
41
42
  feeLine.addView(feePrimary); feeLine.addView(feeSecondary)
42
43
  fee.addView(feeLabel); fee.addView(feeLine)
43
- main.addView(visual)
44
- main.addView(identity, LayoutParams(0, -2, 1f))
44
+ identityColumn.addView(visual)
45
+ identityColumn.addView(identity, LayoutParams(0, -2, 1f))
46
+ main.addView(identityColumn, LayoutParams(0, -2, 1f))
45
47
  main.addView(amounts)
46
48
  main.addView(fee)
47
49
  addView(main, LayoutParams(-1, -2))
@@ -110,7 +112,9 @@ internal class NativeListActivityDetailsView(private val reactContext: ThemedRea
110
112
  val feeData = data.optJSONObject("fee")
111
113
  fee.visibility = if (feeData == null) GONE else VISIBLE
112
114
  fee.alpha = if (feeData?.optBoolean("hidden") == true) 0f else 1f
113
- fee.layoutParams = LayoutParams(-2, -2).apply { marginStart = gap }
115
+ fee.layoutParams = (if (table) LayoutParams(0, -2, 0.6f) else LayoutParams(-2, -2)).apply { marginStart = gap }
116
+ feeLabel.layoutParams.width = if (table) LayoutParams.WRAP_CONTENT else LayoutParams.MATCH_PARENT
117
+ feeLine.layoutParams.width = if (table) LayoutParams.WRAP_CONTENT else LayoutParams.MATCH_PARENT
114
118
  text(feeLabel, feeData?.optString("label") ?: "", style.optJSONObject("secondaryAmount"), 12f, color("secondary", theme), 16)
115
119
  text(feePrimary, feeData?.optString("primary") ?: "", style.optJSONObject("secondaryAmount"), 14f, color("primary", theme), 20)
116
120
  text(feeSecondary, feeData?.optString("secondary") ?: "", style.optJSONObject("secondaryAmount"), 12f, color("secondary", theme), 16)
@@ -240,9 +240,10 @@ internal class NativeListAdapter(
240
240
 
241
241
  private fun isMarketQuoteUpdate(oldItem: NativeListItem, newItem: NativeListItem): Boolean {
242
242
  if (oldItem.type != "market" || newItem.type != "market") return false
243
+ if (!hasSameMarketMetricStructure(oldItem, newItem)) return false
243
244
  fun stableContent(item: NativeListItem): String {
244
245
  val value = JSONObject(item.json.toString())
245
- listOf("revision", "price", "priceSegments", "change", "accessibilityLabel")
246
+ listOf("revision", "price", "priceSegments", "change", "accessibilityLabel", "metrics")
246
247
  .forEach(value::remove)
247
248
  return value.toString()
248
249
  }
@@ -256,3 +257,18 @@ internal const val SELECTION_PAYLOAD = "selection"
256
257
  // OneKey patch: internal payload, never exposed through the serialized row contract.
257
258
  internal const val SELECTION_ECHO_PAYLOAD = "selectionEcho"
258
259
  internal const val MARKET_QUOTE_PAYLOAD = "marketQuote"
260
+
261
+ internal fun hasSameMarketMetricStructure(before: NativeListItem, after: NativeListItem): Boolean {
262
+ val old = before.json.optJSONArray("metrics")
263
+ val next = after.json.optJSONArray("metrics")
264
+ val oldCount = old?.length() ?: 0
265
+ if (oldCount != (next?.length() ?: 0)) return false
266
+ for (index in 0 until oldCount) {
267
+ val previous = old?.optJSONObject(index) ?: return false
268
+ val current = next?.optJSONObject(index) ?: return false
269
+ if (previous.optString("key") != current.optString("key") ||
270
+ previous.optInt("weight", 1) != current.optInt("weight", 1) ||
271
+ previous.optString("alignment", "end") != current.optString("alignment", "end")) return false
272
+ }
273
+ return true
274
+ }
@@ -292,12 +292,14 @@ private class NativeListTableColumnView(context: android.content.Context, privat
292
292
 
293
293
  fun reset() {
294
294
  primaryLine.gravity = Gravity.CENTER_VERTICAL
295
+ secondaryLine.gravity = Gravity.CENTER_VERTICAL
295
296
  for (icon in listOf(titleIcon, secondaryTitleIcon)) {
296
297
  icon.visibility = GONE
297
298
  icon.iconName = ""
298
299
  icon.contentDescription = null
299
300
  icon.setOnClickListener(null)
300
301
  }
302
+ (primaryLine.layoutParams as LayoutParams).gravity = -1
301
303
  primaryLine.layoutParams.width = LayoutParams.WRAP_CONTENT
302
304
  primaryLine.layoutParams.height = LayoutParams.WRAP_CONTENT
303
305
  secondaryLine.layoutParams.height = LayoutParams.WRAP_CONTENT
@@ -357,6 +359,17 @@ private class NativeListTableColumnView(context: android.content.Context, privat
357
359
  "end" -> Gravity.END
358
360
  else -> Gravity.START
359
361
  }
362
+ fun horizontalGravity(alignment: String) = when (alignment) {
363
+ "center" -> Gravity.CENTER_HORIZONTAL
364
+ "end" -> Gravity.END
365
+ else -> Gravity.START
366
+ }
367
+ val primaryAlignment = style.optJSONObject("columns")?.optString("alignment")
368
+ ?.takeIf(String::isNotEmpty) ?: column.optString("alignment", "start")
369
+ (primaryLine.layoutParams as LayoutParams).gravity = horizontalGravity(primaryAlignment)
370
+ val secondaryAlignment = style.optJSONObject("columnSecondary")?.optString("alignment")
371
+ ?.takeIf(String::isNotEmpty) ?: column.optString("alignment", "start")
372
+ secondaryLine.gravity = Gravity.CENTER_VERTICAL or horizontalGravity(secondaryAlignment)
360
373
  fun bindText(
361
374
  view: NativeListTextView,
362
375
  value: String,
@@ -433,6 +446,8 @@ private class NativeListTableColumnView(context: android.content.Context, privat
433
446
  secondaryLine.visibility = VISIBLE
434
447
  }
435
448
  if (!fixedTableGeometry) applyStyle(style)
449
+ (secondary.layoutParams as LayoutParams).marginStart =
450
+ if (secondaryLeading.visibility == VISIBLE) dp(4) else 0
436
451
  if (fixedTableGeometry) {
437
452
  // Legacy table lines: fixed 20dp primary and 16dp secondary/badge boxes.
438
453
  primaryLine.layoutParams.height = dp(20)
@@ -38,6 +38,9 @@ internal class NativeListMarketRowView(context: ThemedReactContext, fonts: Nativ
38
38
  private val marketSubtitleLine = PackedTitleLineLayout(context)
39
39
  private val trailingColumn = LinearLayout(context)
40
40
  private val trailingViews = List(2) { NativeListTextView(context) }
41
+ private val metricColumns = List(3) { LinearLayout(context) }
42
+ private val metricPrimaryViews = List(3) { NativeListTextView(context) }
43
+ private val metricSecondaryViews = List(3) { NativeListTextView(context) }
41
44
  private val marketBadgeViews = List(3) { LinearLayout(context) }
42
45
  private val marketBadgeLabels = List(3) { TextView(context) }
43
46
  private val badgeSlots = List(3) { NativeListImageSlot(context) }
@@ -88,6 +91,12 @@ internal class NativeListMarketRowView(context: ThemedReactContext, fonts: Nativ
88
91
  mainColumn.addView(subtitle)
89
92
  mainColumn.addView(tertiary)
90
93
  trailingViews.forEach(trailingColumn::addView)
94
+ metricColumns.forEachIndexed { index, column ->
95
+ column.orientation = VERTICAL
96
+ column.gravity = Gravity.CENTER_VERTICAL
97
+ column.addView(metricPrimaryViews[index])
98
+ column.addView(metricSecondaryViews[index])
99
+ }
91
100
  leadingActionIcon.useSourceScale = true
92
101
  marketBadgeViews.forEachIndexed { index, badge ->
93
102
  badge.orientation = HORIZONTAL
@@ -129,6 +138,50 @@ internal class NativeListMarketRowView(context: ThemedReactContext, fonts: Nativ
129
138
 
130
139
  private fun weighted() = LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)
131
140
 
141
+ private fun metricColor(theme: JSONObject?, tone: String): Int =
142
+ when (tone) {
143
+ "positive" -> color(theme, "positive", "#218358")
144
+ "negative" -> color(theme, "negative", "#CE2C31")
145
+ "secondary" -> color(theme, "secondaryText", "#646464")
146
+ else -> color(theme, "primaryText", "#202020")
147
+ }
148
+
149
+ private fun bindMetrics(metrics: JSONArray, theme: JSONObject?, gap: Int) {
150
+ updateMetricLabels(metrics, theme)
151
+ for (index in 0 until metrics.length()) {
152
+ val metric = metrics.getJSONObject(index)
153
+ addView(
154
+ metricColumns[index],
155
+ LayoutParams(0, LayoutParams.WRAP_CONTENT, metric.optInt("weight", 1).toFloat()).apply {
156
+ marginStart = dp(gap)
157
+ },
158
+ )
159
+ }
160
+ }
161
+
162
+ private fun updateMetricLabels(metrics: JSONArray, theme: JSONObject?) {
163
+ for (index in 0 until metrics.length()) {
164
+ val metric = metrics.getJSONObject(index)
165
+ val alignment = metric.optString("alignment", "end")
166
+ val primary = metricPrimaryViews[index]
167
+ val secondary = metricSecondaryViews[index]
168
+ applyMarketTextStyle(
169
+ primary, null, 14f, 20, "medium",
170
+ metricColor(theme, metric.optString("tone")), alignment,
171
+ )
172
+ primary.text = marketText(metric.optString("text"), metric.optJSONArray("textSegments"), 14f)
173
+ primary.visibility = VISIBLE
174
+ applyMarketTextStyle(
175
+ secondary, null, 12f, 16, "regular",
176
+ metricColor(theme, metric.optString("secondaryTone", "secondary")), alignment,
177
+ )
178
+ secondary.text = marketText(
179
+ metric.optString("secondaryText"), metric.optJSONArray("secondaryTextSegments"), 12f,
180
+ )
181
+ secondary.visibility = if (secondary.text.isEmpty()) GONE else VISIBLE
182
+ }
183
+ }
184
+
132
185
  private fun showText(view: TextView, text: String, lines: Int) {
133
186
  view.text = text
134
187
  view.maxLines = lines
@@ -146,18 +199,27 @@ internal class NativeListMarketRowView(context: ThemedReactContext, fonts: Nativ
146
199
  emitAction(key, view, source, slot, target)
147
200
  }
148
201
 
149
- override fun accessibilityText(item: NativeListItem) =
150
- item.json.optString(
151
- "accessibilityLabel",
202
+ override fun accessibilityText(item: NativeListItem): String {
203
+ if (item.json.has("accessibilityLabel") && !item.json.isNull("accessibilityLabel")) {
204
+ return item.json.optString("accessibilityLabel")
205
+ }
206
+ val metrics = item.json.optJSONArray("metrics")?.takeIf { it.length() in 2..3 }
207
+ val values = if (metrics != null) {
208
+ (0 until metrics.length()).flatMap { index ->
209
+ listOf(metricPrimaryViews[index], metricSecondaryViews[index])
210
+ .filter { it.visibility == VISIBLE }
211
+ .map { it.text.toString() }
212
+ }
213
+ } else {
152
214
  listOf(
153
- item.json.optString("title"),
154
- item.json.optString("subtitle"),
155
- item.json.optString("price"),
156
- item.json.optJSONObject("change")?.optString("text") ?: "",
157
- )
158
- .filter(String::isNotEmpty)
159
- .joinToString(", "),
160
- )
215
+ item.json.optString("price"),
216
+ item.json.optJSONObject("change")?.optString("text") ?: "",
217
+ )
218
+ }
219
+ return (listOf(item.json.optString("title"), item.json.optString("subtitle")) + values)
220
+ .filter(String::isNotEmpty)
221
+ .joinToString(", ")
222
+ }
161
223
 
162
224
  override fun bindContent(
163
225
  item: NativeListItem,
@@ -183,7 +245,8 @@ internal class NativeListMarketRowView(context: ThemedReactContext, fonts: Nativ
183
245
  if (item.json.optJSONArray("badges")?.optJSONObject(index)?.optJSONObject("icon") == null)
184
246
  badgeSlots[index].recycle()
185
247
  }
186
- for (view in listOf(title, subtitle, tertiary) + trailingViews) {
248
+ for (view in listOf(title, subtitle, tertiary) + trailingViews +
249
+ metricPrimaryViews + metricSecondaryViews) {
187
250
  view.text = ""
188
251
  view.visibility = GONE
189
252
  view.maxLines = 1
@@ -217,7 +280,13 @@ internal class NativeListMarketRowView(context: ThemedReactContext, fonts: Nativ
217
280
  }
218
281
 
219
282
  override fun bindMarketQuote(item: NativeListItem, theme: JSONObject?) {
220
- if (retainBoundItem(item)) bindQuote(item, theme)
283
+ if (retainBoundItem(item)) {
284
+ item.json.optJSONArray("metrics")?.takeIf { it.length() in 2..3 }?.let {
285
+ updateMetricLabels(it, theme)
286
+ }
287
+ bindQuote(item, theme)
288
+ contentDescription = accessibilityText(item)
289
+ }
221
290
  }
222
291
 
223
292
  private fun cancelMarketHold() {
@@ -304,6 +373,7 @@ internal class NativeListMarketRowView(context: ThemedReactContext, fonts: Nativ
304
373
  longPressFired = false
305
374
  leadingFrame.recycle()
306
375
  badgeSlots.forEach { it.recycle() }
376
+ (metricPrimaryViews + metricSecondaryViews).forEach { it.text = "" }
307
377
  imageSignature = ""
308
378
  }
309
379
 
@@ -561,10 +631,12 @@ internal class NativeListMarketRowView(context: ThemedReactContext, fonts: Nativ
561
631
  )
562
632
  // OneKey patch: retain the source gap without changing other templates.
563
633
  // addView(mainColumn, weighted())
634
+ val metrics = item.json.optJSONArray("metrics")?.takeIf { it.length() in 2..3 }
564
635
  addView(
565
636
  mainColumn,
566
637
  weighted().apply {
567
- marginEnd = dp(style?.optDouble("contentTrailingGap", 0.0)?.roundToInt() ?: 0)
638
+ if (metrics != null) weight = 2f
639
+ else marginEnd = dp(style?.optDouble("contentTrailingGap", 0.0)?.roundToInt() ?: 0)
568
640
  },
569
641
  )
570
642
  titleLine.packsChildrenAtStart = true
@@ -748,7 +820,11 @@ internal class NativeListMarketRowView(context: ThemedReactContext, fonts: Nativ
748
820
  }
749
821
  trailingColumn.orientation = HORIZONTAL
750
822
  trailingColumn.gravity = Gravity.END or Gravity.CENTER_VERTICAL
751
- addView(trailingColumn, wrap())
823
+ if (metrics != null) {
824
+ bindMetrics(metrics, theme, style?.optDouble("trailingGap", 8.0)?.roundToInt() ?: 8)
825
+ } else {
826
+ addView(trailingColumn, wrap())
827
+ }
752
828
  bindQuote(item, theme)
753
829
  val nextImageSignature =
754
830
  nativeListCanonical(
@@ -861,18 +937,6 @@ internal class NativeListMarketRowView(context: ThemedReactContext, fonts: Nativ
861
937
  )
862
938
  }
863
939
  }
864
- contentDescription =
865
- item.json.optString(
866
- "accessibilityLabel",
867
- listOf(
868
- item.json.optString("title"),
869
- item.json.optString("subtitle"),
870
- item.json.optString("price"),
871
- changeData.optString("text"),
872
- )
873
- .filter(String::isNotEmpty)
874
- .joinToString(", "),
875
- )
876
940
  }
877
941
 
878
942
  override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
@@ -912,7 +976,7 @@ internal class NativeListMarketRowView(context: ThemedReactContext, fonts: Nativ
912
976
  ?.has("contentVerticalAlignment") == true
913
977
  if (item.type == "market" && item.json.optJSONObject("style") != null) {
914
978
  // OneKey patch: preserve Yoga's half-pixel centering for Market columns and badges.
915
- for (column in listOf(leadingFrame, mainColumn, trailingColumn)) {
979
+ for (column in listOf(leadingFrame, mainColumn, trailingColumn) + metricColumns) {
916
980
  if (!hasContainerAlignment && column.parent === this && column.visibility != GONE) {
917
981
  column.offsetTopAndBottom((height - column.height + 1) / 2 - column.top)
918
982
  }
@@ -2943,6 +2943,7 @@ class NativeListView(
2943
2943
  previous.type == "market" &&
2944
2944
  changes.keys().asSequence().all(MARKET_QUOTE_FIELDS::contains)
2945
2945
  val merged = NativeListItem.parse(mergeRow(previous.json, changes))
2946
+ marketQuoteOnly = marketQuoteOnly && hasSameMarketMetricStructure(previous, merged)
2946
2947
  nextItems[index] = merged
2947
2948
  if (changes.has("selected")) {
2948
2949
  if (changes.optBoolean("selected")) selected.add(merged.key) else selected.remove(merged.key)
@@ -4838,6 +4839,7 @@ class NativeListView(
4838
4839
  "priceSegments",
4839
4840
  "change",
4840
4841
  "accessibilityLabel",
4842
+ "metrics",
4841
4843
  )
4842
4844
  private const val SECTION_INDEX_CONTENT_INSET_DP = 16
4843
4845
  private const val SECTION_INDEX_RAIL_WIDTH_DP = 32
@@ -182,8 +182,12 @@ separately from source implementation in STYLE_SPEC §9.
182
182
  with at most 32 unique keyed entries. Each entry supports a leading visual,
183
183
  tone, primary/secondary text and subscript segments. The title supports at
184
184
  most four keyed badges. `descriptionActionKey` emits a description action.
185
- `presentation: 'table'` allocates equal identity/amount columns and an
186
- optional fee column; `stacked` keeps right-aligned amounts beside identity.
185
+ `presentation: 'table'` allocates identity (including the leading visual and
186
+ its gap), amounts and optional fee in a stable `1:1:0.6` ratio after column
187
+ gaps. Without fee, identity and amounts have equal widths. Amount content
188
+ stays start-aligned; native fee content stays end-aligned, while Web retains its
189
+ existing start alignment. Fee text length and leading visibility do not move
190
+ the amount column. `stacked` keeps right-aligned amounts beside identity.
187
191
  `fee.hidden` reserves its column while hiding its content. The Web rich
188
192
  Activity renderer uses these fields and the same `hideLeading` visibility.
189
193
 
package/docs/SPEC.md CHANGED
@@ -375,6 +375,33 @@ migration itself does not change any other protocol field.
375
375
 
376
376
  ## Lifecycle and concurrency
377
377
 
378
+ ### Updates before native host binding
379
+
380
+ Status: implemented in local source, unreleased. The delayed-host regression fails
381
+ on the previous wrapper and passes with this change; 438 package JS/Web tests and
382
+ the package type check pass. This local JavaScript revision is verified with real
383
+ iOS Fabric/UICollectionView rendering on an iPad simulator: delayed-ref,
384
+ parent-layout-before-ref without an artificial delay, and host-ready control
385
+ scenarios all retain the three complete rows after a later single-row update.
386
+ The actual Home list also recovered all six rows after the task-specific Metro
387
+ reload. Earlier natural re-entry evidence included development Metro bundle
388
+ re-requests, so it does not establish a menu-only trigger or a Sui-specific natural
389
+ reproduction. Android device acceptance remains unverified.
390
+
391
+ Valid snapshots and row patches accepted before the native host is available MUST
392
+ retain the latest owned logical state. When that host binds, its rows MUST reflect
393
+ all accepted updates that are eligible for presentation, including updates to rows
394
+ that do not change again afterward. Later single-row updates MUST NOT leave other
395
+ rows at their initial placeholders. Existing disclosure measurement gates and
396
+ snapshot replacement semantics still apply. Once the host is ready, ordinary row
397
+ patches MUST remain incremental rather than requiring a complete snapshot for each
398
+ patch. This contract does not add a public prop, method, or Nitro ABI field.
399
+
400
+ Conformance covers multiple pre-bind patch batches, newer complete prop and
401
+ imperative snapshots, rejected updates, equivalent prop clones after slot layout,
402
+ selection state, repeated callbacks, distinct replacement hosts, and incremental
403
+ single-row updates after binding.
404
+
378
405
  Full binding must work without a preceding recycle callback, including
379
406
  style set/change/clear and content A → B. A binding epoch invalidates stale
380
407
  image callbacks and action anchors. Recycling/disposal releases pending work
@@ -553,6 +580,14 @@ existing placeholders, and positive title-image dimensions are clamped to a
553
580
  square of 1...40 logical units. Image headers are string dictionaries, never
554
581
  interpreted as geometry. Unsafe object graphs are rejected before normalization.
555
582
 
583
+ Shared image URI validation accepts the entire `onekey-avatar:` scheme,
584
+ case-insensitively, in snapshots and row patches on iOS, Android and Web. It does
585
+ not restrict the host, version, path, query string or fragment, and preserves the
586
+ URI without decoding. Existing image URI schemes retain their acceptance rules.
587
+ Unsupported image URIs fail development assertions and are sanitized out in
588
+ production. This is validation acceptance only; avatar decoding and loading
589
+ remain owned by the platform image loader or Web avatar worker.
590
+
556
591
  A complete snapshot remains atomic after decoration sanitization: invalid
557
592
  schema, duplicate/missing identities, required business fields or wallet-group
558
593
  parent/children relationships reject the snapshot. No row is silently removed.
@@ -1434,9 +1469,14 @@ secondary text and subscript text segments. Segments are caller-formatted and
1434
1469
  never interpreted as numbers by NativeList. Omission preserves the legacy tree.
1435
1470
 
1436
1471
  `presentation: stacked` places the amount lines after the identity column and
1437
- right-aligns them; `table` uses equal identity/amount columns, left-aligns amount
1438
- lines and adds an optional trailing fee column. Fee supports label, primary and
1439
- secondary text with their own segments; `hidden` preserves its column width
1472
+ right-aligns them. In `table`, the identity column includes its leading visual
1473
+ and gap; identity, amounts and an optional fee use stable widths in the ratio
1474
+ `1:1:0.6` after inter-column gaps. Without fee, identity and amounts split the
1475
+ remaining width equally. Amount content stays start-aligned within its column;
1476
+ native fee content stays end-aligned; Web retains its existing start alignment.
1477
+ Fee text length and leading visibility cannot move the amount column. These
1478
+ native widths match the existing Web table layout. Fee supports label, primary
1479
+ and secondary text with their own segments; `hidden` preserves its column width
1440
1480
  while hiding its contents. Up to four uniquely keyed title badges use declared
1441
1481
  tones. `descriptionActionKey` makes only the description emit that action with
1442
1482
  source `description`; footer actions retain their existing source/keys and
@@ -1457,6 +1497,15 @@ baseline, matching NumberSizeableText), badge tone/clear, hidden fee, disabled
1457
1497
  actions, description anchors, dark/RTL/font scaling and explicit/automatic height.
1458
1498
 
1459
1499
 
1500
+ Table regression acceptance additionally measures amount-column start, width and
1501
+ center across short/long/hidden fee, leading visibility, 1/2/6/32 amount lines and
1502
+ table/stacked reuse. Android DataRow primary and secondary groups honor their
1503
+ resolved text-role alignment (role style first, then column alignment), including
1504
+ start/center/end and styled/unstyled reuse. The secondary-prefix gap is present
1505
+ only when that prefix is visible; legacy linear rendering is unchanged. The UIKit
1506
+ regression explicitly forces each renderer view's direction; React Native/Fabric
1507
+ host direction propagation and real App RTL remain unverified.
1508
+
1460
1509
  ### Android ancestor scroll distance
1461
1510
 
1462
1511
  Threshold hysteresis combines the primary RecyclerView's actual consumed scroll
@@ -1634,6 +1683,51 @@ row/presentation recycling, strict validation, native dictionary safe-ignore,
1634
1683
  header/body geometry, 100 quote ticks preserving nodes with zero geometry reads,
1635
1684
  then a real complex Market table before expanding to all five families.
1636
1685
 
1686
+ ### Native Market metric columns
1687
+
1688
+ Status: implemented in local source, unreleased; native runtime verification
1689
+ pending. A `market` row may supply two or three ordered, uniquely
1690
+ keyed text columns through `metrics`, typed as a `Pick<DataColumn, ...>` of
1691
+ the supported text fields. Each column uses
1692
+ the text subset of `DataColumn`: key, primary text and segments, optional
1693
+ secondary text and segments, weight, alignment, and primary/secondary tone.
1694
+ Icon and secondary-leading fields are rejected in development and stripped by
1695
+ the existing production decoration sanitizer.
1696
+ Keys are nonempty and callers format all text. The row keeps its required
1697
+ `price` and `change` fields as compact fallbacks. When `metrics` is present,
1698
+ iOS and Android display the existing leading action, visual, title, subtitle,
1699
+ and Market badges, then the metric columns in place of the compact price and
1700
+ change block. The metrics are authoritative for this native presentation; the
1701
+ SDK does not infer their values from the fallback fields or product data.
1702
+ The default native accessibility label includes the title and subtitle followed
1703
+ by the displayed metric text, primary then secondary, in column order. It
1704
+ updates with each quote. An explicit row accessibility label takes precedence.
1705
+ The metrics use their own text tones and alignment, with both lines aligned
1706
+ the same way; omitted alignment defaults to `end`. Existing change block
1707
+ width, height, and corner radius do not
1708
+ apply to metric columns. The title content receives a relative weight of two;
1709
+ metric columns receive their declared `weight` (1...3, default one) after the
1710
+ row's existing action, visual, padding, and gaps take their space. The SDK
1711
+ does not choose a mode from screen width. Without `metrics`, all platforms
1712
+ retain their existing compact Market presentation. Web ignores `metrics` and
1713
+ continues using `webTable` when supplied.
1714
+
1715
+ Metric values travel in the existing row snapshot JSON and do not change the
1716
+ Nitro ABI, row key, action keys, badge anchors, or scroll ownership. Full
1717
+ snapshots may add, replace, or omit `metrics`; omission restores compact mode.
1718
+ An explicit patch with a valid two- or three-column array replaces metrics,
1719
+ while an omitted patch field leaves them unchanged. A full snapshot is used
1720
+ to remove metrics from a wide row; this contract adds no null patch meaning.
1721
+ Binding and recycling clear inactive metric and compact views in both
1722
+ directions, including changes between two and three columns, so reused rows
1723
+ cannot show stale text or targets. Metric fields contain no new action target;
1724
+ Market badge, leading action, press, press-in, and long-press behavior remains
1725
+ owned by the existing Market row. Conformance requires validation and patch
1726
+ tests, native bind/reuse and action-anchor checks, then consuming App device
1727
+ checks at narrow and wide list widths. Source and unit tests alone do not
1728
+ establish native layout acceptance.
1729
+
1730
+
1637
1731
 
1638
1732
  ### Web grid fractional column geometry
1639
1733
 
@@ -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 shared = (mainWidth - leadingWidth - gap - feeColumn) / 2
118
- identityWidth = min(mainWidth * 0.42, shared)
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 { dimensions.append(amounts.widthAnchor.constraint(equalTo: identity.widthAnchor)) }
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 {