@onekeyfe/react-native-native-list 3.0.157-alpha.253 → 3.0.157-alpha.255

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/android/build.gradle +7 -0
  2. package/android/src/main/java/com/onekey/nativelist/HybridNativeList.kt +20 -0
  3. package/android/src/main/java/com/onekey/nativelist/NativeListActivityDetailsView.kt +179 -0
  4. package/android/src/main/java/com/onekey/nativelist/NativeListActivityRenderer.kt +23 -2
  5. package/android/src/main/java/com/onekey/nativelist/NativeListContainerSlotAdapter.kt +53 -0
  6. package/android/src/main/java/com/onekey/nativelist/NativeListEndAlignment.kt +5 -0
  7. package/android/src/main/java/com/onekey/nativelist/NativeListMediaPreviewSlot.kt +215 -0
  8. package/android/src/main/java/com/onekey/nativelist/NativeListMediaTileRenderer.kt +15 -6
  9. package/android/src/main/java/com/onekey/nativelist/NativeListModels.kt +3 -1
  10. package/android/src/main/java/com/onekey/nativelist/NativeListScrollPositionTracker.kt +29 -0
  11. package/android/src/main/java/com/onekey/nativelist/NativeListSectionHeaderRenderer.kt +16 -1
  12. package/android/src/main/java/com/onekey/nativelist/NativeListView.kt +163 -26
  13. package/android/src/main/res/values/native_scroll_coordinator_ids.xml +4 -0
  14. package/android/src/test/java/com/margelo/nitro/nativelist/NativeListEndAlignmentTest.kt +34 -0
  15. package/android/src/test/java/com/margelo/nitro/nativelist/NativeListScrollPositionTrackerTest.kt +28 -0
  16. package/docs/DESIGN.md +4 -2
  17. package/docs/ROW_TEMPLATES.md +45 -0
  18. package/docs/SPEC.md +227 -1
  19. package/docs/STYLE_SPEC.md +8 -0
  20. package/ios/HybridNativeList.swift +30 -0
  21. package/ios/NativeListActivityDetailsView.swift +229 -0
  22. package/ios/NativeListActivityRenderer.swift +24 -2
  23. package/ios/NativeListMediaPreviewSlot.swift +199 -0
  24. package/ios/NativeListMediaTileRenderer.swift +10 -4
  25. package/ios/NativeListModels.swift +4 -1
  26. package/ios/NativeListScrollPositionTracker.swift +30 -0
  27. package/ios/NativeListSectionHeaderRenderer.swift +27 -1
  28. package/ios/RNCNativeListView.swift +159 -4
  29. package/ios/tests/NativeListActivityPresentationTests.swift +43 -0
  30. package/ios/tests/NativeListLifecycleTests.swift +213 -0
  31. package/ios/tests/NativeListMediaVisibilityTests.swift +42 -0
  32. package/ios/tests/NativeListScrollPositionTrackerTests.swift +25 -0
  33. package/lib/module/NativeList.js +56 -3
  34. package/lib/module/NativeList.web.js +5 -0
  35. package/lib/module/validation.js +64 -4
  36. package/lib/nitrogen/generated/android/c++/JFunc_void_bool.hpp +75 -0
  37. package/lib/nitrogen/generated/android/c++/JHybridNativeListSpec.cpp +39 -3
  38. package/lib/nitrogen/generated/android/c++/JHybridNativeListSpec.hpp +6 -0
  39. package/lib/nitrogen/generated/android/c++/views/JHybridNativeListStateUpdater.cpp +15 -0
  40. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/Func_void_bool.kt +78 -0
  41. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/HybridNativeListSpec.kt +26 -0
  42. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/views/HybridNativeListManager.kt +24 -9
  43. package/lib/nitrogen/generated/android/nativelistOnLoad.cpp +2 -0
  44. package/lib/nitrogen/generated/ios/NativeListModule-Swift-Cxx-Bridge.cpp +8 -0
  45. package/lib/nitrogen/generated/ios/NativeListModule-Swift-Cxx-Bridge.hpp +37 -0
  46. package/lib/nitrogen/generated/ios/c++/HybridNativeListSpecSwift.hpp +23 -2
  47. package/lib/nitrogen/generated/ios/c++/views/HybridNativeListComponent.mm +32 -0
  48. package/lib/nitrogen/generated/ios/swift/Func_void_bool.swift +46 -0
  49. package/lib/nitrogen/generated/ios/swift/HybridNativeListSpec.swift +3 -0
  50. package/lib/nitrogen/generated/ios/swift/HybridNativeListSpec_cxx.swift +54 -0
  51. package/lib/nitrogen/generated/shared/c++/HybridNativeListSpec.cpp +6 -0
  52. package/lib/nitrogen/generated/shared/c++/HybridNativeListSpec.hpp +8 -2
  53. package/lib/nitrogen/generated/shared/c++/views/HybridNativeListComponent.cpp +6 -0
  54. package/lib/nitrogen/generated/shared/c++/views/HybridNativeListComponent.hpp +11 -2
  55. package/lib/nitrogen/generated/shared/json/NativeListConfig.json +3 -0
  56. package/lib/typescript/src/NativeList.nitro.d.ts +3 -0
  57. package/lib/typescript/src/NativeList.types.d.ts +14 -1
  58. package/lib/typescript/src/models.d.ts +33 -5
  59. package/nitrogen/generated/android/c++/JFunc_void_bool.hpp +75 -0
  60. package/nitrogen/generated/android/c++/JHybridNativeListSpec.cpp +39 -3
  61. package/nitrogen/generated/android/c++/JHybridNativeListSpec.hpp +6 -0
  62. package/nitrogen/generated/android/c++/views/JHybridNativeListStateUpdater.cpp +15 -0
  63. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/Func_void_bool.kt +78 -0
  64. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/HybridNativeListSpec.kt +26 -0
  65. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/views/HybridNativeListManager.kt +24 -9
  66. package/nitrogen/generated/android/nativelistOnLoad.cpp +2 -0
  67. package/nitrogen/generated/ios/NativeListModule-Swift-Cxx-Bridge.cpp +8 -0
  68. package/nitrogen/generated/ios/NativeListModule-Swift-Cxx-Bridge.hpp +37 -0
  69. package/nitrogen/generated/ios/c++/HybridNativeListSpecSwift.hpp +23 -2
  70. package/nitrogen/generated/ios/c++/views/HybridNativeListComponent.mm +32 -0
  71. package/nitrogen/generated/ios/swift/Func_void_bool.swift +46 -0
  72. package/nitrogen/generated/ios/swift/HybridNativeListSpec.swift +3 -0
  73. package/nitrogen/generated/ios/swift/HybridNativeListSpec_cxx.swift +54 -0
  74. package/nitrogen/generated/shared/c++/HybridNativeListSpec.cpp +6 -0
  75. package/nitrogen/generated/shared/c++/HybridNativeListSpec.hpp +8 -2
  76. package/nitrogen/generated/shared/c++/views/HybridNativeListComponent.cpp +6 -0
  77. package/nitrogen/generated/shared/c++/views/HybridNativeListComponent.hpp +11 -2
  78. package/nitrogen/generated/shared/json/NativeListConfig.json +3 -0
  79. package/package.json +6 -5
  80. package/react-native-native-list.podspec +6 -0
  81. package/scripts/enable-container-slots.mjs +58 -0
  82. package/scripts/enable-container-slots.test.mjs +58 -0
  83. package/src/NativeList.nitro.ts +3 -0
  84. package/src/NativeList.tsx +84 -2
  85. package/src/NativeList.types.ts +14 -1
  86. package/src/NativeList.web.tsx +5 -0
  87. package/src/models.ts +38 -1
  88. package/src/validation.ts +146 -4
@@ -143,6 +143,26 @@ separately from source implementation in STYLE_SPEC §9.
143
143
  - **Common-capability boundary:** `footerActions` are inside this activity row.
144
144
  The list footer is independently configured through the container.
145
145
 
146
+ - **Native rich amounts:** optional `amounts` replaces the legacy two strings,
147
+ with at most 32 unique keyed entries. Each entry supports a leading visual,
148
+ tone, primary/secondary text and subscript segments. The title supports at
149
+ most four keyed badges. `descriptionActionKey` emits a description action.
150
+ `presentation: 'table'` allocates equal identity/amount columns and an
151
+ optional fee column; `stacked` keeps right-aligned amounts beside identity.
152
+ `fee.hidden` reserves its column while hiding its content. These native
153
+ additions do not change the Web renderer.
154
+
155
+ ```text
156
+ stacked (phone) table (wide)
157
+ ┌────┬────────────────┬───────────┐ ┌────┬────────────┬────────────┬─────────┐
158
+ │icon│title + badges │amount 1 │ │icon│title/badges│amount 1 │fee label│
159
+ │ │description ↗ │secondary │ │ │description │secondary │fee/fiat │
160
+ │ │status │amount 2… │ │ │status │amount 2… │ │
161
+ ├────┴────────────────┴───────────┤ ├────┴────────────┴────────────┴─────────┤
162
+ │ optional row action buttons│ │ optional row action buttons │
163
+ └────────────────────────────────┘ └───────────────────────────────────────┘
164
+ ```
165
+
146
166
  ## message
147
167
 
148
168
  ![Message: title and timestamp over body text, with optional leading visual and thumbnail](images/row-message.svg)
@@ -225,6 +245,19 @@ separately from source implementation in STYLE_SPEC §9.
225
245
  - **Use:** a gallery tile or browser preview.
226
246
  - **Required data:** `variant: gallery | browserPreview`, `title`. Optional
227
247
  image/image-state, subtitle, badge, network image and close-action key.
248
+ - **Native media preview:** optional `media: { source: ImageSource, probeOrder }`
249
+ supplies one or two distinct `image`/`video` candidates. It takes precedence
250
+ over `image`; explicit `imageState: empty | error` still suppresses loading.
251
+ A video candidate uses a muted paused native player, with no playback/controls or
252
+ touch target. Detail playback remains caller-owned. Failure advances once to
253
+ the next candidate, then displays the standard error placeholder. The same
254
+ media box and caption diagram apply; this adds no new layout region.
255
+ Web keeps the existing `image` field and does not execute media probing.
256
+ - **Native lifetime:** request identity includes row key, full source, fit,
257
+ placeholder and probe order. Rebinding only text preserves the preview;
258
+ changed identity or recycle cancels work and rejects late callbacks. Frame
259
+ loading owns at most one player per attached tile. Detach/background/recycle
260
+ releases player resources; no autoplay, audio focus or NativeList disk cache.
228
261
  - **Text style roles:** `title`, `subtitle`, `badge`.
229
262
  - **Layout boundary:** media stays above caption metadata; network/close/badge
230
263
  overlays keep their template anchors. A text style cannot change the grid
@@ -256,6 +289,18 @@ separately from source implementation in STYLE_SPEC §9.
256
289
 
257
290
  ## sectionHeader
258
291
 
292
+ Native-only optional title loading presentation (`titleLoading: true`):
293
+
294
+ ```text
295
+ ┌────────────────────────────────────────────────────────────┐
296
+ │ [spinner 20pt] Title Value/control │
297
+ └────────────────────────────────────────────────────────────┘
298
+ ```
299
+
300
+ The spinner inherits the resolved title color, has an 8-point trailing gap,
301
+ and stops/disappears when loading clears or the row is recycled. It is section
302
+ chrome and emits no row action. Omission preserves existing rendering.
303
+
259
304
  ![Section header: title/subtitle and optional summary value, help or selection control](images/row-section-header.svg)
260
305
 
261
306
  - **Use:** a structural section heading or list summary, independent of the
package/docs/SPEC.md CHANGED
@@ -7,7 +7,8 @@ architecture and runtime evidence.
7
7
  ## Purpose and non-goals
8
8
 
9
9
  NativeList renders bounded data with fixed row templates on iOS, Android and
10
- Web. It does not render arbitrary React children. Integrators own overflow
10
+ Web. Data rows do not render arbitrary React children. Native-only named container slots
11
+ may render React chrome outside the data-row stream. Integrators own overflow
11
12
  prevention; there is no automatic fitting or shrinking pass.
12
13
 
13
14
  ## Definitions and ownership
@@ -52,6 +53,23 @@ image that already loaded. Common container styling clears what it wrote on the
52
53
  next bind, including properties it set on template children. UI mutation remains on the platform
53
54
  UI thread; [DESIGN.md](DESIGN.md) describes update and event ownership.
54
55
 
56
+ ### Native section title loading indicator
57
+
58
+ Status: implemented source; simulator rendering acceptance pending.
59
+ On iOS and Android, `sectionHeader.titleLoading` defaults to `false`. When
60
+ `true`, a 20-point native indeterminate indicator precedes the section heading
61
+ with an 8-point gap and inherits the resolved title color. It emits no action
62
+ and does not create a separate accessibility target. Clearing the field, full
63
+ rebind, recycling and disposal stop and hide the indicator; detached native views do
64
+ not keep a visible animation. The History template's minimum content height
65
+ increases from 16 to 20 points while loading. Explicit caller row/container
66
+ heights still take precedence and must fit their padding. Web does not render
67
+ this new native-only indicator; its existing section presentation is unchanged.
68
+
69
+ Acceptance requires loading → idle → loading reuse, title color updates and
70
+ detachment on each native platform. A title's localized confirming text remains
71
+ caller-owned; the renderer does not infer transaction status.
72
+
55
73
  ## Data, cache and identity
56
74
 
57
75
  `row.key` identifies data; renderer reuse identity describes compatible view
@@ -356,3 +374,211 @@ audit; they are not new in this change.
356
374
  templates to 12 on native; Web defaults to 0 (STYLE_SPEC §6.2).
357
375
  - **DataRow linear layout.** See ROW_TEMPLATES.md: native unstyled linear rows
358
376
  keep the legacy single-label column.
377
+
378
+ ## Native scroll-position thresholds and tablet grids
379
+
380
+ Status: Source implemented; native runtime acceptance pending. These additions are native
381
+ only. Existing Web rendering and caller behavior are unchanged.
382
+
383
+ - `scrollPositionThresholds?: { start: number; end: number; enabled?: boolean }`
384
+ observes the list's logical distance from its content start, in logical pixels.
385
+ `start` and `end` must be finite, nonnegative, and `start < end`. Omitted or
386
+ `enabled: false` disables observation.
387
+ - `onScrollPositionThresholdChange({ isBeyondThreshold })` reports initial state
388
+ after enabling/configuration changes, then only hysteresis transitions: enter
389
+ when distance >= end, leave when distance <= start. No per-frame JS events.
390
+ Replacing the JS callback takes effect on the next event; disabling emits no event.
391
+ Configuration updates reset hysteresis before recomputing current position.
392
+ - iOS uses contentOffset + contentInset, excluding overscroll below zero. Android
393
+ accumulates consumed child scroll distance and rebases from first-item geometry.
394
+ Parent-pager header contributions and arbitrary index-jump acceptance are tracked
395
+ separately; no estimate-based parity claim is made for unverified jumps.
396
+ - `layout.gridColumns` accepts integers 2 through 7 on native. Native parsers
397
+ clamp malformed external JSON to 1 through 7 as before; public JS validation
398
+ rejects invalid requested counts. Grid geometry remains container-owned.
399
+
400
+ Required acceptance: disabled/enable lifecycle, both equality boundaries,
401
+ oscillation inside the band, programmatic scroll, page/identity replacement,
402
+ refresh and header insets, grid 6/7 layout/rotation, and no stale callbacks after
403
+ disposal. Unit checks prove transition semantics, not rendered behavior.
404
+
405
+ ## Native media preview probing
406
+
407
+ Status: Source implemented; native runtime acceptance pending. `mediaTile.media`
408
+ is an opt-in native-only preview descriptor: `{ source: ImageSource, probeOrder:
409
+ ('image' | 'video')[] }`. The order contains one or two distinct candidates.
410
+ The caller supplies the order; the renderer never guesses asset semantics.
411
+ When provided, media takes precedence over `image`, unless `imageState` is
412
+ explicitly empty/error. Web keeps its existing `image` behavior; native callers
413
+ that also target Web must supply that existing image independently.
414
+
415
+ Image uses the shared OneKeyImage lifecycle. Video uses a paused, muted AVPlayer
416
+ on iOS and Media3 ExoPlayer on Android, preserving the native Video source
417
+ families including streaming media instead of approximating them with a frame
418
+ extractor. Video defaults to contain; an explicit image fit may select cover.
419
+ There are no controls or video touch targets; the row owns press interaction.
420
+ Android preserves a black letterbox and iOS a transparent one, matching the
421
+ existing native Video views. No code calls play or requests audio focus.
422
+
423
+ A failed candidate advances once; exhaustion displays the existing error
424
+ placeholder. New source/row identity resets probing; binding, candidate and
425
+ player generations reject stale callbacks, including A→B→A. Text-only updates
426
+ preserve the current request. On Android, terminal image/player completion is
427
+ posted to the next UI queue turn before advancing probes or notifying the row;
428
+ it MUST NOT clear or rebind a Glide request inside its terminal listener.
429
+ Posted work rechecks binding/candidate generations, and player callbacks also
430
+ recheck the player generation. Recycling removes queued completion work before
431
+ releasing resources. This repairs the image-to-video fallback crash without
432
+ changing probe order or the terminal result contract; native runtime regression
433
+ acceptance is still required. A tile owns at most one native player. On iOS,
434
+ eligibility requires intersection with the window and every clipping/scroll
435
+ ancestor viewport; native scroll, bounds, layer transform/position and visibility
436
+ observations release retained offscreen pager pages. Detach, background,
437
+ invisibility and recycle release the player; reattachment rebuilds its retained
438
+ source while remaining paused. Android observes native global scroll/layout and
439
+ pre-draw geometry (including property animations), checking ancestor alpha and
440
+ getGlobalVisibleRect before allocating a player. These observers do not schedule
441
+ frames or emit JavaScript events.
442
+ Player count is bounded by intersecting native video tiles, including partially
443
+ visible tiles; there is no numeric cap that blanks visible iPad cells. iOS requests
444
+ one second of forward buffer; AVFoundation treats this as an advisory preference,
445
+ not a hard byte limit. Android uses a 250–1000 ms forward buffer, zero back buffer
446
+ and a 2 MiB target allocator; Media3 may exceed a target by allocation granularity.
447
+ These are per-player buffer targets, not hard total-process memory limits.
448
+ Platform decoder/surface overhead is additional. No
449
+ process-wide player cache or NativeList media disk cache is introduced. Source headers are never
450
+ logged. Android uses Media3 1.4.1 (matching native Video's default), including
451
+ HLS/DASH, UI and OkHttp data source; the existing root media3Version override is
452
+ honored. Cookie handling uses the React Native cookie store without replacing
453
+ the global HTTP client's cookie jar.
454
+
455
+ Required runtime cases: image→video, video→image, exhaustion, file/HTTP/HLS,
456
+ non-square fit, silent paused initial frame, same-source text update, quick
457
+ A→B→A reuse, detach/background and return, row tap, grid resize, and absence of
458
+ leaked audio/player resources. Static API checks do not prove these cases.
459
+
460
+ ## Native container slots (source implementation, runtime verification pending)
461
+
462
+ `listHeader`, `listEmpty` and `listFooter` accept React nodes on iOS and Android
463
+ vertical lists only. They are optional and default to absent. They are common
464
+ container capabilities, not row templates, and MUST NOT be used for per-item
465
+ rendering. Horizontal orientation with any supplied slot is rejected. Web ignores
466
+ these new native-only props; existing Web lists and callers retain their behavior.
467
+
468
+ The order is header, data rows (or empty slot when there are no data rows), footer.
469
+ On iOS, after the laid-out vertical content shrinks, an offset beyond the new
470
+ bottom is corrected to the current adjusted-inset-aware maximum. In-range offsets
471
+ and negative pull overscroll remain unchanged. Correction waits until active
472
+ dragging, deceleration and refresh finish; it is not an unconditional layout or
473
+ snapshot reset to the top. The native content-offset change remains observable to
474
+ ancestor pager coordinators so their saved logical offsets stay synchronized.
475
+ Recycling/disposal cancels deferred correction. Runtime acceptance includes a
476
+ scrolled retained-page round trip followed by empty content with scrolling slots.
477
+
478
+ Slots scroll with the native list. `fixedFooter` remains a separate fixed native
479
+ row descriptor. `scrollToEnd` aligns the final scrolling slot bottom with the
480
+ viewport end, including footer/empty/header-only slots taller than the viewport. Callers using `listEmpty` MUST omit `snapshot.emptyState`, whose
481
+ existing native descriptor becomes a data item. Horizontal content padding applies
482
+ to slots as well as data rows. Slot React trees own their internal layout and
483
+ business actions; NativeList owns their scroll placement and lifecycle.
484
+
485
+ The wrapper mounts exactly three non-collapsible slot roots and reports measured
486
+ heights through `containerSlotHeightsJson` in header/empty/footer order. Native
487
+ validates three finite, non-negative heights. Slot updates MUST NOT alter data-row
488
+ keys or public row indices. No per-scroll-frame JS position updates are used.
489
+ iOS mounts slot roots inside UICollectionView and reserves native layout space.
490
+ Android uses ConcatAdapter with three single-item slot adapters around the existing
491
+ data adapter; adapter binding indices stay local while layout-manager positions
492
+ are explicitly translated. Slots span all grid columns, do not select/reorder,
493
+ and do not emit row actions, visible-row indices, or image-prefetch work.
494
+
495
+ Fabric mount/unmount ownership remains explicit even when RecyclerView detaches a
496
+ slot holder. The native manager retains at most three roots until Fabric unmount;
497
+ recycling detaches slot content without disposing its React tree. Unmount removes
498
+ that exact view. Native list disposal MUST NOT dispatch pending row/slot events.
499
+ The generated Nitro 0.37 leaf managers require deterministic child-forwarding
500
+ customization: `scripts/enable-container-slots.mjs` runs after nitrogen and rejects
501
+ unexpected generator output. Generated files are never maintained by hand.
502
+
503
+ Acceptance requires both platforms: dynamic header height, empty/nonempty changes,
504
+ scrolling footer with pagination, grid full-span slots, scrolling from interactive
505
+ slot content, refresh from header/empty, row-index scroll APIs, reuse/unmount, and
506
+ rapid account changes. Source/type checks do not establish this runtime acceptance.
507
+
508
+
509
+ ## Refresh trigger distance
510
+
511
+ Optional `snapshot.capabilities.refreshTriggerDistance` is a positive finite
512
+ logical-pixel distance. Omission preserves the platform control's default.
513
+ iOS adds a release-trigger fallback based on the deepest normalized overscroll
514
+ (contentOffset plus contentInset.top); the fallback and UIRefreshControl emit
515
+ at most one refresh action per drag. Android passes the distance to
516
+ SwipeRefreshLayout's native trigger setting. These native controls differ in
517
+ rubber-band/drag scaling, so equal values do not promise equal finger travel.
518
+ The caller continues to own refreshing state, completion, and optional haptic feedback.
519
+ On native, the row-less `nativeList.refresh` bridge envelope is container-owned:
520
+ the JS wrapper consumes it and invokes only `onRefresh`, when supplied. It MUST
521
+ NOT also invoke the public `onRowAction`, even when `onRefresh` is absent.
522
+ An actual row action carrying a `rowKey` remains a row action, including one
523
+ whose caller-defined action key matches this internal name. Web retains its
524
+ legacy dual notification behavior; this native correction does not change Web.
525
+ The container does not emit a second haptic for the release fallback. The fallback is
526
+ inactive unless pull-to-refresh is enabled; no product-specific threshold is
527
+ hardcoded into the module.
528
+
529
+ ## Native rich activity presentation
530
+
531
+ Status: Source implemented; native runtime acceptance pending. Optional `amounts`
532
+ supersedes legacy `primaryAmount`/`secondaryAmount` only on native. It accepts at
533
+ most 32 keyed lines, each with formatted text, optional leading visual, tone,
534
+ secondary text and subscript text segments. Segments are caller-formatted and
535
+ never interpreted as numbers by NativeList. Omission preserves the legacy tree.
536
+
537
+ `presentation: stacked` places the amount lines after the identity column and
538
+ right-aligns them; `table` uses equal identity/amount columns, left-aligns amount
539
+ lines and adds an optional trailing fee column. Fee supports label, primary and
540
+ secondary text with their own segments; `hidden` preserves its column width
541
+ while hiding its contents. Up to four uniquely keyed title badges use declared
542
+ tones. `descriptionActionKey` makes only the description emit that action with
543
+ source `description`; footer actions retain their existing source/keys and
544
+ disabled behavior. All actions use the current host binding epoch.
545
+
546
+ Rich rows measure the declared line/visual metrics; there is no six-line native
547
+ truncation. The existing height override contract still applies. RTL follows
548
+ semantic leading/trailing anchors. Clearing rich fields returns to the legacy
549
+ renderer, and removing amount lines recycles their visual slots. Web rendering
550
+ remains unchanged; rich fields are native-only until a separate Web migration.
551
+
552
+ Acceptance: 1/2/6/32 lines, changed keys/order, paired icons and network overlays,
553
+ small-number segments in amounts/fiat/fee (ceil(0.6 × parent font size), unchanged
554
+ baseline, matching NumberSizeableText), badge tone/clear, hidden fee, disabled
555
+ actions, description anchors, dark/RTL/font scaling and explicit/automatic height.
556
+
557
+
558
+ ### Android ancestor scroll distance
559
+
560
+ Threshold hysteresis combines the primary RecyclerView's actual consumed scroll
561
+ with all native ancestor header contributions. CollapsiblePagerView publishes
562
+ weak-owner contributions through the namespaced View-tag protocol documented in
563
+ its SPEC.md; no pager dependency or product semantics are required. Absence is
564
+ zero. NativeList owns one native listener, registers it on attach, and removes
565
+ only its own listener on detach. Header-only scrolling therefore crosses the
566
+ same logical Home threshold as iOS normalized content scrolling, without a JS
567
+ scroll-frame event. Changes to the listener or public callbacks do not replace
568
+ native resource ownership.
569
+
570
+ Child distance uses actual onScrolled dy and rebases from first-item geometry at
571
+ top; scrollToOffset seeds a known explicit offset. It does not use RecyclerView's
572
+ average-height scrollbar estimate. Continuous scrolling and return-to-top are
573
+ covered by this policy; exact classification immediately after arbitrary distant
574
+ scrollToIndex jumps remains pending measured-prefix correction and MUST NOT be
575
+ claimed as verified. A later top anchor restores exact distance.
576
+
577
+
578
+ ### Container follow-up verification
579
+
580
+ The pure Android end-alignment policy has four focused JVM tests: tall footer,
581
+ tall empty slot with viewport padding, animated decorated-bottom correction,
582
+ and short footer. These validate geometry, not actual RecyclerView animation or
583
+ Fabric lifecycle. Native simulator acceptance remains required. The iOS fallback
584
+ retains one refresh action per drag and leaves haptic feedback to its caller.
@@ -976,3 +976,11 @@ the contract; no shared cross-language renderer or code generator is required.
976
976
  before treating the refactor as device-accepted.
977
977
 
978
978
  Overflow fitting is the integrating developer's responsibility.
979
+
980
+ ### Native tablet grid allocation
981
+
982
+ `layout.gridColumns` accepts integers 2 through 7. The container derives tile
983
+ width from available width, content padding and spacing; mediaTile styles do
984
+ not choose the column count. This extends native parser limits from four to
985
+ seven without changing existing 2/3/4 geometry. Runtime acceptance for 6/7
986
+ columns, rotation, and text scaling remains required.
@@ -7,6 +7,35 @@ final class HybridNativeList: HybridNativeListSpec {
7
7
 
8
8
  var view: UIView { hostView ?? disposedView }
9
9
 
10
+ var scrollPositionThresholdsJson: String = "" {
11
+ didSet {
12
+ guard scrollPositionThresholdsJson != oldValue else { return }
13
+ runOnMain { [weak self] in
14
+ guard let self else { return }
15
+ self.hostView?.setScrollPositionThresholdsJson(self.scrollPositionThresholdsJson)
16
+ }
17
+ }
18
+ }
19
+
20
+ var onScrollPositionThresholdChange: ((Bool) -> Void)? {
21
+ didSet {
22
+ runOnMain { [weak self] in
23
+ guard let self else { return }
24
+ self.hostView?.onScrollPositionThresholdChange = self.onScrollPositionThresholdChange
25
+ }
26
+ }
27
+ }
28
+
29
+ var containerSlotHeightsJson: String = "[]" {
30
+ didSet {
31
+ guard containerSlotHeightsJson != oldValue else { return }
32
+ runOnMain { [weak self] in
33
+ guard let self else { return }
34
+ self.hostView?.setContainerSlotHeightsJson(self.containerSlotHeightsJson)
35
+ }
36
+ }
37
+ }
38
+
10
39
  var snapshotJson: String = "" {
11
40
  didSet {
12
41
  guard snapshotJson != oldValue else { return }
@@ -156,6 +185,7 @@ final class HybridNativeList: HybridNativeListSpec {
156
185
  onReorder = nil
157
186
  onEndReached = nil
158
187
  onVisibleRangeChanged = nil
188
+ onScrollPositionThresholdChange = nil
159
189
  hostView.dispose()
160
190
  self.hostView = nil
161
191
  snapshotJson = ""
@@ -0,0 +1,229 @@
1
+ import UIKit
2
+
3
+ /// The activity template's bounded rich presentation; all values are caller-formatted.
4
+ final class NativeListActivityDetailsView: UIStackView {
5
+ var onAction: ((String, UIView, String, Int) -> Void)?
6
+ private let main = UIStackView()
7
+ private let visual = NativeListLeadingVisual()
8
+ private let identity = UIStackView()
9
+ private let titleLine = UIStackView()
10
+ private let title = NativeListTextLabel()
11
+ private let descriptionLabel = NativeListTextLabel()
12
+ private let status = NativeListTextLabel()
13
+ private let amounts = UIStackView()
14
+ private let fee = UIStackView()
15
+ private let feeLabel = NativeListTextLabel()
16
+ private let feeLine = UIStackView()
17
+ private let feePrimary = NativeListTextLabel()
18
+ private let feeSecondary = NativeListTextLabel()
19
+ private let actions = UIStackView()
20
+ private var amountViews: [NativeListActivityAmountView] = []
21
+ private var badgeViews: [NativeListTextLabel] = []
22
+ private var buttons: [UIButton] = []
23
+ private var actionKeys: [String] = []
24
+ private var descriptionKey = ""
25
+ private var dimensions: [NSLayoutConstraint] = []
26
+
27
+ override init(frame: CGRect) {
28
+ super.init(frame: frame)
29
+ axis = .vertical; alignment = .fill; spacing = 8
30
+ main.axis = .horizontal; main.alignment = .center; main.spacing = 12
31
+ identity.axis = .vertical; identity.alignment = .fill
32
+ titleLine.axis = .horizontal; titleLine.alignment = .center; titleLine.spacing = 4
33
+ titleLine.addArrangedSubview(title)
34
+ [titleLine, descriptionLabel, status].forEach(identity.addArrangedSubview)
35
+ amounts.axis = .vertical; amounts.alignment = .fill; amounts.spacing = 2
36
+ fee.axis = .vertical; fee.alignment = .trailing
37
+ feeLine.axis = .horizontal; feeLine.spacing = 4
38
+ [feePrimary, feeSecondary].forEach(feeLine.addArrangedSubview)
39
+ [feeLabel, feeLine].forEach(fee.addArrangedSubview)
40
+ [visual, identity, amounts, fee].forEach(main.addArrangedSubview)
41
+ [main, actions].forEach(addArrangedSubview)
42
+ actions.axis = .horizontal; actions.alignment = .leading; actions.spacing = 8
43
+ actions.isLayoutMarginsRelativeArrangement = true
44
+ descriptionLabel.isUserInteractionEnabled = true
45
+ descriptionLabel.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(descriptionPressed)))
46
+ title.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
47
+ identity.setContentHuggingPriority(.defaultLow, for: .horizontal)
48
+ amounts.setContentHuggingPriority(.defaultHigh, for: .horizontal)
49
+ }
50
+ required init(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
51
+
52
+ static func measure(_ data: [String: Any], width: CGFloat) -> CGFloat {
53
+ let style = data.dictionary("style") ?? [:]
54
+ let primary = style.dictionary("primaryAmount") ?? [:]
55
+ let secondary = style.dictionary("secondaryAmount") ?? [:]
56
+ let rows = data.dictionaries("amounts")
57
+ let gap = CGFloat(style.double("trailingGap", default: 2))
58
+ let amountsHeight = rows.reduce(CGFloat(0)) { height, row in
59
+ height + CGFloat(primary.double("lineHeight", default: 24)) +
60
+ (row.string("secondaryText").isEmpty ? 0 : CGFloat(secondary.double("lineHeight", default: 20)))
61
+ } + CGFloat(max(0, rows.count - 1)) * gap
62
+ let titleHeight = CGFloat(style.dictionary("title")?.double("lineHeight", default: 24) ?? 24)
63
+ let description = NativeListResolvedText(data.string("description"), style: style.dictionary("description"),
64
+ size: 14, color: .label, lineHeight: 20, lines: 2)
65
+ let identityHeight = titleHeight + description.measure(width: max(1, width * 0.4 - 52)) +
66
+ (data.string("status").isEmpty ? 0 : 16)
67
+ let imageHeight = CGFloat(style.dictionary("image")?.double("height", default: 40) ?? 40)
68
+ return max(imageHeight, identityHeight, amountsHeight) +
69
+ CGFloat(style.double("verticalPadding", default: 8)) * 2 +
70
+ (data.dictionaries("footerActions").isEmpty ? 0 : 40)
71
+ }
72
+
73
+ func bind(_ item: NativeListItem, theme: [String: Any]?) {
74
+ let data = item.data
75
+ let style = data.dictionary("style") ?? [:]
76
+ let image = style.dictionary("image") ?? [:]
77
+ let table = data.string("presentation") == "table"
78
+ let gap = CGFloat(style.double("leadingGap", default: 12))
79
+ main.spacing = gap
80
+ identity.spacing = CGFloat(style.double("lineGap"))
81
+ amounts.spacing = CGFloat(style.double("trailingGap", default: 2))
82
+ titleLine.spacing = CGFloat(style.double("titleBadgeGap", default: 4))
83
+ NSLayoutConstraint.deactivate(dimensions)
84
+ dimensions = [
85
+ visual.widthAnchor.constraint(equalToConstant: CGFloat(image.double("width", default: 40))),
86
+ visual.heightAnchor.constraint(equalToConstant: CGFloat(image.double("height", default: 40))),
87
+ amounts.widthAnchor.constraint(lessThanOrEqualTo: main.widthAnchor, multiplier: table ? 0.42 : 0.5),
88
+ ]
89
+ if table { dimensions.append(amounts.widthAnchor.constraint(equalTo: identity.widthAnchor)) }
90
+ NSLayoutConstraint.activate(dimensions)
91
+ visual.bind(data.dictionary("leading") ?? [:], style: image, key: item.key, theme: theme,
92
+ isUnread: false, secondaryVisual: data.dictionary("secondaryLeading"))
93
+ NativeListResolvedText(data.string("title"), style: style.dictionary("title"), size: 16,
94
+ weight: .medium, color: nativeListColor(theme, "primaryText", "#202020"), lineHeight: 24, lines: 1).bind(title)
95
+ NativeListResolvedText(data.string("description"), style: style.dictionary("description"), size: 14,
96
+ color: nativeListColor(theme, "secondaryText", "#646464"), lineHeight: 20, lines: 2).bind(descriptionLabel)
97
+ NativeListResolvedText(data.string("status"), style: style.dictionary("status"), size: 12,
98
+ color: nativeListColor(theme, "secondaryText", "#646464"), lineHeight: 16, lines: 1).bind(status)
99
+ descriptionKey = data.string("descriptionActionKey")
100
+ descriptionLabel.isUserInteractionEnabled = !descriptionKey.isEmpty
101
+ descriptionLabel.accessibilityTraits = descriptionKey.isEmpty ? .staticText : .button
102
+ for badge in badgeViews { titleLine.removeArrangedSubview(badge); badge.removeFromSuperview() }
103
+ badgeViews = data.dictionaries("badges").map { descriptor in
104
+ let label = NativeListTextLabel()
105
+ let tone = descriptor.string("tone")
106
+ let tint = nativeListActivityTone(tone, theme: theme)
107
+ NativeListResolvedText(" \(descriptor.string("text")) ", style: nil, size: 12,
108
+ weight: .medium, color: tint, lineHeight: 16, lines: 1).bind(label)
109
+ label.backgroundColor = tint.withAlphaComponent(0.1)
110
+ label.layer.cornerRadius = 4; label.clipsToBounds = true
111
+ label.setContentCompressionResistancePriority(.required, for: .horizontal)
112
+ titleLine.addArrangedSubview(label)
113
+ return label
114
+ }
115
+ let descriptors = data.dictionaries("amounts")
116
+ while amountViews.count > descriptors.count {
117
+ let view = amountViews.removeLast(); view.recycle(); amounts.removeArrangedSubview(view); view.removeFromSuperview()
118
+ }
119
+ while amountViews.count < descriptors.count {
120
+ let view = NativeListActivityAmountView(); amountViews.append(view); amounts.addArrangedSubview(view)
121
+ }
122
+ for (index, descriptor) in descriptors.enumerated() {
123
+ amountViews[index].bind(descriptor, key: "\(item.key):\(descriptor.string("key"))", style: style, theme: theme, table: table)
124
+ }
125
+ let feeData = data.dictionary("fee")
126
+ fee.isHidden = feeData == nil
127
+ fee.alpha = feeData?.bool("hidden") == true ? 0 : 1
128
+ NativeListResolvedText(feeData?.string("label") ?? "", style: style.dictionary("secondaryAmount"),
129
+ size: 12, color: nativeListColor(theme, "secondaryText", "#646464"), lineHeight: 16, lines: 1).bind(feeLabel)
130
+ NativeListResolvedText(feeData?.string("primary") ?? "", style: style.dictionary("secondaryAmount"),
131
+ size: 14, color: nativeListColor(theme, "primaryText", "#202020"), lineHeight: 20, lines: 1).bind(feePrimary)
132
+ NativeListResolvedText(feeData?.string("secondary") ?? "", style: style.dictionary("secondaryAmount"),
133
+ size: 12, color: nativeListColor(theme, "secondaryText", "#646464"), lineHeight: 16, lines: 1).bind(feeSecondary)
134
+ for button in buttons { actions.removeArrangedSubview(button); button.removeFromSuperview() }
135
+ buttons = []
136
+ nativeListActivitySegments(feePrimary, feeData?.dictionaries("primaryTextSegments") ?? [])
137
+ nativeListActivitySegments(feeSecondary, feeData?.dictionaries("secondaryTextSegments") ?? [])
138
+ let actionData = data.dictionaries("footerActions")
139
+ actionKeys = actionData.map { $0.string("key") }
140
+ actions.isHidden = actionData.isEmpty
141
+ actions.directionalLayoutMargins = NSDirectionalEdgeInsets(top: 0, leading: CGFloat(image.double("width", default: 40)) + gap, bottom: 0, trailing: 0)
142
+ for (index, descriptor) in actionData.enumerated() {
143
+ let button = UIButton(type: .system); button.tag = index
144
+ button.setTitle(descriptor.string("label"), for: .normal)
145
+ button.titleLabel?.font = nativeListFont(ofSize: 14, weight: .medium)
146
+ button.setTitleColor(nativeListActivityTone(descriptor.string("tone"), theme: theme), for: .normal)
147
+ button.backgroundColor = nativeListColor(theme, "strongBackground", "#F0F0F0")
148
+ button.layer.cornerRadius = 8
149
+ button.contentEdgeInsets = UIEdgeInsets(top: 4, left: 8, bottom: 4, right: 8)
150
+ button.isEnabled = !data.bool("disabled") && !descriptor.bool("disabled")
151
+ button.addTarget(self, action: #selector(actionPressed(_:)), for: .touchUpInside)
152
+ buttons.append(button); actions.addArrangedSubview(button)
153
+ }
154
+ let spacer = UIView(); spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
155
+ // The last flexible spacer is separate from the fixed action buttons.
156
+ actions.arrangedSubviews.filter { !($0 is UIButton) }.forEach { actions.removeArrangedSubview($0); $0.removeFromSuperview() }
157
+ actions.addArrangedSubview(spacer)
158
+ }
159
+ @objc private func descriptionPressed() { onAction?(descriptionKey, descriptionLabel, "description", 0) }
160
+ @objc private func actionPressed(_ button: UIButton) {
161
+ guard button.tag < actionKeys.count else { return }
162
+ onAction?(actionKeys[button.tag], button, "footerAction", button.tag)
163
+ }
164
+ func recycle() {
165
+ visual.recycle(); amountViews.forEach { $0.recycle() }; actionKeys = []; descriptionKey = ""
166
+ }
167
+ }
168
+
169
+ private func nativeListActivityTone(_ tone: String, theme: [String: Any]?) -> UIColor {
170
+ switch tone {
171
+ case "positive", "success": return nativeListColor(theme, "positive", "#218358")
172
+ case "negative", "danger": return nativeListColor(theme, "negative", "#CE2C31")
173
+ case "secondary": return nativeListColor(theme, "secondaryText", "#646464")
174
+ case "warning": return nativeListColor(theme, "warning", "#AB6400")
175
+ case "info": return nativeListColor(theme, "info", "#007BEF")
176
+ default: return nativeListColor(theme, "primaryText", "#202020")
177
+ }
178
+ }
179
+
180
+ private final class NativeListActivityAmountView: UIStackView {
181
+ private let visual = NativeListLeadingVisual()
182
+ private let labels = UIStackView()
183
+ private let primary = NativeListTextLabel()
184
+ private let secondary = NativeListTextLabel()
185
+ override init(frame: CGRect) {
186
+ super.init(frame: frame)
187
+ axis = .horizontal; alignment = .center; spacing = 6
188
+ labels.axis = .vertical; labels.alignment = .fill
189
+ [primary, secondary].forEach(labels.addArrangedSubview)
190
+ [visual, labels].forEach(addArrangedSubview)
191
+ NSLayoutConstraint.activate([visual.widthAnchor.constraint(equalToConstant: 20), visual.heightAnchor.constraint(equalToConstant: 20)])
192
+ }
193
+ required init(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
194
+ func bind(_ data: [String: Any], key: String, style: [String: Any], theme: [String: Any]?, table: Bool) {
195
+ let leading = data.dictionary("leading")
196
+ visual.isHidden = leading == nil
197
+ if let leading { visual.bind(leading, style: ["width": 20, "height": 20], key: key, theme: theme, isUnread: false) } else { visual.recycle() }
198
+ var primaryStyle = style.dictionary("primaryAmount") ?? [:]
199
+ if primaryStyle["alignment"] == nil { primaryStyle["alignment"] = table ? "start" : "end" }
200
+ let resolved = NativeListResolvedText(data.string("text"), style: primaryStyle, size: 16,
201
+ weight: .medium, color: nativeListActivityTone(data.string("tone"), theme: theme), lineHeight: 24, lines: 1, tabular: true)
202
+ resolved.bind(primary)
203
+ nativeListActivitySegments(primary, data.dictionaries("textSegments"))
204
+ var secondaryStyle = style.dictionary("secondaryAmount") ?? [:]
205
+ if secondaryStyle["alignment"] == nil { secondaryStyle["alignment"] = table ? "start" : "end" }
206
+ NativeListResolvedText(data.string("secondaryText"), style: secondaryStyle, size: 14,
207
+ color: nativeListColor(theme, "secondaryText", "#646464"), lineHeight: 20, lines: 1).bind(secondary)
208
+ nativeListActivitySegments(secondary, data.dictionaries("secondaryTextSegments"))
209
+ }
210
+ func recycle() { visual.recycle() }
211
+ }
212
+
213
+ private func nativeListActivitySegments(_ label: NativeListTextLabel, _ segments: [[String: Any]]) {
214
+ guard !segments.isEmpty else { return }
215
+ let text = segments.map { $0.string("text") }.joined()
216
+ let attributes = (label.attributedText?.length ?? 0) > 0
217
+ ? label.attributedText!.attributes(at: 0, effectiveRange: nil)
218
+ : [.font: label.font as Any, .foregroundColor: label.textColor as Any]
219
+ let value = NSMutableAttributedString(string: text, attributes: attributes)
220
+ var offset = 0
221
+ for segment in segments {
222
+ let length = (segment.string("text") as NSString).length
223
+ if segment.string("style") == "subscript" {
224
+ value.addAttributes([.font: label.font.withSize(ceil(label.font.pointSize * 0.6))], range: NSRange(location: offset, length: length))
225
+ }
226
+ offset += length
227
+ }
228
+ label.attributedText = value
229
+ }
@@ -4,9 +4,11 @@ final class NativeListActivityCell: NativeListRendererCell {
4
4
  override class func measure(
5
5
  _ item: NativeListItem, width: CGFloat, theme: [String: Any]?, layout: String
6
6
  ) -> CGFloat? {
7
+ if item.data["amounts"] != nil { return NativeListActivityDetailsView.measure(item.data, width: width) }
7
8
  return item.data.dictionaries("footerActions").isEmpty ? 60 : 100
8
9
  }
9
10
 
11
+ private let details = NativeListActivityDetailsView()
10
12
  private let visual = NativeListLeadingVisual()
11
13
  private let column = UIStackView()
12
14
  private let titleLine = UIStackView()
@@ -22,7 +24,7 @@ final class NativeListActivityCell: NativeListRendererCell {
22
24
  private let buttons = (0..<3).map { _ in UIButton(type: .system) }
23
25
  private var actionKeys: [String] = []
24
26
  private var dimensions: [NSLayoutConstraint] = []
25
- override var assetFields: [String] { ["leading", "secondaryLeading"] }
27
+ override var assetFields: [String] { ["leading", "secondaryLeading", "amounts"] }
26
28
 
27
29
  override init(frame: CGRect) {
28
30
  super.init(frame: frame)
@@ -60,7 +62,11 @@ final class NativeListActivityCell: NativeListRendererCell {
60
62
  button.addTarget(self, action: #selector(pressed(_:)), for: .touchUpInside)
61
63
  actions.addArrangedSubview(button)
62
64
  }
63
- [visual, column, amounts].forEach(root.addArrangedSubview)
65
+ [visual, column, amounts, details].forEach(root.addArrangedSubview)
66
+ details.isHidden = true
67
+ details.onAction = { [weak self] key, view, source, slot in
68
+ self?.emitAction(key, from: view, source: source, slot: slot)
69
+ }
64
70
  }
65
71
  required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
66
72
  override func bindContent(
@@ -68,6 +74,21 @@ final class NativeListActivityCell: NativeListRendererCell {
68
74
  checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String
69
75
  ) {
70
76
  let data = item.data
77
+ let rich = data["amounts"] != nil
78
+ details.isHidden = !rich
79
+ [visual, column, amounts].forEach { $0.isHidden = rich }
80
+ root.axis = rich ? .vertical : .horizontal
81
+ root.alignment = rich ? .fill : .center
82
+ if rich {
83
+ let style = data.dictionary("style") ?? [:]
84
+ let hp = CGFloat(style.double("horizontalPadding", default: data.string("presentation") == "table" ? 16 : 12))
85
+ let vp = CGFloat(style.double("verticalPadding", default: 8))
86
+ contentInsets = UIEdgeInsets(top: vp, left: hp, bottom: vp, right: hp)
87
+ visual.recycle()
88
+ details.bind(item, theme: theme)
89
+ return
90
+ }
91
+ details.recycle()
71
92
  let style = data.dictionary("style") ?? [:]
72
93
  let image = style.dictionary("image") ?? [:]
73
94
  root.spacing = 12
@@ -173,6 +194,7 @@ final class NativeListActivityCell: NativeListRendererCell {
173
194
  }
174
195
  override func recycleContent() {
175
196
  visual.recycle()
197
+ details.recycle()
176
198
  actionKeys = []
177
199
  buttons.forEach { $0.isHidden = true }
178
200
  }