@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.
- package/android/src/main/java/com/onekey/nativelist/NativeListActivityRenderer.kt +23 -0
- package/android/src/main/java/com/onekey/nativelist/NativeListIdentityRenderer.kt +43 -15
- package/android/src/main/java/com/onekey/nativelist/NativeListLeadingVisual.kt +88 -19
- package/android/src/main/java/com/onekey/nativelist/NativeListModels.kt +94 -1
- package/android/src/main/java/com/onekey/nativelist/NativeListPrimitives.kt +29 -0
- package/android/src/test/java/com/margelo/nitro/nativelist/NativeListActivityHostClipPolicyTest.kt +29 -0
- package/android/src/test/java/com/margelo/nitro/nativelist/NativeListIdentityTrailingAxisTest.kt +36 -0
- package/android/src/test/java/com/margelo/nitro/nativelist/NativeListLeadingSlotFrameTest.kt +68 -0
- package/docs/ROW_TEMPLATES.md +4 -0
- package/docs/SPEC.md +180 -37
- package/ios/NativeListDesignAssets.swift +6 -0
- package/ios/NativeListIdentityRenderer.swift +76 -4
- package/ios/NativeListLeadingVisual.swift +70 -8
- package/ios/RNCNativeListView.swift +6 -1
- package/ios/Resources/NativeListIcons.xcassets/onekey_document2.imageset/Contents.json +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_document2.imageset/icon.svg +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_dot_ver_solid.imageset/Contents.json +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_dot_ver_solid.imageset/icon.svg +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_plus_large.imageset/Contents.json +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_plus_large.imageset/icon.svg +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_ticket.imageset/Contents.json +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_ticket.imageset/icon.svg +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_wallet.imageset/Contents.json +1 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_wallet.imageset/icon.svg +1 -0
- package/ios/tests/NativeListIdentityTitleIconLayoutTests.swift +81 -0
- package/ios/tests/NativeListLeadingVisualPairBackdropTests.swift +50 -0
- package/ios/tests/NativeListLifecycleTests.swift +60 -0
- package/lib/module/NativeList.web.js +9 -12
- package/lib/module/models.js +2 -0
- package/lib/module/validation.js +627 -114
- package/lib/module/web/NativeListWebEngine.js +49 -9
- package/lib/module/web/templates/IdentityRowRenderer.js +58 -4
- package/lib/typescript/src/models.d.ts +25 -14
- package/lib/typescript/src/validation.d.ts +7 -2
- package/package.json +3 -3
- package/src/NativeList.web.tsx +10 -14
- package/src/models.ts +144 -153
- package/src/validation.ts +1617 -167
- package/src/web/NativeListWebEngine.ts +62 -10
- 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.
|
|
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.
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
|
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
|
|
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:
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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" {
|
|
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>
|