@onekeyfe/react-native-native-list 6.7.0-alpha.398 → 6.7.0-alpha.400
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/android/src/main/java/com/onekey/nativelist/NativeListView.kt +31 -8
- package/docs/ROW_TEMPLATES.md +4 -0
- package/docs/SPEC.md +161 -35
- package/ios/NativeListIdentityRenderer.swift +50 -3
- package/ios/RNCNativeListView.swift +6 -1
- package/ios/tests/NativeListLifecycleTests.swift +73 -4
- 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
|
}
|
|
@@ -46,6 +46,7 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
|
|
46
46
|
import com.facebook.react.uimanager.ThemedReactContext
|
|
47
47
|
import com.facebook.react.uimanager.PointerEvents
|
|
48
48
|
import com.facebook.react.uimanager.ReactPointerEventsView
|
|
49
|
+
import com.facebook.react.uimanager.events.NativeGestureUtil
|
|
49
50
|
import com.margelo.nitro.nativelogger.OneKeyLog
|
|
50
51
|
import org.json.JSONArray
|
|
51
52
|
import org.json.JSONObject
|
|
@@ -221,6 +222,14 @@ class NativeListView(
|
|
|
221
222
|
if (isLayoutSuppressed) return
|
|
222
223
|
super.onLayout(changed, left, top, right, bottom)
|
|
223
224
|
}
|
|
225
|
+
|
|
226
|
+
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
|
|
227
|
+
val handled = super.dispatchTouchEvent(event)
|
|
228
|
+
if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) {
|
|
229
|
+
finishPagerNativeGesture(event)
|
|
230
|
+
}
|
|
231
|
+
return handled
|
|
232
|
+
}
|
|
224
233
|
}
|
|
225
234
|
private var configuredTopPaddingPx = 0
|
|
226
235
|
private var slotInsetPx = 0
|
|
@@ -1843,6 +1852,7 @@ class NativeListView(
|
|
|
1843
1852
|
// OneKey patch: vertical lists retain vertical drags and leave horizontal drags to a parent pager.
|
|
1844
1853
|
private val pagerGestureTouchSlop = ViewConfiguration.get(context).scaledTouchSlop
|
|
1845
1854
|
private var pagerGestureIsVertical = false
|
|
1855
|
+
private var pagerNativeGestureStarted = false
|
|
1846
1856
|
private val pagerGestureTouchListener = object : RecyclerView.SimpleOnItemTouchListener() {
|
|
1847
1857
|
private var downX = 0f
|
|
1848
1858
|
private var downY = 0f
|
|
@@ -1856,30 +1866,43 @@ class NativeListView(
|
|
|
1856
1866
|
downY = event.y
|
|
1857
1867
|
directionResolved = false
|
|
1858
1868
|
pagerGestureIsVertical = false
|
|
1869
|
+
pagerNativeGestureStarted = false
|
|
1859
1870
|
}
|
|
1860
|
-
MotionEvent.ACTION_MOVE ->
|
|
1871
|
+
MotionEvent.ACTION_MOVE -> {
|
|
1861
1872
|
val deltaX = abs(event.x - downX)
|
|
1862
1873
|
val deltaY = abs(event.y - downY)
|
|
1863
|
-
if (max(deltaX, deltaY) > pagerGestureTouchSlop) {
|
|
1874
|
+
if (!directionResolved && max(deltaX, deltaY) > pagerGestureTouchSlop) {
|
|
1864
1875
|
directionResolved = true
|
|
1865
1876
|
pagerGestureIsVertical = deltaY >= deltaX
|
|
1866
1877
|
recyclerView.parent?.requestDisallowInterceptTouchEvent(pagerGestureIsVertical)
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1878
|
+
} else if (!pagerGestureIsVertical && deltaY > pagerGestureTouchSlop && deltaY >= deltaX) {
|
|
1879
|
+
pagerGestureIsVertical = true
|
|
1880
|
+
recyclerView.parent?.requestDisallowInterceptTouchEvent(true)
|
|
1881
|
+
}
|
|
1882
|
+
if (pagerGestureIsVertical && !pagerNativeGestureStarted) {
|
|
1883
|
+
NativeGestureUtil.notifyNativeGestureStarted(this@NativeListView, event)
|
|
1884
|
+
pagerNativeGestureStarted = true
|
|
1885
|
+
if (keyboardDismissMode == NativeListKeyboardDismissMode.ON_DRAG) {
|
|
1871
1886
|
dismissKeyboard()
|
|
1872
1887
|
}
|
|
1873
1888
|
}
|
|
1874
1889
|
}
|
|
1875
1890
|
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
|
1876
|
-
|
|
1877
|
-
pagerGestureIsVertical = false
|
|
1891
|
+
finishPagerNativeGesture(event)
|
|
1878
1892
|
}
|
|
1879
1893
|
}
|
|
1880
1894
|
return false
|
|
1881
1895
|
}
|
|
1882
1896
|
}
|
|
1897
|
+
|
|
1898
|
+
private fun finishPagerNativeGesture(event: MotionEvent) {
|
|
1899
|
+
if (pagerNativeGestureStarted) {
|
|
1900
|
+
NativeGestureUtil.notifyNativeGestureEnded(this, event)
|
|
1901
|
+
pagerNativeGestureStarted = false
|
|
1902
|
+
}
|
|
1903
|
+
recyclerView.parent?.requestDisallowInterceptTouchEvent(false)
|
|
1904
|
+
pagerGestureIsVertical = false
|
|
1905
|
+
}
|
|
1883
1906
|
// Intercept only a completed tap so RecyclerView keeps drag and reorder ownership.
|
|
1884
1907
|
private val keyboardTapTouchListener = object : RecyclerView.SimpleOnItemTouchListener() {
|
|
1885
1908
|
private var downX = 0f
|
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,
|
|
@@ -333,6 +384,46 @@ image that already loaded. Common container styling clears what it wrote on the
|
|
|
333
384
|
next bind, including properties it set on template children. UI mutation remains on the platform
|
|
334
385
|
UI thread; [DESIGN.md](DESIGN.md) describes update and event ownership.
|
|
335
386
|
|
|
387
|
+
### Android vertical-drag React touch cancellation
|
|
388
|
+
|
|
389
|
+
Status: implemented; four real Android MotionEvent/JSTouchDispatcher regressions
|
|
390
|
+
passed on a dedicated API 34 emulator on 2026-10-30. The focused regression is
|
|
391
|
+
in `native-tests/android/NativeListTouchDeliveryTest.kt`; build and run
|
|
392
|
+
instructions are in that directory's README. Physical-device press/scroll
|
|
393
|
+
acceptance remains separate.
|
|
394
|
+
|
|
395
|
+
This behavior applies to every vertical NativeList, including standalone lists
|
|
396
|
+
outside a CollapsiblePager and touches beginning on React container slots.
|
|
397
|
+
On the UI thread, movement exceeding Android touch slop with vertical
|
|
398
|
+
displacement at least equal to horizontal displacement confirms a vertical
|
|
399
|
+
drag. An initially horizontal movement may become vertical while the list still
|
|
400
|
+
receives the stream; once vertical, ownership stays vertical until release or
|
|
401
|
+
cancellation. Horizontal lists retain their existing touch behavior.
|
|
402
|
+
|
|
403
|
+
The list notifies its React root of native gesture ownership once per confirmed
|
|
404
|
+
drag, cancelling the active React touch before finger-up. Later MOVE events MUST
|
|
405
|
+
NOT repeat that notification. The listener does not consume the stream: native
|
|
406
|
+
RecyclerView scrolling continues. A stationary tap or movement below touch slop
|
|
407
|
+
MUST retain ordinary React touch-end delivery without native-gesture start/end
|
|
408
|
+
notifications. The existing `keyboardDismissMode: 'on-drag'` behavior shares the
|
|
409
|
+
confirmed-drag boundary.
|
|
410
|
+
|
|
411
|
+
ACTION_UP and ACTION_CANCEL finish a started native gesture exactly once and
|
|
412
|
+
release the parent's interception restriction, even after RecyclerView has
|
|
413
|
+
intercepted its child and stops invoking the item-touch interception listener.
|
|
414
|
+
They clear vertical ownership; the next ACTION_DOWN resets direction and
|
|
415
|
+
gesture tracking. Android's touch-target removal cancellation must finish the
|
|
416
|
+
old stream before a retained list is reattached or its slots are reused, so the
|
|
417
|
+
next tap is not suppressed by the previous drag. Disposal releases the listener
|
|
418
|
+
through the existing detached-resource cleanup. This change adds no prop or
|
|
419
|
+
callback and changes neither iOS nor Web touch handling.
|
|
420
|
+
|
|
421
|
+
Conformance uses real MotionEvent dispatch through NativeListView/RecyclerView
|
|
422
|
+
and React Native's JSTouchDispatcher: vertical drag produces one touch-cancel
|
|
423
|
+
and one native end, a tap produces touch-end, and cancellation/removal followed
|
|
424
|
+
by reuse leaves the next tap functional. This proves native touch delivery, not
|
|
425
|
+
execution of a JavaScript press handler or physical-device gesture feel.
|
|
426
|
+
|
|
336
427
|
### Android reorder cancellation
|
|
337
428
|
|
|
338
429
|
Status: implemented; three real Android View/callback regressions verified on
|
|
@@ -445,37 +536,61 @@ row positions. An accepted complete snapshot reuses that index only when its
|
|
|
445
536
|
generation and ordered `(key, type)` sequence match the prior accepted
|
|
446
537
|
snapshot; otherwise it replaces the index. Reorder commits rebuild it and
|
|
447
538
|
disposal clears it. Patch batches reuse the index and never include retiring
|
|
448
|
-
presentation rows in it.
|
|
449
|
-
|
|
450
|
-
|
|
539
|
+
presentation rows in it. Native hosts validate each received canonical batch before changing config or
|
|
540
|
+
presentation; invalid native batches leave both unchanged. The shared production
|
|
541
|
+
JS boundary first isolates rejected row/group units, so a native batch contains
|
|
542
|
+
only accepted canonical updates. iOS updates only changed accepted entries in its
|
|
451
543
|
presentation lookup after a valid patch, preserving any retiring rows.
|
|
452
544
|
|
|
453
545
|
### Validation environments and rejected input
|
|
454
546
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
547
|
+
Status: implemented in source; acceptance and remaining runtime gaps are recorded below.
|
|
548
|
+
|
|
549
|
+
Development runs strict contract assertions after shared null/empty-image
|
|
550
|
+
normalization. Production sanitizes optional decorations without rejecting
|
|
551
|
+
valid business content: invalid colors/styles are omitted, invalid images use
|
|
552
|
+
existing placeholders, and positive title-image dimensions are clamped to a
|
|
553
|
+
square of 1...40 logical units. Image headers are string dictionaries, never
|
|
554
|
+
interpreted as geometry. Unsafe object graphs are rejected before normalization.
|
|
555
|
+
|
|
556
|
+
A complete snapshot remains atomic after decoration sanitization: invalid
|
|
557
|
+
schema, duplicate/missing identities, required business fields or wallet-group
|
|
558
|
+
parent/children relationships reject the snapshot. No row is silently removed.
|
|
559
|
+
Production normalizes contiguous group positions and filters selection to
|
|
560
|
+
selectable keys, respecting none/single modes.
|
|
561
|
+
|
|
562
|
+
Patches use omitted/undefined fields as no-ops. Null explicitly removes an
|
|
563
|
+
optional field on JS, Web, iOS and Android; required fields cannot be cleared.
|
|
564
|
+
Empty optional images and empty top-level segment arrays normalize to null in
|
|
565
|
+
patches and absence in snapshots. Empty images remove their outer titleImage
|
|
566
|
+
or media wrapper. This protocol requires native binaries with null-removal
|
|
567
|
+
support; callers must not enable it against older native SDK implementations.
|
|
568
|
+
|
|
569
|
+
Market price remains required and nonblank. Updating nonempty priceSegments
|
|
570
|
+
requires a price in the same canonical patch; callers own matching formatted
|
|
571
|
+
text (the validator does not reconstruct subscript prices). Updating price
|
|
572
|
+
without segments clears an existing overlay. For identity/market subtitles,
|
|
573
|
+
updating one representation clears the old other representation unless both
|
|
574
|
+
are supplied; clearing segments alone retains the base text. Activity has no
|
|
575
|
+
subtitle pair. Market quote changes with an existing/new webTable must include
|
|
576
|
+
matching table data or explicitly remove webTable.
|
|
577
|
+
|
|
578
|
+
Development rejects invalid patch batches atomically. Production isolates
|
|
579
|
+
failures to each target key and its coupled groupId: all updates to a key are
|
|
580
|
+
merged in order before validation, and a failure rejects that unit while other
|
|
581
|
+
valid units commit. Financial-value and interactive accessories with missing
|
|
582
|
+
required data fail their row; malformed purely decorative accessories are
|
|
583
|
+
removed. Diagnostics contain locations, never row values or image URLs.
|
|
584
|
+
|
|
585
|
+
All targets share the canonical accepted patches. The accepted JS rows and
|
|
586
|
+
serialized native changes derive from the same result. Native owned snapshots
|
|
587
|
+
and accepted Web engine snapshots validate only changed rows during patches;
|
|
588
|
+
unchanged rows are not rescanned. Direct helpers receiving an unowned baseline
|
|
589
|
+
validate that baseline once before incremental merging. Native owned snapshots
|
|
590
|
+
are immutable and indexed by key. Initial invalid snapshots render empty;
|
|
591
|
+
rejected later snapshots retain the last accepted snapshot. Rejected patch
|
|
592
|
+
units retain their previous complete rows. Direct validators still throw on
|
|
593
|
+
unrecoverable input. Image loading failures remain the loader's responsibility.
|
|
479
594
|
|
|
480
595
|
`planNativeListPatches(previous, next)` is an explicit, pure public helper for
|
|
481
596
|
callers that own two valid, immutable snapshots. It returns a readonly empty
|
|
@@ -498,14 +613,25 @@ Rejection emits a diagnostic without including row keys, text or image URLs.
|
|
|
498
613
|
Explicit calls to the exported validation functions still throw on rejected
|
|
499
614
|
input, so development tooling can inspect the full contract failure. Rejection
|
|
500
615
|
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
|
-
|
|
616
|
+
truncated or regenerated, and no row is silently dropped from a snapshot.
|
|
617
|
+
|
|
618
|
+
Acceptance for this revision: 429 JS/Web tests pass, including native React
|
|
619
|
+
wrapper containment, canonical null/undefined semantics, price/segment pairing,
|
|
620
|
+
production row/group isolation and unchanged-row access checks. Package type
|
|
621
|
+
checks, lint (zero errors; existing warnings) and test-integrity checks pass.
|
|
622
|
+
iOS production/XCTest targets and Android production/instrumentation targets
|
|
623
|
+
compile; 29 Android JVM tests pass. Four focused iOS 26.5 XCTest cases verify
|
|
624
|
+
title-icon packing, title-image reuse and null removal. Android 15/API 35
|
|
625
|
+
instrumentation verifies wallet-sidebar image bounds, long-title ellipsis,
|
|
626
|
+
and full-width title restoration after removal. Shared JS/Web regressions also
|
|
627
|
+
verify that malformed production title-image replacements clear the previous
|
|
628
|
+
image and help anchor. Full App acceptance remains pending.
|
|
629
|
+
|
|
630
|
+
Wallet groups use the actual embedded parent/children model, not memberKeys
|
|
631
|
+
references. groupId/groupPosition remain snapshot-owned fields; production
|
|
632
|
+
patch failures also reject other patched rows in the same existing groupId.
|
|
633
|
+
Unknown extension fields (including unsupported sparkline/changeHighlight
|
|
634
|
+
models) are ignored in production rather than introducing a new renderer API.
|
|
509
635
|
|
|
510
636
|
## Platform contract
|
|
511
637
|
|
|
@@ -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") }
|