@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.
@@ -16,7 +16,11 @@ 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)
21
+ /** Whether the bound leading visual lays a badge past its frame; see [setClipping]. */
22
+ var leadingOverhangs = false
23
+ private set
20
24
  private val identity = LinearLayout(context).apply { orientation = VERTICAL }
21
25
  private val titleLine = LinearLayout(context).apply { orientation = HORIZONTAL; gravity = Gravity.CENTER_VERTICAL }
22
26
  private val title = NativeListTextView(context)
@@ -40,13 +44,26 @@ internal class NativeListActivityDetailsView(private val reactContext: ThemedRea
40
44
  identity.addView(status)
41
45
  feeLine.addView(feePrimary); feeLine.addView(feeSecondary)
42
46
  fee.addView(feeLabel); fee.addView(feeLine)
43
- main.addView(visual)
44
- main.addView(identity, LayoutParams(0, -2, 1f))
47
+ identityColumn.addView(visual)
48
+ identityColumn.addView(identity, LayoutParams(0, -2, 1f))
49
+ main.addView(identityColumn, LayoutParams(0, -2, 1f))
45
50
  main.addView(amounts)
46
51
  main.addView(fee)
47
52
  addView(main, LayoutParams(-1, -2))
48
53
  addView(actions, LayoutParams(-1, -2))
49
54
  }
55
+ /**
56
+ * Android clips a child to its own bounds while its parent clips children, so
57
+ * every view between the leading visual and the row host stops clipping when
58
+ * the visual's network badge overhangs its box (SPEC "Leading badge overhang",
59
+ * OK-66275); the host row applies the same policy to itself.
60
+ */
61
+ private fun setClipping(clips: Boolean) {
62
+ for (group in listOf(identityColumn, main, this)) {
63
+ group.clipChildren = clips
64
+ group.clipToPadding = clips
65
+ }
66
+ }
50
67
  private fun dp(value: Int) = if (sourceScale) (value * resources.displayMetrics.density).roundToInt() else NativeListScale.dp(resources, value)
51
68
  private fun px(value: Double) = (value * resources.displayMetrics.density).roundToInt()
52
69
  private fun color(tone: String, theme: JSONObject?): Int {
@@ -77,6 +94,8 @@ internal class NativeListActivityDetailsView(private val reactContext: ThemedRea
77
94
  visual.visibility = if (hideLeading) GONE else VISIBLE
78
95
  if (hideLeading) visual.recycle()
79
96
  else visual.bind(data.optJSONObject("leading") ?: JSONObject(), image, item.key, theme, false, sourceScale, data.optJSONObject("secondaryLeading"))
97
+ leadingOverhangs = !hideLeading && visual.requestsUnclippedHost
98
+ setClipping(nativeListActivityHostClips(leadingOverhangs))
80
99
  amounts.layoutParams = if (table) LayoutParams(0, -2, 1f).apply { marginStart = gap } else LayoutParams(-2, -2).apply { marginStart = gap }
81
100
  text(title, data.optString("title"), style.optJSONObject("title"), 16f, color("primary", theme), 24, "medium")
82
101
  text(description, data.optString("description"), style.optJSONObject("description"), 14f, color("secondary", theme), 20, lines = 2)
@@ -110,7 +129,9 @@ internal class NativeListActivityDetailsView(private val reactContext: ThemedRea
110
129
  val feeData = data.optJSONObject("fee")
111
130
  fee.visibility = if (feeData == null) GONE else VISIBLE
112
131
  fee.alpha = if (feeData?.optBoolean("hidden") == true) 0f else 1f
113
- fee.layoutParams = LayoutParams(-2, -2).apply { marginStart = gap }
132
+ fee.layoutParams = (if (table) LayoutParams(0, -2, 0.6f) else LayoutParams(-2, -2)).apply { marginStart = gap }
133
+ feeLabel.layoutParams.width = if (table) LayoutParams.WRAP_CONTENT else LayoutParams.MATCH_PARENT
134
+ feeLine.layoutParams.width = if (table) LayoutParams.WRAP_CONTENT else LayoutParams.MATCH_PARENT
114
135
  text(feeLabel, feeData?.optString("label") ?: "", style.optJSONObject("secondaryAmount"), 12f, color("secondary", theme), 16)
115
136
  text(feePrimary, feeData?.optString("primary") ?: "", style.optJSONObject("secondaryAmount"), 14f, color("primary", theme), 20)
116
137
  text(feeSecondary, feeData?.optString("secondary") ?: "", style.optJSONObject("secondaryAmount"), 12f, color("secondary", theme), 16)
@@ -149,7 +170,11 @@ internal class NativeListActivityDetailsView(private val reactContext: ThemedRea
149
170
  }
150
171
  label.text = styled
151
172
  }
152
- fun recycle() { visual.recycle(); visual.visibility = VISIBLE; amountViews.forEach { it.recycle() }; description.setOnClickListener(null); actions.removeAllViews() }
173
+ fun recycle() {
174
+ visual.recycle(); visual.visibility = VISIBLE; amountViews.forEach { it.recycle() }; description.setOnClickListener(null); actions.removeAllViews()
175
+ leadingOverhangs = false
176
+ setClipping(true)
177
+ }
153
178
  fun dispose() { recycle(); visual.dispose(); amountViews.forEach { it.dispose() } }
154
179
 
155
180
  private inner class AmountView : LinearLayout(context) {
@@ -83,10 +83,11 @@ internal class NativeListActivityRowView(context: ThemedReactContext, fonts: Nat
83
83
  setPadding(hp, vp, hp, vp)
84
84
  visual.recycle()
85
85
  details.bind(item, theme, sourceScale)
86
- // This view may be reused from a legacy row whose overhanging badge
87
- // switched clipping off; a rich row has no badge on the host, so restore
88
- // the defaults before returning.
89
- setHostClipping(nativeListActivityHostClips(rich = true, leadingOverhangs = false))
86
+ // The rich row's leading visual lives on the details view, where its
87
+ // network badge overhangs the box (OK-66275): this host must stop clipping
88
+ // as well, and a view reused from a badge row clips again when the bound
89
+ // row has no badge.
90
+ setHostClipping(nativeListActivityHostClips(details.leadingOverhangs))
90
91
  return
91
92
  }
92
93
  details.recycle()
@@ -174,12 +175,7 @@ internal class NativeListActivityRowView(context: ThemedReactContext, fonts: Nat
174
175
  // Stop clipping the content row and this row's padding exactly when the
175
176
  // bound visual reports such a badge (SPEC "Leading badge overhang"), as the
176
177
  // Identity renderer does.
177
- setHostClipping(
178
- nativeListActivityHostClips(
179
- rich = false,
180
- leadingOverhangs = !hideLeading && visual.requestsUnclippedHost,
181
- )
182
- )
178
+ setHostClipping(nativeListActivityHostClips(!hideLeading && visual.requestsUnclippedHost))
183
179
  bind(
184
180
  title,
185
181
  data.optString("title"),
@@ -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
  }
@@ -50,16 +50,17 @@ internal fun nativeListLeadingVisualOverhangsFrame(
50
50
  ): Boolean = kind == "token" && sourceCount > 1 || hasCornerIcon || overlayCount > 0
51
51
 
52
52
  /**
53
- * Whether an Activity row host clips its children and padding for this bind.
54
- * One row view serves both the legacy row and the rich (`amounts`) row and is
55
- * reused across binds, so clipping is derived from the bound item alone and
56
- * applied on every bind: only a legacy row whose leading visual lays a badge
57
- * past its frame ([nativeListLeadingVisualOverhangsFrame], OK-66071) opts out;
58
- * a rich row keeps Android's default clipping even when the view it reuses
59
- * last showed such a badge.
53
+ * Whether an Activity row host (and, for a rich `amounts` row, the details view
54
+ * with its nested columns) clips its children and padding for this bind. One
55
+ * row view serves both the legacy row and the rich row and is reused across
56
+ * binds, so clipping is derived from the bound item alone and applied on every
57
+ * bind: a row whose bound leading visual lays a badge past its frame
58
+ * ([nativeListLeadingVisualOverhangsFrame]) opts out, whether that visual sits
59
+ * on the host (legacy row, OK-66071) or on the details view (rich row,
60
+ * OK-66275); a row without such a badge clips again even when the view it
61
+ * reuses last showed one.
60
62
  */
61
- internal fun nativeListActivityHostClips(rich: Boolean, leadingOverhangs: Boolean): Boolean =
62
- rich || !leadingOverhangs
63
+ internal fun nativeListActivityHostClips(leadingOverhangs: Boolean): Boolean = !leadingOverhangs
63
64
 
64
65
  internal data class NativeListSlotFrame(val x: Int, val y: Int, val width: Int, val height: Int)
65
66
 
@@ -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
@@ -5,10 +5,12 @@ import org.junit.Test
5
5
 
6
6
  class NativeListActivityHostClipPolicyTest {
7
7
  // One Activity row view serves legacy and rich rows and is reused across
8
- // binds. A legacy row with an overhanging network badge switches the host's
9
- // clipping off (OK-66071); the next bind must derive clipping afresh, so a
10
- // rich row reusing that view clips again instead of drawing its details over
11
- // a neighbouring row (PR #185 review).
8
+ // binds. A row with an overhanging network badge switches the host's
9
+ // clipping off, whether the badge sits on the host's visual (legacy row,
10
+ // OK-66071) or on the details view's visual (rich row, OK-66275); the next
11
+ // bind must derive clipping afresh, so a row without a badge reusing that
12
+ // view clips again instead of drawing over a neighbouring row (PR #185
13
+ // review).
12
14
  @Test
13
15
  fun rebindSequenceDerivesClippingFromEachBoundItem() {
14
16
  data class Bind(val name: String, val rich: Boolean, val leadingOverhangs: Boolean, val clips: Boolean)
@@ -17,13 +19,17 @@ class NativeListActivityHostClipPolicyTest {
17
19
  Bind("legacy row, plain token", rich = false, leadingOverhangs = false, clips = true),
18
20
  Bind("legacy row, network badge", rich = false, leadingOverhangs = true, clips = false),
19
21
  Bind("rich row reusing the badge row's view", rich = true, leadingOverhangs = false, clips = true),
22
+ // The app's history rows carry `amounts`, so their badge lives on the
23
+ // details view's leading visual: a rich row with such a badge must stop
24
+ // clipping too (OK-66275).
25
+ Bind("rich row, network badge in details", rich = true, leadingOverhangs = true, clips = false),
20
26
  Bind("legacy row, badge again", rich = false, leadingOverhangs = true, clips = false),
21
27
  Bind("legacy row, hidden leading", rich = false, leadingOverhangs = false, clips = true),
22
28
  )
23
29
 
24
30
  assertEquals(
25
31
  binds.map(Bind::clips),
26
- binds.map { nativeListActivityHostClips(rich = it.rich, leadingOverhangs = it.leadingOverhangs) },
32
+ binds.map { nativeListActivityHostClips(leadingOverhangs = it.leadingOverhangs) },
27
33
  )
28
34
  }
29
35
  }
@@ -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
@@ -751,7 +778,14 @@ Host clipping is per-bind state. A row view that serves several row shapes
751
778
  (the Android Activity view serves the legacy row and the rich `amounts` row) is
752
779
  reused across binds, so each bind MUST derive clipping from the item it binds
753
780
  (`nativeListActivityHostClips`): a view that last showed an overhanging badge
754
- clips again when it is rebound to a row without one.
781
+ clips again when it is rebound to a row without one. The rule follows the
782
+ visual, not the row shape: the rich row renders its leading visual on the
783
+ details view, so that view and every column between the visual and the host
784
+ stop clipping too, and the host derives its own clipping from the details
785
+ view's visual (`leadingOverhangs`, OK-66275). Before this rule the rich branch
786
+ forced the host's clipping back on and left the details view at Android's
787
+ defaults, so the app's history rows (which always carry `amounts`) still showed
788
+ the badge cut at the bottom-right after the legacy branch had been fixed.
755
789
 
756
790
  The row's own shape still clips on every platform: iOS masks the cell to its
757
791
  corner radius (`masksToBounds`), Web keeps the row `overflow: hidden`, and
@@ -788,9 +822,10 @@ over the badge. The per-source geometry is one pure function on Android
788
822
  `NativeListLeadingVisual.layoutSubviews` on iOS; both restore the stacking
789
823
  order by role on every bind because slots are reused across roles.
790
824
 
791
- The Android Activity renderer also applies the badge-overhang rule above
792
- (`requestsUnclippedHost` stops the content row and the row padding clipping),
793
- so the history rows' network badges render whole instead of cut flat.
825
+ The Android Activity renderer also applies the badge-overhang rule above on
826
+ both of its branches (`requestsUnclippedHost` stops the legacy content row, the
827
+ rich details view with its columns, and the row padding clipping), so the
828
+ history rows' network badges render whole instead of cut flat.
794
829
 
795
830
  ## Failures, fallback and limits
796
831
 
@@ -1442,9 +1477,14 @@ secondary text and subscript text segments. Segments are caller-formatted and
1442
1477
  never interpreted as numbers by NativeList. Omission preserves the legacy tree.
1443
1478
 
1444
1479
  `presentation: stacked` places the amount lines after the identity column and
1445
- right-aligns them; `table` uses equal identity/amount columns, left-aligns amount
1446
- lines and adds an optional trailing fee column. Fee supports label, primary and
1447
- secondary text with their own segments; `hidden` preserves its column width
1480
+ right-aligns them. In `table`, the identity column includes its leading visual
1481
+ and gap; identity, amounts and an optional fee use stable widths in the ratio
1482
+ `1:1:0.6` after inter-column gaps. Without fee, identity and amounts split the
1483
+ remaining width equally. Amount content stays start-aligned within its column;
1484
+ native fee content stays end-aligned; Web retains its existing start alignment.
1485
+ Fee text length and leading visibility cannot move the amount column. These
1486
+ native widths match the existing Web table layout. Fee supports label, primary
1487
+ and secondary text with their own segments; `hidden` preserves its column width
1448
1488
  while hiding its contents. Up to four uniquely keyed title badges use declared
1449
1489
  tones. `descriptionActionKey` makes only the description emit that action with
1450
1490
  source `description`; footer actions retain their existing source/keys and
@@ -1465,6 +1505,15 @@ baseline, matching NumberSizeableText), badge tone/clear, hidden fee, disabled
1465
1505
  actions, description anchors, dark/RTL/font scaling and explicit/automatic height.
1466
1506
 
1467
1507
 
1508
+ Table regression acceptance additionally measures amount-column start, width and
1509
+ center across short/long/hidden fee, leading visibility, 1/2/6/32 amount lines and
1510
+ table/stacked reuse. Android DataRow primary and secondary groups honor their
1511
+ resolved text-role alignment (role style first, then column alignment), including
1512
+ start/center/end and styled/unstyled reuse. The secondary-prefix gap is present
1513
+ only when that prefix is visible; legacy linear rendering is unchanged. The UIKit
1514
+ regression explicitly forces each renderer view's direction; React Native/Fabric
1515
+ host direction propagation and real App RTL remain unverified.
1516
+
1468
1517
  ### Android ancestor scroll distance
1469
1518
 
1470
1519
  Threshold hysteresis combines the primary RecyclerView's actual consumed scroll
@@ -1642,6 +1691,51 @@ row/presentation recycling, strict validation, native dictionary safe-ignore,
1642
1691
  header/body geometry, 100 quote ticks preserving nodes with zero geometry reads,
1643
1692
  then a real complex Market table before expanding to all five families.
1644
1693
 
1694
+ ### Native Market metric columns
1695
+
1696
+ Status: implemented in local source, unreleased; native runtime verification
1697
+ pending. A `market` row may supply two or three ordered, uniquely
1698
+ keyed text columns through `metrics`, typed as a `Pick<DataColumn, ...>` of
1699
+ the supported text fields. Each column uses
1700
+ the text subset of `DataColumn`: key, primary text and segments, optional
1701
+ secondary text and segments, weight, alignment, and primary/secondary tone.
1702
+ Icon and secondary-leading fields are rejected in development and stripped by
1703
+ the existing production decoration sanitizer.
1704
+ Keys are nonempty and callers format all text. The row keeps its required
1705
+ `price` and `change` fields as compact fallbacks. When `metrics` is present,
1706
+ iOS and Android display the existing leading action, visual, title, subtitle,
1707
+ and Market badges, then the metric columns in place of the compact price and
1708
+ change block. The metrics are authoritative for this native presentation; the
1709
+ SDK does not infer their values from the fallback fields or product data.
1710
+ The default native accessibility label includes the title and subtitle followed
1711
+ by the displayed metric text, primary then secondary, in column order. It
1712
+ updates with each quote. An explicit row accessibility label takes precedence.
1713
+ The metrics use their own text tones and alignment, with both lines aligned
1714
+ the same way; omitted alignment defaults to `end`. Existing change block
1715
+ width, height, and corner radius do not
1716
+ apply to metric columns. The title content receives a relative weight of two;
1717
+ metric columns receive their declared `weight` (1...3, default one) after the
1718
+ row's existing action, visual, padding, and gaps take their space. The SDK
1719
+ does not choose a mode from screen width. Without `metrics`, all platforms
1720
+ retain their existing compact Market presentation. Web ignores `metrics` and
1721
+ continues using `webTable` when supplied.
1722
+
1723
+ Metric values travel in the existing row snapshot JSON and do not change the
1724
+ Nitro ABI, row key, action keys, badge anchors, or scroll ownership. Full
1725
+ snapshots may add, replace, or omit `metrics`; omission restores compact mode.
1726
+ An explicit patch with a valid two- or three-column array replaces metrics,
1727
+ while an omitted patch field leaves them unchanged. A full snapshot is used
1728
+ to remove metrics from a wide row; this contract adds no null patch meaning.
1729
+ Binding and recycling clear inactive metric and compact views in both
1730
+ directions, including changes between two and three columns, so reused rows
1731
+ cannot show stale text or targets. Metric fields contain no new action target;
1732
+ Market badge, leading action, press, press-in, and long-press behavior remains
1733
+ owned by the existing Market row. Conformance requires validation and patch
1734
+ tests, native bind/reuse and action-anchor checks, then consuming App device
1735
+ checks at narrow and wide list widths. Source and unit tests alone do not
1736
+ establish native layout acceptance.
1737
+
1738
+
1645
1739
 
1646
1740
  ### Web grid fractional column geometry
1647
1741