@onekeyfe/react-native-native-list 6.7.0-alpha.397 → 6.7.0-alpha.399

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 (40) 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 +43 -15
  3. package/android/src/main/java/com/onekey/nativelist/NativeListLeadingVisual.kt +88 -19
  4. package/android/src/main/java/com/onekey/nativelist/NativeListModels.kt +94 -1
  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/ROW_TEMPLATES.md +4 -0
  10. package/docs/SPEC.md +180 -37
  11. package/ios/NativeListDesignAssets.swift +6 -0
  12. package/ios/NativeListIdentityRenderer.swift +76 -4
  13. package/ios/NativeListLeadingVisual.swift +70 -8
  14. package/ios/RNCNativeListView.swift +6 -1
  15. package/ios/Resources/NativeListIcons.xcassets/onekey_document2.imageset/Contents.json +1 -0
  16. package/ios/Resources/NativeListIcons.xcassets/onekey_document2.imageset/icon.svg +1 -0
  17. package/ios/Resources/NativeListIcons.xcassets/onekey_dot_ver_solid.imageset/Contents.json +1 -0
  18. package/ios/Resources/NativeListIcons.xcassets/onekey_dot_ver_solid.imageset/icon.svg +1 -0
  19. package/ios/Resources/NativeListIcons.xcassets/onekey_plus_large.imageset/Contents.json +1 -0
  20. package/ios/Resources/NativeListIcons.xcassets/onekey_plus_large.imageset/icon.svg +1 -0
  21. package/ios/Resources/NativeListIcons.xcassets/onekey_ticket.imageset/Contents.json +1 -0
  22. package/ios/Resources/NativeListIcons.xcassets/onekey_ticket.imageset/icon.svg +1 -0
  23. package/ios/Resources/NativeListIcons.xcassets/onekey_wallet.imageset/Contents.json +1 -0
  24. package/ios/Resources/NativeListIcons.xcassets/onekey_wallet.imageset/icon.svg +1 -0
  25. package/ios/tests/NativeListIdentityTitleIconLayoutTests.swift +81 -0
  26. package/ios/tests/NativeListLeadingVisualPairBackdropTests.swift +50 -0
  27. package/ios/tests/NativeListLifecycleTests.swift +60 -0
  28. package/lib/module/NativeList.web.js +9 -12
  29. package/lib/module/models.js +2 -0
  30. package/lib/module/validation.js +627 -114
  31. package/lib/module/web/NativeListWebEngine.js +49 -9
  32. package/lib/module/web/templates/IdentityRowRenderer.js +58 -4
  33. package/lib/typescript/src/models.d.ts +25 -14
  34. package/lib/typescript/src/validation.d.ts +7 -2
  35. package/package.json +3 -3
  36. package/src/NativeList.web.tsx +10 -14
  37. package/src/models.ts +144 -153
  38. package/src/validation.ts +1617 -167
  39. package/src/web/NativeListWebEngine.ts +62 -10
  40. package/src/web/templates/IdentityRowRenderer.ts +70 -4
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
@@ -158,13 +176,68 @@ after semantic text styling. Both lines retain their default size, tone, line
158
176
  height, right alignment and accessibility text. These fields add no action or
159
177
  hit target and travel in existing snapshot JSON without a Nitro ABI change.
160
178
 
179
+ ### Identity title image
180
+
181
+ Status: implemented in local source, unreleased. Type checks, JS/Web tests,
182
+ Web runtime checks, package build, iOS XCTest-target compilation, Android
183
+ production/instrumentation-test compilation and JVM tests pass. Focused iOS
184
+ Simulator XCTest and Android emulator instrumentation verify image lifecycle,
185
+ null removal and wallet-sidebar layout. Complete wallet App acceptance remains
186
+ pending.
187
+ An `identity` row may supply `titleImage: { image, accessibilityLabel?,
188
+ hoverActionKey? }`. `image` uses the existing `ImageSource` contract and must
189
+ declare equal finite width and height within 1...40 logical units. The image
190
+ is circular, follows the title and any title icons with a 4-point gap, and
191
+ precedes title suffixes and inline badges. It reserves its declared square
192
+ while loading or after failure, so neither state moves the text. Existing
193
+ rows without this field keep their current layout. The image does not change
194
+ row identity or infer a stock, issuer, or other product meaning.
195
+
196
+ iOS and Android use the existing OneKeyImage-backed slot, request options and
197
+ retry limits. Slots belong to the mounted row renderer; OneKeyImage owns image
198
+ caches. Text-only rebinds retain an unchanged row/source request. Source or row
199
+ changes, removal, recycling and disposal cancel prior work and prevent stale
200
+ completion from painting the next binding. Failure leaves the reserved square
201
+ empty; the SDK adds no title-image fallback request or separate cache.
202
+
203
+ On Web, optional `hoverActionKey` uses the title icon's existing help semantics
204
+ and `leadingAction` anchor source: mouse/pen entry opens an anchored help event,
205
+ departure invalidates it, mouse/pen press closes it until departure, and touch
206
+ press toggles it. Pressing this help target does not activate the row. Without
207
+ help, the image participates in the ordinary row press. It adds no tab stop.
208
+ Native renderers ignore this Web-only help field and preserve ordinary row
209
+ presses. The consumer owns the tooltip text and presentation. Omitted
210
+ `accessibilityLabel` makes the image decorative; a supplied label describes it.
211
+
212
+ Android wallet-sidebar rows with a title image reserve image width and pack the
213
+ wallet name with the image as one centered group. Long names ellipsize within
214
+ the remaining space; removing the image restores the full-width centered name.
215
+
216
+ Full snapshots without `titleImage` remove the image and old help anchor.
217
+ Explicit patch replacement updates the descriptor; an omitted patch field
218
+ leaves it unchanged. Null patches and empty image URIs remove the image. Development guards
219
+ reject malformed descriptors and invalid display geometry; production sanitizes
220
+ optional decoration as described in the validation contract. A production patch
221
+ that supplies a malformed title-image replacement explicitly clears the old
222
+ image and help target instead of retaining stale decoration. The field travels in existing JSON and does not change the Nitro ABI;
223
+ consumers must use a native SDK that implements the field before enabling it.
224
+
225
+ Acceptance covers protocol validation, image/source replacement and removal,
226
+ text-only request retention, Web help-action isolation and anchor cleanup,
227
+ native bind/recycle geometry, and native compilation. Device and complete App
228
+ acceptance must be reported separately from source and DOM tests.
229
+
161
230
  ### Identity row risk-management controls
162
231
 
163
232
  Status: implemented in source for local consumers, unreleased. Focused validation,
164
233
  Web DOM tests, native builds and device acceptance are tracked separately below.
165
234
  The optional `identity.titleIcon` is a static, non-actionable icon after the
166
235
  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
236
+ label. "After the title" means directly after the title text: the title row
237
+ packs at its start on every platform (Android `PackedTitleLineLayout`, Web flex
238
+ `0 1 auto`, iOS a slack-absorbing filler at the end of the title row), so the
239
+ glyph and any badge follow the text and never drift toward the trailing column
240
+ (OK-66041). Its optional Web-only `hoverActionKey` emits an anchored help action
168
241
  from the icon alone on mouse/pen hover or touch press; the consumer owns the
169
242
  tooltip. Mouse/pen press closes the anchor until pointer leave. Touch press
170
243
  toggles the icon anchor. The icon adds no tab stop or separate click action;
@@ -423,37 +496,61 @@ row positions. An accepted complete snapshot reuses that index only when its
423
496
  generation and ordered `(key, type)` sequence match the prior accepted
424
497
  snapshot; otherwise it replaces the index. Reorder commits rebuild it and
425
498
  disposal clears it. Patch batches reuse the index and never include retiring
426
- presentation rows in it. They validate the whole batch against the current
427
- accepted row models before changing config or presentation; invalid batches
428
- leave both unchanged. iOS updates only changed accepted entries in its
499
+ presentation rows in it. Native hosts validate each received canonical batch before changing config or
500
+ presentation; invalid native batches leave both unchanged. The shared production
501
+ JS boundary first isolates rejected row/group units, so a native batch contains
502
+ only accepted canonical updates. iOS updates only changed accepted entries in its
429
503
  presentation lookup after a valid patch, preserving any retiring rows.
430
504
 
431
505
  ### Validation environments and rejected input
432
506
 
433
- Development builds (`__DEV__ === true`) run the complete snapshot and patch
434
- contract assertions. Production builds run only serialization, required data
435
- shape, supported renderer, finite geometry, identity and patch consistency
436
- guards. Display budgets and style allowlists are development assertions.
437
- Typography token resolution runs in both environments. Production treats empty
438
- optional image URIs as missing images, preserving the existing visual fallback.
439
- An empty optional image field omitted from a patch is a no-op, matching its
440
- serialized native payload. Sparkline sample arrays retain their existing
441
- 256-sample resource bound because the Web renderer passes samples as function
442
- arguments; this protects the runtime's argument limit.
443
-
444
- The component and Web engine contain input validation failures in both modes:
445
- initial invalid input renders an empty list; rejected later snapshots or patch
446
- batches preserve the last accepted state and are not dispatched to native.
447
- On iOS and Android, the React wrapper captures caller data before validation
448
- and owns an immutable accepted snapshot. Later caller mutation cannot rewrite
449
- that accepted state. Imperative selection keys are captured on entry. Eligible
450
- value-only `identity` and `dataRow` patches validate their complete changed
451
- rows; other patches and snapshots without this private ownership use the
452
- existing complete-snapshot validation path. This does not change exported
453
- validators or Web behavior.
454
- An `undefined` value in an eligible field stays on the changed-row path only
455
- when that field was already `undefined`; clearing a present value uses the
456
- complete-snapshot path.
507
+ Status: implemented in source; acceptance and remaining runtime gaps are recorded below.
508
+
509
+ Development runs strict contract assertions after shared null/empty-image
510
+ normalization. Production sanitizes optional decorations without rejecting
511
+ valid business content: invalid colors/styles are omitted, invalid images use
512
+ existing placeholders, and positive title-image dimensions are clamped to a
513
+ square of 1...40 logical units. Image headers are string dictionaries, never
514
+ interpreted as geometry. Unsafe object graphs are rejected before normalization.
515
+
516
+ A complete snapshot remains atomic after decoration sanitization: invalid
517
+ schema, duplicate/missing identities, required business fields or wallet-group
518
+ parent/children relationships reject the snapshot. No row is silently removed.
519
+ Production normalizes contiguous group positions and filters selection to
520
+ selectable keys, respecting none/single modes.
521
+
522
+ Patches use omitted/undefined fields as no-ops. Null explicitly removes an
523
+ optional field on JS, Web, iOS and Android; required fields cannot be cleared.
524
+ Empty optional images and empty top-level segment arrays normalize to null in
525
+ patches and absence in snapshots. Empty images remove their outer titleImage
526
+ or media wrapper. This protocol requires native binaries with null-removal
527
+ support; callers must not enable it against older native SDK implementations.
528
+
529
+ Market price remains required and nonblank. Updating nonempty priceSegments
530
+ requires a price in the same canonical patch; callers own matching formatted
531
+ text (the validator does not reconstruct subscript prices). Updating price
532
+ without segments clears an existing overlay. For identity/market subtitles,
533
+ updating one representation clears the old other representation unless both
534
+ are supplied; clearing segments alone retains the base text. Activity has no
535
+ subtitle pair. Market quote changes with an existing/new webTable must include
536
+ matching table data or explicitly remove webTable.
537
+
538
+ Development rejects invalid patch batches atomically. Production isolates
539
+ failures to each target key and its coupled groupId: all updates to a key are
540
+ merged in order before validation, and a failure rejects that unit while other
541
+ valid units commit. Financial-value and interactive accessories with missing
542
+ required data fail their row; malformed purely decorative accessories are
543
+ removed. Diagnostics contain locations, never row values or image URLs.
544
+
545
+ All targets share the canonical accepted patches. The accepted JS rows and
546
+ serialized native changes derive from the same result. Native owned snapshots
547
+ and accepted Web engine snapshots validate only changed rows during patches;
548
+ unchanged rows are not rescanned. Direct helpers receiving an unowned baseline
549
+ validate that baseline once before incremental merging. Native owned snapshots
550
+ are immutable and indexed by key. Initial invalid snapshots render empty;
551
+ rejected later snapshots retain the last accepted snapshot. Rejected patch
552
+ units retain their previous complete rows. Direct validators still throw on
553
+ unrecoverable input. Image loading failures remain the loader's responsibility.
457
554
 
458
555
  `planNativeListPatches(previous, next)` is an explicit, pure public helper for
459
556
  callers that own two valid, immutable snapshots. It returns a readonly empty
@@ -476,14 +573,25 @@ Rejection emits a diagnostic without including row keys, text or image URLs.
476
573
  Explicit calls to the exported validation functions still throw on rejected
477
574
  input, so development tooling can inspect the full contract failure. Rejection
478
575
  does not imply that the requested update succeeded. No row identity is trimmed,
479
- truncated or regenerated, and no invalid row is silently dropped from a batch.
480
-
481
- Status: implemented. Development/production JS regressions pass, including
482
- native wrappers for iOS/Android and the Web DOM engine. Real Swift XCTest on an
483
- iOS 27 simulator and Android instrumentation on API 35 verify production
484
- serialization, normalized typography, opaque identities and duplicate-key
485
- protection against the shipping native parsers. Full app/device rendering and
486
- gesture acceptance remain separate from these input-boundary checks.
576
+ truncated or regenerated, and no row is silently dropped from a snapshot.
577
+
578
+ Acceptance for this revision: 429 JS/Web tests pass, including native React
579
+ wrapper containment, canonical null/undefined semantics, price/segment pairing,
580
+ production row/group isolation and unchanged-row access checks. Package type
581
+ checks, lint (zero errors; existing warnings) and test-integrity checks pass.
582
+ iOS production/XCTest targets and Android production/instrumentation targets
583
+ compile; 29 Android JVM tests pass. Four focused iOS 26.5 XCTest cases verify
584
+ title-icon packing, title-image reuse and null removal. Android 15/API 35
585
+ instrumentation verifies wallet-sidebar image bounds, long-title ellipsis,
586
+ and full-width title restoration after removal. Shared JS/Web regressions also
587
+ verify that malformed production title-image replacements clear the previous
588
+ image and help anchor. Full App acceptance remains pending.
589
+
590
+ Wallet groups use the actual embedded parent/children model, not memberKeys
591
+ references. groupId/groupPosition remain snapshot-owned fields; production
592
+ patch failures also reject other patched rows in the same existing groupId.
593
+ Unknown extension fields (including unsupported sparkline/changeHighlight
594
+ models) are ignored in production rather than introducing a new renderer API.
487
595
 
488
596
  ## Platform contract
489
597
 
@@ -571,7 +679,8 @@ an action-less accessory as a `span`, so its click reaches the row.
571
679
  ### Leading badge overhang (OK-65694)
572
680
 
573
681
  Status: implemented on Android, the only platform that clipped. Android JVM
574
- policy test (`NativeListLeadingVisualClipPolicyTest`); Android emulator
682
+ policy tests (`NativeListLeadingVisualClipPolicyTest`,
683
+ `NativeListActivityHostClipPolicyTest`); Android emulator
575
684
  acceptance on the example account selector (API 36, 2026-10-27) is recorded in
576
685
  the changelog entry. iOS and Web are unchanged.
577
686
 
@@ -590,6 +699,12 @@ overhanging badge (`requestsUnclippedHost`, decided by
590
699
  already do for their network badges; a visual without such a badge keeps the
591
700
  row's default clipping.
592
701
 
702
+ Host clipping is per-bind state. A row view that serves several row shapes
703
+ (the Android Activity view serves the legacy row and the rich `amounts` row) is
704
+ reused across binds, so each bind MUST derive clipping from the item it binds
705
+ (`nativeListActivityHostClips`): a view that last showed an overhanging badge
706
+ clips again when it is rebound to a row without one.
707
+
593
708
  The row's own shape still clips on every platform: iOS masks the cell to its
594
709
  corner radius (`masksToBounds`), Web keeps the row `overflow: hidden`, and
595
710
  Android keeps `clipToOutline` for a `style.container.cornerRadius` row (its
@@ -601,6 +716,34 @@ padding. The example `NativeListRowStylePage` row `identity-badge-rounded`
601
716
  combines both badges with `container.cornerRadius` as the rendering check for
602
717
  that combination.
603
718
 
719
+ ### Activity leading pair (OK-66071)
720
+
721
+ Status: implemented on iOS and Android; Web already drew the pair. Android JVM
722
+ geometry test (`NativeListLeadingSlotFrameTest`); Android emulator acceptance on
723
+ the example row-style page is recorded in the changelog entry.
724
+
725
+ An Activity row whose `leading` and `secondaryLeading` are both tokens with an
726
+ image (the app's swap and multi-asset history rows) draws them as a pair inside
727
+ the 40-unit visual box, as the legacy avatar and the Web renderer do: the
728
+ primary token at the top-left at 0.6 of the box (24 units), the secondary at the
729
+ same size at the bottom-right inside a 2-unit ring in the row background, above
730
+ the primary. The ring is shown only while the secondary image is displayed:
731
+ while that image loads, after a failed load and after recycling it stays hidden,
732
+ so it never covers the primary's corner with a blank disc. The token `networkImage` badge, when present, keeps its single-token
733
+ geometry at the box's bottom-right corner above the secondary; a pair shows one
734
+ badge, never one per member. A secondary token without an image leaves the
735
+ primary alone at full size, and a primary without an image lets the secondary
736
+ take its place. Before this rule Android laid the secondary over the whole box
737
+ (hiding the primary and the badge) and iOS drew it in the badge's corner slot
738
+ over the badge. The per-source geometry is one pure function on Android
739
+ (`nativeListLeadingSlotFrame`) and the same arithmetic in
740
+ `NativeListLeadingVisual.layoutSubviews` on iOS; both restore the stacking
741
+ order by role on every bind because slots are reused across roles.
742
+
743
+ The Android Activity renderer also applies the badge-overhang rule above
744
+ (`requestsUnclippedHost` stops the content row and the row padding clipping),
745
+ so the history rows' network badges render whole instead of cut flat.
746
+
604
747
  ## Failures, fallback and limits
605
748
 
606
749
  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)?
@@ -91,7 +91,10 @@ final class NativeListIdentityCell: NativeListRendererCell {
91
91
  let tertiary = NativeListResolvedText(
92
92
  item.data.string("tertiary"), style: style.dictionary("tertiary"), size: 14,
93
93
  color: .black, lineHeight: 20, lines: 1, fonts: fonts)
94
- let textHeight = title.measure(width: textWidth)
94
+ let titleImageSize = CGFloat(item.data.dictionary("titleImage")?.dictionary("image")?.double("width") ?? 0)
95
+ let titleImageWidth = titleImageSize > 0
96
+ ? titleImageSize + 4 + (item.data.dictionary("titleIcon") == nil ? 0 : 24) : 0
97
+ let textHeight = max(titleImageSize, title.measure(width: max(1, textWidth - titleImageWidth)))
95
98
  + subtitle.measure(width: textWidth)
96
99
  + tertiary.measure(width: textWidth)
97
100
  + (item.data.string("subtitle").isEmpty ? 0 : CGFloat(style.double("lineGap", default: 0)))
@@ -107,6 +110,12 @@ final class NativeListIdentityCell: NativeListRendererCell {
107
110
  private let titleRowStack = UIStackView()
108
111
  private let titleLabel = NativeListTextLabel()
109
112
  private let titleIconView = UIImageView()
113
+ private var titleImageSlot: NativeListImageSlot?
114
+ private var titleImageConstraints: [NSLayoutConstraint] = []
115
+ // Absorbs the title row's slack so the title keeps its intrinsic width and the
116
+ // title icon / badge follow the text (OK-66041); Android's PackedTitleLineLayout
117
+ // and the Web flex row already pack the title row at its start.
118
+ private let titleRowFiller = UIView()
110
119
  private let subtitleLabel = NativeListTextLabel()
111
120
  private let tertiaryLabel = NativeListTextLabel()
112
121
  private let badgeLabel = NativeListInsetLabel()
@@ -127,7 +136,7 @@ final class NativeListIdentityCell: NativeListRendererCell {
127
136
  private var currentItem: NativeListItem?
128
137
  private var currentTheme: [String: Any]?
129
138
  private var leadingActionKey: String?
130
- override var assetFields: [String] { ["leading"] }
139
+ override var assetFields: [String] { ["leading", "titleImage"] }
131
140
  override var defaultSeparatorInset: CGFloat { 60 }
132
141
  override var defaultCornerRadius: CGFloat {
133
142
  guard let item = currentItem else { return 0 }
@@ -164,6 +173,9 @@ final class NativeListIdentityCell: NativeListRendererCell {
164
173
  titleRowStack.addArrangedSubview(titleLabel)
165
174
  titleRowStack.addArrangedSubview(titleIconView)
166
175
  titleRowStack.addArrangedSubview(badgeLabel)
176
+ titleRowStack.addArrangedSubview(titleRowFiller)
177
+ titleRowFiller.setContentHuggingPriority(UILayoutPriority(1), for: .horizontal)
178
+ titleRowFiller.setContentCompressionResistancePriority(UILayoutPriority(1), for: .horizontal)
167
179
  titleIconView.translatesAutoresizingMaskIntoConstraints = false
168
180
  let titleIconWidth = titleIconView.widthAnchor.constraint(equalToConstant: 20)
169
181
  titleIconWidth.priority = UILayoutPriority(999)
@@ -234,6 +246,11 @@ final class NativeListIdentityCell: NativeListRendererCell {
234
246
  titleIconView.isHidden = true
235
247
  titleIconView.accessibilityLabel = nil
236
248
  titleIconView.isAccessibilityElement = false
249
+ titleImageSlot?.view.isHidden = true
250
+ titleImageSlot?.view.accessibilityLabel = nil
251
+ titleImageSlot?.view.isAccessibilityElement = false
252
+ NSLayoutConstraint.deactivate(titleImageConstraints)
253
+ titleImageConstraints = []
237
254
  titleRowStack.setContentHuggingPriority(.defaultLow, for: .horizontal)
238
255
  titleRowStack.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
239
256
  for label in [titleLabel, subtitleLabel, tertiaryLabel, badgeLabel] {
@@ -291,6 +308,42 @@ final class NativeListIdentityCell: NativeListRendererCell {
291
308
  titleIconView.accessibilityLabel = label.isEmpty ? nil : label
292
309
  titleIconView.isAccessibilityElement = !label.isEmpty
293
310
  titleRowStack.setCustomSpacing(4, after: titleLabel)
311
+ // A long title truncates ahead of the 20pt glyph (Android caps the title
312
+ // at row width minus accessories, Web shrinks the title first); with the
313
+ // title at `.required` the icon's 999 width constraint collapsed to 0.
314
+ titleLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
315
+ }
316
+ if let descriptor = item.data.dictionary("titleImage"),
317
+ let image = descriptor.dictionary("image"), !image.string("uri").isEmpty
318
+ {
319
+ let size = CGFloat(image.double("width"))
320
+ let slot = titleImageSlot ?? NativeListImageSlot()
321
+ titleImageSlot = slot
322
+ let view = slot.view
323
+ if view.superview == nil {
324
+ let index = titleRowStack.arrangedSubviews.firstIndex(of: titleIconView)! + 1
325
+ titleRowStack.insertArrangedSubview(view, at: index)
326
+ }
327
+ view.clipsToBounds = true
328
+ view.setContentHuggingPriority(.required, for: .horizontal)
329
+ view.setContentCompressionResistancePriority(.required, for: .horizontal)
330
+ view.translatesAutoresizingMaskIntoConstraints = false
331
+ view.layer.cornerRadius = size / 2
332
+ view.isHidden = false
333
+ let label = descriptor.string("accessibilityLabel")
334
+ view.accessibilityLabel = label.isEmpty ? nil : label
335
+ view.isAccessibilityElement = !label.isEmpty
336
+ titleImageConstraints = [
337
+ view.widthAnchor.constraint(equalToConstant: size),
338
+ view.heightAnchor.constraint(equalToConstant: size),
339
+ ]
340
+ titleImageConstraints.forEach { $0.priority = UILayoutPriority(999) }
341
+ NSLayoutConstraint.activate(titleImageConstraints)
342
+ titleRowStack.setCustomSpacing(4, after: titleIconView.isHidden ? titleLabel : titleIconView)
343
+ titleLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
344
+ slot.bind(image, key: "\(item.key):titleImage", placeholder: "#00000000")
345
+ } else {
346
+ titleImageSlot?.recycle()
294
347
  }
295
348
  if let ratio = style["leadingContentMaxWidthRatio"] as? NSNumber,
296
349
  root.arrangedSubviews.contains(visual)
@@ -328,7 +381,8 @@ final class NativeListIdentityCell: NativeListRendererCell {
328
381
  } else {
329
382
  titleRowStack.setCustomSpacing(
330
383
  CGFloat(style.double("titleBadgeGap")),
331
- after: titleIconView.isHidden ? titleLabel : titleIconView)
384
+ after: titleImageSlot?.view.isHidden == false ? titleImageSlot!.view
385
+ : titleIconView.isHidden ? titleLabel : titleIconView)
332
386
  }
333
387
  }
334
388
  if style["trailingGap"] != nil { trailingStack.spacing = CGFloat(style.double("trailingGap")) }
@@ -351,8 +405,19 @@ final class NativeListIdentityCell: NativeListRendererCell {
351
405
  : style.dictionary("container")?.string("contentVerticalAlignment") == "bottom"
352
406
  ? .bottom : .center
353
407
  }
408
+ settleTitleRowFillerSpacing()
354
409
  applySelectorTypography(item)
355
410
  }
411
+
412
+ /// The filler is the title row's last arranged view; the stack's default
413
+ /// spacing before it would shave 8pt off the title's available width (visible
414
+ /// under `leadingContentMaxWidthRatio`), so the gap before the filler is zero.
415
+ private func settleTitleRowFillerSpacing() {
416
+ let visible = titleRowStack.arrangedSubviews.filter { $0 !== titleRowFiller && !$0.isHidden }
417
+ if let last = visible.last {
418
+ titleRowStack.setCustomSpacing(0, after: last)
419
+ }
420
+ }
356
421
  override func bindSelectionContent(
357
422
  _ item: NativeListItem,
358
423
  checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String
@@ -391,6 +456,10 @@ final class NativeListIdentityCell: NativeListRendererCell {
391
456
  }
392
457
  override func recycleContent() {
393
458
  visual.recycle()
459
+ titleImageSlot?.recycle()
460
+ titleImageSlot?.view.isHidden = true
461
+ titleImageSlot?.view.accessibilityLabel = nil
462
+ titleImageSlot?.view.isAccessibilityElement = false
394
463
  trailingStack.reset()
395
464
  currentItem = nil
396
465
  }
@@ -593,7 +662,10 @@ final class NativeListIdentityCell: NativeListRendererCell {
593
662
  limit.isActive = true
594
663
  selectorConstraints.append(limit)
595
664
  }
596
- titleRowStack.insertArrangedSubview(label, at: titleRowStack.arrangedSubviews.count - 1)
665
+ titleRowStack.insertArrangedSubview(
666
+ label,
667
+ at: titleRowStack.arrangedSubviews.firstIndex(of: badgeLabel)
668
+ ?? titleRowStack.arrangedSubviews.count)
597
669
  selectorViews.append(label)
598
670
  }
599
671
  }
@@ -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
@@ -1530,7 +1530,10 @@ final class NativeListView: UIView {
1530
1530
  let previous = current.items[index]
1531
1531
  var merged = current.items[index].data
1532
1532
  changes.forEach { key, value in
1533
- if key != "key" && key != "type" { merged[key] = value }
1533
+ if key != "key" && key != "type" {
1534
+ if value is NSNull { merged.removeValue(forKey: key) }
1535
+ else { merged[key] = value }
1536
+ }
1534
1537
  }
1535
1538
  guard let item = try? NativeListItem(data: merged) else { return }
1536
1539
  if rowHeight(previous, usingCompactReorderHeight: false) != rowHeight(item, usingCompactReorderHeight: false) ||
@@ -1554,6 +1557,8 @@ final class NativeListView: UIView {
1554
1557
  }
1555
1558
  if let selected = changes["selected"] as? Bool {
1556
1559
  if selected { current.selectedKeys.insert(item.key) } else { current.selectedKeys.remove(item.key) }
1560
+ } else if changes["selected"] is NSNull {
1561
+ current.selectedKeys.remove(item.key)
1557
1562
  }
1558
1563
  }
1559
1564
  if !changedKeys.isEmpty { invalidateActionAnchor(reason: "snapshot") }
@@ -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>