@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.
- package/android/src/main/java/com/onekey/nativelist/NativeListIdentityRenderer.kt +82 -3
- package/android/src/main/java/com/onekey/nativelist/NativeListPrimitives.kt +6 -3
- package/android/src/main/java/com/onekey/nativelist/NativeListTitleSegmentLayout.kt +38 -0
- package/android/src/main/java/com/onekey/nativelist/NativeListTitleSegmentsView.kt +105 -0
- package/android/src/test/java/com/margelo/nitro/nativelist/NativeListTitleSegmentLayoutTest.kt +65 -0
- package/docs/ROW_TEMPLATES.md +8 -1
- package/docs/SPEC.md +84 -0
- package/ios/NativeListIdentityRenderer.swift +86 -6
- package/ios/NativeListTitleSegmentLayout.swift +32 -0
- package/ios/NativeListTitleSegmentsLabel.swift +80 -0
- package/lib/module/models.js +2 -0
- package/lib/module/validation.js +70 -4
- package/lib/module/web/NativeListWebEngine.js +1 -1
- package/lib/module/web/templates/IdentityRowRenderer.js +91 -0
- package/lib/typescript/src/models.d.ts +21 -1
- package/package.json +3 -3
- package/src/models.ts +23 -0
- package/src/validation.ts +120 -1
- package/src/web/NativeListWebEngine.ts +1 -1
- package/src/web/templates/IdentityRowRenderer.ts +138 -0
|
@@ -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
|
-
(
|
|
544
|
+
(0 until titleLine.childCount).sumOf { index ->
|
|
491
545
|
val view = titleLine.getChildAt(index)
|
|
492
|
-
if (view.visibility == GONE ||
|
|
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
|
|
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
|
+
}
|
package/android/src/test/java/com/margelo/nitro/nativelist/NativeListTitleSegmentLayoutTest.kt
ADDED
|
@@ -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
|
+
}
|
package/docs/ROW_TEMPLATES.md
CHANGED
|
@@ -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`,
|
|
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.
|
|
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:
|
|
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 ?
|
|
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 ?
|
|
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
|
-
|
|
647
|
-
let minimumTitleWidth =
|
|
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)
|