@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.
@@ -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 icon = if (titleIcon.visibility == VISIBLE) dp(24) else 0
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") result.put(key, changes.get(key))
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 -> if (!directionResolved) {
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
- if (
1868
- pagerGestureIsVertical &&
1869
- keyboardDismissMode == NativeListKeyboardDismissMode.ON_DRAG
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
- recyclerView.parent?.requestDisallowInterceptTouchEvent(false)
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
@@ -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. They validate the whole batch against the current
449
- accepted row models before changing config or presentation; invalid batches
450
- leave both unchanged. iOS updates only changed accepted entries in its
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
- Development builds (`__DEV__ === true`) run the complete snapshot and patch
456
- contract assertions. Production builds run only serialization, required data
457
- shape, supported renderer, finite geometry, identity and patch consistency
458
- guards. Display budgets and style allowlists are development assertions.
459
- Typography token resolution runs in both environments. Production treats empty
460
- optional image URIs as missing images, preserving the existing visual fallback.
461
- An empty optional image field omitted from a patch is a no-op, matching its
462
- serialized native payload. Sparkline sample arrays retain their existing
463
- 256-sample resource bound because the Web renderer passes samples as function
464
- arguments; this protects the runtime's argument limit.
465
-
466
- The component and Web engine contain input validation failures in both modes:
467
- initial invalid input renders an empty list; rejected later snapshots or patch
468
- batches preserve the last accepted state and are not dispatched to native.
469
- On iOS and Android, the React wrapper captures caller data before validation
470
- and owns an immutable accepted snapshot. Later caller mutation cannot rewrite
471
- that accepted state. Imperative selection keys are captured on entry. Eligible
472
- value-only `identity` and `dataRow` patches validate their complete changed
473
- rows; other patches and snapshots without this private ownership use the
474
- existing complete-snapshot validation path. This does not change exported
475
- validators or Web behavior.
476
- An `undefined` value in an eligible field stays on the changed-row path only
477
- when that field was already `undefined`; clearing a present value uses the
478
- complete-snapshot path.
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 invalid row is silently dropped from a batch.
502
-
503
- Status: implemented. Development/production JS regressions pass, including
504
- native wrappers for iOS/Android and the Web DOM engine. Real Swift XCTest on an
505
- iOS 27 simulator and Android instrumentation on API 35 verify production
506
- serialization, normalized typography, opaque identities and duplicate-key
507
- protection against the shipping native parsers. Full app/device rendering and
508
- gesture acceptance remain separate from these input-boundary checks.
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 textHeight = title.measure(width: textWidth)
94
+ let titleImageSize = CGFloat(item.data.dictionary("titleImage")?.dictionary("image")?.double("width") ?? 0)
95
+ let titleImageWidth = titleImageSize > 0
96
+ ? titleImageSize + 4 + (item.data.dictionary("titleIcon") == nil ? 0 : 24) : 0
97
+ let textHeight = max(titleImageSize, title.measure(width: max(1, textWidth - titleImageWidth)))
95
98
  + subtitle.measure(width: textWidth)
96
99
  + tertiary.measure(width: textWidth)
97
100
  + (item.data.string("subtitle").isEmpty ? 0 : CGFloat(style.double("lineGap", default: 0)))
@@ -107,6 +110,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: titleIconView.isHidden ? titleLabel : titleIconView)
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" { merged[key] = value }
1533
+ if key != "key" && key != "type" {
1534
+ if value is NSNull { merged.removeValue(forKey: key) }
1535
+ else { merged[key] = value }
1536
+ }
1534
1537
  }
1535
1538
  guard let item = try? NativeListItem(data: merged) else { return }
1536
1539
  if rowHeight(previous, usingCompactReorderHeight: false) != rowHeight(item, usingCompactReorderHeight: false) ||
@@ -1554,6 +1557,8 @@ final class NativeListView: UIView {
1554
1557
  }
1555
1558
  if let selected = changes["selected"] as? Bool {
1556
1559
  if selected { current.selectedKeys.insert(item.key) } else { current.selectedKeys.remove(item.key) }
1560
+ } else if changes["selected"] is NSNull {
1561
+ current.selectedKeys.remove(item.key)
1557
1562
  }
1558
1563
  }
1559
1564
  if !changedKeys.isEmpty { invalidateActionAnchor(reason: "snapshot") }