@onekeyfe/react-native-native-list 6.7.0-alpha.426 → 6.7.0-alpha.430

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.
@@ -31,6 +31,8 @@ internal class NativeListIdentityRowView(context: ThemedReactContext, fonts: Nat
31
31
  private val titleLine = PackedTitleLineLayout(context)
32
32
  private val title = NativeListTextView(context)
33
33
  private val titleIcon = OneKeyIconView(context)
34
+ private val titleLeadingIcon = OneKeyIconView(context)
35
+ private val titleSegments = NativeListTitleSegmentsView(context)
34
36
  private var titleImage: NativeListImageSlot? = null
35
37
  private val subtitle = NativeListTextView(context)
36
38
  private val tertiary = NativeListTextView(context)
@@ -109,8 +111,13 @@ internal class NativeListIdentityRowView(context: ThemedReactContext, fonts: Nat
109
111
  mainColumn.gravity = Gravity.CENTER_VERTICAL
110
112
  titleLine.orientation = HORIZONTAL
111
113
  titleLine.gravity = Gravity.CENTER_VERTICAL
114
+ titleLine.addView(titleLeadingIcon, LayoutParams(dp(16), dp(16)).apply { marginEnd = dp(6) })
112
115
  titleLine.addView(title, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f))
116
+ titleLine.addView(titleSegments, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f))
113
117
  titleLine.addView(titleIcon, LayoutParams(dp(20), dp(20)).apply { marginStart = dp(4) })
118
+ titleLine.titleChild = title
119
+ titleLeadingIcon.visibility = GONE
120
+ titleSegments.visibility = GONE
114
121
  titleLine.addView(badgeLine)
115
122
  trailingColumn.onAction = { key, view, slot, target ->
116
123
  emitAction(key, view, "trailingAccessory", slot, target, trailingColumn.anchorInset(view))
@@ -223,6 +230,12 @@ internal class NativeListIdentityRowView(context: ThemedReactContext, fonts: Nat
223
230
  titleIcon.visibility = GONE
224
231
  titleIcon.contentDescription = null
225
232
  titleIcon.setOnClickListener(null)
233
+ titleLeadingIcon.visibility = GONE
234
+ titleLeadingIcon.contentDescription = null
235
+ titleSegments.visibility = GONE
236
+ titleSegments.clearRuns()
237
+ titleSegments.layoutParams = LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)
238
+ titleLine.titleChild = title
226
239
  for (view in listOf(title, subtitle, tertiary, badgeLine)) {
227
240
  view.text = ""
228
241
  view.visibility = GONE
@@ -343,6 +356,46 @@ internal class NativeListIdentityRowView(context: ThemedReactContext, fonts: Nat
343
356
  }
344
357
  // Legacy: overhanging selector icons/checkboxes must not be clipped by row padding.
345
358
  if (trailingColumn.parent === this && trailingColumn.requestsUnclippedHost) clipToPadding = false
359
+ if (titleSegments.visibility == VISIBLE) bindTitleRuns(item, theme)
360
+ }
361
+
362
+ // Runs take the title's final text appearance and color unless they set
363
+ // their own tone or weight.
364
+ private fun bindTitleRuns(item: NativeListItem, theme: JSONObject?) {
365
+ titleSegments.typeface = title.typeface
366
+ titleSegments.setTextSize(android.util.TypedValue.COMPLEX_UNIT_PX, title.textSize)
367
+ titleSegments.includeFontPadding = title.includeFontPadding
368
+ titleSegments.letterSpacing = title.letterSpacing
369
+ titleSegments.opticalOffsetY = title.opticalOffsetY
370
+ titleSegments.maxLines = 1
371
+ titleSegments.ellipsize = TextUtils.TruncateAt.END
372
+ TextViewCompat.setLineHeight(titleSegments, title.lineHeight)
373
+ val baseColor = title.currentTextColor
374
+ titleSegments.setTextColor(baseColor)
375
+ val segments = item.json.optJSONArray("titleSegments") ?: return
376
+ val runs =
377
+ (0 until segments.length()).mapNotNull { index ->
378
+ val segment = segments.optJSONObject(index) ?: return@mapNotNull null
379
+ NativeListTitleSegmentsView.Run(
380
+ text = segment.optString("text"),
381
+ typeface =
382
+ when (segment.optString("fontWeight")) {
383
+ "medium" -> fonts.medium(context)
384
+ "regular" -> fonts.regular(context)
385
+ else -> null
386
+ },
387
+ color =
388
+ when (segment.optString("tone")) {
389
+ "secondary" -> color(theme, "secondaryText", "#0000009B")
390
+ "primary" -> color(theme, "primaryText", "#000000DF")
391
+ else -> baseColor
392
+ },
393
+ shrink = segment.optBoolean("shrink", false),
394
+ maxWidthRatio =
395
+ if (segment.has("maxWidthRatio")) segment.optDouble("maxWidthRatio").toFloat() else null,
396
+ )
397
+ }
398
+ titleSegments.setRuns(runs, item.json.optString("title"))
346
399
  }
347
400
 
348
401
  private fun styleGap(column: LinearLayout, gap: Int) {
@@ -476,7 +529,8 @@ internal class NativeListIdentityRowView(context: ThemedReactContext, fonts: Nat
476
529
  val imageWidth = titleImage?.view?.takeIf { it.visibility == VISIBLE }?.let {
477
530
  it.layoutParams.width + dp(4)
478
531
  } ?: 0
479
- val icon = (if (titleIcon.visibility == VISIBLE) dp(24) else 0) + imageWidth
532
+ val icon = (if (titleIcon.visibility == VISIBLE) dp(24) else 0) + imageWidth +
533
+ (if (titleLeadingIcon.visibility == VISIBLE) dp(22) else 0)
480
534
  (inner * ratio).roundToInt()
481
535
  .minus(leading.width + leading.marginEnd + icon)
482
536
  .coerceAtLeast(0)
@@ -487,9 +541,10 @@ internal class NativeListIdentityRowView(context: ThemedReactContext, fonts: Nat
487
541
  super.onMeasure(widthMeasureSpec, heightMeasureSpec)
488
542
  if (titleSuffixLabels.isNotEmpty()) {
489
543
  val fixedWidth =
490
- (1 until titleLine.childCount).sumOf { index ->
544
+ (0 until titleLine.childCount).sumOf { index ->
491
545
  val view = titleLine.getChildAt(index)
492
- if (view.visibility == GONE || titleSuffixLabels.any { it === view }) 0
546
+ if (view.visibility == GONE || view === titleLine.titleChild ||
547
+ titleSuffixLabels.any { it === view }) 0
493
548
  else {
494
549
  val margins = view.layoutParams as MarginLayoutParams
495
550
  view.measuredWidth + margins.leftMargin + margins.rightMargin
@@ -753,6 +808,30 @@ internal class NativeListIdentityRowView(context: ThemedReactContext, fonts: Nat
753
808
  titleLine.packsChildrenAtStart = true
754
809
  title.ellipsize = TextUtils.TruncateAt.END
755
810
  showText(title, item.json.optString("title"), item.json.optInt("titleLines", 1))
811
+ // Runs replace the drawn title except where `title` itself is highlighted;
812
+ // their content is bound once the title style is final.
813
+ if ((item.json.optJSONArray("titleSegments")?.length() ?: 0) > 0 &&
814
+ (item.json.optJSONArray("titleMatch")?.length() ?: 0) == 0
815
+ ) {
816
+ title.visibility = GONE
817
+ titleSegments.visibility = VISIBLE
818
+ titleLine.titleChild = titleSegments
819
+ }
820
+ item.json.optJSONObject("titleLeadingIcon")?.let { icon ->
821
+ titleLeadingIcon.iconName = icon.optString("name")
822
+ titleLeadingIcon.glyphSizeDp = 16
823
+ titleLeadingIcon.useSourceScale = sourceScale
824
+ titleLeadingIcon.tintColor = safeColor(
825
+ icon.optString("tintColor").takeIf(String::isNotEmpty),
826
+ color(theme, "iconSubdued", "#00000072"),
827
+ )
828
+ titleLeadingIcon.contentDescription =
829
+ icon.optString("accessibilityLabel").takeIf(String::isNotEmpty)
830
+ titleLeadingIcon.importantForAccessibility =
831
+ if (titleLeadingIcon.contentDescription == null) View.IMPORTANT_FOR_ACCESSIBILITY_NO
832
+ else View.IMPORTANT_FOR_ACCESSIBILITY_YES
833
+ titleLeadingIcon.visibility = VISIBLE
834
+ }
756
835
  item.json.optJSONArray("titleSuffixes")?.let { suffixes ->
757
836
  for (index in 0 until suffixes.length()) {
758
837
  val suffix = suffixes.optJSONObject(index) ?: continue
@@ -109,6 +109,9 @@ internal class PackedTitleLineLayout(context: android.content.Context) : LinearL
109
109
  var packsChildrenAtStart = false
110
110
  // OneKey patch: optional cap for the Market subtitle's localized name.
111
111
  var leadingTextMaxWidth = Int.MAX_VALUE
112
+ // The text view that takes what the other children leave; the first child
113
+ // when unset, so a glyph may precede the title once this names it.
114
+ var titleChild: TextView? = null
112
115
 
113
116
  override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
114
117
  if (!packsChildrenAtStart || childCount < 2) {
@@ -116,13 +119,13 @@ internal class PackedTitleLineLayout(context: android.content.Context) : LinearL
116
119
  return
117
120
  }
118
121
 
119
- val title = getChildAt(0)
122
+ val title = titleChild?.takeIf { it.parent === this } ?: getChildAt(0)
120
123
  val widthMode = MeasureSpec.getMode(widthMeasureSpec)
121
124
  val widthSize = MeasureSpec.getSize(widthMeasureSpec)
122
125
  var accessoryWidth = 0
123
- for (index in 1 until childCount) {
126
+ for (index in 0 until childCount) {
124
127
  val child = getChildAt(index)
125
- if (child.visibility == GONE) continue
128
+ if (child === title || child.visibility == GONE) continue
126
129
  measureChildWithMargins(
127
130
  child,
128
131
  widthMeasureSpec,
@@ -0,0 +1,38 @@
1
+ package com.margelo.nitro.nativelist
2
+
3
+ /**
4
+ * Widths for identity title runs (docs/SPEC.md, "Identity title segments and
5
+ * leading title icon"). A run's base width is its natural width under its cap;
6
+ * the one shrinking run takes what the others leave; runs that do not shrink
7
+ * and still overflow are cut from the end of the line.
8
+ */
9
+ internal fun nativeListTitleSegmentWidths(
10
+ natural: List<Float>,
11
+ shrink: List<Boolean>,
12
+ maxWidthRatio: List<Float?>,
13
+ available: Float,
14
+ ): List<Float> {
15
+ val line = available.coerceAtLeast(0f)
16
+ val widths =
17
+ natural.indices
18
+ .map { index ->
19
+ val width = natural[index].coerceAtLeast(0f)
20
+ val ratio = maxWidthRatio.getOrNull(index)
21
+ if (ratio == null) width else minOf(width, ratio * line)
22
+ }
23
+ .toMutableList()
24
+ val shrinking = shrink.indexOf(true)
25
+ if (shrinking in widths.indices) {
26
+ val others = widths.indices.filter { it != shrinking }.sumOf { widths[it].toDouble() }.toFloat()
27
+ widths[shrinking] = minOf(widths[shrinking], line - others).coerceAtLeast(0f)
28
+ }
29
+ var overflow = widths.sum() - line
30
+ var index = widths.lastIndex
31
+ while (overflow > 0f && index >= 0) {
32
+ val cut = minOf(widths[index], overflow)
33
+ widths[index] -= cut
34
+ overflow -= cut
35
+ index -= 1
36
+ }
37
+ return widths
38
+ }
@@ -0,0 +1,105 @@
1
+ package com.margelo.nitro.nativelist
2
+
3
+ import android.graphics.Typeface
4
+ import android.text.SpannableStringBuilder
5
+ import android.text.Spanned
6
+ import android.text.TextPaint
7
+ import android.text.TextUtils
8
+ import android.text.style.ForegroundColorSpan
9
+ import android.text.style.MetricAffectingSpan
10
+
11
+ /**
12
+ * Identity title runs on one line. The text is rebuilt for each measured width
13
+ * from the widths `nativeListTitleSegmentWidths` allocates, so a run gives up
14
+ * width without the others; accessibility reads the row's `title` instead.
15
+ */
16
+ internal class NativeListTitleSegmentsView(context: android.content.Context) :
17
+ NativeListTextView(context) {
18
+ class Run(
19
+ val text: String,
20
+ // Null keeps the view's typeface, which follows the title's.
21
+ val typeface: Typeface?,
22
+ val color: Int,
23
+ val shrink: Boolean,
24
+ val maxWidthRatio: Float?,
25
+ )
26
+
27
+ private var runs: List<Run> = emptyList()
28
+ private var builtForWidth = -1
29
+
30
+ fun setRuns(runs: List<Run>, spokenText: String) {
31
+ this.runs = runs
32
+ builtForWidth = -1
33
+ contentDescription = spokenText.takeIf(String::isNotEmpty)
34
+ text = runs.joinToString("") { it.text }
35
+ requestLayout()
36
+ }
37
+
38
+ fun clearRuns() {
39
+ runs = emptyList()
40
+ builtForWidth = -1
41
+ contentDescription = null
42
+ text = ""
43
+ }
44
+
45
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
46
+ if (runs.isEmpty()) {
47
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec)
48
+ return
49
+ }
50
+ var outer =
51
+ if (MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.UNSPECIFIED) Int.MAX_VALUE
52
+ else MeasureSpec.getSize(widthMeasureSpec)
53
+ if (maxWidth in 0 until outer) outer = maxWidth
54
+ val available =
55
+ if (outer == Int.MAX_VALUE) outer
56
+ else (outer - compoundPaddingLeft - compoundPaddingRight).coerceAtLeast(0)
57
+ if (available != builtForWidth) {
58
+ builtForWidth = available
59
+ // The same width rebuilds the same text, so the relayout this asks for
60
+ // measures once more and stops.
61
+ setText(build(available), BufferType.SPANNABLE)
62
+ }
63
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec)
64
+ // The runs fill what the title line leaves, so a ratio is a share of it.
65
+ if (outer != Int.MAX_VALUE) setMeasuredDimension(outer, measuredHeight)
66
+ }
67
+
68
+ private fun build(available: Int): CharSequence {
69
+ val paints = runs.map { run -> TextPaint(paint).apply { run.typeface?.let { typeface = it } } }
70
+ val natural = runs.mapIndexed { index, run -> paints[index].measureText(run.text) }
71
+ val widths =
72
+ nativeListTitleSegmentWidths(
73
+ natural,
74
+ runs.map { it.shrink },
75
+ runs.map { it.maxWidthRatio },
76
+ if (available == Int.MAX_VALUE) Float.MAX_VALUE else available.toFloat(),
77
+ )
78
+ val built = SpannableStringBuilder()
79
+ runs.forEachIndexed { index, run ->
80
+ val width = widths[index]
81
+ if (width <= 0f) return@forEachIndexed
82
+ val shown =
83
+ if (width >= natural[index]) run.text
84
+ else TextUtils.ellipsize(run.text, paints[index], width, TextUtils.TruncateAt.END)
85
+ if (shown.isEmpty()) return@forEachIndexed
86
+ val start = built.length
87
+ built.append(shown)
88
+ built.setSpan(ForegroundColorSpan(run.color), start, built.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
89
+ run.typeface?.let {
90
+ built.setSpan(TypefaceRunSpan(it), start, built.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
91
+ }
92
+ }
93
+ return built
94
+ }
95
+ }
96
+
97
+ private class TypefaceRunSpan(private val typeface: Typeface) : MetricAffectingSpan() {
98
+ override fun updateMeasureState(paint: TextPaint) {
99
+ paint.typeface = typeface
100
+ }
101
+
102
+ override fun updateDrawState(paint: TextPaint) {
103
+ paint.typeface = typeface
104
+ }
105
+ }
@@ -0,0 +1,65 @@
1
+ package com.margelo.nitro.nativelist
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Test
5
+
6
+ class NativeListTitleSegmentLayoutTest {
7
+ // Wallet / Account as recipient rows send it: the wallet shrinks, the
8
+ // account is capped at 70% of the line.
9
+ private fun walletFirst(wallet: Float, account: Float, line: Float) =
10
+ nativeListTitleSegmentWidths(
11
+ listOf(wallet, 20f, account),
12
+ listOf(true, false, false),
13
+ listOf(null, null, 0.7f),
14
+ line,
15
+ )
16
+
17
+ private fun assertWidths(expected: List<Float>, actual: List<Float>) {
18
+ assertEquals(expected.size, actual.size)
19
+ expected.zip(actual).forEach { (want, got) -> assertEquals(want, got, 0.001f) }
20
+ }
21
+
22
+ @Test
23
+ fun keepsNaturalWidthsWhenTheyFit() = assertWidths(listOf(80f, 20f, 90f), walletFirst(80f, 90f, 300f))
24
+
25
+ @Test
26
+ fun theShrinkingRunGivesWayFirst() = assertWidths(listOf(130f, 20f, 100f), walletFirst(200f, 100f, 250f))
27
+
28
+ @Test
29
+ fun aCappedRunStopsAtItsShareOfTheLine() = assertWidths(listOf(70f, 20f, 210f), walletFirst(100f, 400f, 300f))
30
+
31
+ @Test
32
+ fun theAccountShrinksWhenTheWalletMustStayWhole() =
33
+ assertWidths(
34
+ listOf(210f, 20f, 70f),
35
+ nativeListTitleSegmentWidths(
36
+ listOf(300f, 20f, 150f),
37
+ listOf(false, false, true),
38
+ listOf(0.7f, null, null),
39
+ 300f,
40
+ ),
41
+ )
42
+
43
+ @Test
44
+ fun runsThatDoNotShrinkAreCutFromTheEnd() {
45
+ assertWidths(
46
+ listOf(100f, 50f, 0f),
47
+ nativeListTitleSegmentWidths(
48
+ listOf(100f, 100f, 100f),
49
+ listOf(false, false, true),
50
+ listOf(null, null, null),
51
+ 150f,
52
+ ),
53
+ )
54
+ assertWidths(
55
+ listOf(100f, 20f),
56
+ nativeListTitleSegmentWidths(listOf(100f, 100f), listOf(false, false), listOf(null, null), 120f),
57
+ )
58
+ }
59
+
60
+ @Test
61
+ fun aNonPositiveLineLeavesNothing() {
62
+ assertWidths(listOf(0f, 0f, 0f), walletFirst(80f, 90f, 0f))
63
+ assertWidths(listOf(0f, 0f, 0f), walletFirst(80f, 90f, -10f))
64
+ }
65
+ }
@@ -54,7 +54,14 @@ separately from source implementation in STYLE_SPEC §9.
54
54
 
55
55
  - **Use:** an identifiable asset, account, wallet, network or setting.
56
56
  - **Required data:** `leading`, `title`; optional `subtitle`, `tertiary`, `badges`,
57
- `trailing`, `leadingAction`, `titleIcon`, Web-only `secondaryTitleIcon`, match ranges and structured subtitle segments.
57
+ `trailing`, `leadingAction`, `titleIcon`, Web-only `secondaryTitleIcon`, `titleSegments`,
58
+ `titleLeadingIcon`, match ranges and structured subtitle segments.
59
+ - **Title runs:** optional `titleSegments` draw 1...4 runs with their own tone and
60
+ weight in place of `title`, on one line. One run may shrink; the others keep
61
+ their width under an optional `maxWidthRatio` cap. `title` stays the spoken
62
+ text. See SPEC.md.
63
+ - **Leading title icon:** optional `titleLeadingIcon` draws a 16-point glyph 6
64
+ points before the title; it is decorative unless labelled.
58
65
  - **Static title icon:** optional 20-point icon immediately after the title with
59
66
  a 4-point gap. It has no click action; an optional Web `hoverActionKey`
60
67
  emits an icon-anchored tooltip request on hover or touch. A trailing `button` is the fixed small
package/docs/SPEC.md CHANGED
@@ -188,6 +188,93 @@ and Web acceptance must cover narrow addresses, dynamic type/fonts, long press
188
188
  versus tap, keyboard interaction, and the horizontal warning border. Source or
189
189
  build success alone is not runtime evidence.
190
190
 
191
+ ### Identity title segments and leading title icon
192
+
193
+ Status: implemented in source for iOS, Android and Web; device and Web runtime
194
+ acceptance is pending. These fields travel in snapshot/patch JSON and do not
195
+ change the Nitro ABI. They serve recipient rows whose title is a wallet and an
196
+ account name in two styles, and address book rows marked by an icon before the
197
+ name.
198
+
199
+ `identity.titleSegments` is an optional list of 1...4 runs drawn on the title
200
+ line in place of `title`:
201
+
202
+ ```ts
203
+ titleSegments?: readonly {
204
+ text: string;
205
+ tone?: 'primary' | 'secondary';
206
+ fontWeight?: 'regular' | 'medium';
207
+ shrink?: boolean;
208
+ maxWidthRatio?: number; // (0, 1]
209
+ }[];
210
+ ```
211
+
212
+ - `title` stays required. It is the spoken label and the fallback text, so it
213
+ carries the same words as the runs; every renderer exposes `title`, not the
214
+ runs, to accessibility.
215
+ - Each run uses the title's font family, size and line height, including
216
+ `style.title` and the configured fonts. `tone` picks the primary or secondary
217
+ text color and defaults to the title color; `fontWeight` picks the weight and
218
+ defaults to the title weight.
219
+ - The runs share one line. `titleLines` and `style.title.lines` do not apply.
220
+ - Layout. The runs take the whole width the title line leaves after the
221
+ leading title icon and the title accessories (title icon, title image,
222
+ suffixes, badges), so those accessories sit at the line's end rather than
223
+ right after the text. W is that width:
224
+ 1. A run's base width is its natural width, capped at `maxWidthRatio x W`
225
+ when given. A run longer than its cap truncates at the cap with a tail
226
+ ellipsis.
227
+ 2. At most one run sets `shrink: true`. Every other run keeps its base width.
228
+ 3. The shrinking run gets `min(base, W - sum of the other runs)` and
229
+ truncates with a tail ellipsis; it may shrink to zero width.
230
+ 4. When the runs that do not shrink exceed W on their own, the line is cut at
231
+ its end. Native renderers end the last visible run with an ellipsis; Web
232
+ clips it. This divergence is accepted: callers avoid the case with
233
+ `maxWidthRatio`.
234
+ - Development validation rejects an empty or oversized list, blank or
235
+ oversized text (the 4096-code-unit text bound), unknown keys, tones or
236
+ weights, a ratio outside `(0, 1]`, more than one shrinking run, `titleMatch`
237
+ on the same row (its ranges index `title`), and
238
+ `presentation: 'walletSidebar'`. Production treats the runs as decoration:
239
+ an invalid list is dropped and the row shows `title`. Renderers draw `title`
240
+ instead of runs on a row that also has `titleMatch`, and ignore runs and the
241
+ leading icon with `presentation: 'walletSidebar'`.
242
+
243
+ `identity.titleLeadingIcon` is an optional `{ name, tintColor?,
244
+ accessibilityLabel? }`: a 16-point glyph before the title text or its runs,
245
+ with a 6-point gap, vertically centered on the title line. It keeps its 16
246
+ points: a long title, or its shrinking run, truncates first. The name comes from
247
+ the same closed icon set as `titleIcon`; an unmapped name leaves an empty
248
+ 16-point slot. `tintColor` is a hex `#RRGGBB` or `#RRGGBBAA` color and defaults
249
+ to the theme's `iconSubdued`. Without `accessibilityLabel` the glyph is hidden
250
+ from accessibility. It has no action of its own. Development validation
251
+ rejects unknown keys, a blank name, a malformed color and
252
+ `presentation: 'walletSidebar'`; production drops an invalid icon.
253
+
254
+ Both fields are identity patch fields. Omission in a full snapshot removes
255
+ them; omission in a patch keeps the current value. Rebind and recycle remove the
256
+ previous runs and glyph, so a row without them never shows another row's.
257
+
258
+ | Platform | Title runs | Leading icon |
259
+ | --- | --- | --- |
260
+ | iOS | `NativeListTitleSegmentsLabel` draws the runs at the widths that `NativeListTitleSegmentLayout.swift` allocates for its bounds; it fills the title row in place of the row's filler. | `NativeListIconImageView`, first in the title row stack. A plain title beside it drops below required horizontal compression resistance, as beside `titleIcon`, so the title truncates before the glyph narrows. |
261
+ | Android | `NativeListTitleSegmentsView`, a `TextView` whose text is rebuilt for each measured width from `NativeListTitleSegmentLayout.kt` and which measures to the width it is offered. `PackedTitleLineLayout.titleChild` names the flexible title view, so a view may precede it. | `OneKeyIconView`, first in the title line. |
262
+ | Web | A flex line that fills the title line: the shrinking run is `flex: 0 1 auto; min-width: 0`, the others `flex: 0 0 auto` with a `max-width` percentage for a cap. | An icon element before the title text. |
263
+
264
+ Conformance: `src/validation.ts` (`assertTitleSegments`,
265
+ `assertTitleLeadingIcon`, production sanitizing) with `validation.test.ts`;
266
+ `src/web/templates/IdentityRowRenderer.ts` with `IdentityTitleRunsWeb.test.ts`
267
+ (run styles, flex behavior, accessibility, rebind);
268
+ `ios/NativeListTitleSegmentLayout.swift` with the SwiftPM
269
+ `NativeListTitleSegmentLayoutTests`; the iOS leading glyph beside a long plain
270
+ title with the XCTest `NativeListIdentityTitleIconLayoutTests`;
271
+ `NativeListTitleSegmentLayout.kt` with the
272
+ JVM `NativeListTitleSegmentLayoutTest`. iOS, Android and Web runtime acceptance is pending: recipient rows
273
+ with long wallet and account names in both truncation directions, narrow rows,
274
+ dark mode, dynamic type and configured fonts, and recycling between rows with
275
+ and without runs or a leading icon. Source and build success alone are not
276
+ runtime evidence.
277
+
191
278
  ### DeFi position row controls
192
279
 
193
280
  Status: implemented in local source, unreleased. Focused validation and Web
@@ -81,7 +81,8 @@ final class NativeListIdentityCell: NativeListRendererCell {
81
81
  let title = NativeListResolvedText(
82
82
  item.data.string("title"), style: style.dictionary("title"), size: 16,
83
83
  weight: .medium, color: .black, lineHeight: 24,
84
- lines: item.data.int("titleLines", default: 1), fonts: fonts)
84
+ lines: item.data.dictionaries("titleSegments").isEmpty
85
+ ? item.data.int("titleLines", default: 1) : 1, fonts: fonts)
85
86
  let subtitle = NativeListResolvedText(
86
87
  item.data.string("subtitle"), style: style.dictionary("subtitle"), size: 14,
87
88
  color: .black, lineHeight: 20,
@@ -110,6 +111,12 @@ final class NativeListIdentityCell: NativeListRendererCell {
110
111
  private let titleRowStack = UIStackView()
111
112
  private let titleLabel = NativeListTextLabel()
112
113
  private let titleIconView = NativeListIconImageView()
114
+ private let titleLeadingIconView = NativeListIconImageView()
115
+ private let titleSegmentsLabel = NativeListTitleSegmentsLabel()
116
+ // Runs, when shown, take the title's place in the title row.
117
+ private var visibleTitleView: UIView {
118
+ titleSegmentsLabel.isHidden ? titleLabel : titleSegmentsLabel
119
+ }
113
120
  private var titleImageSlot: NativeListImageSlot?
114
121
  private var titleImageConstraints: [NSLayoutConstraint] = []
115
122
  // Absorbs the title row's slack so the title keeps its intrinsic width and the
@@ -170,7 +177,9 @@ final class NativeListIdentityCell: NativeListRendererCell {
170
177
  mainStack.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
171
178
  titleRowStack.axis = .horizontal
172
179
  titleRowStack.alignment = .center
180
+ titleRowStack.addArrangedSubview(titleLeadingIconView)
173
181
  titleRowStack.addArrangedSubview(titleLabel)
182
+ titleRowStack.addArrangedSubview(titleSegmentsLabel)
174
183
  titleRowStack.addArrangedSubview(titleIconView)
175
184
  titleRowStack.addArrangedSubview(badgeLabel)
176
185
  titleRowStack.addArrangedSubview(titleRowFiller)
@@ -185,6 +194,18 @@ final class NativeListIdentityCell: NativeListRendererCell {
185
194
  ])
186
195
  titleIconView.contentMode = .scaleAspectFit
187
196
  titleIconView.setContentHuggingPriority(.required, for: .horizontal)
197
+ titleLeadingIconView.translatesAutoresizingMaskIntoConstraints = false
198
+ let leadingIconWidth = titleLeadingIconView.widthAnchor.constraint(equalToConstant: 16)
199
+ leadingIconWidth.priority = UILayoutPriority(999)
200
+ NSLayoutConstraint.activate([
201
+ leadingIconWidth,
202
+ titleLeadingIconView.heightAnchor.constraint(equalToConstant: 16),
203
+ ])
204
+ titleLeadingIconView.contentMode = .scaleAspectFit
205
+ titleLeadingIconView.setContentHuggingPriority(.required, for: .horizontal)
206
+ titleLeadingIconView.setContentCompressionResistancePriority(.required, for: .horizontal)
207
+ titleLeadingIconView.isHidden = true
208
+ titleSegmentsLabel.isHidden = true
188
209
  [titleRowStack, subtitleLabel, tertiaryLabel].forEach(mainStack.addArrangedSubview)
189
210
  visual.translatesAutoresizingMaskIntoConstraints = false
190
211
  leadingWidth = visual.widthAnchor.constraint(equalToConstant: 40)
@@ -249,6 +270,15 @@ final class NativeListIdentityCell: NativeListRendererCell {
249
270
  titleIconView.isHidden = true
250
271
  titleIconView.accessibilityLabel = nil
251
272
  titleIconView.isAccessibilityElement = false
273
+ titleLeadingIconView.image = nil
274
+ titleLeadingIconView.isHidden = true
275
+ titleLeadingIconView.accessibilityLabel = nil
276
+ titleLeadingIconView.isAccessibilityElement = false
277
+ titleSegmentsLabel.isHidden = true
278
+ titleSegmentsLabel.reset()
279
+ titleRowFiller.isHidden = false
280
+ titleSegmentsLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
281
+ titleSegmentsLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
252
282
  titleImageSlot?.view.isHidden = true
253
283
  titleImageSlot?.view.accessibilityLabel = nil
254
284
  titleImageSlot?.view.isAccessibilityElement = false
@@ -310,7 +340,7 @@ final class NativeListIdentityCell: NativeListRendererCell {
310
340
  let label = titleIcon.string("accessibilityLabel")
311
341
  titleIconView.accessibilityLabel = label.isEmpty ? nil : label
312
342
  titleIconView.isAccessibilityElement = !label.isEmpty
313
- titleRowStack.setCustomSpacing(4, after: titleLabel)
343
+ titleRowStack.setCustomSpacing(4, after: visibleTitleView)
314
344
  // A long title truncates ahead of the 20pt glyph (Android caps the title
315
345
  // at row width minus accessories, Web shrinks the title first); with the
316
346
  // title at `.required` the icon's 999 width constraint collapsed to 0.
@@ -342,7 +372,7 @@ final class NativeListIdentityCell: NativeListRendererCell {
342
372
  ]
343
373
  titleImageConstraints.forEach { $0.priority = UILayoutPriority(999) }
344
374
  NSLayoutConstraint.activate(titleImageConstraints)
345
- titleRowStack.setCustomSpacing(4, after: titleIconView.isHidden ? titleLabel : titleIconView)
375
+ titleRowStack.setCustomSpacing(4, after: titleIconView.isHidden ? visibleTitleView : titleIconView)
346
376
  titleLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
347
377
  slot.bind(image, key: "\(item.key):titleImage", placeholder: "#00000000")
348
378
  } else {
@@ -385,7 +415,7 @@ final class NativeListIdentityCell: NativeListRendererCell {
385
415
  titleRowStack.setCustomSpacing(
386
416
  CGFloat(style.double("titleBadgeGap")),
387
417
  after: titleImageSlot?.view.isHidden == false ? titleImageSlot!.view
388
- : titleIconView.isHidden ? titleLabel : titleIconView)
418
+ : titleIconView.isHidden ? visibleTitleView : titleIconView)
389
419
  }
390
420
  }
391
421
  if style["trailingGap"] != nil { trailingStack.spacing = CGFloat(style.double("trailingGap")) }
@@ -399,6 +429,7 @@ final class NativeListIdentityCell: NativeListRendererCell {
399
429
  labels.forEach { NativeListTextStyles.applyStyledText($0, text, fonts: fonts) }
400
430
  }
401
431
  }
432
+ if !titleSegmentsLabel.isHidden { bindTitleRuns(item, theme: theme, style: style) }
402
433
  contentInsets = UIEdgeInsets(
403
434
  top: topInset, left: leftInset, bottom: -bottomInset, right: -rightInset)
404
435
  if root.axis == .horizontal {
@@ -464,6 +495,8 @@ final class NativeListIdentityCell: NativeListRendererCell {
464
495
  }
465
496
  override func recycleContent() {
466
497
  visual.recycle()
498
+ titleSegmentsLabel.reset()
499
+ titleLeadingIconView.image = nil
467
500
  titleImageSlot?.recycle()
468
501
  titleImageSlot?.view.isHidden = true
469
502
  titleImageSlot?.view.accessibilityLabel = nil
@@ -512,6 +545,30 @@ final class NativeListIdentityCell: NativeListRendererCell {
512
545
  visual.bind(
513
546
  descriptor, style: image, key: key, theme: currentTheme, isUnread: false, fonts: fonts)
514
547
  }
548
+ // Runs take the title's font, size and color unless they set their own tone
549
+ // or weight; the title label holds the final style by now.
550
+ private func bindTitleRuns(_ item: NativeListItem, theme: [String: Any]?, style: [String: Any]) {
551
+ let baseFont = titleLabel.font ?? nativeListFont(ofSize: 16, weight: .medium, fonts: fonts)
552
+ let baseColor = titleLabel.textColor ?? nativeListColor(theme, "primaryText", "#202020")
553
+ let runs = item.data.dictionaries("titleSegments").map { segment in
554
+ let weight = segment.string("fontWeight")
555
+ let tone = segment.string("tone")
556
+ return NativeListTitleSegmentsLabel.Run(
557
+ text: segment.string("text"),
558
+ font: weight.isEmpty
559
+ ? baseFont
560
+ : nativeListFont(
561
+ ofSize: baseFont.pointSize, weight: weight == "medium" ? .medium : .regular, fonts: fonts),
562
+ color: tone == "secondary"
563
+ ? nativeListColor(theme, "secondaryText", "#646464")
564
+ : tone == "primary" ? nativeListColor(theme, "primaryText", "#202020") : baseColor,
565
+ shrink: segment.bool("shrink"),
566
+ maxWidthRatio: (segment["maxWidthRatio"] as? NSNumber).map { CGFloat(truncating: $0) })
567
+ }
568
+ titleSegmentsLabel.lineHeight = CGFloat(
569
+ style.dictionary("title")?.double("lineHeight", default: 24) ?? 24)
570
+ titleSegmentsLabel.configure(runs, spokenText: item.data.string("title"))
571
+ }
515
572
  private func bindIdentity(
516
573
  _ item: NativeListItem,
517
574
  theme: [String: Any]?,
@@ -643,10 +700,36 @@ final class NativeListIdentityCell: NativeListRendererCell {
643
700
  }
644
701
  root.addArrangedSubview(mainStack)
645
702
  show(titleLabel, item.data.string("title"), lines: item.data.int("titleLines", default: 1))
703
+ // Runs replace the drawn title except where `title` itself is highlighted;
704
+ // their content is bound once the title style is final.
705
+ if !item.data.dictionaries("titleSegments").isEmpty,
706
+ item.data.dictionaries("titleMatch").isEmpty
707
+ {
708
+ titleLabel.isHidden = true
709
+ titleSegmentsLabel.isHidden = false
710
+ // The runs fill what the title row leaves, so a ratio is a share of it.
711
+ titleRowFiller.isHidden = true
712
+ titleSegmentsLabel.setContentHuggingPriority(UILayoutPriority(1), for: .horizontal)
713
+ }
714
+ if let icon = item.data.dictionary("titleLeadingIcon") {
715
+ titleLeadingIconView.bindIcon(icon.string("name"))
716
+ titleLeadingIconView.tintColor = UIColor(
717
+ nativeListHex: icon.string("tintColor",
718
+ default: theme?["iconSubdued"] as? String ?? "#8D8D8D"),
719
+ fallback: .gray)
720
+ titleLeadingIconView.isHidden = false
721
+ let label = icon.string("accessibilityLabel")
722
+ titleLeadingIconView.accessibilityLabel = label.isEmpty ? nil : label
723
+ titleLeadingIconView.isAccessibilityElement = !label.isEmpty
724
+ titleRowStack.setCustomSpacing(6, after: titleLeadingIconView)
725
+ // As beside `titleIcon`: a plain title left at `.required` collapses the
726
+ // glyph's 999 width constraint instead of truncating.
727
+ titleLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
728
+ }
646
729
  let titleSuffixes = item.data.dictionaries("titleSuffixes")
647
730
  if !titleSuffixes.isEmpty {
648
- titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
649
- let minimumTitleWidth = titleLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: 80)
731
+ visibleTitleView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
732
+ let minimumTitleWidth = visibleTitleView.widthAnchor.constraint(greaterThanOrEqualToConstant: 80)
650
733
  minimumTitleWidth.priority = UILayoutPriority(751)
651
734
  minimumTitleWidth.isActive = true
652
735
  selectorConstraints.append(minimumTitleWidth)