@onekeyfe/react-native-native-list 6.7.0-alpha.402 → 6.7.0-alpha.403

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
@@ -111,6 +111,9 @@ internal class PackedTitleLineLayout(context: android.content.Context) : LinearL
111
111
  var packsChildrenAtStart = false
112
112
  // OneKey patch: optional cap for the Market subtitle's localized name.
113
113
  var leadingTextMaxWidth = Int.MAX_VALUE
114
+ // The text view that takes what the other children leave; the first child
115
+ // when unset, so a glyph may precede the title once this names it.
116
+ var titleChild: TextView? = null
114
117
 
115
118
  override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
116
119
  if (!packsChildrenAtStart || childCount < 2) {
@@ -118,13 +121,13 @@ internal class PackedTitleLineLayout(context: android.content.Context) : LinearL
118
121
  return
119
122
  }
120
123
 
121
- val title = getChildAt(0)
124
+ val title = titleChild?.takeIf { it.parent === this } ?: getChildAt(0)
122
125
  val widthMode = MeasureSpec.getMode(widthMeasureSpec)
123
126
  val widthSize = MeasureSpec.getSize(widthMeasureSpec)
124
127
  var accessoryWidth = 0
125
- for (index in 1 until childCount) {
128
+ for (index in 0 until childCount) {
126
129
  val child = getChildAt(index)
127
- if (child.visibility == GONE) continue
130
+ if (child === title || child.visibility == GONE) continue
128
131
  measureChildWithMargins(
129
132
  child,
130
133
  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
@@ -101,6 +101,90 @@ and Web acceptance must cover narrow addresses, dynamic type/fonts, long press
101
101
  versus tap, keyboard interaction, and the horizontal warning border. Source or
102
102
  build success alone is not runtime evidence.
103
103
 
104
+ ### Identity title segments and leading title icon
105
+
106
+ Status: implemented in source for iOS, Android and Web; device and Web runtime
107
+ acceptance is pending. These fields travel in snapshot/patch JSON and do not
108
+ change the Nitro ABI. They serve recipient rows whose title is a wallet and an
109
+ account name in two styles, and address book rows marked by an icon before the
110
+ name.
111
+
112
+ `identity.titleSegments` is an optional list of 1...4 runs drawn on the title
113
+ line in place of `title`:
114
+
115
+ ```ts
116
+ titleSegments?: readonly {
117
+ text: string;
118
+ tone?: 'primary' | 'secondary';
119
+ fontWeight?: 'regular' | 'medium';
120
+ shrink?: boolean;
121
+ maxWidthRatio?: number; // (0, 1]
122
+ }[];
123
+ ```
124
+
125
+ - `title` stays required. It is the spoken label and the fallback text, so it
126
+ carries the same words as the runs; every renderer exposes `title`, not the
127
+ runs, to accessibility.
128
+ - Each run uses the title's font family, size and line height, including
129
+ `style.title` and the configured fonts. `tone` picks the primary or secondary
130
+ text color and defaults to the title color; `fontWeight` picks the weight and
131
+ defaults to the title weight.
132
+ - The runs share one line. `titleLines` and `style.title.lines` do not apply.
133
+ - Layout. The runs take the whole width the title line leaves after the
134
+ leading title icon and the title accessories (title icon, title image,
135
+ suffixes, badges), so those accessories sit at the line's end rather than
136
+ right after the text. W is that width:
137
+ 1. A run's base width is its natural width, capped at `maxWidthRatio x W`
138
+ when given. A run longer than its cap truncates at the cap with a tail
139
+ ellipsis.
140
+ 2. At most one run sets `shrink: true`. Every other run keeps its base width.
141
+ 3. The shrinking run gets `min(base, W - sum of the other runs)` and
142
+ truncates with a tail ellipsis; it may shrink to zero width.
143
+ 4. When the runs that do not shrink exceed W on their own, the line is cut at
144
+ its end. Native renderers end the last visible run with an ellipsis; Web
145
+ clips it. This divergence is accepted: callers avoid the case with
146
+ `maxWidthRatio`.
147
+ - Development validation rejects an empty or oversized list, blank or
148
+ oversized text (the 4096-code-unit text bound), unknown keys, tones or
149
+ weights, a ratio outside `(0, 1]`, more than one shrinking run, `titleMatch`
150
+ on the same row (its ranges index `title`), and
151
+ `presentation: 'walletSidebar'`. Production treats the runs as decoration:
152
+ an invalid list is dropped and the row shows `title`. Renderers draw `title`
153
+ instead of runs on a row that also has `titleMatch`, and ignore runs and the
154
+ leading icon with `presentation: 'walletSidebar'`.
155
+
156
+ `identity.titleLeadingIcon` is an optional `{ name, tintColor?,
157
+ accessibilityLabel? }`: a 16-point glyph before the title text or its runs,
158
+ with a 6-point gap, vertically centered on the title line. The name comes from
159
+ the same closed icon set as `titleIcon`; an unmapped name leaves an empty
160
+ 16-point slot. `tintColor` is a hex `#RRGGBB` or `#RRGGBBAA` color and defaults
161
+ to the theme's `iconSubdued`. Without `accessibilityLabel` the glyph is hidden
162
+ from accessibility. It has no action of its own. Development validation
163
+ rejects unknown keys, a blank name, a malformed color and
164
+ `presentation: 'walletSidebar'`; production drops an invalid icon.
165
+
166
+ Both fields are identity patch fields. Omission in a full snapshot removes
167
+ them; omission in a patch keeps the current value. Rebind and recycle remove the
168
+ previous runs and glyph, so a row without them never shows another row's.
169
+
170
+ | Platform | Title runs | Leading icon |
171
+ | --- | --- | --- |
172
+ | 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. | `UIImageView`, first in the title row stack. |
173
+ | 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. |
174
+ | 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. |
175
+
176
+ Conformance: `src/validation.ts` (`assertTitleSegments`,
177
+ `assertTitleLeadingIcon`, production sanitizing) with `validation.test.ts`;
178
+ `src/web/templates/IdentityRowRenderer.ts` with `IdentityTitleRunsWeb.test.ts`
179
+ (run styles, flex behavior, accessibility, rebind);
180
+ `ios/NativeListTitleSegmentLayout.swift` with the SwiftPM
181
+ `NativeListTitleSegmentLayoutTests`; `NativeListTitleSegmentLayout.kt` with the
182
+ JVM `NativeListTitleSegmentLayoutTest`. iOS, Android and Web runtime acceptance is pending: recipient rows
183
+ with long wallet and account names in both truncation directions, narrow rows,
184
+ dark mode, dynamic type and configured fonts, and recycling between rows with
185
+ and without runs or a leading icon. Source and build success alone are not
186
+ runtime evidence.
187
+
104
188
  ### DeFi position row controls
105
189
 
106
190
  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 = UIImageView()
114
+ private let titleLeadingIconView = UIImageView()
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)
@@ -246,6 +267,15 @@ final class NativeListIdentityCell: NativeListRendererCell {
246
267
  titleIconView.isHidden = true
247
268
  titleIconView.accessibilityLabel = nil
248
269
  titleIconView.isAccessibilityElement = false
270
+ titleLeadingIconView.image = nil
271
+ titleLeadingIconView.isHidden = true
272
+ titleLeadingIconView.accessibilityLabel = nil
273
+ titleLeadingIconView.isAccessibilityElement = false
274
+ titleSegmentsLabel.isHidden = true
275
+ titleSegmentsLabel.reset()
276
+ titleRowFiller.isHidden = false
277
+ titleSegmentsLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
278
+ titleSegmentsLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
249
279
  titleImageSlot?.view.isHidden = true
250
280
  titleImageSlot?.view.accessibilityLabel = nil
251
281
  titleImageSlot?.view.isAccessibilityElement = false
@@ -307,7 +337,7 @@ final class NativeListIdentityCell: NativeListRendererCell {
307
337
  let label = titleIcon.string("accessibilityLabel")
308
338
  titleIconView.accessibilityLabel = label.isEmpty ? nil : label
309
339
  titleIconView.isAccessibilityElement = !label.isEmpty
310
- titleRowStack.setCustomSpacing(4, after: titleLabel)
340
+ titleRowStack.setCustomSpacing(4, after: visibleTitleView)
311
341
  // A long title truncates ahead of the 20pt glyph (Android caps the title
312
342
  // at row width minus accessories, Web shrinks the title first); with the
313
343
  // title at `.required` the icon's 999 width constraint collapsed to 0.
@@ -339,7 +369,7 @@ final class NativeListIdentityCell: NativeListRendererCell {
339
369
  ]
340
370
  titleImageConstraints.forEach { $0.priority = UILayoutPriority(999) }
341
371
  NSLayoutConstraint.activate(titleImageConstraints)
342
- titleRowStack.setCustomSpacing(4, after: titleIconView.isHidden ? titleLabel : titleIconView)
372
+ titleRowStack.setCustomSpacing(4, after: titleIconView.isHidden ? visibleTitleView : titleIconView)
343
373
  titleLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
344
374
  slot.bind(image, key: "\(item.key):titleImage", placeholder: "#00000000")
345
375
  } else {
@@ -382,7 +412,7 @@ final class NativeListIdentityCell: NativeListRendererCell {
382
412
  titleRowStack.setCustomSpacing(
383
413
  CGFloat(style.double("titleBadgeGap")),
384
414
  after: titleImageSlot?.view.isHidden == false ? titleImageSlot!.view
385
- : titleIconView.isHidden ? titleLabel : titleIconView)
415
+ : titleIconView.isHidden ? visibleTitleView : titleIconView)
386
416
  }
387
417
  }
388
418
  if style["trailingGap"] != nil { trailingStack.spacing = CGFloat(style.double("trailingGap")) }
@@ -396,6 +426,7 @@ final class NativeListIdentityCell: NativeListRendererCell {
396
426
  labels.forEach { NativeListTextStyles.applyStyledText($0, text, fonts: fonts) }
397
427
  }
398
428
  }
429
+ if !titleSegmentsLabel.isHidden { bindTitleRuns(item, theme: theme, style: style) }
399
430
  contentInsets = UIEdgeInsets(
400
431
  top: topInset, left: leftInset, bottom: -bottomInset, right: -rightInset)
401
432
  if root.axis == .horizontal {
@@ -456,6 +487,8 @@ final class NativeListIdentityCell: NativeListRendererCell {
456
487
  }
457
488
  override func recycleContent() {
458
489
  visual.recycle()
490
+ titleSegmentsLabel.reset()
491
+ titleLeadingIconView.image = nil
459
492
  titleImageSlot?.recycle()
460
493
  titleImageSlot?.view.isHidden = true
461
494
  titleImageSlot?.view.accessibilityLabel = nil
@@ -504,6 +537,30 @@ final class NativeListIdentityCell: NativeListRendererCell {
504
537
  visual.bind(
505
538
  descriptor, style: image, key: key, theme: currentTheme, isUnread: false, fonts: fonts)
506
539
  }
540
+ // Runs take the title's font, size and color unless they set their own tone
541
+ // or weight; the title label holds the final style by now.
542
+ private func bindTitleRuns(_ item: NativeListItem, theme: [String: Any]?, style: [String: Any]) {
543
+ let baseFont = titleLabel.font ?? nativeListFont(ofSize: 16, weight: .medium, fonts: fonts)
544
+ let baseColor = titleLabel.textColor ?? nativeListColor(theme, "primaryText", "#202020")
545
+ let runs = item.data.dictionaries("titleSegments").map { segment in
546
+ let weight = segment.string("fontWeight")
547
+ let tone = segment.string("tone")
548
+ return NativeListTitleSegmentsLabel.Run(
549
+ text: segment.string("text"),
550
+ font: weight.isEmpty
551
+ ? baseFont
552
+ : nativeListFont(
553
+ ofSize: baseFont.pointSize, weight: weight == "medium" ? .medium : .regular, fonts: fonts),
554
+ color: tone == "secondary"
555
+ ? nativeListColor(theme, "secondaryText", "#646464")
556
+ : tone == "primary" ? nativeListColor(theme, "primaryText", "#202020") : baseColor,
557
+ shrink: segment.bool("shrink"),
558
+ maxWidthRatio: (segment["maxWidthRatio"] as? NSNumber).map { CGFloat(truncating: $0) })
559
+ }
560
+ titleSegmentsLabel.lineHeight = CGFloat(
561
+ style.dictionary("title")?.double("lineHeight", default: 24) ?? 24)
562
+ titleSegmentsLabel.configure(runs, spokenText: item.data.string("title"))
563
+ }
507
564
  private func bindIdentity(
508
565
  _ item: NativeListItem,
509
566
  theme: [String: Any]?,
@@ -641,10 +698,33 @@ final class NativeListIdentityCell: NativeListRendererCell {
641
698
  }
642
699
  root.addArrangedSubview(mainStack)
643
700
  show(titleLabel, item.data.string("title"), lines: item.data.int("titleLines", default: 1))
701
+ // Runs replace the drawn title except where `title` itself is highlighted;
702
+ // their content is bound once the title style is final.
703
+ if !item.data.dictionaries("titleSegments").isEmpty,
704
+ item.data.dictionaries("titleMatch").isEmpty
705
+ {
706
+ titleLabel.isHidden = true
707
+ titleSegmentsLabel.isHidden = false
708
+ // The runs fill what the title row leaves, so a ratio is a share of it.
709
+ titleRowFiller.isHidden = true
710
+ titleSegmentsLabel.setContentHuggingPriority(UILayoutPriority(1), for: .horizontal)
711
+ }
712
+ if let icon = item.data.dictionary("titleLeadingIcon") {
713
+ titleLeadingIconView.image = nativeListIcon(named: icon.string("name"))
714
+ titleLeadingIconView.tintColor = UIColor(
715
+ nativeListHex: icon.string("tintColor",
716
+ default: theme?["iconSubdued"] as? String ?? "#8D8D8D"),
717
+ fallback: .gray)
718
+ titleLeadingIconView.isHidden = false
719
+ let label = icon.string("accessibilityLabel")
720
+ titleLeadingIconView.accessibilityLabel = label.isEmpty ? nil : label
721
+ titleLeadingIconView.isAccessibilityElement = !label.isEmpty
722
+ titleRowStack.setCustomSpacing(6, after: titleLeadingIconView)
723
+ }
644
724
  let titleSuffixes = item.data.dictionaries("titleSuffixes")
645
725
  if !titleSuffixes.isEmpty {
646
- titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
647
- let minimumTitleWidth = titleLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: 80)
726
+ visibleTitleView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
727
+ let minimumTitleWidth = visibleTitleView.widthAnchor.constraint(greaterThanOrEqualToConstant: 80)
648
728
  minimumTitleWidth.priority = UILayoutPriority(751)
649
729
  minimumTitleWidth.isActive = true
650
730
  selectorConstraints.append(minimumTitleWidth)