@onekeyfe/react-native-native-list 6.7.0-alpha.396 → 6.7.0-alpha.398

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.
Files changed (27) hide show
  1. package/android/src/main/java/com/onekey/nativelist/NativeListActivityRenderer.kt +23 -0
  2. package/android/src/main/java/com/onekey/nativelist/NativeListIdentityRenderer.kt +10 -13
  3. package/android/src/main/java/com/onekey/nativelist/NativeListLeadingVisual.kt +88 -19
  4. package/android/src/main/java/com/onekey/nativelist/NativeListModels.kt +91 -0
  5. package/android/src/main/java/com/onekey/nativelist/NativeListPrimitives.kt +29 -0
  6. package/android/src/test/java/com/margelo/nitro/nativelist/NativeListActivityHostClipPolicyTest.kt +29 -0
  7. package/android/src/test/java/com/margelo/nitro/nativelist/NativeListIdentityTrailingAxisTest.kt +36 -0
  8. package/android/src/test/java/com/margelo/nitro/nativelist/NativeListLeadingSlotFrameTest.kt +68 -0
  9. package/docs/SPEC.md +59 -2
  10. package/ios/NativeListDesignAssets.swift +6 -0
  11. package/ios/NativeListIdentityRenderer.swift +26 -1
  12. package/ios/NativeListLeadingVisual.swift +70 -8
  13. package/ios/Resources/NativeListIcons.xcassets/onekey_document2.imageset/Contents.json +1 -0
  14. package/ios/Resources/NativeListIcons.xcassets/onekey_document2.imageset/icon.svg +1 -0
  15. package/ios/Resources/NativeListIcons.xcassets/onekey_dot_ver_solid.imageset/Contents.json +1 -0
  16. package/ios/Resources/NativeListIcons.xcassets/onekey_dot_ver_solid.imageset/icon.svg +1 -0
  17. package/ios/Resources/NativeListIcons.xcassets/onekey_plus_large.imageset/Contents.json +1 -0
  18. package/ios/Resources/NativeListIcons.xcassets/onekey_plus_large.imageset/icon.svg +1 -0
  19. package/ios/Resources/NativeListIcons.xcassets/onekey_ticket.imageset/Contents.json +1 -0
  20. package/ios/Resources/NativeListIcons.xcassets/onekey_ticket.imageset/icon.svg +1 -0
  21. package/ios/Resources/NativeListIcons.xcassets/onekey_wallet.imageset/Contents.json +1 -0
  22. package/ios/Resources/NativeListIcons.xcassets/onekey_wallet.imageset/icon.svg +1 -0
  23. package/ios/tests/NativeListIdentityTitleIconLayoutTests.swift +81 -0
  24. package/ios/tests/NativeListLeadingVisualPairBackdropTests.swift +50 -0
  25. package/lib/module/web/NativeListWebEngine.js +46 -0
  26. package/package.json +3 -3
  27. package/src/web/NativeListWebEngine.ts +56 -0
@@ -54,6 +54,13 @@ internal class NativeListActivityRowView(context: ThemedReactContext, fonts: Nat
54
54
  details.onAction = { key, view, source, slot -> emitAction(key, view, source, slot) }
55
55
  }
56
56
 
57
+ /** Host clipping for the current bind; see [nativeListActivityHostClips]. */
58
+ private fun setHostClipping(clips: Boolean) {
59
+ content.clipChildren = clips
60
+ clipChildren = clips
61
+ clipToPadding = clips
62
+ }
63
+
57
64
  override fun defaultHeight(item: NativeListItem, layout: String) =
58
65
  if (item.json.has("amounts")) 0 else if ((item.json.optJSONArray("footerActions")?.length() ?: 0) > 0) 104 else 60
59
66
 
@@ -76,6 +83,10 @@ internal class NativeListActivityRowView(context: ThemedReactContext, fonts: Nat
76
83
  setPadding(hp, vp, hp, vp)
77
84
  visual.recycle()
78
85
  details.bind(item, theme, sourceScale)
86
+ // This view may be reused from a legacy row whose overhanging badge
87
+ // switched clipping off; a rich row has no badge on the host, so restore
88
+ // the defaults before returning.
89
+ setHostClipping(nativeListActivityHostClips(rich = true, leadingOverhangs = false))
79
90
  return
80
91
  }
81
92
  details.recycle()
@@ -157,6 +168,18 @@ internal class NativeListActivityRowView(context: ThemedReactContext, fonts: Nat
157
168
  sourceScale,
158
169
  data.optJSONObject("secondaryLeading"),
159
170
  )
171
+ // The token network badge hangs 4dp past the 40dp visual and Android clips a
172
+ // child to its own bounds while its parent clips children, so with the
173
+ // default clipping the history rows showed the badge cut flat (OK-66071).
174
+ // Stop clipping the content row and this row's padding exactly when the
175
+ // bound visual reports such a badge (SPEC "Leading badge overhang"), as the
176
+ // Identity renderer does.
177
+ setHostClipping(
178
+ nativeListActivityHostClips(
179
+ rich = false,
180
+ leadingOverhangs = !hideLeading && visual.requestsUnclippedHost,
181
+ )
182
+ )
160
183
  bind(
161
184
  title,
162
185
  data.optString("title"),
@@ -878,19 +878,16 @@ internal class NativeListIdentityRowView(context: ThemedReactContext, fonts: Nat
878
878
  sourceScale,
879
879
  checkboxState,
880
880
  horizontal =
881
- (0 until accessories.length())
882
- .map { accessories.getJSONObject(it).optString("kind") }
883
- .let { kinds ->
884
- kinds.containsAll(listOf("value", "checkbox")) ||
885
- kinds == listOf("valuePair", "menu") ||
886
- kinds == listOf("valuePair", "chevron") ||
887
- (kinds == listOf("icon", "chevron") &&
888
- accessories.getJSONObject(0).optString("name") == "RefreshCcwOutline" &&
889
- accessories.getJSONObject(0).optString("actionKey").isEmpty()) ||
890
- (kinds == listOf("icon", "icon") &&
891
- accessories.getJSONObject(0).optString("name") == "PencilOutline" &&
892
- accessories.getJSONObject(1).optString("name") == "DragOutline")
893
- },
881
+ nativeListIdentityTrailingIsHorizontal(
882
+ (0 until accessories.length()).map { index ->
883
+ val accessory = accessories.getJSONObject(index)
884
+ NativeListTrailingDescriptor(
885
+ kind = accessory.optString("kind"),
886
+ name = accessory.optString("name"),
887
+ actionKey = accessory.optString("actionKey"),
888
+ )
889
+ }
890
+ ),
894
891
  )
895
892
  trailingColumn.endInset?.let { setPadding(paddingLeft, paddingTop, it, paddingBottom) }
896
893
  }
@@ -23,6 +23,8 @@ internal class NativeListLeadingVisual(private val reactContext: ThemedReactCont
23
23
  private val icon = OneKeyIconView(context)
24
24
  private val unread = View(context)
25
25
  private val networkBackdrop = View(context)
26
+ // Ring under the secondary token of an Activity pair (SPEC "Activity leading pair").
27
+ private val pairBackdrop = View(context)
26
28
  private val corner = OneKeyIconView(context)
27
29
  private val cornerBackdrop = View(context)
28
30
  private val slots = mutableListOf<NativeListImageSlot>()
@@ -32,6 +34,10 @@ internal class NativeListLeadingVisual(private val reactContext: ThemedReactCont
32
34
  private var visual = JSONObject()
33
35
  private var style = JSONObject()
34
36
  private var sources = emptyList<Pair<JSONObject, String>>()
37
+ // Roles inside [sources]: the token `networkImage` badge and the Activity
38
+ // `secondaryLeading` token; null when absent.
39
+ private var networkIndex: Int? = null
40
+ private var pairIndex: Int? = null
35
41
  val fallbackTextView: TextView
36
42
  get() = fallback
37
43
 
@@ -90,7 +96,9 @@ internal class NativeListLeadingVisual(private val reactContext: ThemedReactCont
90
96
  }
91
97
 
92
98
  init {
93
- listOf(fallback, icon, networkBackdrop, cornerBackdrop, corner, unread).forEach { addView(it) }
99
+ listOf(fallback, icon, pairBackdrop, networkBackdrop, cornerBackdrop, corner, unread).forEach {
100
+ addView(it)
101
+ }
94
102
  clipChildren = false
95
103
  clipToPadding = false
96
104
  }
@@ -119,6 +127,8 @@ internal class NativeListLeadingVisual(private val reactContext: ThemedReactCont
119
127
  "wallet" -> "avatar"
120
128
  else -> "generic"
121
129
  }
130
+ networkIndex = null
131
+ pairIndex = null
122
132
  sources =
123
133
  if (kind == "stackedImages")
124
134
  visual
@@ -132,7 +142,10 @@ internal class NativeListLeadingVisual(private val reactContext: ThemedReactCont
132
142
  else if (kind == "icon") emptyList()
133
143
  else visual.optJSONObject("image")?.let { listOf(it to variant) }.orEmpty()
134
144
  if (kind == "token" && sources.isNotEmpty())
135
- visual.optJSONObject("networkImage")?.let { sources = sources + (it to "network") }
145
+ visual.optJSONObject("networkImage")?.let {
146
+ networkIndex = sources.size
147
+ sources = sources + (it to "network")
148
+ }
136
149
  secondaryVisual?.let { second ->
137
150
  val kind = second.optString("kind")
138
151
  val source =
@@ -141,7 +154,10 @@ internal class NativeListLeadingVisual(private val reactContext: ThemedReactCont
141
154
  "icon" -> null
142
155
  else -> second.optJSONObject("image")
143
156
  }
144
- if (source != null)
157
+ if (source != null) {
158
+ // A second token forms a pair only next to a primary image; without one
159
+ // it simply takes the primary's place (OK-66071).
160
+ if (this.visual.optString("kind") == "token" && sources.isNotEmpty()) pairIndex = sources.size
145
161
  sources =
146
162
  sources +
147
163
  (source to
@@ -152,6 +168,7 @@ internal class NativeListLeadingVisual(private val reactContext: ThemedReactCont
152
168
  "wallet" -> "avatar"
153
169
  else -> "generic"
154
170
  })
171
+ }
155
172
  }
156
173
  while (slots.size < sources.size) {
157
174
  val slot = NativeListImageSlot(reactContext)
@@ -228,6 +245,7 @@ internal class NativeListLeadingVisual(private val reactContext: ThemedReactCont
228
245
  ) { loaded ->
229
246
  slot.view.visibility =
230
247
  if (!loaded && (index > 0 || sourceFallback)) INVISIBLE else VISIBLE
248
+ if (index == pairIndex) syncPairBackdrop()
231
249
  if (index == 0 && sourceFallback) {
232
250
  fallbackKey?.let {
233
251
  if (loaded) NativeListSourceFallbackState.forget(it)
@@ -242,9 +260,21 @@ internal class NativeListLeadingVisual(private val reactContext: ThemedReactCont
242
260
  }
243
261
  }
244
262
  }
245
- networkBackdrop.visibility = if (kind == "token" && sources.size > 1) VISIBLE else GONE
263
+ networkBackdrop.visibility = if (networkIndex != null) VISIBLE else GONE
246
264
  networkBackdrop.background =
247
265
  fill(color(theme?.optString("rowBackground", "#FFFFFF") ?: "#FFFFFF"), dp(10).toFloat())
266
+ pairBackdrop.background =
267
+ fill(color(theme?.optString("rowBackground", "#FFFFFF") ?: "#FFFFFF"), 0f)
268
+ syncPairBackdrop()
269
+ // Stacking order by role: primary, pair ring, secondary token, badge
270
+ // backdrop, badge, corner icon. Slots are reused across binds with
271
+ // different roles, so the order is restored on every bind.
272
+ pairBackdrop.bringToFront()
273
+ pairIndex?.let { slots[it].view.bringToFront() }
274
+ networkBackdrop.bringToFront()
275
+ networkIndex?.let { slots[it].view.bringToFront() }
276
+ cornerBackdrop.bringToFront()
277
+ corner.bringToFront()
248
278
  val badge = visual.optJSONObject("cornerIcon")
249
279
  corner.visibility = if (badge == null) GONE else VISIBLE
250
280
  cornerBackdrop.visibility = corner.visibility
@@ -353,7 +383,7 @@ internal class NativeListLeadingVisual(private val reactContext: ThemedReactCont
353
383
  (fallbackGlyph.parent as? android.view.ViewGroup)?.removeView(fallbackGlyph)
354
384
  frame.addView(fallbackGlyph, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
355
385
  }
356
- if (frame.parent == null) addView(frame)
386
+ if (frame.parent == null) addView(frame) else frame.bringToFront()
357
387
  overlays.add(frame to data)
358
388
  }
359
389
  previousOverlays.drop(descriptors.size).forEach { (frame, _) ->
@@ -426,28 +456,46 @@ internal class NativeListLeadingVisual(private val reactContext: ThemedReactCont
426
456
  iconSize,
427
457
  iconSize,
428
458
  )
429
- val tokenPair = kind == "token" && sources.size > 1
459
+ val ring = dp(2)
430
460
  slots.take(sources.size).forEachIndexed { index, slot ->
431
- val size =
432
- if (tokenPair && index == 1) dp(16)
433
- else if (sources.size == 1 || tokenPair) h else (h * 0.72f).roundToInt()
434
- val x =
435
- if (tokenPair && index == 1) w - size + dp(2)
436
- else if (sources.size == 1 || tokenPair) 0
437
- else (index * (w - size).toFloat() / maxOf(1, sources.size - 1)).roundToInt()
438
- val y = if (tokenPair && index == 1) h - size + dp(2) else (h - size) / 2
439
- val imageWidth = if (sources.size == 1 || tokenPair && index == 0) w else size
461
+ val frame =
462
+ nativeListLeadingSlotFrame(
463
+ index = index,
464
+ sourceCount = sources.size,
465
+ networkIndex = networkIndex,
466
+ pairIndex = pairIndex,
467
+ width = w,
468
+ height = h,
469
+ badgeSize = dp(16),
470
+ badgeOverhang = dp(2),
471
+ pairRing = ring,
472
+ )
440
473
  slot.view.measure(
441
- MeasureSpec.makeMeasureSpec(imageWidth, MeasureSpec.EXACTLY),
442
- MeasureSpec.makeMeasureSpec(size, MeasureSpec.EXACTLY),
474
+ MeasureSpec.makeMeasureSpec(frame.width, MeasureSpec.EXACTLY),
475
+ MeasureSpec.makeMeasureSpec(frame.height, MeasureSpec.EXACTLY),
443
476
  )
444
477
  val inset = if (index == 0) bitmapBorderWidth else 0
445
- place(slot.view, x + inset, y + inset, imageWidth - 2 * inset, size - 2 * inset)
478
+ place(
479
+ slot.view,
480
+ frame.x + inset,
481
+ frame.y + inset,
482
+ frame.width - 2 * inset,
483
+ frame.height - 2 * inset,
484
+ )
446
485
  val imageRadius =
447
486
  if (shape == "rounded" && !style.has("shape") && !style.has("cornerRadius"))
448
487
  dp(roundedImageRadius).toFloat()
449
488
  else radius
450
- val r = if (index == 0 && (sources.size == 1 || tokenPair)) imageRadius else size / 2f
489
+ val fillsFrame = index == 0 && pairIndex == null && (sources.size == 1 || networkIndex != null)
490
+ val r =
491
+ when {
492
+ fillsFrame -> imageRadius
493
+ // Pair members keep the token shape at their own size (rounded NFTs
494
+ // use the box's 10dp rule scaled to the member).
495
+ pairIndex != null && index != networkIndex && shape == "rounded" ->
496
+ minOf(dp(roundedImageRadius).toFloat(), frame.height / 4f)
497
+ else -> frame.height / 2f
498
+ }
451
499
  slot.view.outlineProvider =
452
500
  object : ViewOutlineProvider() {
453
501
  override fun getOutline(view: View, outline: Outline) {
@@ -458,6 +506,14 @@ internal class NativeListLeadingVisual(private val reactContext: ThemedReactCont
458
506
  }
459
507
  slot.view.clipToOutline = true
460
508
  }
509
+ if (pairIndex != null) {
510
+ val member = nativeListLeadingPairMemberSize(h)
511
+ val box = member + 2 * ring
512
+ (pairBackdrop.background as? GradientDrawable)?.cornerRadius =
513
+ if (shape == "rounded") minOf(dp(roundedImageRadius).toFloat(), member / 4f) + ring
514
+ else box / 2f
515
+ place(pairBackdrop, w - box, h - box, box, box)
516
+ }
461
517
  place(networkBackdrop, w - dp(16), h - dp(16), dp(20), dp(20))
462
518
  place(cornerBackdrop, w - dp(16), h - dp(16), dp(20), dp(20))
463
519
  place(corner, w - dp(15), h - dp(15), dp(18), dp(18))
@@ -487,9 +543,22 @@ internal class NativeListLeadingVisual(private val reactContext: ThemedReactCont
487
543
  }
488
544
  }
489
545
 
546
+ /**
547
+ * The pair ring sits above the primary token, so it is shown only while the
548
+ * secondary token image is actually displayed: hidden while that image loads,
549
+ * after a failed load and once the slot is recycled, or it would cover the
550
+ * primary's lower-right corner with a blank row-background disc.
551
+ */
552
+ private fun syncPairBackdrop() {
553
+ val secondary = pairIndex?.let { slots.getOrNull(it) }
554
+ pairBackdrop.visibility =
555
+ if (secondary != null && secondary.view.visibility == VISIBLE) VISIBLE else GONE
556
+ }
557
+
490
558
  fun recycle() {
491
559
  slots.forEach { it.recycle() }
492
560
  overlaySlots.forEach { it.recycle() }
561
+ pairBackdrop.visibility = GONE
493
562
  }
494
563
 
495
564
  fun dispose() {
@@ -4,6 +4,7 @@ import org.json.JSONArray
4
4
  import org.json.JSONObject
5
5
  import java.nio.ByteBuffer
6
6
  import java.security.MessageDigest
7
+ import kotlin.math.roundToInt
7
8
  import java.util.Locale
8
9
 
9
10
  internal data class NativeSelectionTarget(val scope: String, val key: String?)
@@ -48,6 +49,96 @@ internal fun nativeListLeadingVisualOverhangsFrame(
48
49
  overlayCount: Int,
49
50
  ): Boolean = kind == "token" && sourceCount > 1 || hasCornerIcon || overlayCount > 0
50
51
 
52
+ /**
53
+ * Whether an Activity row host clips its children and padding for this bind.
54
+ * One row view serves both the legacy row and the rich (`amounts`) row and is
55
+ * reused across binds, so clipping is derived from the bound item alone and
56
+ * applied on every bind: only a legacy row whose leading visual lays a badge
57
+ * past its frame ([nativeListLeadingVisualOverhangsFrame], OK-66071) opts out;
58
+ * a rich row keeps Android's default clipping even when the view it reuses
59
+ * last showed such a badge.
60
+ */
61
+ internal fun nativeListActivityHostClips(rich: Boolean, leadingOverhangs: Boolean): Boolean =
62
+ rich || !leadingOverhangs
63
+
64
+ internal data class NativeListSlotFrame(val x: Int, val y: Int, val width: Int, val height: Int)
65
+
66
+ /** Side of one member of an Activity token pair inside a [frame]-unit box (24 in 40). */
67
+ internal fun nativeListLeadingPairMemberSize(frame: Int): Int = (frame * 0.6f).roundToInt()
68
+
69
+ /**
70
+ * Frame of image source [index] of a leading visual inside its [width]×[height]
71
+ * box (SPEC "Activity leading pair", OK-66071). [networkIndex] is the token
72
+ * `networkImage` badge and [pairIndex] the Activity `secondaryLeading` token;
73
+ * either is null when absent. A pair draws the primary at the top-left and the
74
+ * secondary at the bottom-right, both [nativeListLeadingPairMemberSize] wide and
75
+ * the secondary inset by its [pairRing]; the badge keeps its corner geometry
76
+ * above them. A lone token, or a token with only a badge, fills the box. Any
77
+ * other multi-source visual (stackedImages) steps 0.72-sized images diagonally.
78
+ */
79
+ internal fun nativeListLeadingSlotFrame(
80
+ index: Int,
81
+ sourceCount: Int,
82
+ networkIndex: Int?,
83
+ pairIndex: Int?,
84
+ width: Int,
85
+ height: Int,
86
+ badgeSize: Int,
87
+ badgeOverhang: Int,
88
+ pairRing: Int,
89
+ ): NativeListSlotFrame {
90
+ if (index == networkIndex)
91
+ return NativeListSlotFrame(
92
+ width - badgeSize + badgeOverhang,
93
+ height - badgeSize + badgeOverhang,
94
+ badgeSize,
95
+ badgeSize,
96
+ )
97
+ if (pairIndex != null) {
98
+ val member = nativeListLeadingPairMemberSize(height)
99
+ return if (index == pairIndex)
100
+ NativeListSlotFrame(width - member - pairRing, height - member - pairRing, member, member)
101
+ else NativeListSlotFrame(0, 0, member, member)
102
+ }
103
+ if (sourceCount == 1 || networkIndex != null) return NativeListSlotFrame(0, 0, width, height)
104
+ val side = (height * 0.72f).roundToInt()
105
+ val x = (index * (width - side).toFloat() / maxOf(1, sourceCount - 1)).roundToInt()
106
+ return NativeListSlotFrame(x, (height - side) / 2, side, side)
107
+ }
108
+
109
+ internal data class NativeListTrailingDescriptor(
110
+ val kind: String,
111
+ val name: String = "",
112
+ val actionKey: String = "",
113
+ )
114
+
115
+ /**
116
+ * Identity trailing accessories stack vertically unless they form one of the
117
+ * pairs every platform lays out side by side. The `valuePair` + `DotVerSolid`
118
+ * menu icon pair (the Swap token selector's balance and row menu) matches
119
+ * iOS `configureTrailingStack`; stacking it put the menu under the balance and
120
+ * clipped both inside the fixed row height on Android only.
121
+ */
122
+ internal fun nativeListIdentityTrailingIsHorizontal(
123
+ accessories: List<NativeListTrailingDescriptor>,
124
+ ): Boolean {
125
+ val kinds = accessories.map { it.kind }
126
+ if (kinds.containsAll(listOf("value", "checkbox"))) return true
127
+ if (accessories.size != 2) return false
128
+ val (first, second) = accessories
129
+ return kinds == listOf("valuePair", "menu") ||
130
+ kinds == listOf("valuePair", "chevron") ||
131
+ (kinds == listOf("valuePair", "icon") &&
132
+ second.name == "DotVerSolid" &&
133
+ second.actionKey == "menu") ||
134
+ (kinds == listOf("icon", "chevron") &&
135
+ first.name == "RefreshCcwOutline" &&
136
+ first.actionKey.isEmpty()) ||
137
+ (kinds == listOf("icon", "icon") &&
138
+ first.name == "PencilOutline" &&
139
+ second.name == "DragOutline")
140
+ }
141
+
51
142
  internal fun resolveNativeListSelectorBackgroundColor(
52
143
  rowType: String,
53
144
  styleBackgroundColor: String?,
@@ -287,6 +287,12 @@ internal class OneKeyIconView(context: android.content.Context) : View(context)
287
287
  // use EVEN_ODD. Other existing icons retain their prior rendering behavior.
288
288
  private val actionIconFillTypes =
289
289
  mapOf(
290
+ // Asset-list icons the app passes by name (OK-66072 audit): absent entries drew nothing.
291
+ "DotVerSolid" to listOf(Path.FillType.WINDING),
292
+ "Document2Outline" to listOf(Path.FillType.WINDING, Path.FillType.EVEN_ODD),
293
+ "WalletOutline" to listOf(Path.FillType.WINDING, Path.FillType.EVEN_ODD),
294
+ "PlusLargeOutline" to listOf(Path.FillType.WINDING),
295
+ "TicketOutline" to listOf(Path.FillType.WINDING, Path.FillType.EVEN_ODD),
290
296
  "GlobusOutline" to listOf(Path.FillType.EVEN_ODD),
291
297
  "GasSolid" to listOf(Path.FillType.EVEN_ODD),
292
298
  "RefreshCcwOutline" to listOf(Path.FillType.WINDING),
@@ -333,6 +339,29 @@ internal class OneKeyIconView(context: android.content.Context) : View(context)
333
339
  // Exact 24x24 paths from app-monorepo packages/components Icon sources.
334
340
  private val iconPaths =
335
341
  mapOf(
342
+ "DotVerSolid" to
343
+ listOf(
344
+ "M14 18v4h-4v-4zm0-4h-4v-4h4zm0-8h-4V2h4z"
345
+ ),
346
+ "Document2Outline" to
347
+ listOf(
348
+ "M12 16H8v-2h4zm4-4H8v-2h8zm0-4H8V6h8z",
349
+ "M20 22H4V2h16zM6 20h12V4H6z"
350
+ ),
351
+ "WalletOutline" to
352
+ listOf(
353
+ "M15.5 13.25a1.25 1.25 0 1 1 0 2.5 1.25 1.25 0 0 1 0-2.5",
354
+ "M17 8h4v13H7a4 4 0 0 1-4-4V6.5A3.5 3.5 0 0 1 6.5 3H17zM5 17a2 2 0 0 0 2 2h12v-9H6.5c-.537 0-1.045-.12-1.5-.337zM6.5 5a1.5 1.5 0 1 0 0 3H15V5z"
355
+ ),
356
+ "PlusLargeOutline" to
357
+ listOf(
358
+ "M13 11h8v2h-8v8h-2v-8H3v-2h8V3h2z"
359
+ ),
360
+ "TicketOutline" to
361
+ listOf(
362
+ "M16 17.01h-2V15h2zm0-4h-2V11h2zm0-4h-2V7h2z",
363
+ "m22 9.927-.629.252c-1.661.665-1.661 2.977 0 3.642l.629.252V20H2v-5.927l.629-.252c1.661-.665 1.661-2.977 0-3.642L2 9.927V4h20zM4 8.636c2.5 1.52 2.499 5.207 0 6.727V18h16v-2.637c-2.499-1.52-2.5-5.208 0-6.727V6H4z"
364
+ ),
336
365
  "RefreshCcwOutline" to
337
366
  listOf(
338
367
  "M5 12a7 7 0 0 0 7 7 7.15 7.15 0 0 0 4.955-2h-2.943v-2h6v6h-2v-2.257A9.148 9.148 0 0 1 12 21a9 9 0 0 1-9-9v-1h2v1Zm1-6.754A9.148 9.148 0 0 1 12 3a9 9 0 0 1 9 9v1h-2v-1a7 7 0 0 0-7-7 7.15 7.15 0 0 0-4.955 2H10v2H4V3h2v2.246Z"
@@ -0,0 +1,29 @@
1
+ package com.margelo.nitro.nativelist
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Test
5
+
6
+ class NativeListActivityHostClipPolicyTest {
7
+ // One Activity row view serves legacy and rich rows and is reused across
8
+ // binds. A legacy row with an overhanging network badge switches the host's
9
+ // clipping off (OK-66071); the next bind must derive clipping afresh, so a
10
+ // rich row reusing that view clips again instead of drawing its details over
11
+ // a neighbouring row (PR #185 review).
12
+ @Test
13
+ fun rebindSequenceDerivesClippingFromEachBoundItem() {
14
+ data class Bind(val name: String, val rich: Boolean, val leadingOverhangs: Boolean, val clips: Boolean)
15
+
16
+ val binds = listOf(
17
+ Bind("legacy row, plain token", rich = false, leadingOverhangs = false, clips = true),
18
+ Bind("legacy row, network badge", rich = false, leadingOverhangs = true, clips = false),
19
+ Bind("rich row reusing the badge row's view", rich = true, leadingOverhangs = false, clips = true),
20
+ Bind("legacy row, badge again", rich = false, leadingOverhangs = true, clips = false),
21
+ Bind("legacy row, hidden leading", rich = false, leadingOverhangs = false, clips = true),
22
+ )
23
+
24
+ assertEquals(
25
+ binds.map(Bind::clips),
26
+ binds.map { nativeListActivityHostClips(rich = it.rich, leadingOverhangs = it.leadingOverhangs) },
27
+ )
28
+ }
29
+ }
@@ -0,0 +1,36 @@
1
+ package com.margelo.nitro.nativelist
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Test
5
+
6
+ class NativeListIdentityTrailingAxisTest {
7
+ // Identity trailing accessories stack vertically except for the pairs iOS
8
+ // lays out side by side. The Swap token selector's `valuePair` + DotVerSolid
9
+ // menu icon was missing from the Android list, so the balance was pushed
10
+ // above the menu and clipped inside the 60dp row.
11
+ @Test
12
+ fun matchesTheSideBySidePairsOfIos() {
13
+ fun d(kind: String, name: String = "", actionKey: String = "") =
14
+ NativeListTrailingDescriptor(kind, name, actionKey)
15
+
16
+ val cases: List<Pair<List<NativeListTrailingDescriptor>, Boolean>> = listOf(
17
+ listOf(d("valuePair")) to false,
18
+ listOf(d("valuePair"), d("menu", actionKey = "menu")) to true,
19
+ listOf(d("valuePair"), d("icon", "DotVerSolid", "menu")) to true,
20
+ listOf(d("valuePair"), d("icon", "DotVerSolid")) to false,
21
+ listOf(d("valuePair"), d("icon", "InfoCircleOutline", "menu")) to false,
22
+ listOf(d("icon", "DotVerSolid", "menu"), d("valuePair")) to false,
23
+ listOf(d("valuePair"), d("chevron")) to true,
24
+ listOf(d("icon", "RefreshCcwOutline"), d("chevron")) to true,
25
+ listOf(d("icon", "RefreshCcwOutline", "refresh"), d("chevron")) to false,
26
+ listOf(d("icon", "PencilOutline", "edit"), d("icon", "DragOutline")) to true,
27
+ listOf(d("value"), d("checkbox")) to true,
28
+ listOf(d("valuePair"), d("icon", "DotVerSolid", "menu"), d("chevron")) to false,
29
+ )
30
+
31
+ assertEquals(
32
+ cases.map { it.second },
33
+ cases.map { nativeListIdentityTrailingIsHorizontal(it.first) },
34
+ )
35
+ }
36
+ }
@@ -0,0 +1,68 @@
1
+ package com.margelo.nitro.nativelist
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Test
5
+
6
+ class NativeListLeadingSlotFrameTest {
7
+ // 40dp box, 16dp badge hanging 2dp past the frame, 2dp pair ring.
8
+ private fun frame(index: Int, count: Int, network: Int? = null, pair: Int? = null) =
9
+ nativeListLeadingSlotFrame(
10
+ index = index,
11
+ sourceCount = count,
12
+ networkIndex = network,
13
+ pairIndex = pair,
14
+ width = 40,
15
+ height = 40,
16
+ badgeSize = 16,
17
+ badgeOverhang = 2,
18
+ pairRing = 2,
19
+ )
20
+
21
+ private val full = NativeListSlotFrame(0, 0, 40, 40)
22
+ private val badge = NativeListSlotFrame(26, 26, 16, 16)
23
+ private val primary = NativeListSlotFrame(0, 0, 24, 24)
24
+ private val secondary = NativeListSlotFrame(14, 14, 24, 24)
25
+
26
+ @Test
27
+ fun loneTokenFillsTheBox() {
28
+ assertEquals(full, frame(0, 1))
29
+ }
30
+
31
+ @Test
32
+ fun tokenWithNetworkBadgeKeepsTheCornerBadge() {
33
+ assertEquals(listOf(full, badge), listOf(frame(0, 2, network = 1), frame(1, 2, network = 1)))
34
+ }
35
+
36
+ // OK-66071: the history swap rows bind [send, network, receive]. Android laid
37
+ // the receive token over the whole box, hiding the send token and the network
38
+ // badge; iOS drew it in the badge's corner slot over the badge.
39
+ @Test
40
+ fun activityPairStepsTheSecondaryToTheBottomRightUnderTheBadge() {
41
+ assertEquals(
42
+ listOf(primary, badge, secondary),
43
+ (0 until 3).map { frame(it, 3, network = 1, pair = 2) },
44
+ )
45
+ }
46
+
47
+ @Test
48
+ fun activityPairWithoutNetworkBadge() {
49
+ assertEquals(listOf(primary, secondary), listOf(frame(0, 2, pair = 1), frame(1, 2, pair = 1)))
50
+ }
51
+
52
+ @Test
53
+ fun pairMemberIsSixTenthsOfTheBox() {
54
+ assertEquals(listOf(24, 19, 29), listOf(40, 32, 48).map(::nativeListLeadingPairMemberSize))
55
+ }
56
+
57
+ @Test
58
+ fun stackedImagesStepDiagonally() {
59
+ assertEquals(
60
+ listOf(
61
+ NativeListSlotFrame(0, 5, 29, 29),
62
+ NativeListSlotFrame(6, 5, 29, 29),
63
+ NativeListSlotFrame(11, 5, 29, 29),
64
+ ),
65
+ (0 until 3).map { frame(it, 3) },
66
+ )
67
+ }
68
+ }
package/docs/SPEC.md CHANGED
@@ -51,6 +51,24 @@ ordinary press for that gesture. Motion cancellation, row rebind/recycle,
51
51
  disabled state and disposal cancel pending recognition. Trailing accessory
52
52
  actions remain independent. Omission preserves existing tap behavior.
53
53
 
54
+ Identity trailing accessories stack vertically, except for the pairs every
55
+ platform lays out side by side: `value` + `checkbox`, `valuePair` + `menu`,
56
+ `valuePair` + an `icon` named `DotVerSolid` whose `actionKey` is `menu`,
57
+ `valuePair` + `chevron`, a `RefreshCcwOutline` icon without an action key +
58
+ `chevron`, and `PencilOutline` + `DragOutline` icons. iOS decides this in
59
+ `configureTrailingStack`; Android in `nativeListIdentityTrailingIsHorizontal`
60
+ (JVM test `NativeListIdentityTrailingAxisTest`). The `valuePair` + menu-icon
61
+ pair is the Swap token selector's balance column and row menu; stacking it
62
+ clipped both inside the fixed 60dp row on Android.
63
+
64
+ Icon names are a closed set drawn from the app's 24-unit Icon sources on every
65
+ platform (`iconPaths` on Android, `NativeListIcons.xcassets` on iOS,
66
+ `selectorIcons` on Web); an unmapped name renders an empty slot rather than a
67
+ placeholder. The set includes every name the app's NativeList builders pass,
68
+ including `DotVerSolid`, `Document2Outline`, `WalletOutline`, `PlusLargeOutline`
69
+ and `TicketOutline` (added 2026-10-30 after the OK-66072 audit found them missing
70
+ on native).
71
+
54
72
  A `message` row may opt into `bodyLines: 0`; its complete body wraps within its
55
73
  existing text area and determines natural height unless an explicit row or
56
74
  container height overrides it. Existing omitted and one-to-three-line behavior
@@ -164,7 +182,11 @@ Status: implemented in source for local consumers, unreleased. Focused validatio
164
182
  Web DOM tests, native builds and device acceptance are tracked separately below.
165
183
  The optional `identity.titleIcon` is a static, non-actionable icon after the
166
184
  title: a 20-point glyph with a 4-point gap, optional tint and accessibility
167
- label. Its optional Web-only `hoverActionKey` emits an anchored help action
185
+ label. "After the title" means directly after the title text: the title row
186
+ packs at its start on every platform (Android `PackedTitleLineLayout`, Web flex
187
+ `0 1 auto`, iOS a slack-absorbing filler at the end of the title row), so the
188
+ glyph and any badge follow the text and never drift toward the trailing column
189
+ (OK-66041). Its optional Web-only `hoverActionKey` emits an anchored help action
168
190
  from the icon alone on mouse/pen hover or touch press; the consumer owns the
169
191
  tooltip. Mouse/pen press closes the anchor until pointer leave. Touch press
170
192
  toggles the icon anchor. The icon adds no tab stop or separate click action;
@@ -571,7 +593,8 @@ an action-less accessory as a `span`, so its click reaches the row.
571
593
  ### Leading badge overhang (OK-65694)
572
594
 
573
595
  Status: implemented on Android, the only platform that clipped. Android JVM
574
- policy test (`NativeListLeadingVisualClipPolicyTest`); Android emulator
596
+ policy tests (`NativeListLeadingVisualClipPolicyTest`,
597
+ `NativeListActivityHostClipPolicyTest`); Android emulator
575
598
  acceptance on the example account selector (API 36, 2026-10-27) is recorded in
576
599
  the changelog entry. iOS and Web are unchanged.
577
600
 
@@ -590,6 +613,12 @@ overhanging badge (`requestsUnclippedHost`, decided by
590
613
  already do for their network badges; a visual without such a badge keeps the
591
614
  row's default clipping.
592
615
 
616
+ Host clipping is per-bind state. A row view that serves several row shapes
617
+ (the Android Activity view serves the legacy row and the rich `amounts` row) is
618
+ reused across binds, so each bind MUST derive clipping from the item it binds
619
+ (`nativeListActivityHostClips`): a view that last showed an overhanging badge
620
+ clips again when it is rebound to a row without one.
621
+
593
622
  The row's own shape still clips on every platform: iOS masks the cell to its
594
623
  corner radius (`masksToBounds`), Web keeps the row `overflow: hidden`, and
595
624
  Android keeps `clipToOutline` for a `style.container.cornerRadius` row (its
@@ -601,6 +630,34 @@ padding. The example `NativeListRowStylePage` row `identity-badge-rounded`
601
630
  combines both badges with `container.cornerRadius` as the rendering check for
602
631
  that combination.
603
632
 
633
+ ### Activity leading pair (OK-66071)
634
+
635
+ Status: implemented on iOS and Android; Web already drew the pair. Android JVM
636
+ geometry test (`NativeListLeadingSlotFrameTest`); Android emulator acceptance on
637
+ the example row-style page is recorded in the changelog entry.
638
+
639
+ An Activity row whose `leading` and `secondaryLeading` are both tokens with an
640
+ image (the app's swap and multi-asset history rows) draws them as a pair inside
641
+ the 40-unit visual box, as the legacy avatar and the Web renderer do: the
642
+ primary token at the top-left at 0.6 of the box (24 units), the secondary at the
643
+ same size at the bottom-right inside a 2-unit ring in the row background, above
644
+ the primary. The ring is shown only while the secondary image is displayed:
645
+ while that image loads, after a failed load and after recycling it stays hidden,
646
+ so it never covers the primary's corner with a blank disc. The token `networkImage` badge, when present, keeps its single-token
647
+ geometry at the box's bottom-right corner above the secondary; a pair shows one
648
+ badge, never one per member. A secondary token without an image leaves the
649
+ primary alone at full size, and a primary without an image lets the secondary
650
+ take its place. Before this rule Android laid the secondary over the whole box
651
+ (hiding the primary and the badge) and iOS drew it in the badge's corner slot
652
+ over the badge. The per-source geometry is one pure function on Android
653
+ (`nativeListLeadingSlotFrame`) and the same arithmetic in
654
+ `NativeListLeadingVisual.layoutSubviews` on iOS; both restore the stacking
655
+ order by role on every bind because slots are reused across roles.
656
+
657
+ The Android Activity renderer also applies the badge-overhang rule above
658
+ (`requestsUnclippedHost` stops the content row and the row padding clipping),
659
+ so the history rows' network badges render whole instead of cut flat.
660
+
604
661
  ## Failures, fallback and limits
605
662
 
606
663
  Snapshot validation rejects malformed/duplicate-key input without partial
@@ -139,6 +139,12 @@ func nativeListIcon(named name: String) -> UIImage? {
139
139
  case "ErrorSolid": assetName = "onekey_error_solid"
140
140
  case "QuestionmarkSolid": assetName = "onekey_questionmark_solid"
141
141
  case "ImageSquareWavesOutline": assetName = "onekey_image_square_waves"
142
+ // Asset-list icons the app passes by name (OK-66072 audit): unmapped names rendered nothing.
143
+ case "DotVerSolid": assetName = "onekey_dot_ver_solid"
144
+ case "Document2Outline": assetName = "onekey_document2"
145
+ case "WalletOutline": assetName = "onekey_wallet"
146
+ case "PlusLargeOutline": assetName = "onekey_plus_large"
147
+ case "TicketOutline": assetName = "onekey_ticket"
142
148
  default: return nil
143
149
  }
144
150
  return UIImage(named: assetName, in: NativeListResources.bundle, compatibleWith: nil)?
@@ -107,6 +107,10 @@ final class NativeListIdentityCell: NativeListRendererCell {
107
107
  private let titleRowStack = UIStackView()
108
108
  private let titleLabel = NativeListTextLabel()
109
109
  private let titleIconView = UIImageView()
110
+ // Absorbs the title row's slack so the title keeps its intrinsic width and the
111
+ // title icon / badge follow the text (OK-66041); Android's PackedTitleLineLayout
112
+ // and the Web flex row already pack the title row at its start.
113
+ private let titleRowFiller = UIView()
110
114
  private let subtitleLabel = NativeListTextLabel()
111
115
  private let tertiaryLabel = NativeListTextLabel()
112
116
  private let badgeLabel = NativeListInsetLabel()
@@ -164,6 +168,9 @@ final class NativeListIdentityCell: NativeListRendererCell {
164
168
  titleRowStack.addArrangedSubview(titleLabel)
165
169
  titleRowStack.addArrangedSubview(titleIconView)
166
170
  titleRowStack.addArrangedSubview(badgeLabel)
171
+ titleRowStack.addArrangedSubview(titleRowFiller)
172
+ titleRowFiller.setContentHuggingPriority(UILayoutPriority(1), for: .horizontal)
173
+ titleRowFiller.setContentCompressionResistancePriority(UILayoutPriority(1), for: .horizontal)
167
174
  titleIconView.translatesAutoresizingMaskIntoConstraints = false
168
175
  let titleIconWidth = titleIconView.widthAnchor.constraint(equalToConstant: 20)
169
176
  titleIconWidth.priority = UILayoutPriority(999)
@@ -291,6 +298,10 @@ final class NativeListIdentityCell: NativeListRendererCell {
291
298
  titleIconView.accessibilityLabel = label.isEmpty ? nil : label
292
299
  titleIconView.isAccessibilityElement = !label.isEmpty
293
300
  titleRowStack.setCustomSpacing(4, after: titleLabel)
301
+ // A long title truncates ahead of the 20pt glyph (Android caps the title
302
+ // at row width minus accessories, Web shrinks the title first); with the
303
+ // title at `.required` the icon's 999 width constraint collapsed to 0.
304
+ titleLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
294
305
  }
295
306
  if let ratio = style["leadingContentMaxWidthRatio"] as? NSNumber,
296
307
  root.arrangedSubviews.contains(visual)
@@ -351,8 +362,19 @@ final class NativeListIdentityCell: NativeListRendererCell {
351
362
  : style.dictionary("container")?.string("contentVerticalAlignment") == "bottom"
352
363
  ? .bottom : .center
353
364
  }
365
+ settleTitleRowFillerSpacing()
354
366
  applySelectorTypography(item)
355
367
  }
368
+
369
+ /// The filler is the title row's last arranged view; the stack's default
370
+ /// spacing before it would shave 8pt off the title's available width (visible
371
+ /// under `leadingContentMaxWidthRatio`), so the gap before the filler is zero.
372
+ private func settleTitleRowFillerSpacing() {
373
+ let visible = titleRowStack.arrangedSubviews.filter { $0 !== titleRowFiller && !$0.isHidden }
374
+ if let last = visible.last {
375
+ titleRowStack.setCustomSpacing(0, after: last)
376
+ }
377
+ }
356
378
  override func bindSelectionContent(
357
379
  _ item: NativeListItem,
358
380
  checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String
@@ -593,7 +615,10 @@ final class NativeListIdentityCell: NativeListRendererCell {
593
615
  limit.isActive = true
594
616
  selectorConstraints.append(limit)
595
617
  }
596
- titleRowStack.insertArrangedSubview(label, at: titleRowStack.arrangedSubviews.count - 1)
618
+ titleRowStack.insertArrangedSubview(
619
+ label,
620
+ at: titleRowStack.arrangedSubviews.firstIndex(of: badgeLabel)
621
+ ?? titleRowStack.arrangedSubviews.count)
597
622
  selectorViews.append(label)
598
623
  }
599
624
  }
@@ -17,6 +17,8 @@ final class NativeListLeadingVisual: UIView {
17
17
  private let icon = UIImageView()
18
18
  private let unread = UIView()
19
19
  private let networkBackdrop = UIView()
20
+ // Ring under the secondary token of an Activity pair (SPEC "Activity leading pair").
21
+ private let pairBackdrop = UIView()
20
22
  private let corner = UIImageView()
21
23
  private let cornerBackdrop = UIView()
22
24
  private var slots: [NativeListImageSlot] = []
@@ -27,10 +29,14 @@ final class NativeListLeadingVisual: UIView {
27
29
  private var visual: [String: Any] = [:]
28
30
  private var imageStyle: [String: Any] = [:]
29
31
  private var sources: [([String: Any], String)] = []
32
+ // Roles inside `sources`: the token `networkImage` badge and the Activity
33
+ // `secondaryLeading` token; nil when absent.
34
+ private var networkIndex: Int?
35
+ private var pairIndex: Int?
30
36
 
31
37
  override init(frame: CGRect) {
32
38
  super.init(frame: frame)
33
- [fallback, icon, networkBackdrop, cornerBackdrop, corner, unread].forEach(addSubview)
39
+ [fallback, icon, pairBackdrop, networkBackdrop, cornerBackdrop, corner, unread].forEach(addSubview)
34
40
  fallback.textAlignment = .center
35
41
  fallback.font = nativeListFont(ofSize: 13, weight: .bold, fonts: fonts)
36
42
  icon.contentMode = .scaleAspectFit
@@ -57,11 +63,14 @@ final class NativeListLeadingVisual: UIView {
57
63
  kind == "token"
58
64
  ? "token"
59
65
  : kind == "network" ? "network" : ["account", "wallet"].contains(kind) ? "avatar" : "generic"
66
+ networkIndex = nil
67
+ pairIndex = nil
60
68
  sources =
61
69
  kind == "stackedImages"
62
70
  ? visual.dictionaries("images").prefix(3).map { ($0, "generic") }
63
71
  : kind == "icon" ? [] : visual.dictionary("image").map { [($0, variant)] } ?? []
64
72
  if kind == "token", !sources.isEmpty, let network = visual.dictionary("networkImage") {
73
+ networkIndex = sources.count
65
74
  sources.append((network, "network"))
66
75
  }
67
76
  if let secondaryVisual {
@@ -71,6 +80,9 @@ final class NativeListLeadingVisual: UIView {
71
80
  ? secondaryVisual.dictionaries("images").first
72
81
  : secondaryKind == "icon" ? nil : secondaryVisual.dictionary("image")
73
82
  if let source {
83
+ // A second token forms a pair only next to a primary image; without one
84
+ // it simply takes the primary's place (OK-66071).
85
+ if kind == "token", !sources.isEmpty { pairIndex = sources.count }
74
86
  sources.append(
75
87
  (
76
88
  source,
@@ -132,6 +144,7 @@ final class NativeListLeadingVisual: UIView {
132
144
  fit: index == 0 ? style["contentFit"] as? String : nil, placeholder: placeholder
133
145
  ) { [weak self, weak slot] loaded in
134
146
  slot?.view.isHidden = !loaded && (index > 0 || sourceFallback)
147
+ if let self, index == self.pairIndex { self.syncPairBackdrop() }
135
148
  guard let self, index == 0, sourceFallback else { return }
136
149
  if let fallbackKey {
137
150
  if loaded {
@@ -146,8 +159,19 @@ final class NativeListLeadingVisual: UIView {
146
159
  self.icon.isHidden = loaded || fallbackIcon == nil
147
160
  }
148
161
  }
149
- networkBackdrop.isHidden = !(kind == "token" && sources.count > 1)
162
+ networkBackdrop.isHidden = networkIndex == nil
150
163
  networkBackdrop.backgroundColor = nativeListColor(theme, "rowBackground", "#FFFFFF")
164
+ pairBackdrop.backgroundColor = nativeListColor(theme, "rowBackground", "#FFFFFF")
165
+ syncPairBackdrop()
166
+ // Stacking order by role: primary, pair ring, secondary token, badge
167
+ // backdrop, badge, corner icon. Slots are reused across binds with
168
+ // different roles, so the order is restored on every bind.
169
+ bringSubviewToFront(pairBackdrop)
170
+ if let pairIndex { bringSubviewToFront(slots[pairIndex].view) }
171
+ bringSubviewToFront(networkBackdrop)
172
+ if let networkIndex { bringSubviewToFront(slots[networkIndex].view) }
173
+ bringSubviewToFront(cornerBackdrop)
174
+ bringSubviewToFront(corner)
151
175
  let badge = visual.dictionary("cornerIcon")
152
176
  corner.isHidden = badge == nil
153
177
  cornerBackdrop.isHidden = badge == nil
@@ -229,7 +253,7 @@ final class NativeListLeadingVisual: UIView {
229
253
  frame.subviews.forEach { $0.removeFromSuperview() }
230
254
  frame.addSubview(child)
231
255
  if data.dictionary("image") != nil && fallbackData != nil { frame.addSubview(fallbackGlyph) }
232
- if frame.superview == nil { addSubview(frame) }
256
+ if frame.superview == nil { addSubview(frame) } else { bringSubviewToFront(frame) }
233
257
  overlays.append((frame, data))
234
258
  }
235
259
  previousOverlays.dropFirst(descriptors.count).forEach { $0.0.removeFromSuperview() }
@@ -284,13 +308,22 @@ final class NativeListLeadingVisual: UIView {
284
308
  icon.frame = CGRect(
285
309
  x: (bounds.width - iconSize) / 2, y: (bounds.height - iconSize) / 2, width: iconSize,
286
310
  height: iconSize)
287
- let tokenPair = kind == "token" && sources.count > 1
311
+ // Same geometry as Android's `nativeListLeadingSlotFrame` (SPEC "Activity leading pair").
312
+ let pairMember = (bounds.height * 0.6).rounded()
313
+ let pairRing: CGFloat = 2
288
314
  for (index, slot) in slots.prefix(sources.count).enumerated() {
289
315
  let frame: CGRect
290
- if sources.count == 1 || tokenPair && index == 0 {
291
- frame = bounds
292
- } else if tokenPair {
316
+ if index == networkIndex {
293
317
  frame = CGRect(x: bounds.width - 14, y: bounds.height - 14, width: 16, height: 16)
318
+ } else if pairIndex != nil {
319
+ frame =
320
+ index == pairIndex
321
+ ? CGRect(
322
+ x: bounds.width - pairMember - pairRing, y: bounds.height - pairMember - pairRing,
323
+ width: pairMember, height: pairMember)
324
+ : CGRect(x: 0, y: 0, width: pairMember, height: pairMember)
325
+ } else if sources.count == 1 || networkIndex != nil {
326
+ frame = bounds
294
327
  } else {
295
328
  let side = bounds.height * 0.72
296
329
  frame = CGRect(
@@ -302,10 +335,23 @@ final class NativeListLeadingVisual: UIView {
302
335
  slot.view.clipsToBounds =
303
336
  !(singleOuterMask && sources.count == 1 && overlays.isEmpty
304
337
  && visual.dictionary("cornerIcon") == nil)
338
+ let fillsFrame = index == 0 && pairIndex == nil && (sources.count == 1 || networkIndex != nil)
339
+ // Pair members keep the token shape at their own size (rounded NFTs use
340
+ // the box's 10pt rule scaled to the member).
341
+ let pairRadius: CGFloat = shape == "rounded" ? min(10, frame.height / 4) : frame.height / 2
305
342
  slot.view.layer.cornerRadius =
306
343
  index == 0 && singleOuterMask && sources.count == 1 && overlays.isEmpty
307
344
  && visual.dictionary("cornerIcon") == nil
308
- ? 0 : index == 0 && (sources.count == 1 || tokenPair) ? radius : frame.height / 2
345
+ ? 0
346
+ : fillsFrame
347
+ ? radius : pairIndex != nil && index != networkIndex ? pairRadius : frame.height / 2
348
+ }
349
+ if pairIndex != nil {
350
+ let box = pairMember + 2 * pairRing
351
+ pairBackdrop.frame = logical(
352
+ CGRect(x: bounds.width - box, y: bounds.height - box, width: box, height: box))
353
+ pairBackdrop.layer.cornerRadius =
354
+ shape == "rounded" ? min(10, pairMember / 4) + pairRing : box / 2
309
355
  }
310
356
  for (index, slot) in slots.enumerated() {
311
357
  if index == 0 && bitmapBorderWidth > 0 {
@@ -381,9 +427,22 @@ final class NativeListLeadingVisual: UIView {
381
427
  UIBezierPath(ovalIn: bounds.insetBy(dx: dashedBorderWidth / 2, dy: dashedBorderWidth / 2))
382
428
  .cgPath
383
429
  }
430
+ /// The pair ring sits above the primary token, so it is shown only while the
431
+ /// secondary token image is actually displayed: hidden while that image loads,
432
+ /// after a failed load and once the slot is recycled, or it would cover the
433
+ /// primary's lower-right corner with a blank row-background disc.
434
+ private func syncPairBackdrop() {
435
+ guard let pairIndex, slots.indices.contains(pairIndex) else {
436
+ pairBackdrop.isHidden = true
437
+ return
438
+ }
439
+ pairBackdrop.isHidden = slots[pairIndex].view.isHidden
440
+ }
441
+
384
442
  func recycle() {
385
443
  slots.forEach { $0.recycle() }
386
444
  overlaySlots.forEach { $0.recycle() }
445
+ pairBackdrop.isHidden = true
387
446
  }
388
447
  /// Empty slot (legacy leading container without a visual): no image, fallback, fill,
389
448
  /// border or overlays from a previous binding.
@@ -399,6 +458,9 @@ final class NativeListLeadingVisual: UIView {
399
458
  icon.image = nil
400
459
  icon.isHidden = true
401
460
  networkBackdrop.isHidden = true
461
+ pairBackdrop.isHidden = true
462
+ networkIndex = nil
463
+ pairIndex = nil
402
464
  corner.isHidden = true
403
465
  cornerBackdrop.isHidden = true
404
466
  unread.isHidden = true
@@ -0,0 +1 @@
1
+ {"images":[{"filename":"icon.svg","idiom":"universal"}],"info":{"author":"xcode","version":1},"properties":{"preserves-vector-representation":true,"template-rendering-intent":"template"}}
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#000"><path d="M12 16H8v-2h4zm4-4H8v-2h8zm0-4H8V6h8z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M20 22H4V2h16zM6 20h12V4H6z"/></svg>
@@ -0,0 +1 @@
1
+ {"images":[{"filename":"icon.svg","idiom":"universal"}],"info":{"author":"xcode","version":1},"properties":{"preserves-vector-representation":true,"template-rendering-intent":"template"}}
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#000"><path d="M14 18v4h-4v-4zm0-4h-4v-4h4zm0-8h-4V2h4z"/></svg>
@@ -0,0 +1 @@
1
+ {"images":[{"filename":"icon.svg","idiom":"universal"}],"info":{"author":"xcode","version":1},"properties":{"preserves-vector-representation":true,"template-rendering-intent":"template"}}
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#000"><path d="M13 11h8v2h-8v8h-2v-8H3v-2h8V3h2z"/></svg>
@@ -0,0 +1 @@
1
+ {"images":[{"filename":"icon.svg","idiom":"universal"}],"info":{"author":"xcode","version":1},"properties":{"preserves-vector-representation":true,"template-rendering-intent":"template"}}
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#000"><path d="M16 17.01h-2V15h2zm0-4h-2V11h2zm0-4h-2V7h2z"/><path fill-rule="evenodd" clip-rule="evenodd" d="m22 9.927-.629.252c-1.661.665-1.661 2.977 0 3.642l.629.252V20H2v-5.927l.629-.252c1.661-.665 1.661-2.977 0-3.642L2 9.927V4h20zM4 8.636c2.5 1.52 2.499 5.207 0 6.727V18h16v-2.637c-2.499-1.52-2.5-5.208 0-6.727V6H4z"/></svg>
@@ -0,0 +1 @@
1
+ {"images":[{"filename":"icon.svg","idiom":"universal"}],"info":{"author":"xcode","version":1},"properties":{"preserves-vector-representation":true,"template-rendering-intent":"template"}}
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#000"><path d="M15.5 13.25a1.25 1.25 0 1 1 0 2.5 1.25 1.25 0 0 1 0-2.5"/><path fill-rule="evenodd" clip-rule="evenodd" d="M17 8h4v13H7a4 4 0 0 1-4-4V6.5A3.5 3.5 0 0 1 6.5 3H17zM5 17a2 2 0 0 0 2 2h12v-9H6.5c-.537 0-1.045-.12-1.5-.337zM6.5 5a1.5 1.5 0 1 0 0 3H15V5z"/></svg>
@@ -0,0 +1,81 @@
1
+ import UIKit
2
+ import XCTest
3
+ @testable import NativeListModule
4
+
5
+ @MainActor
6
+ final class NativeListIdentityTitleIconLayoutTests: XCTestCase {
7
+ private func child<T>(_ cell: NativeListIdentityCell, _ label: String, as type: T.Type) throws -> T {
8
+ try XCTUnwrap(Mirror(reflecting: cell).children.first { $0.label == label }?.value as? T)
9
+ }
10
+
11
+ // OK-66041: the Home native-token row carries a GasSolid `titleIcon`. The
12
+ // glyph must sit right after the title text (4pt gap), as Android's
13
+ // PackedTitleLineLayout and the Web flex row already do. iOS let the title
14
+ // label stretch across the whole title row, so the icon landed next to the
15
+ // trailing value column instead of beside "ETH".
16
+ func testTitleIconFollowsTheTitleTextInsteadOfTheRowEnd() throws {
17
+ let cell = NativeListIdentityCell(frame: CGRect(x: 0, y: 0, width: 390, height: 60))
18
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 200))
19
+ window.addSubview(cell)
20
+ let titleIcon = try child(cell, "titleIconView", as: UIImageView.self)
21
+ let titleLabel = try child(cell, "titleLabel", as: UILabel.self)
22
+ let titleStack = try child(cell, "titleRowStack", as: UIStackView.self)
23
+ let item = try NativeListItem(data: [
24
+ "key": "eth", "type": "identity", "title": "ETH", "height": 60,
25
+ "leading": ["kind": "token", "fallbackIcon": ["name": "CryptoCoinOutline"]],
26
+ "titleIcon": ["name": "GasSolid"],
27
+ "subtitleSegments": [
28
+ ["text": "$2,330.23", "tone": "secondary"],
29
+ ["text": "+0.23%", "tone": "positive", "separatorBefore": true],
30
+ ],
31
+ "trailing": [["kind": "valuePair", "primary": "0.00001417", "secondary": "$0.03"]],
32
+ ])
33
+ cell.bind(item: item, theme: nil, layout: "linear", selected: false,
34
+ checkboxState: { _, _, fallback in fallback }, fonts: NativeListFontFamilies())
35
+ cell.layoutIfNeeded()
36
+
37
+ XCTAssertFalse(titleIcon.isHidden)
38
+ // The title keeps its intrinsic width; the slack goes to the end of the row.
39
+ XCTAssertEqual(
40
+ titleLabel.bounds.width, titleLabel.intrinsicContentSize.width, accuracy: 1,
41
+ "title label must not stretch across the title row")
42
+ let labelFrame = titleLabel.convert(titleLabel.bounds, to: cell)
43
+ let iconFrame = titleIcon.convert(titleIcon.bounds, to: cell)
44
+ XCTAssertEqual(iconFrame.minX, labelFrame.maxX + 4, accuracy: 1,
45
+ "gas icon must follow the title text with the 4pt gap")
46
+ let iconInRow = titleIcon.convert(titleIcon.bounds, to: titleStack)
47
+ XCTAssertLessThan(iconInRow.maxX, titleStack.bounds.width / 2,
48
+ "gas icon must stay in the leading half of the title row, not at the value column")
49
+ }
50
+
51
+ // A long title still truncates in front of the icon instead of pushing it
52
+ // out of the row, and the badge keeps following the icon.
53
+ func testLongTitleTruncatesAndKeepsIconAndBadgeAdjacent() throws {
54
+ let cell = NativeListIdentityCell(frame: CGRect(x: 0, y: 0, width: 320, height: 60))
55
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 200))
56
+ window.addSubview(cell)
57
+ let titleIcon = try child(cell, "titleIconView", as: UIImageView.self)
58
+ let titleLabel = try child(cell, "titleLabel", as: UILabel.self)
59
+ let badge = try child(cell, "badgeLabel", as: UILabel.self)
60
+ let item = try NativeListItem(data: [
61
+ "key": "long", "type": "identity",
62
+ "title": "A very long token name that cannot possibly fit on one line", "height": 60,
63
+ "leading": ["kind": "token", "fallbackIcon": ["name": "CryptoCoinOutline"]],
64
+ "titleIcon": ["name": "GasSolid"],
65
+ "badges": [["key": "native", "text": "Native"]],
66
+ "trailing": [["kind": "valuePair", "primary": "1.00", "secondary": "$1.00"]],
67
+ ])
68
+ cell.bind(item: item, theme: nil, layout: "linear", selected: false,
69
+ checkboxState: { _, _, fallback in fallback }, fonts: NativeListFontFamilies())
70
+ cell.layoutIfNeeded()
71
+
72
+ let labelFrame = titleLabel.convert(titleLabel.bounds, to: cell)
73
+ let iconFrame = titleIcon.convert(titleIcon.bounds, to: cell)
74
+ let badgeFrame = badge.convert(badge.bounds, to: cell)
75
+ XCTAssertEqual(titleIcon.bounds.width, 20, accuracy: 0.5)
76
+ XCTAssertEqual(iconFrame.minX, labelFrame.maxX + 4, accuracy: 1)
77
+ XCTAssertFalse(badge.isHidden)
78
+ XCTAssertGreaterThan(badgeFrame.minX, iconFrame.maxX)
79
+ XCTAssertLessThanOrEqual(badgeFrame.maxX, cell.bounds.width)
80
+ }
81
+ }
@@ -0,0 +1,50 @@
1
+ import UIKit
2
+ import XCTest
3
+ @testable import NativeListModule
4
+
5
+ @MainActor
6
+ final class NativeListLeadingVisualPairBackdropTests: XCTestCase {
7
+ private func child<T>(_ visual: NativeListLeadingVisual, _ label: String, as type: T.Type) throws -> T {
8
+ try XCTUnwrap(Mirror(reflecting: visual).children.first { $0.label == label }?.value as? T)
9
+ }
10
+
11
+ private func token(_ uri: String) -> [String: Any] {
12
+ [
13
+ "kind": "token", "shape": "circle",
14
+ "image": ["uri": uri, "width": 40, "height": 40],
15
+ "fallbackIcon": ["name": "CryptoCoinOutline"],
16
+ ]
17
+ }
18
+
19
+ // PR #185 review: the pair ring sits above the primary token. While the
20
+ // secondary image has not loaded (or failed), its slot is hidden, so the ring
21
+ // must be hidden too; otherwise a blank row-background disc covers the
22
+ // primary's lower-right corner.
23
+ func testPairRingStaysHiddenWhileTheSecondaryImageIsNotDisplayed() throws {
24
+ let visual = NativeListLeadingVisual(frame: CGRect(x: 0, y: 0, width: 40, height: 40))
25
+ let ring = try child(visual, "pairBackdrop", as: UIView.self)
26
+ visual.bind(
27
+ token("https://example.invalid/primary.png"), style: [:], key: "pair", theme: nil,
28
+ isUnread: false, secondaryVisual: token("https://example.invalid/secondary.png"),
29
+ fonts: NativeListFontFamilies())
30
+ visual.layoutIfNeeded()
31
+ // `slots` is a value-type array: read it after bind has created the slots.
32
+ let slots = try child(visual, "slots", as: [NativeListImageSlot].self)
33
+ XCTAssertEqual(slots.count, 2)
34
+ XCTAssertTrue(slots[1].view.isHidden, "secondary slot is hidden until its image loads")
35
+ XCTAssertTrue(ring.isHidden, "ring must follow the hidden secondary slot")
36
+
37
+ visual.recycle()
38
+ XCTAssertTrue(ring.isHidden)
39
+ }
40
+
41
+ func testNoPairMeansNoRing() throws {
42
+ let visual = NativeListLeadingVisual(frame: CGRect(x: 0, y: 0, width: 40, height: 40))
43
+ let ring = try child(visual, "pairBackdrop", as: UIView.self)
44
+ visual.bind(
45
+ token("https://example.invalid/primary.png"), style: [:], key: "single", theme: nil,
46
+ isUnread: false, fonts: NativeListFontFamilies())
47
+ visual.layoutIfNeeded()
48
+ XCTAssertTrue(ring.isHidden)
49
+ }
50
+ }
@@ -735,6 +735,52 @@ const selectorIcons = {
735
735
  opacity: 1
736
736
  }]
737
737
  },
738
+ DotVerSolid: {
739
+ viewBox: '0 0 24 24',
740
+ paths: [{
741
+ d: 'M14 18v4h-4v-4zm0-4h-4v-4h4zm0-8h-4V2h4z',
742
+ fill: 'currentColor',
743
+ fillRule: 'nonzero',
744
+ opacity: 1
745
+ }]
746
+ },
747
+ Document2Outline: {
748
+ viewBox: '0 0 24 24',
749
+ paths: [{
750
+ d: 'M12 16H8v-2h4zm4-4H8v-2h8zm0-4H8V6h8z',
751
+ fill: 'currentColor',
752
+ fillRule: 'nonzero',
753
+ opacity: 1
754
+ }, {
755
+ d: 'M20 22H4V2h16zM6 20h12V4H6z',
756
+ fill: 'currentColor',
757
+ fillRule: 'evenodd',
758
+ opacity: 1
759
+ }]
760
+ },
761
+ WalletOutline: {
762
+ viewBox: '0 0 24 24',
763
+ paths: [{
764
+ d: 'M15.5 13.25a1.25 1.25 0 1 1 0 2.5 1.25 1.25 0 0 1 0-2.5',
765
+ fill: 'currentColor',
766
+ fillRule: 'nonzero',
767
+ opacity: 1
768
+ }, {
769
+ d: 'M17 8h4v13H7a4 4 0 0 1-4-4V6.5A3.5 3.5 0 0 1 6.5 3H17zM5 17a2 2 0 0 0 2 2h12v-9H6.5c-.537 0-1.045-.12-1.5-.337zM6.5 5a1.5 1.5 0 1 0 0 3H15V5z',
770
+ fill: 'currentColor',
771
+ fillRule: 'evenodd',
772
+ opacity: 1
773
+ }]
774
+ },
775
+ PlusLargeOutline: {
776
+ viewBox: '0 0 24 24',
777
+ paths: [{
778
+ d: 'M13 11h8v2h-8v8h-2v-8H3v-2h8V3h2z',
779
+ fill: 'currentColor',
780
+ fillRule: 'nonzero',
781
+ opacity: 1
782
+ }]
783
+ },
738
784
  TicketOutline: {
739
785
  viewBox: '0 0 24 24',
740
786
  paths: [{
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/react-native-native-list",
3
- "version": "6.7.0-alpha.396",
3
+ "version": "6.7.0-alpha.398",
4
4
  "description": "Template-driven native RecyclerView and UICollectionView for React Native",
5
5
  "source": "./src/index.ts",
6
6
  "main": "./lib/module/index.js",
@@ -86,8 +86,8 @@
86
86
  "typescript": "^5.9.2"
87
87
  },
88
88
  "peerDependencies": {
89
- "@onekeyfe/react-native-image": "6.7.0-alpha.396",
90
- "@onekeyfe/react-native-native-logger": "6.7.0-alpha.396",
89
+ "@onekeyfe/react-native-image": "6.7.0-alpha.398",
90
+ "@onekeyfe/react-native-native-logger": "6.7.0-alpha.398",
91
91
  "react": "*",
92
92
  "react-native": "*",
93
93
  "react-native-nitro-modules": "0.37.0"
@@ -1206,6 +1206,62 @@ const selectorIcons: Readonly<
1206
1206
  },
1207
1207
  ],
1208
1208
  },
1209
+ DotVerSolid: {
1210
+ viewBox: '0 0 24 24',
1211
+ paths: [
1212
+ {
1213
+ d: 'M14 18v4h-4v-4zm0-4h-4v-4h4zm0-8h-4V2h4z',
1214
+ fill: 'currentColor',
1215
+ fillRule: 'nonzero',
1216
+ opacity: 1,
1217
+ },
1218
+ ],
1219
+ },
1220
+ Document2Outline: {
1221
+ viewBox: '0 0 24 24',
1222
+ paths: [
1223
+ {
1224
+ d: 'M12 16H8v-2h4zm4-4H8v-2h8zm0-4H8V6h8z',
1225
+ fill: 'currentColor',
1226
+ fillRule: 'nonzero',
1227
+ opacity: 1,
1228
+ },
1229
+ {
1230
+ d: 'M20 22H4V2h16zM6 20h12V4H6z',
1231
+ fill: 'currentColor',
1232
+ fillRule: 'evenodd',
1233
+ opacity: 1,
1234
+ },
1235
+ ],
1236
+ },
1237
+ WalletOutline: {
1238
+ viewBox: '0 0 24 24',
1239
+ paths: [
1240
+ {
1241
+ d: 'M15.5 13.25a1.25 1.25 0 1 1 0 2.5 1.25 1.25 0 0 1 0-2.5',
1242
+ fill: 'currentColor',
1243
+ fillRule: 'nonzero',
1244
+ opacity: 1,
1245
+ },
1246
+ {
1247
+ d: 'M17 8h4v13H7a4 4 0 0 1-4-4V6.5A3.5 3.5 0 0 1 6.5 3H17zM5 17a2 2 0 0 0 2 2h12v-9H6.5c-.537 0-1.045-.12-1.5-.337zM6.5 5a1.5 1.5 0 1 0 0 3H15V5z',
1248
+ fill: 'currentColor',
1249
+ fillRule: 'evenodd',
1250
+ opacity: 1,
1251
+ },
1252
+ ],
1253
+ },
1254
+ PlusLargeOutline: {
1255
+ viewBox: '0 0 24 24',
1256
+ paths: [
1257
+ {
1258
+ d: 'M13 11h8v2h-8v8h-2v-8H3v-2h8V3h2z',
1259
+ fill: 'currentColor',
1260
+ fillRule: 'nonzero',
1261
+ opacity: 1,
1262
+ },
1263
+ ],
1264
+ },
1209
1265
  TicketOutline: {
1210
1266
  viewBox: '0 0 24 24',
1211
1267
  paths: [