@onekeyfe/react-native-native-list 6.7.0-alpha.398 → 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/NativeListIdentityRenderer.kt +33 -2
- package/android/src/main/java/com/onekey/nativelist/NativeListModels.kt +3 -1
- package/docs/ROW_TEMPLATES.md +4 -0
- package/docs/SPEC.md +121 -35
- package/ios/NativeListIdentityRenderer.swift +50 -3
- package/ios/RNCNativeListView.swift +6 -1
- 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 +3 -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 +6 -10
- package/src/web/templates/IdentityRowRenderer.ts +70 -4
|
@@ -31,6 +31,7 @@ internal class NativeListIdentityRowView(context: ThemedReactContext, fonts: Nat
|
|
|
31
31
|
private val titleLine = PackedTitleLineLayout(context)
|
|
32
32
|
private val title = NativeListTextView(context)
|
|
33
33
|
private val titleIcon = OneKeyIconView(context)
|
|
34
|
+
private var titleImage: NativeListImageSlot? = null
|
|
34
35
|
private val subtitle = NativeListTextView(context)
|
|
35
36
|
private val tertiary = NativeListTextView(context)
|
|
36
37
|
private val badgeLine = NativeListTextView(context)
|
|
@@ -79,7 +80,7 @@ internal class NativeListIdentityRowView(context: ThemedReactContext, fonts: Nat
|
|
|
79
80
|
"single"
|
|
80
81
|
else item.json.optString("groupPosition")
|
|
81
82
|
|
|
82
|
-
override val assetFields = listOf("leading")
|
|
83
|
+
override val assetFields = listOf("leading", "titleImage")
|
|
83
84
|
|
|
84
85
|
override fun usesSourceScale(item: NativeListItem, provided: Boolean) =
|
|
85
86
|
item.usesSelectorSourceScale || provided
|
|
@@ -262,6 +263,29 @@ internal class NativeListIdentityRowView(context: ThemedReactContext, fonts: Nat
|
|
|
262
263
|
leadingActionIcon.useSourceScale = sourceScale
|
|
263
264
|
leadingActionIcon.setOnClickListener(null)
|
|
264
265
|
bindIdentity(item, theme, item.json.optBoolean("selected"), checkboxState)
|
|
266
|
+
val titleImageDescriptor = item.json.optJSONObject("titleImage")
|
|
267
|
+
val titleImageSource = titleImageDescriptor?.optJSONObject("image")
|
|
268
|
+
if (titleImageSource != null && titleImageSource.optString("uri").isNotEmpty()) {
|
|
269
|
+
if (item.json.optString("presentation") == "walletSidebar") {
|
|
270
|
+
titleLine.packsChildrenAtStart = true
|
|
271
|
+
title.layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
|
|
272
|
+
}
|
|
273
|
+
val slot = titleImage ?: NativeListImageSlot(reactContext).also { titleImage = it }
|
|
274
|
+
// Reinsert after icons so newly added suffixes cannot precede the image.
|
|
275
|
+
titleLine.removeView(slot.view)
|
|
276
|
+
titleLine.addView(slot.view, titleLine.indexOfChild(titleIcon) + 1)
|
|
277
|
+
val size = stylePx(titleImageSource.optDouble("width"))
|
|
278
|
+
slot.view.layoutParams = LayoutParams(size, size).apply { marginStart = dp(4) }
|
|
279
|
+
slot.view.visibility = VISIBLE
|
|
280
|
+
slot.view.contentDescription = titleImageDescriptor.optString("accessibilityLabel").takeIf(String::isNotEmpty)
|
|
281
|
+
slot.view.importantForAccessibility = if (slot.view.contentDescription == null)
|
|
282
|
+
View.IMPORTANT_FOR_ACCESSIBILITY_NO else View.IMPORTANT_FOR_ACCESSIBILITY_YES
|
|
283
|
+
slot.bind(titleImageSource, "${item.key}:titleImage", placeholder = "#00000000", round = true)
|
|
284
|
+
} else {
|
|
285
|
+
titleImage?.recycle()
|
|
286
|
+
titleImage?.view?.visibility = GONE
|
|
287
|
+
titleImage?.view?.contentDescription = null
|
|
288
|
+
}
|
|
265
289
|
TextViewCompat.setLineHeight(
|
|
266
290
|
title,
|
|
267
291
|
dp(if (item.json.optString("presentation") == "walletSidebar") 16 else 24),
|
|
@@ -449,7 +473,10 @@ internal class NativeListIdentityRowView(context: ThemedReactContext, fonts: Nat
|
|
|
449
473
|
val inner = (MeasureSpec.getSize(widthMeasureSpec) - paddingLeft - paddingRight)
|
|
450
474
|
.coerceAtLeast(0)
|
|
451
475
|
val leading = leadingFrame.layoutParams as LayoutParams
|
|
452
|
-
val
|
|
476
|
+
val imageWidth = titleImage?.view?.takeIf { it.visibility == VISIBLE }?.let {
|
|
477
|
+
it.layoutParams.width + dp(4)
|
|
478
|
+
} ?: 0
|
|
479
|
+
val icon = (if (titleIcon.visibility == VISIBLE) dp(24) else 0) + imageWidth
|
|
453
480
|
(inner * ratio).roundToInt()
|
|
454
481
|
.minus(leading.width + leading.marginEnd + icon)
|
|
455
482
|
.coerceAtLeast(0)
|
|
@@ -555,6 +582,9 @@ internal class NativeListIdentityRowView(context: ThemedReactContext, fonts: Nat
|
|
|
555
582
|
longPressPoint = null
|
|
556
583
|
longPressPointEpoch = -1L
|
|
557
584
|
leadingFrame.recycle()
|
|
585
|
+
titleImage?.recycle()
|
|
586
|
+
titleImage?.view?.visibility = GONE
|
|
587
|
+
titleImage?.view?.contentDescription = null
|
|
558
588
|
trailingColumn.reset()
|
|
559
589
|
currentItem = null
|
|
560
590
|
}
|
|
@@ -562,6 +592,7 @@ internal class NativeListIdentityRowView(context: ThemedReactContext, fonts: Nat
|
|
|
562
592
|
override fun disposeContent() {
|
|
563
593
|
recycleContent()
|
|
564
594
|
leadingFrame.dispose()
|
|
595
|
+
titleImage?.dispose()
|
|
565
596
|
}
|
|
566
597
|
|
|
567
598
|
private fun bindIdentity(
|
|
@@ -378,7 +378,9 @@ internal fun mergeRow(original: JSONObject, changes: JSONObject): JSONObject {
|
|
|
378
378
|
val keys = changes.keys()
|
|
379
379
|
while (keys.hasNext()) {
|
|
380
380
|
val key = keys.next()
|
|
381
|
-
if (key != "key" && key != "type")
|
|
381
|
+
if (key != "key" && key != "type") {
|
|
382
|
+
if (changes.isNull(key)) result.remove(key) else result.put(key, changes.get(key))
|
|
383
|
+
}
|
|
382
384
|
}
|
|
383
385
|
return result
|
|
384
386
|
}
|
package/docs/ROW_TEMPLATES.md
CHANGED
|
@@ -59,6 +59,10 @@ separately from source implementation in STYLE_SPEC §9.
|
|
|
59
59
|
a 4-point gap. It has no click action; an optional Web `hoverActionKey`
|
|
60
60
|
emits an icon-anchored tooltip request on hover or touch. A trailing `button` is the fixed small
|
|
61
61
|
secondary text action and uses the normal accessory event slot.
|
|
62
|
+
- **Title image:** optional `titleImage` renders a circular image after the title
|
|
63
|
+
icons and before suffixes/badges. Its `ImageSource` dimensions must be square,
|
|
64
|
+
1...40 logical units. Optional Web `hoverActionKey` uses the existing anchored
|
|
65
|
+
help event; native keeps the image decorative for touch. See SPEC.md.
|
|
62
66
|
- **Second Web help icon:** `secondaryTitleIcon` uses the same descriptor after
|
|
63
67
|
`titleIcon`; each glyph has its own hover/touch anchor. Native ignores it.
|
|
64
68
|
- **Decorative refresh:** a trailing `RefreshCcwOutline` without `actionKey`
|
package/docs/SPEC.md
CHANGED
|
@@ -176,6 +176,57 @@ after semantic text styling. Both lines retain their default size, tone, line
|
|
|
176
176
|
height, right alignment and accessibility text. These fields add no action or
|
|
177
177
|
hit target and travel in existing snapshot JSON without a Nitro ABI change.
|
|
178
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
|
+
|
|
179
230
|
### Identity row risk-management controls
|
|
180
231
|
|
|
181
232
|
Status: implemented in source for local consumers, unreleased. Focused validation,
|
|
@@ -445,37 +496,61 @@ row positions. An accepted complete snapshot reuses that index only when its
|
|
|
445
496
|
generation and ordered `(key, type)` sequence match the prior accepted
|
|
446
497
|
snapshot; otherwise it replaces the index. Reorder commits rebuild it and
|
|
447
498
|
disposal clears it. Patch batches reuse the index and never include retiring
|
|
448
|
-
presentation rows in it.
|
|
449
|
-
|
|
450
|
-
|
|
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
|
|
451
503
|
presentation lookup after a valid patch, preserving any retiring rows.
|
|
452
504
|
|
|
453
505
|
### Validation environments and rejected input
|
|
454
506
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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.
|
|
479
554
|
|
|
480
555
|
`planNativeListPatches(previous, next)` is an explicit, pure public helper for
|
|
481
556
|
callers that own two valid, immutable snapshots. It returns a readonly empty
|
|
@@ -498,14 +573,25 @@ Rejection emits a diagnostic without including row keys, text or image URLs.
|
|
|
498
573
|
Explicit calls to the exported validation functions still throw on rejected
|
|
499
574
|
input, so development tooling can inspect the full contract failure. Rejection
|
|
500
575
|
does not imply that the requested update succeeded. No row identity is trimmed,
|
|
501
|
-
truncated or regenerated, and no
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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.
|
|
509
595
|
|
|
510
596
|
## Platform contract
|
|
511
597
|
|
|
@@ -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,8 @@ 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] = []
|
|
110
115
|
// Absorbs the title row's slack so the title keeps its intrinsic width and the
|
|
111
116
|
// title icon / badge follow the text (OK-66041); Android's PackedTitleLineLayout
|
|
112
117
|
// and the Web flex row already pack the title row at its start.
|
|
@@ -131,7 +136,7 @@ final class NativeListIdentityCell: NativeListRendererCell {
|
|
|
131
136
|
private var currentItem: NativeListItem?
|
|
132
137
|
private var currentTheme: [String: Any]?
|
|
133
138
|
private var leadingActionKey: String?
|
|
134
|
-
override var assetFields: [String] { ["leading"] }
|
|
139
|
+
override var assetFields: [String] { ["leading", "titleImage"] }
|
|
135
140
|
override var defaultSeparatorInset: CGFloat { 60 }
|
|
136
141
|
override var defaultCornerRadius: CGFloat {
|
|
137
142
|
guard let item = currentItem else { return 0 }
|
|
@@ -241,6 +246,11 @@ final class NativeListIdentityCell: NativeListRendererCell {
|
|
|
241
246
|
titleIconView.isHidden = true
|
|
242
247
|
titleIconView.accessibilityLabel = nil
|
|
243
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 = []
|
|
244
254
|
titleRowStack.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
245
255
|
titleRowStack.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
|
|
246
256
|
for label in [titleLabel, subtitleLabel, tertiaryLabel, badgeLabel] {
|
|
@@ -303,6 +313,38 @@ final class NativeListIdentityCell: NativeListRendererCell {
|
|
|
303
313
|
// title at `.required` the icon's 999 width constraint collapsed to 0.
|
|
304
314
|
titleLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
|
|
305
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()
|
|
347
|
+
}
|
|
306
348
|
if let ratio = style["leadingContentMaxWidthRatio"] as? NSNumber,
|
|
307
349
|
root.arrangedSubviews.contains(visual)
|
|
308
350
|
{
|
|
@@ -339,7 +381,8 @@ final class NativeListIdentityCell: NativeListRendererCell {
|
|
|
339
381
|
} else {
|
|
340
382
|
titleRowStack.setCustomSpacing(
|
|
341
383
|
CGFloat(style.double("titleBadgeGap")),
|
|
342
|
-
after:
|
|
384
|
+
after: titleImageSlot?.view.isHidden == false ? titleImageSlot!.view
|
|
385
|
+
: titleIconView.isHidden ? titleLabel : titleIconView)
|
|
343
386
|
}
|
|
344
387
|
}
|
|
345
388
|
if style["trailingGap"] != nil { trailingStack.spacing = CGFloat(style.double("trailingGap")) }
|
|
@@ -413,6 +456,10 @@ final class NativeListIdentityCell: NativeListRendererCell {
|
|
|
413
456
|
}
|
|
414
457
|
override func recycleContent() {
|
|
415
458
|
visual.recycle()
|
|
459
|
+
titleImageSlot?.recycle()
|
|
460
|
+
titleImageSlot?.view.isHidden = true
|
|
461
|
+
titleImageSlot?.view.accessibilityLabel = nil
|
|
462
|
+
titleImageSlot?.view.isAccessibilityElement = false
|
|
416
463
|
trailingStack.reset()
|
|
417
464
|
currentItem = nil
|
|
418
465
|
}
|
|
@@ -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") }
|
|
@@ -4,6 +4,25 @@ import XCTest
|
|
|
4
4
|
|
|
5
5
|
@MainActor
|
|
6
6
|
final class NativeListLifecycleTests: XCTestCase {
|
|
7
|
+
func testCanonicalNullPatchRemovesStoredOptionalFields() throws {
|
|
8
|
+
let view = NativeListView(frame: CGRect(x: 0, y: 0, width: 320, height: 480))
|
|
9
|
+
let row: [String: Any] = ["key": "token", "type": "identity", "title": "Token",
|
|
10
|
+
"leading": ["kind": "token"], "subtitle": "Old",
|
|
11
|
+
"titleImage": ["image": ["uri": "data:image/png;base64,AA==", "width": 14, "height": 14]]]
|
|
12
|
+
let snapshot: [String: Any] = ["schemaVersion": 1, "generation": 1,
|
|
13
|
+
"layout": ["kind": "linear"], "rows": [row]]
|
|
14
|
+
view.applySnapshotJson(String(decoding: try JSONSerialization.data(withJSONObject: snapshot), as: UTF8.self))
|
|
15
|
+
let patches: [[String: Any]] = [["key": "token", "type": "identity",
|
|
16
|
+
"changes": ["title": "New", "subtitle": NSNull(), "titleImage": NSNull()]]]
|
|
17
|
+
view.applyPatchesJson(String(decoding: try JSONSerialization.data(withJSONObject: patches), as: UTF8.self))
|
|
18
|
+
let items = try XCTUnwrap(Mirror(reflecting: view).descendant("itemsByKey") as? [String: NativeListItem])
|
|
19
|
+
let accepted = try XCTUnwrap(items["token"])
|
|
20
|
+
XCTAssertEqual(accepted.data.string("title"), "New")
|
|
21
|
+
XCTAssertNil(accepted.data["subtitle"])
|
|
22
|
+
XCTAssertNil(accepted.data["titleImage"])
|
|
23
|
+
XCTAssertEqual(accepted.key, "token")
|
|
24
|
+
}
|
|
25
|
+
|
|
7
26
|
func testAcceptedRowIndexReusesStableShapeAndRejectsLateInvalidPatch() throws {
|
|
8
27
|
let view = NativeListView(frame: CGRect(x: 0, y: 0, width: 320, height: 480))
|
|
9
28
|
func snapshot(_ keys: [String], generation: Int, title: String = "Old") throws -> String {
|
|
@@ -229,6 +248,47 @@ final class NativeListLifecycleTests: XCTestCase {
|
|
|
229
248
|
XCTAssertNotEqual(cell.bindingEpoch, changedEpoch)
|
|
230
249
|
}
|
|
231
250
|
|
|
251
|
+
func testIdentityTitleImageRetainsRequestAndClearsOnProductionCellReuse() throws {
|
|
252
|
+
let cell = NativeListIdentityCell(frame: CGRect(x: 0, y: 0, width: 320, height: 60))
|
|
253
|
+
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 200))
|
|
254
|
+
window.addSubview(cell)
|
|
255
|
+
func bind(_ descriptor: [String: Any]?, title: String = "STOCK") throws {
|
|
256
|
+
var data: [String: Any] = ["key": "stock", "type": "identity", "title": title,
|
|
257
|
+
"height": 60, "leading": ["kind": "token", "fallbackIcon": ["name": "CryptoCoinOutline"]]]
|
|
258
|
+
if let descriptor { data["titleImage"] = descriptor }
|
|
259
|
+
cell.bind(item: try NativeListItem(data: data), theme: nil, layout: "linear", selected: false,
|
|
260
|
+
checkboxState: { _, _, fallback in fallback }, fonts: NativeListFontFamilies())
|
|
261
|
+
cell.layoutIfNeeded()
|
|
262
|
+
}
|
|
263
|
+
let descriptor: [String: Any] = ["image": ["uri": "data:image/png;base64,AA==", "width": 14, "height": 14],
|
|
264
|
+
"accessibilityLabel": "Issuer", "hoverActionKey": "issuer-info"]
|
|
265
|
+
try bind(descriptor)
|
|
266
|
+
let slot = try XCTUnwrap(Mirror(reflecting: cell).children.first {
|
|
267
|
+
$0.label == "titleImageSlot"
|
|
268
|
+
}?.value as? NativeListImageSlot)
|
|
269
|
+
XCTAssertFalse(slot.view.isHidden)
|
|
270
|
+
XCTAssertEqual(slot.view.bounds.width, 14, accuracy: 0.5)
|
|
271
|
+
XCTAssertEqual(slot.view.bounds.height, 14, accuracy: 0.5)
|
|
272
|
+
XCTAssertEqual(slot.view.layer.cornerRadius, 7, accuracy: 0.1)
|
|
273
|
+
XCTAssertEqual(slot.view.accessibilityLabel, "Issuer")
|
|
274
|
+
let identity = Mirror(reflecting: slot).children.first { $0.label == "identity" }?.value as? String
|
|
275
|
+
XCTAssertNotNil(identity)
|
|
276
|
+
try bind(descriptor, title: "Changed title")
|
|
277
|
+
XCTAssertEqual(Mirror(reflecting: slot).children.first { $0.label == "identity" }?.value as? String, identity)
|
|
278
|
+
try bind(["image": ["uri": "data:image/png;base64,AQ==", "width": 20, "height": 20]])
|
|
279
|
+
XCTAssertEqual(slot.view.bounds.width, 20, accuracy: 0.5)
|
|
280
|
+
XCTAssertEqual(slot.view.layer.cornerRadius, 10, accuracy: 0.1)
|
|
281
|
+
XCTAssertNil(slot.view.accessibilityLabel)
|
|
282
|
+
XCTAssertNotEqual(Mirror(reflecting: slot).children.first { $0.label == "identity" }?.value as? String, identity)
|
|
283
|
+
try bind(nil)
|
|
284
|
+
XCTAssertTrue(slot.view.isHidden)
|
|
285
|
+
XCTAssertNil(Mirror(reflecting: slot).children.first { $0.label == "identity" }?.value as? String)
|
|
286
|
+
try bind(descriptor)
|
|
287
|
+
cell.prepareForReuse()
|
|
288
|
+
XCTAssertTrue(slot.view.isHidden)
|
|
289
|
+
XCTAssertNil(slot.view.accessibilityLabel)
|
|
290
|
+
}
|
|
291
|
+
|
|
232
292
|
func testRiskIdentityTitleIconAndButtonResetOnProductionCellBind() throws {
|
|
233
293
|
let cell = NativeListIdentityCell(frame: CGRect(x: 0, y: 0, width: 320, height: 56))
|
|
234
294
|
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 200))
|
|
@@ -4,7 +4,7 @@ import { useNativeListFontFamiliesJson } from "./fonts.js";
|
|
|
4
4
|
import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
5
5
|
import { View } from 'react-native';
|
|
6
6
|
import { normalizeIndexScroll, normalizeKeyScroll, normalizePositionScroll, resolveLocationIndex, scrollFailure, validateOffset } from "./scrolling.js";
|
|
7
|
-
import { applyRowPatches, containNativeListInput, emptyNativeListSnapshot,
|
|
7
|
+
import { applyRowPatches, containNativeListInput, emptyNativeListSnapshot, validateSnapshot } from "./validation.js";
|
|
8
8
|
import { NativeListWebEngine } from "./web/NativeListWebEngine.js";
|
|
9
9
|
import { acquireNativeListAvatar, canonicalNativeListAvatarUri } from "./web/NativeListWebAvatarCache.js";
|
|
10
10
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
@@ -255,18 +255,15 @@ export const NativeList = /*#__PURE__*/forwardRef(function NativeList({
|
|
|
255
255
|
synchronizeSlots(next);
|
|
256
256
|
},
|
|
257
257
|
applyPatches(patches) {
|
|
258
|
-
const
|
|
259
|
-
if (
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
const next = containNativeListInput(() => applyRowPatches(snapshotRef.current, accepted));
|
|
266
|
-
if (next) snapshotRef.current = next;
|
|
267
|
-
}
|
|
268
|
-
acceptedSnapshotRef.current = snapshotRef.current;
|
|
258
|
+
const engine = engineRef.current;
|
|
259
|
+
if (engine) {
|
|
260
|
+
engine.applyPatches(patches);
|
|
261
|
+
snapshotRef.current = engine.currentSnapshot();
|
|
262
|
+
} else {
|
|
263
|
+
const next = containNativeListInput(() => applyRowPatches(snapshotRef.current, patches));
|
|
264
|
+
if (next) snapshotRef.current = next;
|
|
269
265
|
}
|
|
266
|
+
acceptedSnapshotRef.current = snapshotRef.current;
|
|
270
267
|
},
|
|
271
268
|
reconcileSelection(selectedKeys) {
|
|
272
269
|
engineRef.current?.reconcileSelection(selectedKeys);
|
package/lib/module/models.js
CHANGED
|
@@ -54,4 +54,6 @@ export const ROW_BOX_STYLE_KEYS_BY_TYPE = {
|
|
|
54
54
|
// type CommonPatchFields = 'revision' | 'disabled' | 'selected' | 'separator';
|
|
55
55
|
// OneKey patch: balance patches also refresh the existing row's spoken content.
|
|
56
56
|
// type CommonPatchFields = 'revision' | 'disabled' | 'selected' | 'separator' | 'height' | 'heightRounding' | 'opacity' | 'pressDisabled';
|
|
57
|
+
|
|
58
|
+
/** Null removes optional fields; undefined leaves the existing value unchanged. */
|
|
57
59
|
//# sourceMappingURL=models.js.map
|